@bettercms-ai/codegen 0.5.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 +102 -0
- package/dist/cli.js +501 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +131 -0
- package/dist/index.js +400 -0
- package/dist/index.js.map +1 -0
- package/package.json +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# @bettercms-ai/codegen
|
|
2
|
+
|
|
3
|
+
Generate TypeScript types from your BetterCMS content schema.
|
|
4
|
+
|
|
5
|
+
Your models are defined once — in the dashboard schema builder **or** by your AI through
|
|
6
|
+
the MCP tools. Both write the same underlying field schema, so the types this generates can
|
|
7
|
+
never drift from what your editors and agents actually create. This is the **deterministic
|
|
8
|
+
floor** under the AI integration: even if an agent mis-wires something, you still have exact,
|
|
9
|
+
committed types to fall back on.
|
|
10
|
+
|
|
11
|
+
## Use it
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# In your site repo (key from the dashboard → Settings → API Keys, content:manage scope)
|
|
15
|
+
BETTERCMS_API_KEY=bcms_xxx npx @bettercms-ai/codegen --out src/bettercms.generated.ts
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
✓ Generated 2 model types → src/bettercms.generated.ts
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Commit the generated file and re-run codegen after any schema change (a CI step or the
|
|
23
|
+
BetterCMS GitHub Action does this for you on every build).
|
|
24
|
+
|
|
25
|
+
### Options
|
|
26
|
+
|
|
27
|
+
| Flag | Env | Default |
|
|
28
|
+
|------|-----|---------|
|
|
29
|
+
| `--out, -o` | — | `bettercms.generated.ts` |
|
|
30
|
+
| `--bindings-out` | — | _(off)_ — also emit Live Preview bindings to this path |
|
|
31
|
+
| `--api-url` | `BETTERCMS_API_URL` | `https://api.bettercms.ai/api/v1` |
|
|
32
|
+
| `--key` | `BETTERCMS_API_KEY` | — |
|
|
33
|
+
|
|
34
|
+
## What it emits
|
|
35
|
+
|
|
36
|
+
For a `blog` model with `title` (text, required), `body` (richtext), `hero` (group):
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
export interface BlogFields {
|
|
40
|
+
readonly title: string;
|
|
41
|
+
readonly body?: RichText;
|
|
42
|
+
readonly hero?: {
|
|
43
|
+
readonly heading: string;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface BetterCMSSchema {
|
|
48
|
+
readonly "blog": BlogFields;
|
|
49
|
+
}
|
|
50
|
+
export type BetterCMSModelSlug = keyof BetterCMSSchema;
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The `BetterCMSSchema` registry lets the Next.js adapter type `getEntry("blog", …)` by slug.
|
|
54
|
+
|
|
55
|
+
## Field type → TypeScript
|
|
56
|
+
|
|
57
|
+
| Field | TS |
|
|
58
|
+
|-------|-----|
|
|
59
|
+
| text · date · datetime | `string` |
|
|
60
|
+
| richtext | `RichText` |
|
|
61
|
+
| image | `BetterCMSImage` |
|
|
62
|
+
| boolean / number | `boolean` / `number` |
|
|
63
|
+
| select | `"a" \| "b"` (or `string`) |
|
|
64
|
+
| reference / multi-reference | `string` / `string[]` (entry ids) |
|
|
65
|
+
| array | `string[]` / `number[]` |
|
|
66
|
+
| group | `{ …nested }` |
|
|
67
|
+
| repeater | `Array<{ …nested }>` |
|
|
68
|
+
|
|
69
|
+
## Live Preview bindings (`--bindings-out`)
|
|
70
|
+
|
|
71
|
+
Pass `--bindings-out src/bettercms.bindings.generated.ts` to also emit a tiny, schema-derived
|
|
72
|
+
helper that powers the dashboard's **Live Preview** editor. Spread a binding onto the element
|
|
73
|
+
that renders each field — the editor reads the resulting `data-bcms-field` attribute to make the
|
|
74
|
+
real, running site editable in place:
|
|
75
|
+
|
|
76
|
+
```tsx
|
|
77
|
+
import { bcms } from "./bettercms.bindings.generated";
|
|
78
|
+
|
|
79
|
+
<h1 {...bcms.blog.title}>{entry.fields.title}</h1> // scalar
|
|
80
|
+
<li {...bcms.blog.tags.value(i)}>{tag}</li> // primitive-array item
|
|
81
|
+
<article {...bcms.blog.features.$(i)}> // array item root
|
|
82
|
+
<h3 {...bcms.blog.features.label(i)}>{f.label}</h3> // array item sub-field
|
|
83
|
+
</article>
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The attributes are emitted **only** when the site is built with `BCMS_ANNOTATE` set (preview
|
|
87
|
+
builds); a normal production build ships zero extra attributes (`bcmsField` returns `{}`). Same
|
|
88
|
+
generated file for both — no separate mode. Bindings follow the editor's one-level path grammar
|
|
89
|
+
(`field`, `field[i]`, `field[i].sub`); non-repeatable zones and deeper nesting aren't
|
|
90
|
+
index-addressable yet, so they're omitted rather than emitted as paths that can't bind.
|
|
91
|
+
|
|
92
|
+
## Library API
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { generateTypes, generateBindings, fetchModels } from "@bettercms-ai/codegen";
|
|
96
|
+
|
|
97
|
+
const models = await fetchModels({ apiUrl, apiKey });
|
|
98
|
+
const types = generateTypes(models); // pure, deterministic
|
|
99
|
+
const bindings = generateBindings(models); // pure, deterministic (Live Preview)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`generateTypes` and `generateBindings` are pure and deterministic — same models in, byte-identical output out.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
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
|
+
export type RichText = {
|
|
51
|
+
readonly format: string;
|
|
52
|
+
readonly value: unknown;
|
|
53
|
+
readonly html?: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Image / media field value as stored and returned verbatim by the Delivery API
|
|
58
|
+
* (server-normalized on write to the canonical shape). \`url\` is always present; an
|
|
59
|
+
* unresolved/external value may carry only \`url\`. \`altText\` is the accessibility text
|
|
60
|
+
* for \`<img alt>\`.
|
|
61
|
+
*/
|
|
62
|
+
export interface BetterCMSImage {
|
|
63
|
+
readonly id?: string;
|
|
64
|
+
readonly url: string;
|
|
65
|
+
readonly name?: string;
|
|
66
|
+
readonly altText?: string | null;
|
|
67
|
+
readonly width?: number;
|
|
68
|
+
readonly height?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Delivery envelope around a model's typed \`data\`. \`getEntry\`/\`listEntries\` in the
|
|
73
|
+
* Next adapter return this shape, with \`fields\` typed by the model.
|
|
74
|
+
*/
|
|
75
|
+
export interface BetterCMSEntry<TFields> {
|
|
76
|
+
readonly slug: string;
|
|
77
|
+
readonly status: "draft" | "published";
|
|
78
|
+
readonly fields: TFields;
|
|
79
|
+
readonly updatedAt: string;
|
|
80
|
+
}
|
|
81
|
+
`;
|
|
82
|
+
function pascalCase(slug) {
|
|
83
|
+
const parts = slug.split(/[-_\s]+/).filter(Boolean);
|
|
84
|
+
const pascal = parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join("");
|
|
85
|
+
return /^[0-9]/.test(pascal) ? `Model${pascal}` : pascal || "Model";
|
|
86
|
+
}
|
|
87
|
+
function escapeJsDoc(text) {
|
|
88
|
+
return text.replace(/\*\//g, "* /").replace(/[\r\n]+/g, " ").trim();
|
|
89
|
+
}
|
|
90
|
+
var VALID_IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
91
|
+
function propName(key) {
|
|
92
|
+
return VALID_IDENT.test(key) ? key : JSON.stringify(key);
|
|
93
|
+
}
|
|
94
|
+
function scalarType(field) {
|
|
95
|
+
const t = field.type;
|
|
96
|
+
switch (t) {
|
|
97
|
+
case "text":
|
|
98
|
+
return "string";
|
|
99
|
+
case "richtext":
|
|
100
|
+
return "RichText";
|
|
101
|
+
case "image":
|
|
102
|
+
return "BetterCMSImage";
|
|
103
|
+
case "boolean":
|
|
104
|
+
return "boolean";
|
|
105
|
+
case "number":
|
|
106
|
+
return "number";
|
|
107
|
+
case "date":
|
|
108
|
+
case "datetime":
|
|
109
|
+
return "string";
|
|
110
|
+
// ISO 8601
|
|
111
|
+
case "select": {
|
|
112
|
+
const opts = field.options?.filter((o) => typeof o === "string") ?? [];
|
|
113
|
+
return opts.length > 0 ? opts.map((o) => JSON.stringify(o)).join(" | ") : "string";
|
|
114
|
+
}
|
|
115
|
+
case "reference":
|
|
116
|
+
return "string";
|
|
117
|
+
// referenced entry id
|
|
118
|
+
case "multi-reference":
|
|
119
|
+
return "string[]";
|
|
120
|
+
// referenced entry ids
|
|
121
|
+
case "array": {
|
|
122
|
+
const itemType = field.config?.itemType ?? "text";
|
|
123
|
+
const inner = itemType === "number" ? "number" : "string";
|
|
124
|
+
return `${inner}[]`;
|
|
125
|
+
}
|
|
126
|
+
default: {
|
|
127
|
+
const _exhaustive = t;
|
|
128
|
+
return "unknown";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function arrayZoneType(field, indent) {
|
|
133
|
+
const zones = field.config?.zones;
|
|
134
|
+
const parts = [];
|
|
135
|
+
if (zones?.nonRepeatable?.length) {
|
|
136
|
+
const nested = fieldsToBody(zones.nonRepeatable, indent + " ");
|
|
137
|
+
parts.push(`${indent} readonly nonRepeatable?: {
|
|
138
|
+
${nested}
|
|
139
|
+
${indent} };`);
|
|
140
|
+
}
|
|
141
|
+
if (zones?.repeatable?.fields?.length) {
|
|
142
|
+
const nested = fieldsToBody(zones.repeatable.fields, indent + " ");
|
|
143
|
+
parts.push(`${indent} readonly repeatable?: Array<{
|
|
144
|
+
${nested}
|
|
145
|
+
${indent} }>;`);
|
|
146
|
+
}
|
|
147
|
+
if (parts.length === 0) return "Record<string, unknown>";
|
|
148
|
+
return `{
|
|
149
|
+
${parts.join("\n")}
|
|
150
|
+
${indent}}`;
|
|
151
|
+
}
|
|
152
|
+
function fieldsToBody(fields, indent) {
|
|
153
|
+
const lines = [];
|
|
154
|
+
for (const field of fields) {
|
|
155
|
+
const optional = field.required ? "" : "?";
|
|
156
|
+
let typeExpr;
|
|
157
|
+
if (field.type === "array" && field.config?.zones) {
|
|
158
|
+
typeExpr = arrayZoneType(field, indent);
|
|
159
|
+
} else {
|
|
160
|
+
typeExpr = scalarType(field);
|
|
161
|
+
}
|
|
162
|
+
const safeLabel = field.label ? escapeJsDoc(field.label) : "";
|
|
163
|
+
if (safeLabel && safeLabel !== field.key) {
|
|
164
|
+
lines.push(`${indent}/** ${safeLabel} */`);
|
|
165
|
+
}
|
|
166
|
+
lines.push(`${indent}readonly ${propName(field.key)}${optional}: ${typeExpr};`);
|
|
167
|
+
}
|
|
168
|
+
return lines.join("\n");
|
|
169
|
+
}
|
|
170
|
+
function generateTypes(models, opts = {}) {
|
|
171
|
+
const version = opts.version ?? "0.1.0";
|
|
172
|
+
const sorted = [...models].sort(
|
|
173
|
+
(a, b) => a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0
|
|
174
|
+
);
|
|
175
|
+
const header = `// \u26A0\uFE0F AUTO-GENERATED by @bettercms-ai/codegen v${version} \u2014 DO NOT EDIT.
|
|
176
|
+
// Regenerate with: npx @bettercms-ai/codegen
|
|
177
|
+
// Source of truth: your BetterCMS content models (the same schema the dashboard
|
|
178
|
+
// builder and the MCP tools write). Re-run codegen after any schema change.
|
|
179
|
+
${opts.bannerComment ? `// ${opts.bannerComment}
|
|
180
|
+
` : ""}`;
|
|
181
|
+
const interfaces = [];
|
|
182
|
+
const mapEntries = [];
|
|
183
|
+
const usedNames = /* @__PURE__ */ new Set();
|
|
184
|
+
for (const model of sorted) {
|
|
185
|
+
const base = `${pascalCase(model.slug)}Fields`;
|
|
186
|
+
let typeName = base;
|
|
187
|
+
for (let n = 2; usedNames.has(typeName); n++) typeName = `${base}_${n}`;
|
|
188
|
+
usedNames.add(typeName);
|
|
189
|
+
const name = model.name ? escapeJsDoc(model.name) : "";
|
|
190
|
+
const desc = model.description ? escapeJsDoc(model.description) : "";
|
|
191
|
+
const doc = name ? `/**
|
|
192
|
+
* ${name}${desc ? ` \u2014 ${desc}` : ""}
|
|
193
|
+
* Model slug: \`${model.slug}\`
|
|
194
|
+
*/
|
|
195
|
+
` : "";
|
|
196
|
+
const body = model.fields.length ? fieldsToBody(model.fields, " ") : " // (no fields defined yet)";
|
|
197
|
+
interfaces.push(`${doc}export interface ${typeName} {
|
|
198
|
+
${body}
|
|
199
|
+
}`);
|
|
200
|
+
mapEntries.push(` readonly ${JSON.stringify(model.slug)}: ${typeName};`);
|
|
201
|
+
}
|
|
202
|
+
const schemaMap = `/**
|
|
203
|
+
* Registry mapping each model slug to its typed fields. The Next adapter uses this to
|
|
204
|
+
* type \`getEntry("blog", ...)\` by slug \u2014 autocomplete and exhaustiveness for free.
|
|
205
|
+
*/
|
|
206
|
+
export interface BetterCMSSchema {
|
|
207
|
+
${mapEntries.join("\n") || " // (no models defined yet)"}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Union of all model slugs. */
|
|
211
|
+
export type BetterCMSModelSlug = keyof BetterCMSSchema;`;
|
|
212
|
+
return [header, PREAMBLE, interfaces.join("\n\n"), schemaMap, ""].join("\n");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// src/bindings.ts
|
|
216
|
+
var VALID_IDENT2 = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
217
|
+
function propName2(key) {
|
|
218
|
+
return VALID_IDENT2.test(key) ? key : JSON.stringify(key);
|
|
219
|
+
}
|
|
220
|
+
function bindingKind(t) {
|
|
221
|
+
switch (t) {
|
|
222
|
+
case "text":
|
|
223
|
+
case "richtext":
|
|
224
|
+
case "image":
|
|
225
|
+
case "boolean":
|
|
226
|
+
case "number":
|
|
227
|
+
case "select":
|
|
228
|
+
case "array":
|
|
229
|
+
return t;
|
|
230
|
+
// reference / multi-reference / date / datetime → plain text in the editor v1.
|
|
231
|
+
default:
|
|
232
|
+
return "text";
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function indexedPath(prefix, suffix) {
|
|
236
|
+
return `${JSON.stringify(prefix)} + i + ${JSON.stringify(suffix)}`;
|
|
237
|
+
}
|
|
238
|
+
function repeaterBinding(itemFields, path, indent) {
|
|
239
|
+
const lines = [
|
|
240
|
+
`${indent} $: (i: number) => bcmsField(${indexedPath(`${path}[`, "]")}, "array"),`
|
|
241
|
+
];
|
|
242
|
+
for (const sub of itemFields) {
|
|
243
|
+
if (sub.type === "array") continue;
|
|
244
|
+
lines.push(
|
|
245
|
+
`${indent} ${propName2(sub.key)}: (i: number) => bcmsField(${indexedPath(`${path}[`, `].${sub.key}`)}, ${JSON.stringify(bindingKind(sub.type))}),`
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
return `{
|
|
249
|
+
${lines.join("\n")}
|
|
250
|
+
${indent}}`;
|
|
251
|
+
}
|
|
252
|
+
function fieldBinding(field, prefix, indent) {
|
|
253
|
+
const path = prefix ? `${prefix}.${field.key}` : field.key;
|
|
254
|
+
const name = propName2(field.key);
|
|
255
|
+
if (field.type !== "array") {
|
|
256
|
+
return `${indent}${name}: bcmsField(${JSON.stringify(path)}, ${JSON.stringify(bindingKind(field.type))}),`;
|
|
257
|
+
}
|
|
258
|
+
const zones = field.config?.zones;
|
|
259
|
+
if (zones?.nonRepeatable?.length) {
|
|
260
|
+
const body = zones.nonRepeatable.map((child) => fieldBinding(child, path, `${indent} `)).join("\n");
|
|
261
|
+
return `${indent}${name}: {
|
|
262
|
+
${body}
|
|
263
|
+
${indent}},`;
|
|
264
|
+
}
|
|
265
|
+
if (zones?.repeatable?.fields?.length) {
|
|
266
|
+
return `${indent}${name}: ${repeaterBinding(zones.repeatable.fields, path, indent)},`;
|
|
267
|
+
}
|
|
268
|
+
const lines = [
|
|
269
|
+
`${indent} $: (i: number) => bcmsField(${indexedPath(`${path}[`, "]")}, "array"),`,
|
|
270
|
+
`${indent} value: (i: number) => bcmsField(${indexedPath(`${path}[`, "].value")}, "text"),`
|
|
271
|
+
];
|
|
272
|
+
return `${indent}${name}: {
|
|
273
|
+
${lines.join("\n")}
|
|
274
|
+
${indent}},`;
|
|
275
|
+
}
|
|
276
|
+
function fieldsToBindings(fields, indent) {
|
|
277
|
+
return fields.map((field) => fieldBinding(field, "", indent)).join("\n");
|
|
278
|
+
}
|
|
279
|
+
var PREAMBLE2 = `/**
|
|
280
|
+
* True when this site is built for Live Preview annotation. Set \`BCMS_ANNOTATE=1\`
|
|
281
|
+
* in the preview build only; unset (the default) ships zero binding attributes.
|
|
282
|
+
* Read defensively so the module is safe in any runtime (browser, Node, edge).
|
|
283
|
+
*/
|
|
284
|
+
const BCMS_ANNOTATE: boolean = (() => {
|
|
285
|
+
try {
|
|
286
|
+
const v = (globalThis as { process?: { env?: Record<string, string | undefined> } })
|
|
287
|
+
.process?.env?.BCMS_ANNOTATE;
|
|
288
|
+
return v != null && v !== "" && v !== "0" && v !== "false";
|
|
289
|
+
} catch {
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
})();
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Binding attributes for a CMS-bound element. Spread onto the element that renders a
|
|
296
|
+
* field: \`<h1 {...bcmsField("title", "text")}>\`. Returns \`{}\` unless BCMS_ANNOTATE
|
|
297
|
+
* is set, so production markup is untouched.
|
|
298
|
+
*/
|
|
299
|
+
export function bcmsField(path: string, kind: string): Record<string, string> {
|
|
300
|
+
return BCMS_ANNOTATE ? { "data-bcms-field": path, "data-bcms-kind": kind } : {};
|
|
301
|
+
}
|
|
302
|
+
`;
|
|
303
|
+
function generateBindings(models, opts = {}) {
|
|
304
|
+
const version = opts.version ?? "0.1.0";
|
|
305
|
+
const sorted = [...models].sort(
|
|
306
|
+
(a, b) => a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0
|
|
307
|
+
);
|
|
308
|
+
const header = `// \u26A0\uFE0F AUTO-GENERATED by @bettercms-ai/codegen v${version} \u2014 DO NOT EDIT.
|
|
309
|
+
// Live Preview field bindings. Regenerate with: npx @bettercms-ai/codegen --bindings-out <path>
|
|
310
|
+
// Spread these onto the elements that render your content; they emit
|
|
311
|
+
// data-bcms-field/data-bcms-kind only when the site is built with BCMS_ANNOTATE set.
|
|
312
|
+
${opts.bannerComment ? `// ${opts.bannerComment}
|
|
313
|
+
` : ""}`;
|
|
314
|
+
const entries = sorted.map((model) => {
|
|
315
|
+
const body = model.fields.length ? `
|
|
316
|
+
${fieldsToBindings(model.fields, " ")}
|
|
317
|
+
` : "";
|
|
318
|
+
return ` ${JSON.stringify(model.slug)}: {${body}},`;
|
|
319
|
+
});
|
|
320
|
+
const bcms = `/**
|
|
321
|
+
* Field bindings keyed by model slug. Spread a binding onto the element that renders
|
|
322
|
+
* that field. Arrays expose \`$(i)\` for the item element and one accessor per
|
|
323
|
+
* (one-level) sub-field; primitive arrays expose \`value(i)\` for the item's scalar.
|
|
324
|
+
*/
|
|
325
|
+
export const bcms = {
|
|
326
|
+
${entries.join("\n") || " // (no models defined yet)"}
|
|
327
|
+
} as const;`;
|
|
328
|
+
return [header, PREAMBLE2, bcms, ""].join("\n");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// src/components.ts
|
|
332
|
+
function generateComponents(opts = {}) {
|
|
333
|
+
const version = opts.version ?? "0.1.0";
|
|
334
|
+
const header = `// \u26A0\uFE0F AUTO-GENERATED by @bettercms-ai/codegen v${version} \u2014 DO NOT EDIT.
|
|
335
|
+
// Regenerate with: npx @bettercms-ai/codegen --components-out <path>
|
|
336
|
+
// Typed render components for BetterCMS field shapes. Use these instead of
|
|
337
|
+
// hand-rendering richtext/image values \u2014 they render the canonical shapes correctly.
|
|
338
|
+
${opts.bannerComment ? `// ${opts.bannerComment}
|
|
339
|
+
` : ""}`;
|
|
340
|
+
const body = `import * as React from "react";
|
|
341
|
+
|
|
342
|
+
/** Rich-text value from the Delivery API. \`html\` is server-rendered + sanitized. */
|
|
343
|
+
export type RichTextValue = {
|
|
344
|
+
readonly format: string;
|
|
345
|
+
readonly value: unknown;
|
|
346
|
+
readonly html?: string;
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
/** Normalized image/media value from the Delivery API. */
|
|
350
|
+
export interface BetterCMSImageValue {
|
|
351
|
+
readonly url: string;
|
|
352
|
+
readonly altText?: string | null;
|
|
353
|
+
readonly width?: number;
|
|
354
|
+
readonly height?: number;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
type RichTextProps = {
|
|
358
|
+
/** The richtext field value (\`entry.fields.someRichText\`). */
|
|
359
|
+
field?: RichTextValue | null;
|
|
360
|
+
/** Element/component to render as. Default: \`"div"\`. */
|
|
361
|
+
as?: React.ElementType;
|
|
362
|
+
} & Omit<React.HTMLAttributes<HTMLElement>, "dangerouslySetInnerHTML" | "children">;
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Render a richtext field as HTML. Uses the server-sanitized \`html\` via
|
|
366
|
+
* \`dangerouslySetInnerHTML\` \u2014 NEVER interpolate a richtext value as a JSX child
|
|
367
|
+
* (React escapes it, so the page shows literal tags). Renders nothing when unset.
|
|
368
|
+
*/
|
|
369
|
+
export function RichText({ field, as: Tag = "div", ...rest }: RichTextProps) {
|
|
370
|
+
if (!field || !field.html) return null;
|
|
371
|
+
return <Tag {...rest} dangerouslySetInnerHTML={{ __html: field.html }} />;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
type ImageProps = {
|
|
375
|
+
/** The image field value (\`entry.fields.someImage\`). */
|
|
376
|
+
field?: BetterCMSImageValue | null;
|
|
377
|
+
/** Alt text override; defaults to the field's \`altText\`, then \`""\`. */
|
|
378
|
+
alt?: string;
|
|
379
|
+
} & Omit<React.ImgHTMLAttributes<HTMLImageElement>, "src">;
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Render an image field as an \`<img>\` from its normalized \`.url\`/\`.altText\`.
|
|
383
|
+
* Renders nothing when unset. Pass \`alt\` to override the stored alt text.
|
|
384
|
+
*/
|
|
385
|
+
export function Image({ field, alt, ...rest }: ImageProps) {
|
|
386
|
+
if (!field || !field.url) return null;
|
|
387
|
+
return (
|
|
388
|
+
<img
|
|
389
|
+
src={field.url}
|
|
390
|
+
alt={alt ?? field.altText ?? ""}
|
|
391
|
+
width={field.width}
|
|
392
|
+
height={field.height}
|
|
393
|
+
{...rest}
|
|
394
|
+
/>
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
`;
|
|
398
|
+
return [header, body].join("\n");
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// src/cli.ts
|
|
402
|
+
var VERSION = "0.2.0";
|
|
403
|
+
var DEFAULT_API_URL = "https://api.bettercms.ai/api/v1";
|
|
404
|
+
var DEFAULT_OUT = "bettercms.generated.ts";
|
|
405
|
+
function parseArgs(argv) {
|
|
406
|
+
const args = {
|
|
407
|
+
apiUrl: process.env.BETTERCMS_API_URL ?? DEFAULT_API_URL,
|
|
408
|
+
apiKey: process.env.BETTERCMS_API_KEY,
|
|
409
|
+
out: DEFAULT_OUT,
|
|
410
|
+
bindingsOut: void 0,
|
|
411
|
+
componentsOut: void 0,
|
|
412
|
+
help: false
|
|
413
|
+
};
|
|
414
|
+
for (let i = 0; i < argv.length; i++) {
|
|
415
|
+
const arg = argv[i];
|
|
416
|
+
const next = () => argv[++i];
|
|
417
|
+
switch (arg) {
|
|
418
|
+
case "--api-url":
|
|
419
|
+
args.apiUrl = next() ?? args.apiUrl;
|
|
420
|
+
break;
|
|
421
|
+
case "--key":
|
|
422
|
+
case "--api-key":
|
|
423
|
+
args.apiKey = next();
|
|
424
|
+
break;
|
|
425
|
+
case "--out":
|
|
426
|
+
case "-o":
|
|
427
|
+
args.out = next() ?? args.out;
|
|
428
|
+
break;
|
|
429
|
+
case "--bindings-out":
|
|
430
|
+
args.bindingsOut = next();
|
|
431
|
+
break;
|
|
432
|
+
case "--components-out":
|
|
433
|
+
args.componentsOut = next();
|
|
434
|
+
break;
|
|
435
|
+
case "--help":
|
|
436
|
+
case "-h":
|
|
437
|
+
args.help = true;
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return args;
|
|
442
|
+
}
|
|
443
|
+
var HELP = `bettercms-codegen v${VERSION} \u2014 generate TypeScript types from your BetterCMS schema
|
|
444
|
+
|
|
445
|
+
Usage:
|
|
446
|
+
npx @bettercms-ai/codegen [options]
|
|
447
|
+
|
|
448
|
+
Options:
|
|
449
|
+
-o, --out <path> Output file (default: ${DEFAULT_OUT})
|
|
450
|
+
--bindings-out <path> Also emit the Live Preview bindings module to <path>
|
|
451
|
+
--components-out <path> Also emit typed <RichText>/<Image> React components (.tsx) to <path>
|
|
452
|
+
--api-url <url> Management API base (default: ${DEFAULT_API_URL})
|
|
453
|
+
--key <key> Management API key (or set BETTERCMS_API_KEY)
|
|
454
|
+
-h, --help Show this help
|
|
455
|
+
|
|
456
|
+
Env:
|
|
457
|
+
BETTERCMS_API_KEY Management-scoped key (content:manage)
|
|
458
|
+
BETTERCMS_API_URL Override the API base
|
|
459
|
+
`;
|
|
460
|
+
async function main() {
|
|
461
|
+
const args = parseArgs(process.argv.slice(2));
|
|
462
|
+
if (args.help) {
|
|
463
|
+
process.stdout.write(HELP);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
if (!args.apiKey) {
|
|
467
|
+
process.stderr.write(
|
|
468
|
+
"error: no API key. Pass --key <key> or set BETTERCMS_API_KEY.\n"
|
|
469
|
+
);
|
|
470
|
+
process.exit(1);
|
|
471
|
+
}
|
|
472
|
+
const models = await fetchModels({ apiUrl: args.apiUrl, apiKey: args.apiKey });
|
|
473
|
+
const outPath = resolve(process.cwd(), args.out);
|
|
474
|
+
await mkdir(dirname(outPath), { recursive: true });
|
|
475
|
+
await writeFile(outPath, generateTypes(models, { version: VERSION }), "utf8");
|
|
476
|
+
const plural = models.length === 1 ? "" : "s";
|
|
477
|
+
process.stdout.write(
|
|
478
|
+
`\u2713 Generated ${models.length} model type${plural} \u2192 ${args.out}
|
|
479
|
+
`
|
|
480
|
+
);
|
|
481
|
+
if (args.bindingsOut) {
|
|
482
|
+
const bindingsPath = resolve(process.cwd(), args.bindingsOut);
|
|
483
|
+
await mkdir(dirname(bindingsPath), { recursive: true });
|
|
484
|
+
await writeFile(bindingsPath, generateBindings(models, { version: VERSION }), "utf8");
|
|
485
|
+
process.stdout.write(`\u2713 Generated Live Preview bindings \u2192 ${args.bindingsOut}
|
|
486
|
+
`);
|
|
487
|
+
}
|
|
488
|
+
if (args.componentsOut) {
|
|
489
|
+
const componentsPath = resolve(process.cwd(), args.componentsOut);
|
|
490
|
+
await mkdir(dirname(componentsPath), { recursive: true });
|
|
491
|
+
await writeFile(componentsPath, generateComponents({ version: VERSION }), "utf8");
|
|
492
|
+
process.stdout.write(`\u2713 Generated render components \u2192 ${args.componentsOut}
|
|
493
|
+
`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
main().catch((err) => {
|
|
497
|
+
process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}
|
|
498
|
+
`);
|
|
499
|
+
process.exit(1);
|
|
500
|
+
});
|
|
501
|
+
//# sourceMappingURL=cli.js.map
|