@llm-cms/core 0.0.1 → 0.0.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/package.json +1 -1
- package/src/block.ts +103 -0
- package/src/config.ts +9 -2
- package/src/doc-format.ts +32 -0
- package/src/fields.ts +4 -1
- package/src/index.ts +20 -0
- package/src/model.ts +56 -7
- package/src/node/cli.ts +30 -11
- package/src/node/create-llmcms.ts +5 -1
- package/src/node/discover.ts +69 -11
- package/src/node/eval-block.ts +202 -0
- package/src/node/eval-model.ts +143 -0
- package/src/node/fs-loader.ts +11 -4
- package/src/node/generate.ts +47 -11
- package/src/node/index.ts +10 -1
- package/src/path.ts +24 -7
- package/src/sync-schema.ts +3 -1
- package/src/validate.ts +67 -1
- package/src/zod.ts +12 -1
package/package.json
CHANGED
package/src/block.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// packages/core/src/block.ts
|
|
2
|
+
import {
|
|
3
|
+
type FieldDef,
|
|
4
|
+
type FieldKind,
|
|
5
|
+
type FieldSnapshot,
|
|
6
|
+
parseFieldSnapshot,
|
|
7
|
+
serializeField,
|
|
8
|
+
} from "./fields";
|
|
9
|
+
|
|
10
|
+
export type BlockInstance = {
|
|
11
|
+
id: string;
|
|
12
|
+
type: string;
|
|
13
|
+
props: Record<string, unknown>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type BlockDef<C = unknown> = {
|
|
17
|
+
name: string;
|
|
18
|
+
fields: Record<string, FieldDef>;
|
|
19
|
+
component: C;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type BlockSnapshot = {
|
|
23
|
+
name: string;
|
|
24
|
+
fields: Record<string, FieldSnapshot>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const PASCAL = /^[A-Z][A-Za-z0-9]*$/;
|
|
28
|
+
|
|
29
|
+
export function defineBlock<const D extends BlockDef>(def: D): D {
|
|
30
|
+
if (!def.name || typeof def.name !== "string") {
|
|
31
|
+
throw new Error("block.name is required");
|
|
32
|
+
}
|
|
33
|
+
if (!PASCAL.test(def.name)) {
|
|
34
|
+
throw new Error('block.name must be PascalCase (e.g. "Hero")');
|
|
35
|
+
}
|
|
36
|
+
if (!def.fields || typeof def.fields !== "object") {
|
|
37
|
+
throw new Error("block.fields is required");
|
|
38
|
+
}
|
|
39
|
+
if (def.component === undefined) {
|
|
40
|
+
throw new Error("block.component is required");
|
|
41
|
+
}
|
|
42
|
+
return def;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type Simplify<T> = { -readonly [K in keyof T]: T[K] } & {};
|
|
46
|
+
|
|
47
|
+
type ScalarOf<K extends FieldKind> = K extends "number"
|
|
48
|
+
? number
|
|
49
|
+
: K extends "boolean"
|
|
50
|
+
? boolean
|
|
51
|
+
: K extends "blocks"
|
|
52
|
+
? BlockInstance[]
|
|
53
|
+
: string;
|
|
54
|
+
|
|
55
|
+
type IsRequired<F> = F extends { required?: infer R }
|
|
56
|
+
? [Exclude<R, undefined>] extends [true]
|
|
57
|
+
? true
|
|
58
|
+
: false
|
|
59
|
+
: false;
|
|
60
|
+
|
|
61
|
+
/** TypeScript shape of a block's props, derived from `field.*` calls. */
|
|
62
|
+
export type InferBlockProps<Fields extends Record<string, FieldDef>> = Simplify<
|
|
63
|
+
{
|
|
64
|
+
[N in keyof Fields as IsRequired<Fields[N]> extends true ? N : never]: ScalarOf<
|
|
65
|
+
Fields[N]["kind"]
|
|
66
|
+
>;
|
|
67
|
+
} & {
|
|
68
|
+
[N in keyof Fields as IsRequired<Fields[N]> extends true ? never : N]?: ScalarOf<
|
|
69
|
+
Fields[N]["kind"]
|
|
70
|
+
>;
|
|
71
|
+
}
|
|
72
|
+
>;
|
|
73
|
+
|
|
74
|
+
export function serializeBlock(block: BlockDef): BlockSnapshot {
|
|
75
|
+
const fields: Record<string, FieldSnapshot> = {};
|
|
76
|
+
for (const [name, def] of Object.entries(block.fields)) {
|
|
77
|
+
fields[name] = serializeField(def);
|
|
78
|
+
}
|
|
79
|
+
return { name: block.name, fields };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function parseBlockSnapshot(input: unknown): BlockSnapshot {
|
|
83
|
+
if (!input || typeof input !== "object") {
|
|
84
|
+
throw new Error("block must be an object");
|
|
85
|
+
}
|
|
86
|
+
const value = input as Record<string, unknown>;
|
|
87
|
+
if (typeof value.name !== "string" || !value.name) {
|
|
88
|
+
throw new Error("block.name is required");
|
|
89
|
+
}
|
|
90
|
+
if (!value.fields || typeof value.fields !== "object" || Array.isArray(value.fields)) {
|
|
91
|
+
throw new Error("block.fields must be an object");
|
|
92
|
+
}
|
|
93
|
+
const fields: Record<string, FieldSnapshot> = {};
|
|
94
|
+
for (const [name, fieldRaw] of Object.entries(value.fields as Record<string, unknown>)) {
|
|
95
|
+
try {
|
|
96
|
+
fields[name] = parseFieldSnapshot(fieldRaw);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
const msg = err instanceof Error ? err.message : "invalid field";
|
|
99
|
+
throw new Error(`block.fields.${name}: ${msg}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { name: value.name, fields };
|
|
103
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -6,8 +6,10 @@ import { detectGitBranch } from "./sync-schema";
|
|
|
6
6
|
export type LlmcmsDirs = {
|
|
7
7
|
/** Folder with one `defineModel` default export per file. Default: "models". */
|
|
8
8
|
models?: string;
|
|
9
|
-
/** Folder with one
|
|
9
|
+
/** Folder with one `defineBlock` default export per file. Default: "blocks". */
|
|
10
10
|
blocks?: string;
|
|
11
|
+
/** Folder with one MDX tag component default export per file. Default: "mdx". */
|
|
12
|
+
mdx?: string;
|
|
11
13
|
};
|
|
12
14
|
|
|
13
15
|
export type LlmcmsConfig = {
|
|
@@ -40,7 +42,11 @@ export type ResolvedConfig = Omit<LlmcmsConfig, "dirs" | "defaultLocale"> & {
|
|
|
40
42
|
dirs: Required<LlmcmsDirs>;
|
|
41
43
|
};
|
|
42
44
|
|
|
43
|
-
export const DEFAULT_DIRS: Required<LlmcmsDirs> = {
|
|
45
|
+
export const DEFAULT_DIRS: Required<LlmcmsDirs> = {
|
|
46
|
+
models: "models",
|
|
47
|
+
blocks: "blocks",
|
|
48
|
+
mdx: "mdx",
|
|
49
|
+
};
|
|
44
50
|
export const DEFAULT_LOCALE = "en";
|
|
45
51
|
|
|
46
52
|
function withHttps(url: string): string {
|
|
@@ -106,6 +112,7 @@ export function resolveConfig(
|
|
|
106
112
|
dirs: {
|
|
107
113
|
models: config.dirs?.models ?? DEFAULT_DIRS.models,
|
|
108
114
|
blocks: config.dirs?.blocks ?? DEFAULT_DIRS.blocks,
|
|
115
|
+
mdx: config.dirs?.mdx ?? DEFAULT_DIRS.mdx,
|
|
109
116
|
},
|
|
110
117
|
};
|
|
111
118
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// packages/core/src/doc-format.ts
|
|
2
|
+
import { parseMdx, serializeMdx, type Frontmatter, type ParsedMdx } from "./mdx";
|
|
3
|
+
|
|
4
|
+
export type DocFormat = "mdx" | "page";
|
|
5
|
+
|
|
6
|
+
export function modelFormat(model: { format?: string } | null | undefined): DocFormat {
|
|
7
|
+
return model?.format === "page" ? "page" : "mdx";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function parseDoc(raw: string, format: DocFormat = "mdx"): ParsedMdx {
|
|
11
|
+
if (format !== "page") return parseMdx(raw);
|
|
12
|
+
let value: unknown;
|
|
13
|
+
try {
|
|
14
|
+
value = JSON.parse(raw);
|
|
15
|
+
} catch (err) {
|
|
16
|
+
const msg = err instanceof Error ? err.message : "invalid JSON";
|
|
17
|
+
throw new Error(`JSON: ${msg}`);
|
|
18
|
+
}
|
|
19
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
20
|
+
throw new Error("page document must be a JSON object");
|
|
21
|
+
}
|
|
22
|
+
return { frontmatter: value as Frontmatter, body: "" };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function serializeDoc(
|
|
26
|
+
format: DocFormat,
|
|
27
|
+
frontmatter: Frontmatter,
|
|
28
|
+
body: string,
|
|
29
|
+
): string {
|
|
30
|
+
if (format !== "page") return serializeMdx(frontmatter, body);
|
|
31
|
+
return `${JSON.stringify(frontmatter, null, 2)}\n`;
|
|
32
|
+
}
|
package/src/fields.ts
CHANGED
|
@@ -7,7 +7,8 @@ export type FieldKind =
|
|
|
7
7
|
| "image"
|
|
8
8
|
| "relation"
|
|
9
9
|
| "number"
|
|
10
|
-
| "boolean"
|
|
10
|
+
| "boolean"
|
|
11
|
+
| "blocks";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Field definition. Generic parameters only exist for type inference
|
|
@@ -52,6 +53,7 @@ export const field = {
|
|
|
52
53
|
boolean: <const R extends boolean = false>(opts?: Opts<R>) => base("boolean", opts),
|
|
53
54
|
relation: <To extends string, const R extends boolean = false>(to: To, opts?: Opts<R>) =>
|
|
54
55
|
base("relation", { ...opts, relationTo: to }),
|
|
56
|
+
blocks: <const R extends boolean = false>(opts?: Opts<R>) => base("blocks", opts),
|
|
55
57
|
};
|
|
56
58
|
|
|
57
59
|
export function serializeField(def: FieldDef): FieldSnapshot {
|
|
@@ -76,6 +78,7 @@ export function parseFieldSnapshot(input: unknown): FieldSnapshot {
|
|
|
76
78
|
"relation",
|
|
77
79
|
"number",
|
|
78
80
|
"boolean",
|
|
81
|
+
"blocks",
|
|
79
82
|
];
|
|
80
83
|
if (typeof kind !== "string" || !allowed.includes(kind as FieldKind)) {
|
|
81
84
|
throw new Error(`invalid field.kind: ${String(kind)}`);
|
package/src/index.ts
CHANGED
|
@@ -20,11 +20,27 @@ export {
|
|
|
20
20
|
defineModel,
|
|
21
21
|
defaultModelPath,
|
|
22
22
|
DEFAULT_MODEL_PATH,
|
|
23
|
+
DEFAULT_PAGE_PATH,
|
|
23
24
|
serializeModel,
|
|
24
25
|
serializeSchema,
|
|
25
26
|
parseSchemaSnapshot,
|
|
26
27
|
} from "./model";
|
|
27
28
|
|
|
29
|
+
export type {
|
|
30
|
+
BlockInstance,
|
|
31
|
+
BlockDef,
|
|
32
|
+
BlockSnapshot,
|
|
33
|
+
InferBlockProps,
|
|
34
|
+
} from "./block";
|
|
35
|
+
export {
|
|
36
|
+
defineBlock,
|
|
37
|
+
serializeBlock,
|
|
38
|
+
parseBlockSnapshot,
|
|
39
|
+
} from "./block";
|
|
40
|
+
|
|
41
|
+
export type { DocFormat } from "./doc-format";
|
|
42
|
+
export { modelFormat, parseDoc, serializeDoc } from "./doc-format";
|
|
43
|
+
|
|
28
44
|
export type { LlmcmsConfig, LlmcmsDirs, ResolvedConfig } from "./config";
|
|
29
45
|
export { defineConfig, resolveConfig, detectSiteUrl, DEFAULT_DIRS, DEFAULT_LOCALE } from "./config";
|
|
30
46
|
|
|
@@ -46,6 +62,8 @@ export {
|
|
|
46
62
|
matchModelPath,
|
|
47
63
|
matchAnyModelPath,
|
|
48
64
|
repoPathFromModel,
|
|
65
|
+
contentExtFromPath,
|
|
66
|
+
contentExtForType,
|
|
49
67
|
encodeGitBranch,
|
|
50
68
|
decodeGitBranch,
|
|
51
69
|
s3HeadKey,
|
|
@@ -63,6 +81,8 @@ export type { ValidationResult, ContentFileResult } from "./validate";
|
|
|
63
81
|
export {
|
|
64
82
|
validateFrontmatter,
|
|
65
83
|
validateMdxFile,
|
|
84
|
+
validateDocFile,
|
|
85
|
+
validateBlocks,
|
|
66
86
|
validateContentTree,
|
|
67
87
|
} from "./validate";
|
|
68
88
|
|
package/src/model.ts
CHANGED
|
@@ -6,6 +6,9 @@ import {
|
|
|
6
6
|
parseFieldSnapshot,
|
|
7
7
|
serializeField,
|
|
8
8
|
} from "./fields";
|
|
9
|
+
import { parseBlockSnapshot, serializeBlock, type BlockDef, type BlockSnapshot } from "./block";
|
|
10
|
+
import type { BlockInstance } from "./block";
|
|
11
|
+
import type { DocFormat } from "./doc-format";
|
|
9
12
|
|
|
10
13
|
export type ModelDef = {
|
|
11
14
|
type: string;
|
|
@@ -16,6 +19,8 @@ export type ModelDef = {
|
|
|
16
19
|
* Defaults to "/{locale}/{type}/{slug}" when omitted (see routes.ts).
|
|
17
20
|
*/
|
|
18
21
|
route?: string;
|
|
22
|
+
/** File format. Default `"mdx"`. `"page"` stores JSON with a blocks array. */
|
|
23
|
+
format?: DocFormat;
|
|
19
24
|
fields: Record<string, FieldDef>;
|
|
20
25
|
};
|
|
21
26
|
|
|
@@ -24,21 +29,25 @@ export type ModelInput = Omit<ModelDef, "path"> & { path?: string };
|
|
|
24
29
|
|
|
25
30
|
/** Default repo path when a model does not declare one. */
|
|
26
31
|
export const DEFAULT_MODEL_PATH = "content/{locale}/{type}/{slug}.mdx";
|
|
32
|
+
export const DEFAULT_PAGE_PATH = "content/{locale}/{type}/{slug}.json";
|
|
27
33
|
|
|
28
|
-
export function defaultModelPath(type: string): string {
|
|
29
|
-
|
|
34
|
+
export function defaultModelPath(type: string, format: DocFormat = "mdx"): string {
|
|
35
|
+
const template = format === "page" ? DEFAULT_PAGE_PATH : DEFAULT_MODEL_PATH;
|
|
36
|
+
return template.replaceAll("{type}", type);
|
|
30
37
|
}
|
|
31
38
|
|
|
32
39
|
export type ModelSnapshot = {
|
|
33
40
|
type: string;
|
|
34
41
|
path: string;
|
|
35
42
|
route?: string;
|
|
43
|
+
format?: DocFormat;
|
|
36
44
|
fields: Record<string, FieldSnapshot>;
|
|
37
45
|
};
|
|
38
46
|
|
|
39
47
|
export type SchemaSnapshot = {
|
|
40
48
|
version: number;
|
|
41
49
|
models: ModelSnapshot[];
|
|
50
|
+
blocks?: BlockSnapshot[];
|
|
42
51
|
};
|
|
43
52
|
|
|
44
53
|
type WithPath<D extends ModelInput> = D extends { path: string } ? D : D & { path: string };
|
|
@@ -51,7 +60,10 @@ export function defineModel<const D extends ModelInput>(def: D): WithPath<D> {
|
|
|
51
60
|
if (!def.type || typeof def.type !== "string") {
|
|
52
61
|
throw new Error("model.type is required");
|
|
53
62
|
}
|
|
54
|
-
|
|
63
|
+
if (def.format !== undefined && def.format !== "mdx" && def.format !== "page") {
|
|
64
|
+
throw new Error('model.format must be "mdx" or "page"');
|
|
65
|
+
}
|
|
66
|
+
const path = def.path ?? defaultModelPath(def.type, def.format ?? "mdx");
|
|
55
67
|
if (typeof path !== "string" || !path) {
|
|
56
68
|
throw new Error("model.path must be a non-empty string");
|
|
57
69
|
}
|
|
@@ -78,7 +90,9 @@ type ScalarOf<K extends FieldKind> = K extends "number"
|
|
|
78
90
|
? number
|
|
79
91
|
: K extends "boolean"
|
|
80
92
|
? boolean
|
|
81
|
-
:
|
|
93
|
+
: K extends "blocks"
|
|
94
|
+
? BlockInstance[]
|
|
95
|
+
: string;
|
|
82
96
|
|
|
83
97
|
type IsRequired<F> = F extends { required?: infer R }
|
|
84
98
|
? [Exclude<R, undefined>] extends [true]
|
|
@@ -110,14 +124,22 @@ export function serializeModel(model: ModelDef): ModelSnapshot {
|
|
|
110
124
|
fields,
|
|
111
125
|
};
|
|
112
126
|
if (model.route !== undefined) snapshot.route = model.route;
|
|
127
|
+
if (model.format === "page") snapshot.format = "page";
|
|
113
128
|
return snapshot;
|
|
114
129
|
}
|
|
115
130
|
|
|
116
|
-
export function serializeSchema(
|
|
117
|
-
|
|
131
|
+
export function serializeSchema(
|
|
132
|
+
models: ModelDef[],
|
|
133
|
+
blocks?: BlockDef[],
|
|
134
|
+
): SchemaSnapshot {
|
|
135
|
+
const snapshot: SchemaSnapshot = {
|
|
118
136
|
version: 1,
|
|
119
137
|
models: models.map(serializeModel),
|
|
120
138
|
};
|
|
139
|
+
if (blocks && blocks.length > 0) {
|
|
140
|
+
snapshot.blocks = blocks.map(serializeBlock);
|
|
141
|
+
}
|
|
142
|
+
return snapshot;
|
|
121
143
|
}
|
|
122
144
|
|
|
123
145
|
export function parseSchemaSnapshot(input: unknown): SchemaSnapshot {
|
|
@@ -167,6 +189,10 @@ export function parseSchemaSnapshot(input: unknown): SchemaSnapshot {
|
|
|
167
189
|
}
|
|
168
190
|
const snapshot: ModelSnapshot = { type: m.type, path: m.path, fields };
|
|
169
191
|
if (typeof m.route === "string") snapshot.route = m.route;
|
|
192
|
+
if (m.format === "page") snapshot.format = "page";
|
|
193
|
+
else if (m.format !== undefined && m.format !== "mdx") {
|
|
194
|
+
throw new Error(`schema.models[${i}].format must be "mdx" or "page"`);
|
|
195
|
+
}
|
|
170
196
|
return snapshot;
|
|
171
197
|
});
|
|
172
198
|
|
|
@@ -178,5 +204,28 @@ export function parseSchemaSnapshot(input: unknown): SchemaSnapshot {
|
|
|
178
204
|
types.add(m.type);
|
|
179
205
|
}
|
|
180
206
|
|
|
181
|
-
|
|
207
|
+
let blocks: BlockSnapshot[] = [];
|
|
208
|
+
const catalogRaw = value.blocks ?? value.pageblocks;
|
|
209
|
+
if (catalogRaw !== undefined) {
|
|
210
|
+
if (!Array.isArray(catalogRaw)) {
|
|
211
|
+
throw new Error("schema.blocks must be an array");
|
|
212
|
+
}
|
|
213
|
+
blocks = catalogRaw.map((raw, i) => {
|
|
214
|
+
try {
|
|
215
|
+
return parseBlockSnapshot(raw);
|
|
216
|
+
} catch (err) {
|
|
217
|
+
const msg = err instanceof Error ? err.message : "invalid block";
|
|
218
|
+
throw new Error(`schema.blocks[${i}]: ${msg}`);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
const names = new Set<string>();
|
|
222
|
+
for (const block of blocks) {
|
|
223
|
+
if (names.has(block.name)) {
|
|
224
|
+
throw new Error(`duplicate block name: ${block.name}`);
|
|
225
|
+
}
|
|
226
|
+
names.add(block.name);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return { version, models, blocks };
|
|
182
231
|
}
|
package/src/node/cli.ts
CHANGED
|
@@ -7,14 +7,15 @@ import { pathToFileURL } from "node:url";
|
|
|
7
7
|
import {
|
|
8
8
|
frontmatterJsonSchema,
|
|
9
9
|
serializeModel,
|
|
10
|
+
serializeBlock,
|
|
10
11
|
validateContentTree,
|
|
11
12
|
type ContentFileResult,
|
|
12
13
|
type ModelDef,
|
|
13
14
|
type ModelSnapshot,
|
|
14
15
|
} from "../index";
|
|
15
16
|
import { resolveConfig, type LlmcmsConfig, type ResolvedConfig } from "../config";
|
|
16
|
-
import { discoverModels } from "./discover";
|
|
17
|
-
import {
|
|
17
|
+
import { discoverModels, discoverBlocks } from "./discover";
|
|
18
|
+
import { walkContentFiles } from "./fs-loader";
|
|
18
19
|
import { detectSdk, findConfigFile, generate, type GenerateResult } from "./generate";
|
|
19
20
|
|
|
20
21
|
export type CliArgs = {
|
|
@@ -28,7 +29,7 @@ export type CliArgs = {
|
|
|
28
29
|
|
|
29
30
|
const USAGE = `Usage:
|
|
30
31
|
llmcms init scaffold llmcms.config.ts, the catch-all route and .llmcms/
|
|
31
|
-
llmcms generate [--root <dir>] write .llmcms/index.ts (typed cms) and .
|
|
32
|
+
llmcms generate [--root <dir>] write .llmcms/index.ts (typed cms), blocks.ts and mdx.ts
|
|
32
33
|
llmcms validate [--root <dir>] [--models <file>] [--json]
|
|
33
34
|
llmcms schema [--root <dir>] [--models <file>] [--out <dir>]
|
|
34
35
|
|
|
@@ -162,13 +163,21 @@ export async function runValidate(
|
|
|
162
163
|
): Promise<{ results: ContentFileResult[]; roots: string[]; exitCode: 0 | 1 }> {
|
|
163
164
|
const root = path.resolve(args.root);
|
|
164
165
|
const models = (await loadModels(root, args.models)).map(serializeModel);
|
|
166
|
+
const config = await loadConfig(root);
|
|
167
|
+
let catalog: ReturnType<typeof serializeBlock>[] = [];
|
|
168
|
+
try {
|
|
169
|
+
const defs = await discoverBlocks(root, config.dirs.blocks);
|
|
170
|
+
catalog = defs.map((d) => serializeBlock(d as never));
|
|
171
|
+
} catch {
|
|
172
|
+
catalog = [];
|
|
173
|
+
}
|
|
165
174
|
const roots = contentRoots(models);
|
|
166
175
|
const files: string[] = [];
|
|
167
176
|
for (const contentRoot of roots) {
|
|
168
177
|
const dir = path.join(root, contentRoot);
|
|
169
178
|
const info = await stat(dir).catch(() => null);
|
|
170
179
|
if (!info?.isDirectory()) continue; // missing roots are simply skipped
|
|
171
|
-
files.push(...(await
|
|
180
|
+
files.push(...(await walkContentFiles(dir)));
|
|
172
181
|
}
|
|
173
182
|
files.sort();
|
|
174
183
|
const inputs = await Promise.all(
|
|
@@ -177,7 +186,7 @@ export async function runValidate(
|
|
|
177
186
|
raw: await readFile(full, "utf-8"),
|
|
178
187
|
})),
|
|
179
188
|
);
|
|
180
|
-
const results = validateContentTree({ models, files: inputs });
|
|
189
|
+
const results = validateContentTree({ models, files: inputs, catalog });
|
|
181
190
|
return { results, roots, exitCode: results.some((r) => !r.ok) ? 1 : 0 };
|
|
182
191
|
}
|
|
183
192
|
|
|
@@ -221,7 +230,12 @@ export async function runGenerate(args: CliArgs): Promise<GenerateResult> {
|
|
|
221
230
|
);
|
|
222
231
|
config = resolveConfig({});
|
|
223
232
|
}
|
|
224
|
-
return generate({
|
|
233
|
+
return generate({
|
|
234
|
+
root,
|
|
235
|
+
modelsDir: config.dirs.models,
|
|
236
|
+
blocksDir: config.dirs.blocks,
|
|
237
|
+
mdxDir: config.dirs.mdx,
|
|
238
|
+
});
|
|
225
239
|
}
|
|
226
240
|
|
|
227
241
|
const CATCH_ALL_DIR = "[...llmcms]";
|
|
@@ -231,8 +245,9 @@ function renderConfigFile(sdk: string): string {
|
|
|
231
245
|
return `// llmcms.config.ts
|
|
232
246
|
import { defineConfig } from "${from}";
|
|
233
247
|
|
|
234
|
-
// General settings only. Models live in models/*.ts, blocks in
|
|
235
|
-
//
|
|
248
|
+
// General settings only. Models live in models/*.ts, composer blocks in
|
|
249
|
+
// blocks/*.tsx, MDX tags in mdx/*.tsx, and content in
|
|
250
|
+
// content/{locale}/{type}/{slug}.{mdx,json} — all discovered automatically.
|
|
236
251
|
// workspaceId / apiUrl / tokens fall back to LLMCMS_* env vars when omitted.
|
|
237
252
|
export default defineConfig({
|
|
238
253
|
defaultLocale: "en",
|
|
@@ -244,9 +259,10 @@ function renderCatchAllPage(registryImport: string): string {
|
|
|
244
259
|
return `// ${CATCH_ALL_DIR}/page.tsx — mounts every model at its route (default /{locale}/{type}/{slug}).
|
|
245
260
|
import { createCatchAll } from "@llm-cms/next/routes";
|
|
246
261
|
import { cms } from "${registryImport}";
|
|
262
|
+
import { components } from "${registryImport}/mdx";
|
|
247
263
|
import { blocks } from "${registryImport}/blocks";
|
|
248
264
|
|
|
249
|
-
const route = createCatchAll(cms, { components
|
|
265
|
+
const route = createCatchAll(cms, { components, blocks });
|
|
250
266
|
|
|
251
267
|
export const generateStaticParams = route.generateStaticParams;
|
|
252
268
|
export default route.Page;
|
|
@@ -312,7 +328,7 @@ export async function runInit(args: CliArgs): Promise<string[]> {
|
|
|
312
328
|
notes.push("no @llm-cms/next dependency: skipped the catch-all route");
|
|
313
329
|
}
|
|
314
330
|
|
|
315
|
-
for (const dir of ["models", "content", "blocks"]) {
|
|
331
|
+
for (const dir of ["models", "content", "blocks", "mdx"]) {
|
|
316
332
|
await mkdir(path.join(root, dir), { recursive: true });
|
|
317
333
|
}
|
|
318
334
|
|
|
@@ -339,7 +355,7 @@ async function main(argv: string[]): Promise<number> {
|
|
|
339
355
|
const { results, roots, exitCode } = await runValidate(args);
|
|
340
356
|
if (results.length === 0) {
|
|
341
357
|
console.error(
|
|
342
|
-
`llmcms: no .mdx files found under ${roots.join(", ")} (check --root)`,
|
|
358
|
+
`llmcms: no .mdx or .json files found under ${roots.join(", ")} (check --root)`,
|
|
343
359
|
);
|
|
344
360
|
}
|
|
345
361
|
if (args.json) {
|
|
@@ -368,6 +384,9 @@ async function main(argv: string[]): Promise<number> {
|
|
|
368
384
|
for (const skipped of result.skippedBlocks) {
|
|
369
385
|
console.error(`llmcms: skipped ${skipped} (block file names must be PascalCase)`);
|
|
370
386
|
}
|
|
387
|
+
for (const skipped of result.skippedMdx) {
|
|
388
|
+
console.error(`llmcms: skipped ${skipped} (MDX file names must be PascalCase)`);
|
|
389
|
+
}
|
|
371
390
|
return 0;
|
|
372
391
|
}
|
|
373
392
|
if (args.command === "init") {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// packages/core/src/node/create-llmcms.ts
|
|
2
2
|
import type { ModelDef, ModelSnapshot } from "../model";
|
|
3
|
+
import type { BlockDef } from "../block";
|
|
3
4
|
import { getDocFromApi, listDocsFromApi } from "../api-loader";
|
|
4
5
|
import { getDocFromFs, listDocsFromFs } from "./fs-loader";
|
|
5
6
|
import { createQuery, type DocLoader, type Query } from "../query";
|
|
@@ -15,6 +16,7 @@ import { contentCacheTags, listCacheTags } from "../content-committed";
|
|
|
15
16
|
export type CreateLlmcmsOptions<Models extends readonly ModelDef[] = readonly ModelDef[]> =
|
|
16
17
|
LlmcmsConfig & {
|
|
17
18
|
models: Models;
|
|
19
|
+
blocks?: readonly BlockDef[];
|
|
18
20
|
/**
|
|
19
21
|
* When true, API fetches use Next cache tags instead of `cache: "no-store"`.
|
|
20
22
|
* `@llm-cms/next` turns this on.
|
|
@@ -48,6 +50,7 @@ function toSnapshots(models: readonly ModelDef[]): ModelSnapshot[] {
|
|
|
48
50
|
fields: m.fields as ModelSnapshot["fields"],
|
|
49
51
|
};
|
|
50
52
|
if (m.route !== undefined) snapshot.route = m.route;
|
|
53
|
+
if (m.format === "page") snapshot.format = "page";
|
|
51
54
|
return snapshot;
|
|
52
55
|
});
|
|
53
56
|
}
|
|
@@ -55,7 +58,7 @@ function toSnapshots(models: readonly ModelDef[]): ModelSnapshot[] {
|
|
|
55
58
|
export function createLlmcms<const Models extends readonly ModelDef[]>(
|
|
56
59
|
options: CreateLlmcmsOptions<Models>,
|
|
57
60
|
): LlmcmsClient<Models> {
|
|
58
|
-
const { models: modelDefs, taggedApiFetch, ...rest } = options;
|
|
61
|
+
const { models: modelDefs, blocks: blockDefs, taggedApiFetch, ...rest } = options;
|
|
59
62
|
const config = resolveConfig(rest);
|
|
60
63
|
const models = toSnapshots(modelDefs);
|
|
61
64
|
const siteRoot = config.siteRoot ?? process.cwd();
|
|
@@ -78,6 +81,7 @@ export function createLlmcms<const Models extends readonly ModelDef[]>(
|
|
|
78
81
|
workspaceId,
|
|
79
82
|
hostToken,
|
|
80
83
|
models: [...modelDefs],
|
|
84
|
+
blocks: blockDefs ? [...blockDefs] : undefined,
|
|
81
85
|
branch: config.branch,
|
|
82
86
|
siteUrl,
|
|
83
87
|
}).catch((err) => {
|
package/src/node/discover.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// packages/core/src/node/discover.ts
|
|
2
|
-
// Folder conventions: `models/{type}.ts` (default export = defineModel)
|
|
3
|
-
// `blocks/{Name}.tsx` (default export =
|
|
4
|
-
//
|
|
2
|
+
// Folder conventions: `models/{type}.ts` (default export = defineModel),
|
|
3
|
+
// `blocks/{Name}.tsx` (default export = defineBlock), and `mdx/{Name}.tsx`
|
|
4
|
+
// (default export = MDX tag component). Listing is pure filesystem so it can
|
|
5
|
+
// run inside next.config; importing needs Bun/TS.
|
|
5
6
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
6
7
|
import path from "node:path";
|
|
7
8
|
import { pathToFileURL } from "node:url";
|
|
@@ -18,7 +19,7 @@ export type ModelFile = {
|
|
|
18
19
|
|
|
19
20
|
export type BlockFile = {
|
|
20
21
|
file: string;
|
|
21
|
-
/** File stem
|
|
22
|
+
/** File stem: block name or MDX tag name. */
|
|
22
23
|
name: string;
|
|
23
24
|
};
|
|
24
25
|
|
|
@@ -63,18 +64,42 @@ export function listModelFiles(root: string, modelsDir = "models"): ModelFile[]
|
|
|
63
64
|
|
|
64
65
|
const JSX_TAG = /^[A-Z][A-Za-z0-9]*$/;
|
|
65
66
|
|
|
66
|
-
|
|
67
|
+
function listPascalCaseFiles(
|
|
67
68
|
root: string,
|
|
68
|
-
|
|
69
|
-
): {
|
|
70
|
-
const
|
|
69
|
+
dir: string,
|
|
70
|
+
): { files: BlockFile[]; skipped: string[] } {
|
|
71
|
+
const files: BlockFile[] = [];
|
|
71
72
|
const skipped: string[] = [];
|
|
72
|
-
for (const file of listSourceFiles(path.resolve(root,
|
|
73
|
+
for (const file of listSourceFiles(path.resolve(root, dir))) {
|
|
73
74
|
const name = path.basename(file).replace(SOURCE_EXT, "");
|
|
74
|
-
if (JSX_TAG.test(name))
|
|
75
|
+
if (JSX_TAG.test(name)) files.push({ file, name });
|
|
75
76
|
else skipped.push(path.relative(root, file));
|
|
76
77
|
}
|
|
77
|
-
return {
|
|
78
|
+
return { files, skipped };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Structured composer blocks (`defineBlock`). */
|
|
82
|
+
export function listBlockFiles(
|
|
83
|
+
root: string,
|
|
84
|
+
blocksDir = "blocks",
|
|
85
|
+
): { blocks: BlockFile[]; skipped: string[] } {
|
|
86
|
+
const { files, skipped } = listPascalCaseFiles(root, blocksDir);
|
|
87
|
+
return { blocks: files, skipped };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** MDX tag components. */
|
|
91
|
+
export function listMdxFiles(
|
|
92
|
+
root: string,
|
|
93
|
+
mdxDir = "mdx",
|
|
94
|
+
): { blocks: BlockFile[]; skipped: string[] } {
|
|
95
|
+
const { files, skipped } = listPascalCaseFiles(root, mdxDir);
|
|
96
|
+
return { blocks: files, skipped };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isBlockDef(value: unknown): boolean {
|
|
100
|
+
if (!value || typeof value !== "object") return false;
|
|
101
|
+
const m = value as Record<string, unknown>;
|
|
102
|
+
return typeof m.name === "string" && typeof m.fields === "object" && "component" in m;
|
|
78
103
|
}
|
|
79
104
|
|
|
80
105
|
function isModelDef(value: unknown): value is ModelDef {
|
|
@@ -114,3 +139,36 @@ export async function discoverModels(root: string, modelsDir = "models"): Promis
|
|
|
114
139
|
}
|
|
115
140
|
return models;
|
|
116
141
|
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Import every block file. The file stem must equal `block.name`.
|
|
145
|
+
*/
|
|
146
|
+
export async function discoverBlocks(
|
|
147
|
+
root: string,
|
|
148
|
+
blocksDir = "blocks",
|
|
149
|
+
): Promise<Array<{ name: string; fields: Record<string, unknown>; component: unknown }>> {
|
|
150
|
+
const blocks: Array<{ name: string; fields: Record<string, unknown>; component: unknown }> = [];
|
|
151
|
+
const names = new Set<string>();
|
|
152
|
+
const { blocks: files } = listBlockFiles(root, blocksDir);
|
|
153
|
+
for (const { file, name } of files) {
|
|
154
|
+
const rel = path.relative(root, file);
|
|
155
|
+
const mod = (await import(
|
|
156
|
+
/* webpackIgnore: true */ /* turbopackIgnore: true */ pathToFileURL(file).href
|
|
157
|
+
)) as { default?: unknown };
|
|
158
|
+
if (!isBlockDef(mod.default)) {
|
|
159
|
+
throw new Error(`${rel} must \`export default defineBlock({ ... })\``);
|
|
160
|
+
}
|
|
161
|
+
const def = mod.default as { name: string; fields: Record<string, unknown>; component: unknown };
|
|
162
|
+
if (def.name !== name) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`${rel} defines name "${def.name}" but the file is named "${name}"; rename one so they match`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
if (names.has(def.name)) {
|
|
168
|
+
throw new Error(`duplicate block name "${def.name}" (${rel})`);
|
|
169
|
+
}
|
|
170
|
+
names.add(def.name);
|
|
171
|
+
blocks.push(def);
|
|
172
|
+
}
|
|
173
|
+
return blocks;
|
|
174
|
+
}
|