@llm-cms/core 0.0.1

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/src/model.ts ADDED
@@ -0,0 +1,182 @@
1
+ // packages/core/src/model.ts
2
+ import {
3
+ type FieldDef,
4
+ type FieldKind,
5
+ type FieldSnapshot,
6
+ parseFieldSnapshot,
7
+ serializeField,
8
+ } from "./fields";
9
+
10
+ export type ModelDef = {
11
+ type: string;
12
+ /** Repo-relative path template, e.g. content/{locale}/blog/{slug}.mdx */
13
+ path: string;
14
+ /**
15
+ * URL template for the host catch-all route, e.g. "/blog/{slug}".
16
+ * Defaults to "/{locale}/{type}/{slug}" when omitted (see routes.ts).
17
+ */
18
+ route?: string;
19
+ fields: Record<string, FieldDef>;
20
+ };
21
+
22
+ /** What `defineModel` accepts: `path` falls back to the folder convention. */
23
+ export type ModelInput = Omit<ModelDef, "path"> & { path?: string };
24
+
25
+ /** Default repo path when a model does not declare one. */
26
+ export const DEFAULT_MODEL_PATH = "content/{locale}/{type}/{slug}.mdx";
27
+
28
+ export function defaultModelPath(type: string): string {
29
+ return DEFAULT_MODEL_PATH.replaceAll("{type}", type);
30
+ }
31
+
32
+ export type ModelSnapshot = {
33
+ type: string;
34
+ path: string;
35
+ route?: string;
36
+ fields: Record<string, FieldSnapshot>;
37
+ };
38
+
39
+ export type SchemaSnapshot = {
40
+ version: number;
41
+ models: ModelSnapshot[];
42
+ };
43
+
44
+ type WithPath<D extends ModelInput> = D extends { path: string } ? D : D & { path: string };
45
+
46
+ /**
47
+ * Validates the definition at runtime and preserves its literal types.
48
+ * `path` defaults to `content/{locale}/{type}/{slug}.mdx`.
49
+ */
50
+ export function defineModel<const D extends ModelInput>(def: D): WithPath<D> {
51
+ if (!def.type || typeof def.type !== "string") {
52
+ throw new Error("model.type is required");
53
+ }
54
+ const path = def.path ?? defaultModelPath(def.type);
55
+ if (typeof path !== "string" || !path) {
56
+ throw new Error("model.path must be a non-empty string");
57
+ }
58
+ if (!path.includes("{locale}") || !path.includes("{slug}")) {
59
+ throw new Error("model.path must include {locale} and {slug}");
60
+ }
61
+ if (def.route !== undefined) {
62
+ if (typeof def.route !== "string" || !def.route.startsWith("/")) {
63
+ throw new Error('model.route must start with "/"');
64
+ }
65
+ if (!def.route.includes("{slug}")) {
66
+ throw new Error("model.route must include {slug}");
67
+ }
68
+ }
69
+ if (!def.fields || typeof def.fields !== "object") {
70
+ throw new Error("model.fields is required");
71
+ }
72
+ return (def.path === undefined ? { ...def, path } : def) as WithPath<D>;
73
+ }
74
+
75
+ type Simplify<T> = { -readonly [K in keyof T]: T[K] } & {};
76
+
77
+ type ScalarOf<K extends FieldKind> = K extends "number"
78
+ ? number
79
+ : K extends "boolean"
80
+ ? boolean
81
+ : string;
82
+
83
+ type IsRequired<F> = F extends { required?: infer R }
84
+ ? [Exclude<R, undefined>] extends [true]
85
+ ? true
86
+ : false
87
+ : false;
88
+
89
+ /** TypeScript shape of a model's frontmatter, derived from `field.*` calls. */
90
+ export type InferFrontmatter<M extends ModelDef> = Simplify<
91
+ {
92
+ [N in keyof M["fields"] as IsRequired<M["fields"][N]> extends true
93
+ ? N
94
+ : never]: ScalarOf<M["fields"][N]["kind"]>;
95
+ } & {
96
+ [N in keyof M["fields"] as IsRequired<M["fields"][N]> extends true
97
+ ? never
98
+ : N]?: ScalarOf<M["fields"][N]["kind"]>;
99
+ }
100
+ >;
101
+
102
+ export function serializeModel(model: ModelDef): ModelSnapshot {
103
+ const fields: Record<string, FieldSnapshot> = {};
104
+ for (const [name, def] of Object.entries(model.fields)) {
105
+ fields[name] = serializeField(def);
106
+ }
107
+ const snapshot: ModelSnapshot = {
108
+ type: model.type,
109
+ path: model.path,
110
+ fields,
111
+ };
112
+ if (model.route !== undefined) snapshot.route = model.route;
113
+ return snapshot;
114
+ }
115
+
116
+ export function serializeSchema(models: ModelDef[]): SchemaSnapshot {
117
+ return {
118
+ version: 1,
119
+ models: models.map(serializeModel),
120
+ };
121
+ }
122
+
123
+ export function parseSchemaSnapshot(input: unknown): SchemaSnapshot {
124
+ if (!input || typeof input !== "object") {
125
+ throw new Error("schema must be an object");
126
+ }
127
+ const value = input as Record<string, unknown>;
128
+ const version = value.version;
129
+ if (typeof version !== "number" || !Number.isInteger(version) || version < 1) {
130
+ throw new Error("schema.version must be a positive integer");
131
+ }
132
+ if (!Array.isArray(value.models)) {
133
+ throw new Error("schema.models must be an array");
134
+ }
135
+ const models: ModelSnapshot[] = value.models.map((raw, i) => {
136
+ if (!raw || typeof raw !== "object") {
137
+ throw new Error(`schema.models[${i}] must be an object`);
138
+ }
139
+ const m = raw as Record<string, unknown>;
140
+ if (typeof m.type !== "string" || !m.type) {
141
+ throw new Error(`schema.models[${i}].type is required`);
142
+ }
143
+ if (typeof m.path !== "string" || !m.path) {
144
+ throw new Error(`schema.models[${i}].path is required`);
145
+ }
146
+ if (!m.path.includes("{locale}") || !m.path.includes("{slug}")) {
147
+ throw new Error(
148
+ `schema.models[${i}].path must include {locale} and {slug}`,
149
+ );
150
+ }
151
+ if (m.route !== undefined && (typeof m.route !== "string" || !m.route.startsWith("/"))) {
152
+ throw new Error(`schema.models[${i}].route must be a string starting with "/"`);
153
+ }
154
+ if (!m.fields || typeof m.fields !== "object" || Array.isArray(m.fields)) {
155
+ throw new Error(`schema.models[${i}].fields must be an object`);
156
+ }
157
+ const fields: Record<string, FieldSnapshot> = {};
158
+ for (const [name, fieldRaw] of Object.entries(
159
+ m.fields as Record<string, unknown>,
160
+ )) {
161
+ try {
162
+ fields[name] = parseFieldSnapshot(fieldRaw);
163
+ } catch (err) {
164
+ const msg = err instanceof Error ? err.message : "invalid field";
165
+ throw new Error(`schema.models[${i}].fields.${name}: ${msg}`);
166
+ }
167
+ }
168
+ const snapshot: ModelSnapshot = { type: m.type, path: m.path, fields };
169
+ if (typeof m.route === "string") snapshot.route = m.route;
170
+ return snapshot;
171
+ });
172
+
173
+ const types = new Set<string>();
174
+ for (const m of models) {
175
+ if (types.has(m.type)) {
176
+ throw new Error(`duplicate model type: ${m.type}`);
177
+ }
178
+ types.add(m.type);
179
+ }
180
+
181
+ return { version, models };
182
+ }
@@ -0,0 +1,387 @@
1
+ #!/usr/bin/env bun
2
+ // packages/core/src/node/cli.ts
3
+ import { existsSync } from "node:fs";
4
+ import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+ import {
8
+ frontmatterJsonSchema,
9
+ serializeModel,
10
+ validateContentTree,
11
+ type ContentFileResult,
12
+ type ModelDef,
13
+ type ModelSnapshot,
14
+ } from "../index";
15
+ import { resolveConfig, type LlmcmsConfig, type ResolvedConfig } from "../config";
16
+ import { discoverModels } from "./discover";
17
+ import { walkMdxFiles } from "./fs-loader";
18
+ import { detectSdk, findConfigFile, generate, type GenerateResult } from "./generate";
19
+
20
+ export type CliArgs = {
21
+ command?: string;
22
+ root: string;
23
+ /** Optional barrel module exporting `models`; default is folder discovery. */
24
+ models?: string;
25
+ out: string;
26
+ json: boolean;
27
+ };
28
+
29
+ const USAGE = `Usage:
30
+ llmcms init scaffold llmcms.config.ts, the catch-all route and .llmcms/
31
+ llmcms generate [--root <dir>] write .llmcms/index.ts (typed cms) and .llmcms/blocks.ts
32
+ llmcms validate [--root <dir>] [--models <file>] [--json]
33
+ llmcms schema [--root <dir>] [--models <file>] [--out <dir>]
34
+
35
+ Defaults: --root . --out .llmcms/schemas
36
+ Models are discovered from models/*.ts (one \`export default defineModel(...)\` per
37
+ file, file name = model type). Pass --models <file> to use a module that exports
38
+ \`models\` instead. Importing your code sets LLMCMS_CLI=1 so createLlmcms never
39
+ syncs the schema from the CLI.`;
40
+
41
+ export function parseArgs(argv: string[]): CliArgs {
42
+ const args: CliArgs = {
43
+ command: undefined,
44
+ root: ".",
45
+ models: undefined,
46
+ out: ".llmcms/schemas",
47
+ json: false,
48
+ };
49
+ for (let i = 0; i < argv.length; i++) {
50
+ const arg = argv[i]!;
51
+ if (arg === "--root") {
52
+ const value = argv[++i];
53
+ if (value === undefined || value.startsWith("--")) {
54
+ throw new Error("usage: missing value for --root");
55
+ }
56
+ args.root = value;
57
+ } else if (arg === "--models") {
58
+ const value = argv[++i];
59
+ if (value === undefined || value.startsWith("--")) {
60
+ throw new Error("usage: missing value for --models");
61
+ }
62
+ args.models = value;
63
+ } else if (arg === "--out") {
64
+ const value = argv[++i];
65
+ if (value === undefined || value.startsWith("--")) {
66
+ throw new Error("usage: missing value for --out");
67
+ }
68
+ args.out = value;
69
+ } else if (arg === "--json") {
70
+ args.json = true;
71
+ } else if (arg.startsWith("--")) {
72
+ throw new Error(`usage: unknown flag ${arg}`);
73
+ } else if (args.command === undefined) {
74
+ args.command = arg;
75
+ } else {
76
+ throw new Error("usage: unexpected positional argument");
77
+ }
78
+ }
79
+ return args;
80
+ }
81
+
82
+ /** Run `fn` with LLMCMS_CLI=1 so imported host code never syncs the schema. */
83
+ async function withCliFlag<T>(fn: () => Promise<T>): Promise<T> {
84
+ const prevCli = process.env.LLMCMS_CLI;
85
+ process.env.LLMCMS_CLI = "1";
86
+ try {
87
+ return await fn();
88
+ } finally {
89
+ if (prevCli === undefined) delete process.env.LLMCMS_CLI;
90
+ else process.env.LLMCMS_CLI = prevCli;
91
+ }
92
+ }
93
+
94
+ /** Import `llmcms.config.ts` (default export) when present; `{}` otherwise. */
95
+ export async function loadConfig(root: string): Promise<ResolvedConfig> {
96
+ const file = findConfigFile(root);
97
+ if (!file) return resolveConfig({});
98
+ const mod = await withCliFlag(
99
+ () =>
100
+ import(/* webpackIgnore: true */ /* turbopackIgnore: true */ pathToFileURL(file).href) as Promise<{ default?: unknown; config?: unknown }>,
101
+ );
102
+ const candidate = mod.default ?? mod.config;
103
+ if (candidate !== undefined && (typeof candidate !== "object" || candidate === null)) {
104
+ throw new Error(`${path.basename(file)} must \`export default defineConfig({ ... })\``);
105
+ }
106
+ return resolveConfig((candidate ?? {}) as LlmcmsConfig);
107
+ }
108
+
109
+ /**
110
+ * Models from the `models/` folder (default) or from an explicit barrel module
111
+ * that exports `models` / a default array.
112
+ */
113
+ export async function loadModels(root: string, modelsPath?: string): Promise<ModelDef[]> {
114
+ if (modelsPath === undefined) {
115
+ const config = await loadConfig(root);
116
+ return withCliFlag(() => discoverModels(root, config.dirs.models));
117
+ }
118
+ const full = path.resolve(root, modelsPath);
119
+ const mod = await withCliFlag(
120
+ () =>
121
+ import(/* webpackIgnore: true */ /* turbopackIgnore: true */ pathToFileURL(full).href) as Promise<{ models?: unknown; default?: unknown }>,
122
+ );
123
+ const candidate = mod.models ?? mod.default;
124
+ if (!Array.isArray(candidate)) {
125
+ throw new Error(
126
+ `${modelsPath} must export \`models\` (array of defineModel results) or a default array`,
127
+ );
128
+ }
129
+ return candidate as ModelDef[];
130
+ }
131
+
132
+ /**
133
+ * Static directory roots the models' path templates live under, e.g.
134
+ * `content/{locale}/blog/{slug}.mdx` → `content`. Roots that are sub-paths of
135
+ * another root are dropped; a template with no static leading segment yields ".".
136
+ */
137
+ export function contentRoots(models: ModelSnapshot[]): string[] {
138
+ const candidates = new Set<string>();
139
+ for (const model of models) {
140
+ const segments = model.path.split("/").slice(0, -1); // drop the filename segment
141
+ const leading: string[] = [];
142
+ for (const segment of segments) {
143
+ if (segment.includes("{")) break;
144
+ leading.push(segment);
145
+ }
146
+ candidates.add(leading.length > 0 ? leading.join("/") : ".");
147
+ }
148
+ const list = [...candidates];
149
+ return list
150
+ .filter(
151
+ (root) =>
152
+ !list.some(
153
+ (other) =>
154
+ other !== root && (other === "." || root.startsWith(`${other}/`)),
155
+ ),
156
+ )
157
+ .sort();
158
+ }
159
+
160
+ export async function runValidate(
161
+ args: CliArgs,
162
+ ): Promise<{ results: ContentFileResult[]; roots: string[]; exitCode: 0 | 1 }> {
163
+ const root = path.resolve(args.root);
164
+ const models = (await loadModels(root, args.models)).map(serializeModel);
165
+ const roots = contentRoots(models);
166
+ const files: string[] = [];
167
+ for (const contentRoot of roots) {
168
+ const dir = path.join(root, contentRoot);
169
+ const info = await stat(dir).catch(() => null);
170
+ if (!info?.isDirectory()) continue; // missing roots are simply skipped
171
+ files.push(...(await walkMdxFiles(dir)));
172
+ }
173
+ files.sort();
174
+ const inputs = await Promise.all(
175
+ files.map(async (full) => ({
176
+ path: path.relative(root, full).split(path.sep).join("/"),
177
+ raw: await readFile(full, "utf-8"),
178
+ })),
179
+ );
180
+ const results = validateContentTree({ models, files: inputs });
181
+ return { results, roots, exitCode: results.some((r) => !r.ok) ? 1 : 0 };
182
+ }
183
+
184
+ export function formatResults(results: ContentFileResult[]): string[] {
185
+ const lines: string[] = [];
186
+ for (const r of results) {
187
+ if (!r.ok) {
188
+ for (const err of r.errors) lines.push(`${r.path}: ${err}`);
189
+ } else if ("skipped" in r) {
190
+ lines.push(`${r.path}: no matching model (skipped)`);
191
+ }
192
+ }
193
+ return lines;
194
+ }
195
+
196
+ export async function runSchema(args: CliArgs): Promise<string[]> {
197
+ const root = path.resolve(args.root);
198
+ const models = (await loadModels(root, args.models)).map(serializeModel);
199
+ const outDir = path.resolve(root, args.out);
200
+ await mkdir(outDir, { recursive: true });
201
+ const written: string[] = [];
202
+ for (const model of models) {
203
+ const file = path.join(outDir, `${model.type}.json`);
204
+ await writeFile(file, `${JSON.stringify(frontmatterJsonSchema(model), null, 2)}\n`);
205
+ written.push(path.relative(root, file).split(path.sep).join("/"));
206
+ }
207
+ return written;
208
+ }
209
+
210
+ export async function runGenerate(args: CliArgs): Promise<GenerateResult> {
211
+ const root = path.resolve(args.root);
212
+ // Codegen must not depend on the host's dependencies being installed yet
213
+ // (e.g. right after `init`); fall back to default folders if the config
214
+ // cannot be imported.
215
+ let config: ResolvedConfig;
216
+ try {
217
+ config = await loadConfig(root);
218
+ } catch (err) {
219
+ console.error(
220
+ `llmcms: could not load llmcms.config (${err instanceof Error ? err.message : String(err)}); using default folders`,
221
+ );
222
+ config = resolveConfig({});
223
+ }
224
+ return generate({ root, modelsDir: config.dirs.models, blocksDir: config.dirs.blocks });
225
+ }
226
+
227
+ const CATCH_ALL_DIR = "[...llmcms]";
228
+
229
+ function renderConfigFile(sdk: string): string {
230
+ const from = sdk === "@llm-cms/next" ? "@llm-cms/next" : "@llm-cms/core";
231
+ return `// llmcms.config.ts
232
+ import { defineConfig } from "${from}";
233
+
234
+ // General settings only. Models live in models/*.ts, blocks in blocks/*.tsx and
235
+ // content in content/{locale}/{type}/{slug}.mdx — all discovered automatically.
236
+ // workspaceId / apiUrl / tokens fall back to LLMCMS_* env vars when omitted.
237
+ export default defineConfig({
238
+ defaultLocale: "en",
239
+ });
240
+ `;
241
+ }
242
+
243
+ function renderCatchAllPage(registryImport: string): string {
244
+ return `// ${CATCH_ALL_DIR}/page.tsx — mounts every model at its route (default /{locale}/{type}/{slug}).
245
+ import { createCatchAll } from "@llm-cms/next/routes";
246
+ import { cms } from "${registryImport}";
247
+ import { blocks } from "${registryImport}/blocks";
248
+
249
+ const route = createCatchAll(cms, { components: blocks });
250
+
251
+ export const generateStaticParams = route.generateStaticParams;
252
+ export default route.Page;
253
+ `;
254
+ }
255
+
256
+ function renderRevalidateRoute(registryImport: string): string {
257
+ return `// api/llmcms/revalidate/route.ts — content.committed from the LLMCMS server.
258
+ import { createRevalidateRoute } from "@llm-cms/next/revalidate";
259
+ import { cms } from "${registryImport}";
260
+
261
+ export const POST = createRevalidateRoute(cms);
262
+ `;
263
+ }
264
+
265
+ export async function runInit(args: CliArgs): Promise<string[]> {
266
+ const root = path.resolve(args.root);
267
+ const sdk = detectSdk(root);
268
+ const notes: string[] = [];
269
+
270
+ if (!findConfigFile(root)) {
271
+ await writeFile(path.join(root, "llmcms.config.ts"), renderConfigFile(sdk));
272
+ notes.push("wrote llmcms.config.ts");
273
+ } else {
274
+ notes.push("kept existing llmcms.config.*");
275
+ }
276
+
277
+ const gitignore = path.join(root, ".gitignore");
278
+ const current = existsSync(gitignore) ? await readFile(gitignore, "utf-8") : "";
279
+ if (!current.split("\n").some((line) => line.trim() === ".llmcms/" || line.trim() === ".llmcms")) {
280
+ const prefix = current.length && !current.endsWith("\n") ? "\n" : "";
281
+ await appendFile(gitignore, `${prefix}# llmcms generated registry\n.llmcms/\n`);
282
+ notes.push("added .llmcms/ to .gitignore");
283
+ }
284
+
285
+ if (sdk === "@llm-cms/next") {
286
+ const appDir = existsSync(path.join(root, "src", "app")) ? path.join("src", "app") : "app";
287
+ const pageDir = path.join(root, appDir, CATCH_ALL_DIR);
288
+ const page = path.join(pageDir, "page.tsx");
289
+ if (!existsSync(page)) {
290
+ const registry = path.relative(pageDir, path.join(root, ".llmcms")).split(path.sep).join("/");
291
+ await mkdir(pageDir, { recursive: true });
292
+ await writeFile(page, renderCatchAllPage(registry.startsWith(".") ? registry : `./${registry}`));
293
+ notes.push(`wrote ${path.join(appDir, CATCH_ALL_DIR, "page.tsx")}`);
294
+ } else {
295
+ notes.push(`kept existing ${path.join(appDir, CATCH_ALL_DIR, "page.tsx")}`);
296
+ }
297
+
298
+ const revalidateDir = path.join(root, appDir, "api", "llmcms", "revalidate");
299
+ const revalidateFile = path.join(revalidateDir, "route.ts");
300
+ if (!existsSync(revalidateFile)) {
301
+ const registry = path.relative(revalidateDir, path.join(root, ".llmcms")).split(path.sep).join("/");
302
+ await mkdir(revalidateDir, { recursive: true });
303
+ await writeFile(
304
+ revalidateFile,
305
+ renderRevalidateRoute(registry.startsWith(".") ? registry : `./${registry}`),
306
+ );
307
+ notes.push(`wrote ${path.join(appDir, "api/llmcms/revalidate/route.ts")}`);
308
+ } else {
309
+ notes.push(`kept existing ${path.join(appDir, "api/llmcms/revalidate/route.ts")}`);
310
+ }
311
+ } else {
312
+ notes.push("no @llm-cms/next dependency: skipped the catch-all route");
313
+ }
314
+
315
+ for (const dir of ["models", "content", "blocks"]) {
316
+ await mkdir(path.join(root, dir), { recursive: true });
317
+ }
318
+
319
+ const result = await runGenerate(args);
320
+ notes.push(...result.files.map((f) => `wrote ${f}`));
321
+ return notes;
322
+ }
323
+
324
+ async function main(argv: string[]): Promise<number> {
325
+ let args: CliArgs;
326
+ try {
327
+ args = parseArgs(argv);
328
+ } catch (err) {
329
+ if (err instanceof Error && err.message.startsWith("usage:")) {
330
+ console.error(err.message);
331
+ console.error(USAGE);
332
+ return 2;
333
+ }
334
+ console.error(`llmcms: ${err instanceof Error ? err.message : String(err)}`);
335
+ return 2;
336
+ }
337
+ try {
338
+ if (args.command === "validate") {
339
+ const { results, roots, exitCode } = await runValidate(args);
340
+ if (results.length === 0) {
341
+ console.error(
342
+ `llmcms: no .mdx files found under ${roots.join(", ")} (check --root)`,
343
+ );
344
+ }
345
+ if (args.json) {
346
+ console.log(JSON.stringify(results, null, 2));
347
+ } else {
348
+ for (const line of formatResults(results)) console.log(line);
349
+ const checked = results.filter((r) => !("skipped" in r)).length;
350
+ console.log(
351
+ exitCode === 0
352
+ ? `llmcms: ${checked} document(s) valid`
353
+ : `llmcms: validation failed`,
354
+ );
355
+ }
356
+ return exitCode;
357
+ }
358
+ if (args.command === "schema") {
359
+ const written = await runSchema(args);
360
+ for (const file of written) console.log(`wrote ${file}`);
361
+ return 0;
362
+ }
363
+ if (args.command === "generate") {
364
+ const result = await runGenerate(args);
365
+ for (const file of result.files) {
366
+ console.log(`${result.changed.includes(file) ? "wrote" : "unchanged"} ${file}`);
367
+ }
368
+ for (const skipped of result.skippedBlocks) {
369
+ console.error(`llmcms: skipped ${skipped} (block file names must be PascalCase)`);
370
+ }
371
+ return 0;
372
+ }
373
+ if (args.command === "init") {
374
+ for (const note of await runInit(args)) console.log(note);
375
+ return 0;
376
+ }
377
+ console.error(USAGE);
378
+ return 2;
379
+ } catch (err) {
380
+ console.error(`llmcms: ${err instanceof Error ? err.message : String(err)}`);
381
+ return 2;
382
+ }
383
+ }
384
+
385
+ if (import.meta.main) {
386
+ main(process.argv.slice(2)).then((code) => process.exit(code));
387
+ }
@@ -0,0 +1,125 @@
1
+ // packages/core/src/node/create-llmcms.ts
2
+ import type { ModelDef, ModelSnapshot } from "../model";
3
+ import { getDocFromApi, listDocsFromApi } from "../api-loader";
4
+ import { getDocFromFs, listDocsFromFs } from "./fs-loader";
5
+ import { createQuery, type DocLoader, type Query } from "../query";
6
+ import { syncSchema } from "../sync-schema";
7
+ import type { LlmcmsDoc, LlmcmsDocSummary } from "../doc";
8
+ import { resolveConfig, type LlmcmsConfig, type ResolvedConfig } from "../config";
9
+ import { contentCacheTags, listCacheTags } from "../content-committed";
10
+
11
+ /**
12
+ * `LlmcmsConfig` (usually spread from `llmcms.config.ts`) plus the discovered
13
+ * models. Unset fields fall back to `LLMCMS_*` env vars.
14
+ */
15
+ export type CreateLlmcmsOptions<Models extends readonly ModelDef[] = readonly ModelDef[]> =
16
+ LlmcmsConfig & {
17
+ models: Models;
18
+ /**
19
+ * When true, API fetches use Next cache tags instead of `cache: "no-store"`.
20
+ * `@llm-cms/next` turns this on.
21
+ */
22
+ taggedApiFetch?: boolean;
23
+ };
24
+
25
+ export type LlmcmsClient<Models extends readonly ModelDef[] = readonly ModelDef[]> = {
26
+ listDocs: (filter?: {
27
+ type?: string;
28
+ locale?: string;
29
+ }) => Promise<LlmcmsDocSummary[]>;
30
+ getDoc: (
31
+ locale: string,
32
+ type: string,
33
+ slug: string,
34
+ ) => Promise<LlmcmsDoc | null>;
35
+ /** Typed access keyed by model.type: cms.query.blog.get / findMany */
36
+ query: Query<Models>;
37
+ /** The registered models, e.g. for route matching. */
38
+ models: Models;
39
+ /** Effective config after env fallbacks. */
40
+ config: ResolvedConfig;
41
+ };
42
+
43
+ function toSnapshots(models: readonly ModelDef[]): ModelSnapshot[] {
44
+ return models.map((m) => {
45
+ const snapshot: ModelSnapshot = {
46
+ type: m.type,
47
+ path: m.path,
48
+ fields: m.fields as ModelSnapshot["fields"],
49
+ };
50
+ if (m.route !== undefined) snapshot.route = m.route;
51
+ return snapshot;
52
+ });
53
+ }
54
+
55
+ export function createLlmcms<const Models extends readonly ModelDef[]>(
56
+ options: CreateLlmcmsOptions<Models>,
57
+ ): LlmcmsClient<Models> {
58
+ const { models: modelDefs, taggedApiFetch, ...rest } = options;
59
+ const config = resolveConfig(rest);
60
+ const models = toSnapshots(modelDefs);
61
+ const siteRoot = config.siteRoot ?? process.cwd();
62
+ const useApi = Boolean(config.apiUrl && config.workspaceId);
63
+ const { workspaceId, apiUrl, contentToken, hostToken, branch, siteUrl } = config;
64
+
65
+ if (apiUrl && !workspaceId) {
66
+ throw new Error(
67
+ "llmcms: workspaceId is required when apiUrl is set (llmcms.config.ts or LLMCMS_WORKSPACE_ID)",
68
+ );
69
+ }
70
+
71
+ // llmcms CLI imports the config to read models; never sync from there.
72
+ if (apiUrl && hostToken && workspaceId) {
73
+ if (process.env.LLMCMS_CLI === "1") {
74
+ console.warn("llmcms: schema sync skipped (LLMCMS_CLI=1)");
75
+ } else {
76
+ void syncSchema({
77
+ apiUrl,
78
+ workspaceId,
79
+ hostToken,
80
+ models: [...modelDefs],
81
+ branch: config.branch,
82
+ siteUrl,
83
+ }).catch((err) => {
84
+ console.error("llmcms schema sync failed", err);
85
+ });
86
+ }
87
+ }
88
+
89
+ const loader: DocLoader = {
90
+ async listDocs(filter) {
91
+ const docs = useApi
92
+ ? await listDocsFromApi(apiUrl!, workspaceId!, contentToken, {
93
+ branch,
94
+ type: filter?.type,
95
+ locale: filter?.locale,
96
+ tags: taggedApiFetch ? listCacheTags(filter?.type) : undefined,
97
+ })
98
+ : await listDocsFromFs(siteRoot, models);
99
+
100
+ return docs.filter((d) => {
101
+ if (filter?.type && d.type !== filter.type) return false;
102
+ if (filter?.locale && d.locale !== filter.locale) return false;
103
+ return true;
104
+ });
105
+ },
106
+
107
+ async getDoc(locale, type, slug) {
108
+ if (useApi) {
109
+ return getDocFromApi(apiUrl!, workspaceId!, contentToken, locale, type, slug, {
110
+ branch,
111
+ tags: taggedApiFetch ? contentCacheTags(locale, type, slug) : undefined,
112
+ });
113
+ }
114
+ return getDocFromFs(siteRoot, models, locale, type, slug);
115
+ },
116
+ };
117
+
118
+ return {
119
+ listDocs: loader.listDocs,
120
+ getDoc: loader.getDoc,
121
+ query: createQuery(modelDefs, loader),
122
+ models: modelDefs,
123
+ config,
124
+ };
125
+ }