@bettercms-ai/codegen 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.
- package/README.md +4 -4
- package/dist/cli.js +733 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +155 -19
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
ADDED
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { writeFile, mkdir } from "fs/promises";
|
|
5
|
+
import { dirname, resolve } from "path";
|
|
6
|
+
|
|
7
|
+
// src/fetch-models.ts
|
|
8
|
+
async function fetchModels(opts) {
|
|
9
|
+
const doFetch = opts.fetchImpl ?? globalThis.fetch;
|
|
10
|
+
const base = opts.apiUrl.replace(/\/+$/, "");
|
|
11
|
+
const url = `${base}/management/content/models`;
|
|
12
|
+
let res;
|
|
13
|
+
try {
|
|
14
|
+
res = await doFetch(url, {
|
|
15
|
+
// No Content-Type: this is a bodyless GET; the header is incorrect here and
|
|
16
|
+
// strict edge runtimes/proxies may reject it.
|
|
17
|
+
headers: { Authorization: `Bearer ${opts.apiKey}`, Accept: "application/json" }
|
|
18
|
+
});
|
|
19
|
+
} catch (err) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`Could not reach the BetterCMS Management API at ${url}: ${err instanceof Error ? err.message : String(err)}`
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
if (!res.ok) {
|
|
25
|
+
const hint = res.status === 401 || res.status === 403 ? " \u2014 check your management API key (it must have the content:manage scope)." : "";
|
|
26
|
+
throw new Error(`Management API returned ${res.status} ${res.statusText}${hint}`);
|
|
27
|
+
}
|
|
28
|
+
const body = await res.json();
|
|
29
|
+
const rows = body.data ?? [];
|
|
30
|
+
return rows.map((r) => ({
|
|
31
|
+
slug: r.slug,
|
|
32
|
+
name: r.name,
|
|
33
|
+
description: r.description ?? null,
|
|
34
|
+
fields: r.fields ?? []
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// src/generate.ts
|
|
39
|
+
var PREAMBLE = `/**
|
|
40
|
+
* Rich-text field value returned by the Delivery API.
|
|
41
|
+
*
|
|
42
|
+
* - \`format\`/\`value\`: the portable, editor-agnostic payload (Lexical EditorState) \u2014
|
|
43
|
+
* render it with your editor's serializer for full fidelity.
|
|
44
|
+
* - \`html\`: server-rendered, sanitized HTML (computed render-on-write). Present on
|
|
45
|
+
* Delivery reads; the simplest path for non-React consumers \u2014 safe to inject directly
|
|
46
|
+
* (e.g. \`dangerouslySetInnerHTML\`). Optional: legacy/un-normalized values may omit it.
|
|
47
|
+
*
|
|
48
|
+
* The \`{ format, value }\` contract is unchanged; \`html\` is additive.
|
|
49
|
+
*/
|
|
50
|
+
/**
|
|
51
|
+
* One Portable Text block, or a block object (an image, an embed, a placed component).
|
|
52
|
+
*
|
|
53
|
+
* Inlined rather than imported: this preamble is emitted INTO your repo and is deliberately
|
|
54
|
+
* dependency-free. To render structure, \`npm i @portabletext/react\` and pass
|
|
55
|
+
* \`portableText(field)\` to it; to render without adding anything, keep using \`rich()\`.
|
|
56
|
+
*/
|
|
57
|
+
export type PortableTextBlock = {
|
|
58
|
+
readonly _type: string;
|
|
59
|
+
readonly _key: string;
|
|
60
|
+
readonly style?: string;
|
|
61
|
+
readonly listItem?: string;
|
|
62
|
+
readonly level?: number;
|
|
63
|
+
readonly markDefs?: readonly { readonly _type: string; readonly _key: string; readonly [k: string]: unknown }[];
|
|
64
|
+
readonly children?: readonly { readonly _type: string; readonly _key: string; readonly text?: string; readonly marks?: readonly string[] }[];
|
|
65
|
+
readonly [k: string]: unknown;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type RichText = {
|
|
69
|
+
/**
|
|
70
|
+
* The storage format. Deliberately \`string\` and NOT a literal union: a project mid-backfill
|
|
71
|
+
* holds both \`"lexical-\u2026"\` and \`"portable-text-1"\` values, and narrowing this would give a
|
|
72
|
+
* type error to anyone regenerating types against it.
|
|
73
|
+
*/
|
|
74
|
+
readonly format: string;
|
|
75
|
+
/** Portable Text blocks when \`format\` is \`"portable-text-1"\`; editor state otherwise. */
|
|
76
|
+
readonly value: unknown;
|
|
77
|
+
/**
|
|
78
|
+
* Server-rendered, sanitized HTML. ALWAYS present, in every format, forever \u2014 sites built
|
|
79
|
+
* before Portable Text existed read this directly and cannot be rebuilt.
|
|
80
|
+
*/
|
|
81
|
+
readonly html?: string;
|
|
82
|
+
/**
|
|
83
|
+
* @deprecated Superseded by Portable Text \u2014 read \`portableText(field)\` instead. Retained so
|
|
84
|
+
* entries written before the migration keep type-checking; nothing mints it any more.
|
|
85
|
+
*
|
|
86
|
+
* Structured blocks. Present on Body (\`document\`) fields ONLY, and optional even there \u2014
|
|
87
|
+
* it is derived at write time, so an entry saved before this existed carries none until its
|
|
88
|
+
* next save, and there is no backfill. Branch on its absence; \`html\` is always there.
|
|
89
|
+
*
|
|
90
|
+
* \`id\` is stable within ONE document, never a global key \u2014 two entries both have a "0.0".
|
|
91
|
+
* A cross-document anchor is (entryId, fieldKey, id).
|
|
92
|
+
*/
|
|
93
|
+
readonly doc?: {
|
|
94
|
+
readonly version: 1;
|
|
95
|
+
readonly blocks: readonly { readonly id: string; readonly type: string; readonly [k: string]: unknown }[];
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A field that may arrive as EITHER shape.
|
|
101
|
+
*
|
|
102
|
+
* Switching a field between \`text\` and \`richtext\` in the CMS switches what Delivery
|
|
103
|
+
* returns for it \u2014 a bare string becomes \`{ format, value, html }\`. Type author-editable
|
|
104
|
+
* text with this and read it through \`plain()\`/\`rich()\` below, and that switch stops being
|
|
105
|
+
* a site-breaking change. Interpolating the value directly renders \`[object Object]\`.
|
|
106
|
+
*/
|
|
107
|
+
export type TextOrRich = string | RichText | null | undefined;
|
|
108
|
+
|
|
109
|
+
/** True when the value is a rich-text envelope rather than a bare string. */
|
|
110
|
+
export function isRichText(value: unknown): value is RichText {
|
|
111
|
+
return (
|
|
112
|
+
typeof value === "object" && value !== null && !Array.isArray(value) &&
|
|
113
|
+
("html" in value || "format" in value)
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The marker on a Portable Text envelope. */
|
|
118
|
+
export const PORTABLE_TEXT_FORMAT = "portable-text-1";
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Portable Text blocks when the field stores them, otherwise null.
|
|
122
|
+
*
|
|
123
|
+
* ADDITIVE. \`rich()\` and \`plain()\` keep working exactly as before on every format, so
|
|
124
|
+
* nothing you have already shipped needs to change. Use this only if you want to render the
|
|
125
|
+
* structure yourself \u2014 for example with \`@portabletext/react\`.
|
|
126
|
+
*/
|
|
127
|
+
export function portableText(value: TextOrRich): readonly PortableTextBlock[] | null {
|
|
128
|
+
if (!isRichText(value)) return null;
|
|
129
|
+
return value.format === PORTABLE_TEXT_FORMAT && Array.isArray(value.value)
|
|
130
|
+
? (value.value as readonly PortableTextBlock[])
|
|
131
|
+
: null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Plain text for attribute contexts \u2014 \`<title>\`, meta description, JSON-LD, \`alt\`. */
|
|
135
|
+
export function plain(value: TextOrRich): string {
|
|
136
|
+
if (typeof value === "string") return value;
|
|
137
|
+
if (!isRichText(value) || typeof value.html !== "string") return "";
|
|
138
|
+
return decodeEntities(value.html.replace(/<[^>]+>/g, "")).trim();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Renderable HTML, for \`set:html\` / \`dangerouslySetInnerHTML\`. Rich text keeps its inline
|
|
143
|
+
* marks (the server sanitizes \`html\` on write); a bare string is escaped, so a plain field
|
|
144
|
+
* can never inject markup. A LONE wrapping block is unwrapped \u2014 a field switched from
|
|
145
|
+
* \`text\` stores \`<p>\u2026</p>\`, and \`<h1><p>\u2026</p></h1>\` is invalid HTML (the parser closes the
|
|
146
|
+
* heading early, dropping the text out of it). Real block structure is left alone.
|
|
147
|
+
*/
|
|
148
|
+
export function rich(value: TextOrRich, fallback = ""): string {
|
|
149
|
+
const html = (
|
|
150
|
+
typeof value === "string" ? escapeHtml(value) : isRichText(value) ? (value.html ?? "") : ""
|
|
151
|
+
).trim();
|
|
152
|
+
return html ? unwrapLoneBlock(html) : escapeHtml(fallback);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function unwrapLoneBlock(html: string): string {
|
|
156
|
+
const m = html.match(/^<(p|div|h[1-6])(?:\\s[^>]*)?>([\\s\\S]*)<\\/\\1>$/i);
|
|
157
|
+
return m && !new RegExp(\`</\${m[1]}>\`, "i").test(m[2]) ? m[2] : html;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function escapeHtml(s: string): string {
|
|
161
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function decodeEntities(s: string): string {
|
|
165
|
+
return s
|
|
166
|
+
.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"')
|
|
167
|
+
.replace(/�?39;/g, "'").replace(/ /g, " ").replace(/&/g, "&");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Image / media field value as stored and returned verbatim by the Delivery API
|
|
172
|
+
* (server-normalized on write to the canonical shape). \`url\` is always present; an
|
|
173
|
+
* unresolved/external value may carry only \`url\`. \`altText\` is the accessibility text
|
|
174
|
+
* for \`<img alt>\`.
|
|
175
|
+
*/
|
|
176
|
+
export interface BetterCMSImage {
|
|
177
|
+
readonly id?: string;
|
|
178
|
+
readonly url: string;
|
|
179
|
+
readonly name?: string;
|
|
180
|
+
readonly altText?: string | null;
|
|
181
|
+
readonly width?: number;
|
|
182
|
+
readonly height?: number;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* A component slot's value, as stored and delivered.
|
|
187
|
+
*
|
|
188
|
+
* \`componentId\` points at a component definition; \`overrides\` are the author's values,
|
|
189
|
+
* keyed by the component's declared prop keys.
|
|
190
|
+
*
|
|
191
|
+
* \`resolved\` is the SNAPSHOT: at publish time the component is resolved (its block tree
|
|
192
|
+
* with the overrides applied) and frozen onto the published value. That is why editing a
|
|
193
|
+
* component does not silently rewrite entries that were already published \u2014 a published
|
|
194
|
+
* entry carries what it was published with until it is published again.
|
|
195
|
+
*
|
|
196
|
+
* Read \`resolved\` when it is there; it is absent on draft-perspective reads, where you
|
|
197
|
+
* should resolve \`componentId\` yourself against the components endpoint.
|
|
198
|
+
*/
|
|
199
|
+
export interface BetterCMSComponentRef {
|
|
200
|
+
readonly componentId: string;
|
|
201
|
+
readonly overrides?: Readonly<Record<string, unknown>>;
|
|
202
|
+
readonly resolved?: readonly unknown[];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* One block in a section zone, as stored and delivered.
|
|
207
|
+
*
|
|
208
|
+
* This is the SAME shape a page's \`blockJson\` holds \u2014 a section zone is a composable page
|
|
209
|
+
* region, so it delivers page blocks, not a shape of its own. Deliberately structural rather
|
|
210
|
+
* than a discriminated union over every block type: the container blocks
|
|
211
|
+
* (\`columns\`, \`section\`, \`slider\`, \`tabs\`) nest \`BetterCMSBlock\` inside \`props\`,
|
|
212
|
+
* and the block vocabulary is server-side and versioned independently of any generated SDK.
|
|
213
|
+
* Narrow on \`type\` at the call site.
|
|
214
|
+
*
|
|
215
|
+
* \`style\` carries the block's design tokens when the author set any.
|
|
216
|
+
*/
|
|
217
|
+
export interface BetterCMSBlock {
|
|
218
|
+
readonly type: string;
|
|
219
|
+
readonly id: string;
|
|
220
|
+
readonly props?: Readonly<Record<string, unknown>>;
|
|
221
|
+
readonly style?: Readonly<Record<string, unknown>>;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Delivery envelope around a model's typed \`data\`. \`getEntry\`/\`listEntries\` in the
|
|
226
|
+
* Next adapter return this shape, with \`fields\` typed by the model.
|
|
227
|
+
*/
|
|
228
|
+
export interface BetterCMSEntry<TFields> {
|
|
229
|
+
readonly slug: string;
|
|
230
|
+
readonly status: "draft" | "published";
|
|
231
|
+
readonly fields: TFields;
|
|
232
|
+
readonly updatedAt: string;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Fully resolved published or preview Layout returned beside a delivered page. */
|
|
236
|
+
export interface BetterCMSLayout {
|
|
237
|
+
readonly version: 1;
|
|
238
|
+
readonly nodes: ReadonlyArray<
|
|
239
|
+
| { readonly kind: "page-content"; readonly id: "page-content" }
|
|
240
|
+
| {
|
|
241
|
+
readonly kind: "section";
|
|
242
|
+
readonly id: string;
|
|
243
|
+
readonly slug: string;
|
|
244
|
+
readonly name: string;
|
|
245
|
+
readonly source: "inherit" | "override-content" | "customize-structure" | "page-only" | "detached";
|
|
246
|
+
}
|
|
247
|
+
>;
|
|
248
|
+
readonly sections: Readonly<Record<string, {
|
|
249
|
+
readonly id: string;
|
|
250
|
+
readonly slug: string;
|
|
251
|
+
readonly name: string;
|
|
252
|
+
/** Headless Section values, including values represented by direct field items. */
|
|
253
|
+
readonly fields: Readonly<Record<string, unknown>>;
|
|
254
|
+
readonly items: ReadonlyArray<
|
|
255
|
+
| { readonly id: string; readonly kind: "field"; readonly fieldId: string; readonly value: unknown }
|
|
256
|
+
| {
|
|
257
|
+
readonly id: string;
|
|
258
|
+
readonly kind: "component";
|
|
259
|
+
readonly componentId: string;
|
|
260
|
+
readonly variantGroupId?: string;
|
|
261
|
+
readonly canonicalInputs: readonly BetterCMSLayoutInput[];
|
|
262
|
+
readonly bindings: ReadonlyArray<{ readonly inputId: string; readonly fieldId: string }>;
|
|
263
|
+
readonly canonicalValues: Readonly<Record<string, unknown>>;
|
|
264
|
+
readonly resolvedProps: Readonly<Record<string, unknown>>;
|
|
265
|
+
readonly blocks: readonly unknown[];
|
|
266
|
+
}
|
|
267
|
+
>;
|
|
268
|
+
}>>;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Recursive canonical input description exposed with each delivered Component item. */
|
|
272
|
+
export interface BetterCMSLayoutInput {
|
|
273
|
+
readonly id: string;
|
|
274
|
+
readonly slug: string;
|
|
275
|
+
readonly label: string;
|
|
276
|
+
readonly type: string;
|
|
277
|
+
readonly required?: boolean;
|
|
278
|
+
readonly defaultValue?: unknown;
|
|
279
|
+
readonly config?: Readonly<Record<string, unknown>>;
|
|
280
|
+
readonly fields?: readonly BetterCMSLayoutInput[];
|
|
281
|
+
}
|
|
282
|
+
`;
|
|
283
|
+
function pascalCase(slug) {
|
|
284
|
+
const parts = slug.split(/[-_\s]+/).filter(Boolean);
|
|
285
|
+
const pascal = parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join("");
|
|
286
|
+
return /^[0-9]/.test(pascal) ? `Model${pascal}` : pascal || "Model";
|
|
287
|
+
}
|
|
288
|
+
function escapeJsDoc(text) {
|
|
289
|
+
return text.replace(/\*\//g, "* /").replace(/[\r\n]+/g, " ").trim();
|
|
290
|
+
}
|
|
291
|
+
var VALID_IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
292
|
+
function propName(key) {
|
|
293
|
+
return VALID_IDENT.test(key) ? key : JSON.stringify(key);
|
|
294
|
+
}
|
|
295
|
+
function scalarType(field) {
|
|
296
|
+
const t = field.type;
|
|
297
|
+
switch (t) {
|
|
298
|
+
case "text":
|
|
299
|
+
return "string";
|
|
300
|
+
case "richtext":
|
|
301
|
+
// A document field stores the SAME {format, value, html} envelope as richtext — the
|
|
302
|
+
// difference is the editor and the placement, not the wire shape. So it maps to the same
|
|
303
|
+
// generated type, and the `doc` rendition lands on `RichText` itself rather than here.
|
|
304
|
+
case "document":
|
|
305
|
+
return "RichText";
|
|
306
|
+
case "image":
|
|
307
|
+
return "BetterCMSImage";
|
|
308
|
+
case "boolean":
|
|
309
|
+
return "boolean";
|
|
310
|
+
case "number":
|
|
311
|
+
return "number";
|
|
312
|
+
case "date":
|
|
313
|
+
case "datetime":
|
|
314
|
+
return "string";
|
|
315
|
+
// ISO 8601
|
|
316
|
+
case "select": {
|
|
317
|
+
const opts = field.options?.filter((o) => typeof o === "string") ?? [];
|
|
318
|
+
return opts.length > 0 ? opts.map((o) => JSON.stringify(o)).join(" | ") : "string";
|
|
319
|
+
}
|
|
320
|
+
case "reference":
|
|
321
|
+
return "string";
|
|
322
|
+
// referenced entry id
|
|
323
|
+
// Both spellings are live (see ContentModelFieldType). Template- and Webflow-seeded
|
|
324
|
+
// models carry the camelCase one; until it was handled here it fell through to the
|
|
325
|
+
// exhaustiveness default, so generated types for every template-created collection
|
|
326
|
+
// typed this field as `unknown` instead of `string[]`.
|
|
327
|
+
case "multi-reference":
|
|
328
|
+
case "multiReference":
|
|
329
|
+
return "string[]";
|
|
330
|
+
// referenced entry ids
|
|
331
|
+
case "array": {
|
|
332
|
+
const itemType = field.config?.itemType ?? "text";
|
|
333
|
+
const inner = itemType === "number" ? "number" : "string";
|
|
334
|
+
return `${inner}[]`;
|
|
335
|
+
}
|
|
336
|
+
// ── Builder scalars ────────────────────────────────────────────────────────
|
|
337
|
+
// All string-shaped on the wire; each is value-validated on write (see
|
|
338
|
+
// src/lib/content/reference-validation.ts), so the generated type is the
|
|
339
|
+
// narrowest thing that is actually true of the stored value.
|
|
340
|
+
case "longtext":
|
|
341
|
+
case "slug":
|
|
342
|
+
case "email":
|
|
343
|
+
case "phone":
|
|
344
|
+
case "link":
|
|
345
|
+
case "color":
|
|
346
|
+
return "string";
|
|
347
|
+
case "json":
|
|
348
|
+
return "unknown";
|
|
349
|
+
case "component-ref":
|
|
350
|
+
return "BetterCMSComponentRef";
|
|
351
|
+
case "modular":
|
|
352
|
+
return "ReadonlyArray<{ readonly __id: string; readonly __type: string; readonly data: Record<string, unknown> }>";
|
|
353
|
+
case "sections": {
|
|
354
|
+
const config = field.config;
|
|
355
|
+
const unresolvedLegacy = config?.mode === "authored-v2" && Object.prototype.hasOwnProperty.call(config, "allowedSections") && config.legacyResolved !== true;
|
|
356
|
+
if (config?.mode === "authored-v2" && !unresolvedLegacy) {
|
|
357
|
+
return "ReadonlyArray<{ readonly __id: string; readonly __section: string; readonly __type: string; readonly data: Record<string, unknown> }>";
|
|
358
|
+
}
|
|
359
|
+
return "ReadonlyArray<BetterCMSBlock>";
|
|
360
|
+
}
|
|
361
|
+
case "location":
|
|
362
|
+
return "{ readonly lat: number; readonly lng: number; readonly label?: string }";
|
|
363
|
+
case "file":
|
|
364
|
+
return "BetterCMSImage";
|
|
365
|
+
default: {
|
|
366
|
+
const _exhaustive = t;
|
|
367
|
+
return "unknown";
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
function arrayZoneType(field, indent) {
|
|
372
|
+
const zones = field.config?.zones;
|
|
373
|
+
const parts = [];
|
|
374
|
+
if (zones?.nonRepeatable?.length) {
|
|
375
|
+
const nested = fieldsToBody(zones.nonRepeatable, indent + " ");
|
|
376
|
+
parts.push(`${indent} readonly nonRepeatable?: {
|
|
377
|
+
${nested}
|
|
378
|
+
${indent} };`);
|
|
379
|
+
}
|
|
380
|
+
if (zones?.repeatable?.fields?.length) {
|
|
381
|
+
const nested = fieldsToBody(zones.repeatable.fields, indent + " ");
|
|
382
|
+
parts.push(`${indent} readonly repeatable?: Array<{
|
|
383
|
+
${nested}
|
|
384
|
+
${indent} }>;`);
|
|
385
|
+
}
|
|
386
|
+
if (parts.length === 0) return "Record<string, unknown>";
|
|
387
|
+
return `{
|
|
388
|
+
${parts.join("\n")}
|
|
389
|
+
${indent}}`;
|
|
390
|
+
}
|
|
391
|
+
function fieldsToBody(fields, indent) {
|
|
392
|
+
const lines = [];
|
|
393
|
+
for (const field of fields) {
|
|
394
|
+
const optional = field.required ? "" : "?";
|
|
395
|
+
let typeExpr;
|
|
396
|
+
if (field.type === "array" && field.config?.zones) {
|
|
397
|
+
typeExpr = arrayZoneType(field, indent);
|
|
398
|
+
} else {
|
|
399
|
+
typeExpr = scalarType(field);
|
|
400
|
+
}
|
|
401
|
+
const safeLabel = field.label ? escapeJsDoc(field.label) : "";
|
|
402
|
+
if (safeLabel && safeLabel !== field.key) {
|
|
403
|
+
lines.push(`${indent}/** ${safeLabel} */`);
|
|
404
|
+
}
|
|
405
|
+
lines.push(`${indent}readonly ${propName(field.key)}${optional}: ${typeExpr};`);
|
|
406
|
+
}
|
|
407
|
+
return lines.join("\n");
|
|
408
|
+
}
|
|
409
|
+
function generateTypes(models, opts = {}) {
|
|
410
|
+
const version = opts.version ?? "0.1.0";
|
|
411
|
+
const sorted = [...models].sort(
|
|
412
|
+
(a, b) => a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0
|
|
413
|
+
);
|
|
414
|
+
const header = `// \u26A0\uFE0F AUTO-GENERATED by @bettercms-ai/codegen v${version} \u2014 DO NOT EDIT.
|
|
415
|
+
// Regenerate with: npx @bettercms-ai/codegen
|
|
416
|
+
// Source of truth: your BetterCMS content models (the same schema the dashboard
|
|
417
|
+
// builder and the MCP tools write). Re-run codegen after any schema change.
|
|
418
|
+
${opts.bannerComment ? `// ${opts.bannerComment}
|
|
419
|
+
` : ""}`;
|
|
420
|
+
const interfaces = [];
|
|
421
|
+
const mapEntries = [];
|
|
422
|
+
const usedNames = /* @__PURE__ */ new Set();
|
|
423
|
+
for (const model of sorted) {
|
|
424
|
+
const base = `${pascalCase(model.slug)}Fields`;
|
|
425
|
+
let typeName = base;
|
|
426
|
+
for (let n = 2; usedNames.has(typeName); n++) typeName = `${base}_${n}`;
|
|
427
|
+
usedNames.add(typeName);
|
|
428
|
+
const name = model.name ? escapeJsDoc(model.name) : "";
|
|
429
|
+
const desc = model.description ? escapeJsDoc(model.description) : "";
|
|
430
|
+
const doc = name ? `/**
|
|
431
|
+
* ${name}${desc ? ` \u2014 ${desc}` : ""}
|
|
432
|
+
* Model slug: \`${model.slug}\`
|
|
433
|
+
*/
|
|
434
|
+
` : "";
|
|
435
|
+
const body = model.fields.length ? fieldsToBody(model.fields, " ") : " // (no fields defined yet)";
|
|
436
|
+
interfaces.push(`${doc}export interface ${typeName} {
|
|
437
|
+
${body}
|
|
438
|
+
}`);
|
|
439
|
+
mapEntries.push(` readonly ${JSON.stringify(model.slug)}: ${typeName};`);
|
|
440
|
+
}
|
|
441
|
+
const schemaMap = `/**
|
|
442
|
+
* Registry mapping each model slug to its typed fields. The Next adapter uses this to
|
|
443
|
+
* type \`getEntry("blog", ...)\` by slug \u2014 autocomplete and exhaustiveness for free.
|
|
444
|
+
*/
|
|
445
|
+
export interface BetterCMSSchema {
|
|
446
|
+
${mapEntries.join("\n") || " // (no models defined yet)"}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Union of all model slugs. */
|
|
450
|
+
export type BetterCMSModelSlug = keyof BetterCMSSchema;`;
|
|
451
|
+
return [header, PREAMBLE, interfaces.join("\n\n"), schemaMap, ""].join("\n");
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// src/bindings.ts
|
|
455
|
+
var VALID_IDENT2 = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
456
|
+
function propName2(key) {
|
|
457
|
+
return VALID_IDENT2.test(key) ? key : JSON.stringify(key);
|
|
458
|
+
}
|
|
459
|
+
function bindingKind(t) {
|
|
460
|
+
switch (t) {
|
|
461
|
+
case "text":
|
|
462
|
+
case "richtext":
|
|
463
|
+
case "image":
|
|
464
|
+
case "boolean":
|
|
465
|
+
case "number":
|
|
466
|
+
case "select":
|
|
467
|
+
case "array":
|
|
468
|
+
return t;
|
|
469
|
+
case "document":
|
|
470
|
+
return "richtext";
|
|
471
|
+
// reference / multi-reference / date / datetime → plain text in the editor v1.
|
|
472
|
+
default:
|
|
473
|
+
return "text";
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
function indexedPath(prefix, suffix) {
|
|
477
|
+
return `${JSON.stringify(prefix)} + i + ${JSON.stringify(suffix)}`;
|
|
478
|
+
}
|
|
479
|
+
function repeaterBinding(itemFields, path, indent) {
|
|
480
|
+
const lines = [
|
|
481
|
+
`${indent} $: (i: number) => bcmsField(${indexedPath(`${path}[`, "]")}, "array"),`
|
|
482
|
+
];
|
|
483
|
+
for (const sub of itemFields) {
|
|
484
|
+
if (sub.type === "array") continue;
|
|
485
|
+
lines.push(
|
|
486
|
+
`${indent} ${propName2(sub.key)}: (i: number) => bcmsField(${indexedPath(`${path}[`, `].${sub.key}`)}, ${JSON.stringify(bindingKind(sub.type))}),`
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
return `{
|
|
490
|
+
${lines.join("\n")}
|
|
491
|
+
${indent}}`;
|
|
492
|
+
}
|
|
493
|
+
function fieldBinding(field, prefix, indent) {
|
|
494
|
+
const path = prefix ? `${prefix}.${field.key}` : field.key;
|
|
495
|
+
const name = propName2(field.key);
|
|
496
|
+
if (field.type !== "array") {
|
|
497
|
+
return `${indent}${name}: bcmsField(${JSON.stringify(path)}, ${JSON.stringify(bindingKind(field.type))}),`;
|
|
498
|
+
}
|
|
499
|
+
const zones = field.config?.zones;
|
|
500
|
+
if (zones?.nonRepeatable?.length) {
|
|
501
|
+
const body = zones.nonRepeatable.map((child) => fieldBinding(child, path, `${indent} `)).join("\n");
|
|
502
|
+
return `${indent}${name}: {
|
|
503
|
+
${body}
|
|
504
|
+
${indent}},`;
|
|
505
|
+
}
|
|
506
|
+
if (zones?.repeatable?.fields?.length) {
|
|
507
|
+
return `${indent}${name}: ${repeaterBinding(zones.repeatable.fields, path, indent)},`;
|
|
508
|
+
}
|
|
509
|
+
const lines = [
|
|
510
|
+
`${indent} $: (i: number) => bcmsField(${indexedPath(`${path}[`, "]")}, "array"),`,
|
|
511
|
+
`${indent} value: (i: number) => bcmsField(${indexedPath(`${path}[`, "].value")}, "text"),`
|
|
512
|
+
];
|
|
513
|
+
return `${indent}${name}: {
|
|
514
|
+
${lines.join("\n")}
|
|
515
|
+
${indent}},`;
|
|
516
|
+
}
|
|
517
|
+
function fieldsToBindings(fields, indent) {
|
|
518
|
+
return fields.map((field) => fieldBinding(field, "", indent)).join("\n");
|
|
519
|
+
}
|
|
520
|
+
var PREAMBLE2 = `/**
|
|
521
|
+
* Binding attributes for a CMS-bound element. Spread onto the element that renders a
|
|
522
|
+
* field: \`<h1 {...bcmsField("title", "text")}>\`. Always emitted, on every build \u2014
|
|
523
|
+
* two inert \`data-*\` attributes are what makes the site editable in Live Preview.
|
|
524
|
+
*/
|
|
525
|
+
export function bcmsField(path: string, kind: string): Record<string, string> {
|
|
526
|
+
return { "data-bcms-field": path, "data-bcms-kind": kind };
|
|
527
|
+
}
|
|
528
|
+
`;
|
|
529
|
+
function generateBindings(models, opts = {}) {
|
|
530
|
+
const version = opts.version ?? "0.1.0";
|
|
531
|
+
const sorted = [...models].sort(
|
|
532
|
+
(a, b) => a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0
|
|
533
|
+
);
|
|
534
|
+
const header = `// \u26A0\uFE0F AUTO-GENERATED by @bettercms-ai/codegen v${version} \u2014 DO NOT EDIT.
|
|
535
|
+
// Live Preview field bindings. Regenerate with: npx @bettercms-ai/codegen --bindings-out <path>
|
|
536
|
+
// Spread these onto the elements that render your content; they emit
|
|
537
|
+
// data-bcms-field/data-bcms-kind on every build.
|
|
538
|
+
${opts.bannerComment ? `// ${opts.bannerComment}
|
|
539
|
+
` : ""}`;
|
|
540
|
+
const entries = sorted.map((model) => {
|
|
541
|
+
const body = model.fields.length ? `
|
|
542
|
+
${fieldsToBindings(model.fields, " ")}
|
|
543
|
+
` : "";
|
|
544
|
+
return ` ${JSON.stringify(model.slug)}: {${body}},`;
|
|
545
|
+
});
|
|
546
|
+
const bcms = `/**
|
|
547
|
+
* Field bindings keyed by model slug. Spread a binding onto the element that renders
|
|
548
|
+
* that field. Arrays expose \`$(i)\` for the item element and one accessor per
|
|
549
|
+
* (one-level) sub-field; primitive arrays expose \`value(i)\` for the item's scalar.
|
|
550
|
+
*/
|
|
551
|
+
export const bcms = {
|
|
552
|
+
${entries.join("\n") || " // (no models defined yet)"}
|
|
553
|
+
} as const;`;
|
|
554
|
+
return [header, PREAMBLE2, bcms, ""].join("\n");
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// src/components.ts
|
|
558
|
+
function generateComponents(opts = {}) {
|
|
559
|
+
const version = opts.version ?? "0.1.0";
|
|
560
|
+
const header = `// \u26A0\uFE0F AUTO-GENERATED by @bettercms-ai/codegen v${version} \u2014 DO NOT EDIT.
|
|
561
|
+
// Regenerate with: npx @bettercms-ai/codegen --components-out <path>
|
|
562
|
+
// Typed render components for BetterCMS field shapes. Use these instead of
|
|
563
|
+
// hand-rendering richtext/image values \u2014 they render the canonical shapes correctly.
|
|
564
|
+
${opts.bannerComment ? `// ${opts.bannerComment}
|
|
565
|
+
` : ""}`;
|
|
566
|
+
const body = `import * as React from "react";
|
|
567
|
+
|
|
568
|
+
/** Rich-text value from the Delivery API. \`html\` is server-rendered + sanitized. */
|
|
569
|
+
export type RichTextValue = {
|
|
570
|
+
readonly format: string;
|
|
571
|
+
readonly value: unknown;
|
|
572
|
+
readonly html?: string;
|
|
573
|
+
/**
|
|
574
|
+
* Structured blocks \u2014 Body (\`document\`) fields only, and optional even there: derived at
|
|
575
|
+
* write time, so entries saved before it existed carry none until re-saved. No backfill.
|
|
576
|
+
* Block ids are stable within one document only, never a global key.
|
|
577
|
+
*/
|
|
578
|
+
readonly doc?: { readonly version: 1; readonly blocks: readonly { readonly id: string; readonly type: string; readonly [k: string]: unknown }[] };
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
/** Normalized image/media value from the Delivery API. */
|
|
582
|
+
export interface BetterCMSImageValue {
|
|
583
|
+
readonly url: string;
|
|
584
|
+
readonly altText?: string | null;
|
|
585
|
+
readonly width?: number;
|
|
586
|
+
readonly height?: number;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
type RichTextProps = {
|
|
590
|
+
/** The richtext field value (\`entry.fields.someRichText\`). */
|
|
591
|
+
field?: RichTextValue | null;
|
|
592
|
+
/** Element/component to render as. Default: \`"div"\`. */
|
|
593
|
+
as?: React.ElementType;
|
|
594
|
+
} & Omit<React.HTMLAttributes<HTMLElement>, "dangerouslySetInnerHTML" | "children">;
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Render a richtext field as HTML. Uses the server-sanitized \`html\` via
|
|
598
|
+
* \`dangerouslySetInnerHTML\` \u2014 NEVER interpolate a richtext value as a JSX child
|
|
599
|
+
* (React escapes it, so the page shows literal tags). Renders nothing when unset.
|
|
600
|
+
*/
|
|
601
|
+
export function RichText({ field, as: Tag = "div", ...rest }: RichTextProps) {
|
|
602
|
+
if (!field || !field.html) return null;
|
|
603
|
+
return <Tag {...rest} dangerouslySetInnerHTML={{ __html: field.html }} />;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
type ImageProps = {
|
|
607
|
+
/** The image field value (\`entry.fields.someImage\`). */
|
|
608
|
+
field?: BetterCMSImageValue | null;
|
|
609
|
+
/** Alt text override; defaults to the field's \`altText\`, then \`""\`. */
|
|
610
|
+
alt?: string;
|
|
611
|
+
} & Omit<React.ImgHTMLAttributes<HTMLImageElement>, "src">;
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Render an image field as an \`<img>\` from its normalized \`.url\`/\`.altText\`.
|
|
615
|
+
* Renders nothing when unset. Pass \`alt\` to override the stored alt text.
|
|
616
|
+
*/
|
|
617
|
+
export function Image({ field, alt, ...rest }: ImageProps) {
|
|
618
|
+
if (!field || !field.url) return null;
|
|
619
|
+
return (
|
|
620
|
+
<img
|
|
621
|
+
src={field.url}
|
|
622
|
+
alt={alt ?? field.altText ?? ""}
|
|
623
|
+
width={field.width}
|
|
624
|
+
height={field.height}
|
|
625
|
+
{...rest}
|
|
626
|
+
/>
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
`;
|
|
630
|
+
return [header, body].join("\n");
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// src/cli.ts
|
|
634
|
+
var VERSION = "0.2.0";
|
|
635
|
+
var DEFAULT_API_URL = "https://api.bettercms.ai/api/v1";
|
|
636
|
+
var DEFAULT_OUT = "bettercms.generated.ts";
|
|
637
|
+
function parseArgs(argv) {
|
|
638
|
+
const args = {
|
|
639
|
+
apiUrl: process.env.BETTERCMS_API_URL ?? DEFAULT_API_URL,
|
|
640
|
+
apiKey: process.env.BETTERCMS_API_KEY,
|
|
641
|
+
out: DEFAULT_OUT,
|
|
642
|
+
bindingsOut: void 0,
|
|
643
|
+
componentsOut: void 0,
|
|
644
|
+
help: false
|
|
645
|
+
};
|
|
646
|
+
for (let i = 0; i < argv.length; i++) {
|
|
647
|
+
const arg = argv[i];
|
|
648
|
+
const next = () => argv[++i];
|
|
649
|
+
switch (arg) {
|
|
650
|
+
case "--api-url":
|
|
651
|
+
args.apiUrl = next() ?? args.apiUrl;
|
|
652
|
+
break;
|
|
653
|
+
case "--key":
|
|
654
|
+
case "--api-key":
|
|
655
|
+
args.apiKey = next();
|
|
656
|
+
break;
|
|
657
|
+
case "--out":
|
|
658
|
+
case "-o":
|
|
659
|
+
args.out = next() ?? args.out;
|
|
660
|
+
break;
|
|
661
|
+
case "--bindings-out":
|
|
662
|
+
args.bindingsOut = next();
|
|
663
|
+
break;
|
|
664
|
+
case "--components-out":
|
|
665
|
+
args.componentsOut = next();
|
|
666
|
+
break;
|
|
667
|
+
case "--help":
|
|
668
|
+
case "-h":
|
|
669
|
+
args.help = true;
|
|
670
|
+
break;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
return args;
|
|
674
|
+
}
|
|
675
|
+
var HELP = `bettercms-codegen v${VERSION} \u2014 generate TypeScript types from your BetterCMS schema
|
|
676
|
+
|
|
677
|
+
Usage:
|
|
678
|
+
npx @bettercms-ai/codegen [options]
|
|
679
|
+
|
|
680
|
+
Options:
|
|
681
|
+
-o, --out <path> Output file (default: ${DEFAULT_OUT})
|
|
682
|
+
--bindings-out <path> Also emit the Live Preview bindings module to <path>
|
|
683
|
+
--components-out <path> Also emit typed <RichText>/<Image> React components (.tsx) to <path>
|
|
684
|
+
--api-url <url> Management API base (default: ${DEFAULT_API_URL})
|
|
685
|
+
--key <key> Management API key (or set BETTERCMS_API_KEY)
|
|
686
|
+
-h, --help Show this help
|
|
687
|
+
|
|
688
|
+
Env:
|
|
689
|
+
BETTERCMS_API_KEY Management-scoped key (content:manage)
|
|
690
|
+
BETTERCMS_API_URL Override the API base
|
|
691
|
+
`;
|
|
692
|
+
async function main() {
|
|
693
|
+
const args = parseArgs(process.argv.slice(2));
|
|
694
|
+
if (args.help) {
|
|
695
|
+
process.stdout.write(HELP);
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
if (!args.apiKey) {
|
|
699
|
+
process.stderr.write(
|
|
700
|
+
"error: no API key. Pass --key <key> or set BETTERCMS_API_KEY.\n"
|
|
701
|
+
);
|
|
702
|
+
process.exit(1);
|
|
703
|
+
}
|
|
704
|
+
const models = await fetchModels({ apiUrl: args.apiUrl, apiKey: args.apiKey });
|
|
705
|
+
const outPath = resolve(process.cwd(), args.out);
|
|
706
|
+
await mkdir(dirname(outPath), { recursive: true });
|
|
707
|
+
await writeFile(outPath, generateTypes(models, { version: VERSION }), "utf8");
|
|
708
|
+
const plural = models.length === 1 ? "" : "s";
|
|
709
|
+
process.stdout.write(
|
|
710
|
+
`\u2713 Generated ${models.length} model type${plural} \u2192 ${args.out}
|
|
711
|
+
`
|
|
712
|
+
);
|
|
713
|
+
if (args.bindingsOut) {
|
|
714
|
+
const bindingsPath = resolve(process.cwd(), args.bindingsOut);
|
|
715
|
+
await mkdir(dirname(bindingsPath), { recursive: true });
|
|
716
|
+
await writeFile(bindingsPath, generateBindings(models, { version: VERSION }), "utf8");
|
|
717
|
+
process.stdout.write(`\u2713 Generated Live Preview bindings \u2192 ${args.bindingsOut}
|
|
718
|
+
`);
|
|
719
|
+
}
|
|
720
|
+
if (args.componentsOut) {
|
|
721
|
+
const componentsPath = resolve(process.cwd(), args.componentsOut);
|
|
722
|
+
await mkdir(dirname(componentsPath), { recursive: true });
|
|
723
|
+
await writeFile(componentsPath, generateComponents({ version: VERSION }), "utf8");
|
|
724
|
+
process.stdout.write(`\u2713 Generated render components \u2192 ${args.componentsOut}
|
|
725
|
+
`);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
main().catch((err) => {
|
|
729
|
+
process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}
|
|
730
|
+
`);
|
|
731
|
+
process.exit(1);
|
|
732
|
+
});
|
|
733
|
+
//# sourceMappingURL=cli.js.map
|