@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
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// packages/core/src/node/eval-block.ts
|
|
2
|
+
// Sandbox-eval a convention `blocks/{Name}.tsx` blob (GitHub import).
|
|
3
|
+
// `defineBlock` / `field` come from core; other imports are stubbed so the
|
|
4
|
+
// SaaS never loads customer React, Next, or the host checkout.
|
|
5
|
+
import { runInNewContext } from "node:vm";
|
|
6
|
+
import { field } from "../fields";
|
|
7
|
+
import { defineBlock, type BlockDef } from "../block";
|
|
8
|
+
|
|
9
|
+
const SOURCE_EXT = /\.(ts|tsx|js|jsx|mjs)$/;
|
|
10
|
+
const IGNORED = /(\.test\.|\.spec\.|\.d\.ts$|\.stories\.)/;
|
|
11
|
+
const PASCAL = /^[A-Z][A-Za-z0-9]*$/;
|
|
12
|
+
const CORE_IMPORT =
|
|
13
|
+
/^\s*import\s+\{([^}]+)\}\s+from\s+["'](@llm-cms\/core|@llm-cms\/next)["']\s*;?\s*$/gm;
|
|
14
|
+
const TYPE_IMPORT = /^\s*import\s+type\s+[\s\S]*?from\s+["'][^"']+["']\s*;?\s*$/gm;
|
|
15
|
+
const VALUE_IMPORT = /^\s*import\s+([\s\S]*?)\s+from\s+["'][^"']+["']\s*;?\s*$/gm;
|
|
16
|
+
const USE_CLIENT = /^["']use client["'];?\s*$/gm;
|
|
17
|
+
const ALLOWED_NAMES = new Set(["defineBlock", "field", "InferBlockProps"]);
|
|
18
|
+
const FORBIDDEN = [
|
|
19
|
+
{ re: /\brequire\s*\(/, name: "require" },
|
|
20
|
+
{ re: /\bimport\s*\(/, name: "import" },
|
|
21
|
+
{ re: /\bprocess\b/, name: "process" },
|
|
22
|
+
{ re: /\bBun\b/, name: "Bun" },
|
|
23
|
+
{ re: /\bDeno\b/, name: "Deno" },
|
|
24
|
+
{ re: /\bglobalThis\b/, name: "globalThis" },
|
|
25
|
+
{ re: /\bFunction\s*\(/, name: "Function" },
|
|
26
|
+
{ re: /\beval\s*\(/, name: "eval" },
|
|
27
|
+
];
|
|
28
|
+
const EVAL_TIMEOUT_MS = 1000;
|
|
29
|
+
|
|
30
|
+
function isBlockDef(value: unknown): value is BlockDef {
|
|
31
|
+
if (!value || typeof value !== "object") return false;
|
|
32
|
+
const m = value as Record<string, unknown>;
|
|
33
|
+
return typeof m.name === "string" && typeof m.fields === "object" && "component" in m;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** True for a repo-relative `blocks/{Name}.tsx` (PascalCase stem). */
|
|
37
|
+
export function isBlockSourcePath(
|
|
38
|
+
repoPath: string,
|
|
39
|
+
blocksDir = "blocks",
|
|
40
|
+
): boolean {
|
|
41
|
+
const normalized = repoPath.split("\\").join("/");
|
|
42
|
+
const prefix = `${blocksDir.replace(/\/+$/, "")}/`;
|
|
43
|
+
if (!normalized.startsWith(prefix)) return false;
|
|
44
|
+
const name = normalized.slice(prefix.length);
|
|
45
|
+
if (!name || name.includes("/")) return false;
|
|
46
|
+
if (name.startsWith("_") || name.startsWith(".")) return false;
|
|
47
|
+
if (!SOURCE_EXT.test(name) || IGNORED.test(name)) return false;
|
|
48
|
+
return PASCAL.test(name.replace(SOURCE_EXT, ""));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function stemOf(repoPath: string): string {
|
|
52
|
+
const name = repoPath.split("\\").join("/").split("/").pop() ?? "";
|
|
53
|
+
return name.replace(SOURCE_EXT, "");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function identifierOf(part: string): string | null {
|
|
57
|
+
const trimmed = part.trim();
|
|
58
|
+
if (!trimmed || trimmed.startsWith("type ")) return null;
|
|
59
|
+
const aliased = trimmed.split(/\s+as\s+/);
|
|
60
|
+
const name = (aliased[1] ?? aliased[0] ?? "").trim();
|
|
61
|
+
return name || null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function stubImportSpec(spec: string): string {
|
|
65
|
+
const stubs: string[] = [];
|
|
66
|
+
const named = spec.match(/\{([^}]*)\}/);
|
|
67
|
+
if (named) {
|
|
68
|
+
for (const part of named[1].split(",")) {
|
|
69
|
+
const id = identifierOf(part);
|
|
70
|
+
if (id) stubs.push(`var ${id} = function () { return null; };`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const before = spec.split("{")[0] ?? "";
|
|
74
|
+
const defaultName = before.replace(/,\s*$/, "").trim();
|
|
75
|
+
if (defaultName.startsWith("*")) {
|
|
76
|
+
const as = defaultName.match(/as\s+(\w+)/);
|
|
77
|
+
if (as) stubs.push(`var ${as[1]} = {};`);
|
|
78
|
+
} else if (defaultName) {
|
|
79
|
+
stubs.push(`var ${defaultName} = function () { return null; };`);
|
|
80
|
+
}
|
|
81
|
+
return stubs.join("\n");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function rewriteSource(source: string): string {
|
|
85
|
+
let body = source.replace(USE_CLIENT, "");
|
|
86
|
+
body = body.replace(TYPE_IMPORT, "");
|
|
87
|
+
body = body.replace(CORE_IMPORT, (_full, specifiers: string) => {
|
|
88
|
+
for (const part of specifiers.split(",")) {
|
|
89
|
+
const raw = part.trim();
|
|
90
|
+
if (!raw) continue;
|
|
91
|
+
const name = raw.replace(/^type\s+/, "").split(/\s+as\s+/)[0]?.trim();
|
|
92
|
+
if (!name) continue;
|
|
93
|
+
if (!ALLOWED_NAMES.has(name)) {
|
|
94
|
+
throw new Error(`import "${name}" is not allowed from @llm-cms/core`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return "";
|
|
98
|
+
});
|
|
99
|
+
body = body.replace(VALUE_IMPORT, (line) => {
|
|
100
|
+
const m = line.match(/^\s*import\s+([\s\S]*?)\s+from\s+["'][^"']+["']\s*;?\s*$/);
|
|
101
|
+
if (!m) throw new Error("import is not allowed");
|
|
102
|
+
return stubImportSpec(m[1]);
|
|
103
|
+
});
|
|
104
|
+
if (/^\s*import\b/m.test(body)) {
|
|
105
|
+
throw new Error("import is not allowed except defineBlock/field from @llm-cms/core");
|
|
106
|
+
}
|
|
107
|
+
if (!/\bexport\s+default\b/.test(body)) {
|
|
108
|
+
throw new Error("must export default defineBlock({ ... })");
|
|
109
|
+
}
|
|
110
|
+
const rewritten = stripNamedExports(body.replace(/\bexport\s+default\s+/, "return "));
|
|
111
|
+
if (/\bexport\b/.test(rewritten)) {
|
|
112
|
+
throw new Error("must export default defineBlock({ ... })");
|
|
113
|
+
}
|
|
114
|
+
return rewritten;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Host files also `export function Hero` for the Next composer map. */
|
|
118
|
+
function stripNamedExports(source: string): string {
|
|
119
|
+
return source
|
|
120
|
+
.replace(/\bexport\s+\{[^}]*\}\s*;?/g, "")
|
|
121
|
+
.replace(/\bexport\s+(async\s+)?function\s+/g, "$1function ")
|
|
122
|
+
.replace(/\bexport\s+const\s+/g, "const ")
|
|
123
|
+
.replace(/\bexport\s+let\s+/g, "let ")
|
|
124
|
+
.replace(/\bexport\s+class\s+/g, "class ");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function assertNoForbidden(source: string): void {
|
|
128
|
+
for (const { re, name } of FORBIDDEN) {
|
|
129
|
+
if (re.test(source)) throw new Error(`${name} is not allowed in block files`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function transpile(source: string): string {
|
|
134
|
+
const transpiler = new Bun.Transpiler({ loader: "tsx", target: "node" });
|
|
135
|
+
return transpiler.transformSync(source);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Evaluate one convention block file. `stem` must equal `block.name`. */
|
|
139
|
+
export function evalBlockSource(source: string, stem: string): BlockDef {
|
|
140
|
+
const body = rewriteSource(source);
|
|
141
|
+
assertNoForbidden(body);
|
|
142
|
+
const wrapped = `(function () {\n${body}\n})()`;
|
|
143
|
+
const js = transpile(wrapped);
|
|
144
|
+
const sandbox = Object.create(null) as {
|
|
145
|
+
defineBlock: typeof defineBlock;
|
|
146
|
+
field: typeof field;
|
|
147
|
+
React: { createElement: (...args: unknown[]) => null; Fragment: string };
|
|
148
|
+
jsx: (...args: unknown[]) => null;
|
|
149
|
+
jsxs: (...args: unknown[]) => null;
|
|
150
|
+
jsxDEV: (...args: unknown[]) => null;
|
|
151
|
+
};
|
|
152
|
+
const el = () => null;
|
|
153
|
+
sandbox.defineBlock = defineBlock;
|
|
154
|
+
sandbox.field = field;
|
|
155
|
+
sandbox.React = { createElement: el, Fragment: "Fragment" };
|
|
156
|
+
sandbox.jsx = el;
|
|
157
|
+
sandbox.jsxs = el;
|
|
158
|
+
sandbox.jsxDEV = el;
|
|
159
|
+
Object.freeze(sandbox);
|
|
160
|
+
|
|
161
|
+
let result: unknown;
|
|
162
|
+
try {
|
|
163
|
+
result = runInNewContext(js, sandbox, {
|
|
164
|
+
timeout: EVAL_TIMEOUT_MS,
|
|
165
|
+
filename: `${stem}.js`,
|
|
166
|
+
});
|
|
167
|
+
} catch (err) {
|
|
168
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
169
|
+
throw new Error(`block "${stem}" failed to evaluate: ${message}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!isBlockDef(result)) {
|
|
173
|
+
throw new Error("must export default defineBlock({ ... })");
|
|
174
|
+
}
|
|
175
|
+
if (result.name !== stem) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`defines name "${result.name}" but the file is named "${stem}"; rename one so they match`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function evalBlocksFromTree(
|
|
184
|
+
files: Array<{ path: string; source: string }>,
|
|
185
|
+
blocksDir = "blocks",
|
|
186
|
+
): BlockDef[] {
|
|
187
|
+
const blocks: BlockDef[] = [];
|
|
188
|
+
const names = new Set<string>();
|
|
189
|
+
const sorted = files
|
|
190
|
+
.filter((f) => isBlockSourcePath(f.path, blocksDir))
|
|
191
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
192
|
+
for (const file of sorted) {
|
|
193
|
+
const stem = stemOf(file.path);
|
|
194
|
+
const block = evalBlockSource(file.source, stem);
|
|
195
|
+
if (names.has(block.name)) {
|
|
196
|
+
throw new Error(`duplicate block name "${block.name}" (${file.path})`);
|
|
197
|
+
}
|
|
198
|
+
names.add(block.name);
|
|
199
|
+
blocks.push(block);
|
|
200
|
+
}
|
|
201
|
+
return blocks;
|
|
202
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// packages/core/src/node/eval-model.ts
|
|
2
|
+
// Sandbox-eval a convention `models/{type}.ts` blob (GitHub import). Only
|
|
3
|
+
// `defineModel` / `field` from @llm-cms/core or @llm-cms/next are in scope —
|
|
4
|
+
// never import() the customer checkout.
|
|
5
|
+
import { runInNewContext } from "node:vm";
|
|
6
|
+
import { defineModel, type ModelDef } from "../model";
|
|
7
|
+
import { field } from "../fields";
|
|
8
|
+
|
|
9
|
+
const SOURCE_EXT = /\.(ts|tsx|js|jsx|mjs)$/;
|
|
10
|
+
const IGNORED = /(\.test\.|\.spec\.|\.d\.ts$|\.stories\.)/;
|
|
11
|
+
const ALLOWED_IMPORT = /^\s*import\s+\{([^}]+)\}\s+from\s+["'](@llm-cms\/core|@llm-cms\/next)["']\s*;?\s*$/gm;
|
|
12
|
+
const ALLOWED_NAMES = new Set(["defineModel", "field"]);
|
|
13
|
+
const FORBIDDEN = [
|
|
14
|
+
{ re: /\brequire\s*\(/, name: "require" },
|
|
15
|
+
{ re: /\bimport\s*\(/, name: "import" },
|
|
16
|
+
{ re: /\bprocess\b/, name: "process" },
|
|
17
|
+
{ re: /\bBun\b/, name: "Bun" },
|
|
18
|
+
{ re: /\bDeno\b/, name: "Deno" },
|
|
19
|
+
{ re: /\bglobalThis\b/, name: "globalThis" },
|
|
20
|
+
{ re: /\bFunction\s*\(/, name: "Function" },
|
|
21
|
+
{ re: /\beval\s*\(/, name: "eval" },
|
|
22
|
+
];
|
|
23
|
+
const EVAL_TIMEOUT_MS = 1000;
|
|
24
|
+
|
|
25
|
+
function isModelDef(value: unknown): value is ModelDef {
|
|
26
|
+
if (!value || typeof value !== "object") return false;
|
|
27
|
+
const m = value as Record<string, unknown>;
|
|
28
|
+
return typeof m.type === "string" && typeof m.path === "string" && typeof m.fields === "object";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** True for a repo-relative `models/{type}.ts` (flat folder, same filters as listModelFiles). */
|
|
32
|
+
export function isModelSourcePath(repoPath: string, modelsDir = "models"): boolean {
|
|
33
|
+
const normalized = repoPath.split("\\").join("/");
|
|
34
|
+
const prefix = `${modelsDir.replace(/\/+$/, "")}/`;
|
|
35
|
+
if (!normalized.startsWith(prefix)) return false;
|
|
36
|
+
const name = normalized.slice(prefix.length);
|
|
37
|
+
if (!name || name.includes("/")) return false;
|
|
38
|
+
if (name.startsWith("_") || name.startsWith(".")) return false;
|
|
39
|
+
return SOURCE_EXT.test(name) && !IGNORED.test(name);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function stemOf(repoPath: string): string {
|
|
43
|
+
const name = repoPath.split("\\").join("/").split("/").pop() ?? "";
|
|
44
|
+
return name.replace(SOURCE_EXT, "");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function stripAllowedImports(source: string): string {
|
|
48
|
+
const names = new Set<string>();
|
|
49
|
+
const stripped = source.replace(ALLOWED_IMPORT, (_full, specifiers: string) => {
|
|
50
|
+
for (const part of specifiers.split(",")) {
|
|
51
|
+
const name = part.trim();
|
|
52
|
+
if (!name) continue;
|
|
53
|
+
if (/\bas\b/.test(name) || !ALLOWED_NAMES.has(name)) {
|
|
54
|
+
throw new Error(`import "${name}" is not allowed; only defineModel and field`);
|
|
55
|
+
}
|
|
56
|
+
names.add(name);
|
|
57
|
+
}
|
|
58
|
+
return "";
|
|
59
|
+
});
|
|
60
|
+
if (/^\s*import\b/m.test(stripped)) {
|
|
61
|
+
throw new Error("import is not allowed except defineModel/field from @llm-cms/core or @llm-cms/next");
|
|
62
|
+
}
|
|
63
|
+
return stripped;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function assertNoForbidden(source: string): void {
|
|
67
|
+
for (const { re, name } of FORBIDDEN) {
|
|
68
|
+
if (re.test(source)) throw new Error(`${name} is not allowed in model files`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function rewriteDefaultExport(source: string): string {
|
|
73
|
+
if (!/\bexport\s+default\b/.test(source)) {
|
|
74
|
+
throw new Error("must export default defineModel({ ... })");
|
|
75
|
+
}
|
|
76
|
+
const rewritten = source.replace(/\bexport\s+default\s+/, "return ");
|
|
77
|
+
if (/\bexport\b/.test(rewritten)) {
|
|
78
|
+
throw new Error("must export default defineModel({ ... })");
|
|
79
|
+
}
|
|
80
|
+
return rewritten;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function transpile(source: string): string {
|
|
84
|
+
const transpiler = new Bun.Transpiler({ loader: "ts", target: "node" });
|
|
85
|
+
return transpiler.transformSync(source);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Evaluate one convention model file. `stem` is the file name without extension
|
|
90
|
+
* and must equal `model.type`.
|
|
91
|
+
*/
|
|
92
|
+
export function evalModelSource(source: string, stem: string): ModelDef {
|
|
93
|
+
const body = rewriteDefaultExport(stripAllowedImports(source));
|
|
94
|
+
assertNoForbidden(body);
|
|
95
|
+
const wrapped = `(function () {\n${body}\n})()`;
|
|
96
|
+
const js = transpile(wrapped);
|
|
97
|
+
const sandbox = Object.create(null) as { defineModel: typeof defineModel; field: typeof field };
|
|
98
|
+
sandbox.defineModel = defineModel;
|
|
99
|
+
sandbox.field = field;
|
|
100
|
+
Object.freeze(sandbox);
|
|
101
|
+
|
|
102
|
+
let result: unknown;
|
|
103
|
+
try {
|
|
104
|
+
result = runInNewContext(js, sandbox, {
|
|
105
|
+
timeout: EVAL_TIMEOUT_MS,
|
|
106
|
+
filename: `${stem}.js`,
|
|
107
|
+
});
|
|
108
|
+
} catch (err) {
|
|
109
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
110
|
+
throw new Error(`model "${stem}" failed to evaluate: ${message}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!isModelDef(result)) {
|
|
114
|
+
throw new Error("must export default defineModel({ ... })");
|
|
115
|
+
}
|
|
116
|
+
if (result.type !== stem) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`defines type "${result.type}" but the file is named "${stem}"; rename one so they match`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function evalModelsFromTree(
|
|
125
|
+
files: Array<{ path: string; source: string }>,
|
|
126
|
+
modelsDir = "models",
|
|
127
|
+
): ModelDef[] {
|
|
128
|
+
const models: ModelDef[] = [];
|
|
129
|
+
const types = new Set<string>();
|
|
130
|
+
const sorted = files
|
|
131
|
+
.filter((f) => isModelSourcePath(f.path, modelsDir))
|
|
132
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
133
|
+
for (const file of sorted) {
|
|
134
|
+
const stem = stemOf(file.path);
|
|
135
|
+
const model = evalModelSource(file.source, stem);
|
|
136
|
+
if (types.has(model.type)) {
|
|
137
|
+
throw new Error(`duplicate model type "${model.type}" (${file.path})`);
|
|
138
|
+
}
|
|
139
|
+
types.add(model.type);
|
|
140
|
+
models.push(model);
|
|
141
|
+
}
|
|
142
|
+
return models;
|
|
143
|
+
}
|
package/src/node/fs-loader.ts
CHANGED
|
@@ -3,7 +3,8 @@ import { readdir, readFile } from "node:fs/promises";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import {
|
|
5
5
|
matchAnyModelPath,
|
|
6
|
-
|
|
6
|
+
modelFormat,
|
|
7
|
+
parseDoc,
|
|
7
8
|
repoPathFromModel,
|
|
8
9
|
type ModelSnapshot,
|
|
9
10
|
} from "../index";
|
|
@@ -11,7 +12,13 @@ import type { LlmcmsDoc, LlmcmsDocSummary } from "../doc";
|
|
|
11
12
|
|
|
12
13
|
const SKIP_DIRS = new Set(["node_modules", ".next", ".git"]);
|
|
13
14
|
|
|
15
|
+
const CONTENT_EXT = /\.(mdx|json)$/;
|
|
16
|
+
|
|
14
17
|
export async function walkMdxFiles(rootDir: string): Promise<string[]> {
|
|
18
|
+
return walkContentFiles(rootDir);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function walkContentFiles(rootDir: string): Promise<string[]> {
|
|
15
22
|
const out: string[] = [];
|
|
16
23
|
|
|
17
24
|
async function walk(dir: string) {
|
|
@@ -26,7 +33,7 @@ export async function walkMdxFiles(rootDir: string): Promise<string[]> {
|
|
|
26
33
|
const full = path.join(dir, entry.name);
|
|
27
34
|
if (entry.isDirectory()) {
|
|
28
35
|
await walk(full);
|
|
29
|
-
} else if (entry.isFile() && entry.name
|
|
36
|
+
} else if (entry.isFile() && CONTENT_EXT.test(entry.name)) {
|
|
30
37
|
out.push(full);
|
|
31
38
|
}
|
|
32
39
|
}
|
|
@@ -40,7 +47,7 @@ export async function listDocsFromFs(
|
|
|
40
47
|
siteRoot: string,
|
|
41
48
|
models: ModelSnapshot[],
|
|
42
49
|
): Promise<LlmcmsDocSummary[]> {
|
|
43
|
-
const files = await
|
|
50
|
+
const files = await walkContentFiles(siteRoot);
|
|
44
51
|
const docs: LlmcmsDocSummary[] = [];
|
|
45
52
|
for (const full of files) {
|
|
46
53
|
const rel = path.relative(siteRoot, full).split(path.sep).join("/");
|
|
@@ -76,7 +83,7 @@ export async function getDocFromFs(
|
|
|
76
83
|
} catch {
|
|
77
84
|
return null;
|
|
78
85
|
}
|
|
79
|
-
const parsed =
|
|
86
|
+
const parsed = parseDoc(raw, modelFormat(model));
|
|
80
87
|
return {
|
|
81
88
|
locale,
|
|
82
89
|
type,
|
package/src/node/generate.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// packages/core/src/node/generate.ts
|
|
2
|
-
// Writes the `.llmcms/` registry: a models barrel + typed `cms` client,
|
|
3
|
-
// blocks barrel. Pure filesystem (no TS import)
|
|
4
|
-
// next.config and on every file change in dev.
|
|
2
|
+
// Writes the `.llmcms/` registry: a models barrel + typed `cms` client, a
|
|
3
|
+
// blocks barrel, and an MDX components barrel. Pure filesystem (no TS import)
|
|
4
|
+
// so it can run inside next.config and on every file change in dev.
|
|
5
5
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { DEFAULT_DIRS } from "../config";
|
|
8
|
-
import { listBlockFiles, listModelFiles, type BlockFile, type ModelFile } from "./discover";
|
|
8
|
+
import { listBlockFiles, listMdxFiles, listModelFiles, type BlockFile, type ModelFile } from "./discover";
|
|
9
9
|
|
|
10
10
|
// Re-exported so `@llm-cms/core/generate` is a self-sufficient, dependency-light
|
|
11
11
|
// entry for bundler-side code (no zod / yaml pulled in).
|
|
@@ -22,6 +22,7 @@ export type GenerateOptions = {
|
|
|
22
22
|
outDir?: string;
|
|
23
23
|
modelsDir?: string;
|
|
24
24
|
blocksDir?: string;
|
|
25
|
+
mdxDir?: string;
|
|
25
26
|
/** Module that exports `createLlmcms`. Auto-detected from package.json when omitted. */
|
|
26
27
|
sdk?: string;
|
|
27
28
|
/** Write to disk. Default: true. `false` only renders (used by the bundler loader). */
|
|
@@ -47,7 +48,9 @@ export type GenerateResult = {
|
|
|
47
48
|
changed: string[];
|
|
48
49
|
models: ModelFile[];
|
|
49
50
|
blocks: BlockFile[];
|
|
51
|
+
mdx: BlockFile[];
|
|
50
52
|
skippedBlocks: string[];
|
|
53
|
+
skippedMdx: string[];
|
|
51
54
|
/** Absolute path of `llmcms.config.*` when present. */
|
|
52
55
|
configFile: string | null;
|
|
53
56
|
};
|
|
@@ -83,6 +86,7 @@ export function detectSdk(root: string): string {
|
|
|
83
86
|
export function renderModelsBarrel(input: {
|
|
84
87
|
outDir: string;
|
|
85
88
|
models: ModelFile[];
|
|
89
|
+
blocks?: BlockFile[];
|
|
86
90
|
configFile: string | null;
|
|
87
91
|
sdk: string;
|
|
88
92
|
}): string {
|
|
@@ -95,22 +99,44 @@ export function renderModelsBarrel(input: {
|
|
|
95
99
|
for (const m of input.models) {
|
|
96
100
|
lines.push(`import ${m.importName} from "${importSpecifier(input.outDir, m.file)}";`);
|
|
97
101
|
}
|
|
102
|
+
const blocks = input.blocks ?? [];
|
|
103
|
+
for (const b of blocks) {
|
|
104
|
+
lines.push(`import ${b.name} from "${importSpecifier(input.outDir, b.file)}";`);
|
|
105
|
+
}
|
|
98
106
|
lines.push("");
|
|
99
107
|
lines.push(`export const models = [${input.models.map((m) => m.importName).join(", ")}] as const;`);
|
|
100
108
|
lines.push("export type Models = typeof models;");
|
|
101
109
|
lines.push("");
|
|
110
|
+
lines.push(
|
|
111
|
+
`export const blocks = [${blocks.map((b) => b.name).join(", ")}] as const;`,
|
|
112
|
+
);
|
|
113
|
+
lines.push("");
|
|
102
114
|
lines.push("/** Typed content client: cms.query.{type}, cms.getDoc, cms.listDocs. */");
|
|
103
|
-
lines.push("export const cms = createLlmcms({ ...config, models });");
|
|
115
|
+
lines.push("export const cms = createLlmcms({ ...config, models, blocks });");
|
|
116
|
+
return `${lines.join("\n")}\n`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function renderMdxBarrel(input: { outDir: string; mdx: BlockFile[] }): string {
|
|
120
|
+
const lines = [GENERATED_HEADER];
|
|
121
|
+
for (const b of input.mdx) {
|
|
122
|
+
lines.push(`import ${b.name} from "${importSpecifier(input.outDir, b.file)}";`);
|
|
123
|
+
}
|
|
124
|
+
lines.push("");
|
|
125
|
+
lines.push("/** MDX component map: <Hero /> in content resolves to mdx/Hero.tsx. */");
|
|
126
|
+
const names = input.mdx.map((b) => b.name).join(", ");
|
|
127
|
+
lines.push(`export const components = ${names ? `{ ${names} }` : "{}"};`);
|
|
104
128
|
return `${lines.join("\n")}\n`;
|
|
105
129
|
}
|
|
106
130
|
|
|
107
131
|
export function renderBlocksBarrel(input: { outDir: string; blocks: BlockFile[] }): string {
|
|
108
132
|
const lines = [GENERATED_HEADER];
|
|
109
133
|
for (const b of input.blocks) {
|
|
110
|
-
lines.push(`import ${b.name} from "${importSpecifier(input.outDir, b.file)}";`);
|
|
134
|
+
lines.push(`import { ${b.name} } from "${importSpecifier(input.outDir, b.file)}";`);
|
|
111
135
|
}
|
|
112
136
|
lines.push("");
|
|
113
|
-
|
|
137
|
+
// Named imports, not `def.component`: Next cannot resolve a nested property
|
|
138
|
+
// on a `"use client"` module from a Server Component.
|
|
139
|
+
lines.push("/** Composer map: JSON `{ type: \"Hero\" }` resolves to blocks/Hero.tsx. */");
|
|
114
140
|
const names = input.blocks.map((b) => b.name).join(", ");
|
|
115
141
|
lines.push(`export const blocks = ${names ? `{ ${names} }` : "{}"};`);
|
|
116
142
|
return `${lines.join("\n")}\n`;
|
|
@@ -121,20 +147,28 @@ function differsOnDisk(file: string, content: string): boolean {
|
|
|
121
147
|
}
|
|
122
148
|
|
|
123
149
|
/** Registry file names inside the out dir. */
|
|
124
|
-
export const REGISTRY_FILES = ["index.ts", "blocks.ts"] as const;
|
|
150
|
+
export const REGISTRY_FILES = ["index.ts", "blocks.ts", "mdx.ts"] as const;
|
|
125
151
|
|
|
126
152
|
export function generate(options: GenerateOptions): GenerateResult {
|
|
127
153
|
const root = path.resolve(options.root);
|
|
128
154
|
const outDir = path.resolve(root, options.outDir ?? DEFAULT_OUT_DIR);
|
|
129
155
|
const models = listModelFiles(root, options.modelsDir ?? DEFAULT_DIRS.models);
|
|
130
|
-
const { blocks, skipped } = listBlockFiles(
|
|
156
|
+
const { blocks, skipped: skippedBlocks } = listBlockFiles(
|
|
157
|
+
root,
|
|
158
|
+
options.blocksDir ?? DEFAULT_DIRS.blocks,
|
|
159
|
+
);
|
|
160
|
+
const { blocks: mdx, skipped: skippedMdx } = listMdxFiles(
|
|
161
|
+
root,
|
|
162
|
+
options.mdxDir ?? DEFAULT_DIRS.mdx,
|
|
163
|
+
);
|
|
131
164
|
const sdk = options.sdk ?? detectSdk(root);
|
|
132
165
|
const configFile = findConfigFile(root);
|
|
133
166
|
const write = options.write ?? true;
|
|
134
167
|
|
|
135
168
|
const rendered: Record<(typeof REGISTRY_FILES)[number], string> = {
|
|
136
|
-
"index.ts": renderModelsBarrel({ outDir, models, configFile, sdk }),
|
|
169
|
+
"index.ts": renderModelsBarrel({ outDir, models, blocks, configFile, sdk }),
|
|
137
170
|
"blocks.ts": renderBlocksBarrel({ outDir, blocks }),
|
|
171
|
+
"mdx.ts": renderMdxBarrel({ outDir, mdx }),
|
|
138
172
|
};
|
|
139
173
|
const outputs: GeneratedFile[] = REGISTRY_FILES.map((name) => {
|
|
140
174
|
const file = path.join(outDir, name);
|
|
@@ -152,7 +186,9 @@ export function generate(options: GenerateOptions): GenerateResult {
|
|
|
152
186
|
changed: outputs.filter((o) => o.changed).map((o) => o.rel),
|
|
153
187
|
models,
|
|
154
188
|
blocks,
|
|
155
|
-
|
|
189
|
+
mdx,
|
|
190
|
+
skippedBlocks,
|
|
191
|
+
skippedMdx,
|
|
156
192
|
configFile,
|
|
157
193
|
};
|
|
158
194
|
}
|
package/src/node/index.ts
CHANGED
|
@@ -4,19 +4,28 @@
|
|
|
4
4
|
// `@llm-cms/core/preview` instead.
|
|
5
5
|
export { createLlmcms } from "./create-llmcms";
|
|
6
6
|
export type { CreateLlmcmsOptions, LlmcmsClient } from "./create-llmcms";
|
|
7
|
-
export { getDocFromFs, listDocsFromFs, walkMdxFiles } from "./fs-loader";
|
|
7
|
+
export { getDocFromFs, listDocsFromFs, walkMdxFiles, walkContentFiles } from "./fs-loader";
|
|
8
8
|
export {
|
|
9
9
|
discoverModels,
|
|
10
|
+
discoverBlocks,
|
|
10
11
|
listBlockFiles,
|
|
12
|
+
listMdxFiles,
|
|
11
13
|
listModelFiles,
|
|
12
14
|
toIdentifier,
|
|
13
15
|
} from "./discover";
|
|
14
16
|
export type { BlockFile, ModelFile } from "./discover";
|
|
17
|
+
export { evalModelSource, evalModelsFromTree, isModelSourcePath } from "./eval-model";
|
|
18
|
+
export {
|
|
19
|
+
evalBlockSource,
|
|
20
|
+
evalBlocksFromTree,
|
|
21
|
+
isBlockSourcePath,
|
|
22
|
+
} from "./eval-block";
|
|
15
23
|
export {
|
|
16
24
|
detectSdk,
|
|
17
25
|
findConfigFile,
|
|
18
26
|
generate,
|
|
19
27
|
renderBlocksBarrel,
|
|
28
|
+
renderMdxBarrel,
|
|
20
29
|
renderModelsBarrel,
|
|
21
30
|
DEFAULT_OUT_DIR,
|
|
22
31
|
REGISTRY_FILES,
|
package/src/path.ts
CHANGED
|
@@ -32,10 +32,12 @@ export function matchModelPath(
|
|
|
32
32
|
const re = compilePathPattern(model.path);
|
|
33
33
|
const m = re.exec(normalized);
|
|
34
34
|
if (!m?.groups?.locale || !m.groups.slug) return null;
|
|
35
|
-
// Strip trailing
|
|
35
|
+
// Strip trailing extension from slug if the template baked it into the capture.
|
|
36
36
|
let slug = m.groups.slug;
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
const ext = contentExtFromPath(model.path);
|
|
38
|
+
const suffix = `.${ext}`;
|
|
39
|
+
if (model.path.endsWith(suffix) && slug.endsWith(suffix)) {
|
|
40
|
+
slug = slug.slice(0, -suffix.length);
|
|
39
41
|
}
|
|
40
42
|
if (!slug) return null;
|
|
41
43
|
return { locale: m.groups.locale, slug, type: model.type };
|
|
@@ -52,6 +54,19 @@ export function matchAnyModelPath(
|
|
|
52
54
|
return null;
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
export function contentExtFromPath(modelPath: string): string {
|
|
58
|
+
const m = /\.([a-z0-9]+)$/i.exec(modelPath);
|
|
59
|
+
return m?.[1]?.toLowerCase() ?? "mdx";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function contentExtForType(
|
|
63
|
+
models: Array<Pick<ModelSnapshot, "type" | "path">> | undefined,
|
|
64
|
+
type: string,
|
|
65
|
+
): string {
|
|
66
|
+
const model = models?.find((m) => m.type === type);
|
|
67
|
+
return model ? contentExtFromPath(model.path) : "mdx";
|
|
68
|
+
}
|
|
69
|
+
|
|
55
70
|
export function repoPathFromModel(
|
|
56
71
|
model: Pick<ModelSnapshot, "path">,
|
|
57
72
|
locale: string,
|
|
@@ -106,8 +121,9 @@ export function s3HeadKey(
|
|
|
106
121
|
locale: string,
|
|
107
122
|
type: string,
|
|
108
123
|
slug: string,
|
|
124
|
+
ext = "mdx",
|
|
109
125
|
): string {
|
|
110
|
-
return `${s3HeadPrefix(workspaceId, branch)}${locale}/${type}/${slug}
|
|
126
|
+
return `${s3HeadPrefix(workspaceId, branch)}${locale}/${type}/${slug}.${ext}`;
|
|
111
127
|
}
|
|
112
128
|
|
|
113
129
|
export function s3TreeKey(
|
|
@@ -117,8 +133,9 @@ export function s3TreeKey(
|
|
|
117
133
|
locale: string,
|
|
118
134
|
type: string,
|
|
119
135
|
slug: string,
|
|
136
|
+
ext = "mdx",
|
|
120
137
|
): string {
|
|
121
|
-
return `${s3TreePrefix(workspaceId, branch, userId)}${locale}/${type}/${slug}
|
|
138
|
+
return `${s3TreePrefix(workspaceId, branch, userId)}${locale}/${type}/${slug}.${ext}`;
|
|
122
139
|
}
|
|
123
140
|
|
|
124
141
|
/** HEAD blob sha for conflict checks. */
|
|
@@ -151,7 +168,7 @@ export function parseS3ContentKey(
|
|
|
151
168
|
if (!key.startsWith(prefix)) return null;
|
|
152
169
|
const rest = key.slice(prefix.length);
|
|
153
170
|
|
|
154
|
-
const head = /^([^/]+)\/head\/([^/]+)\/([^/]+)\/(.+)\.mdx$/.exec(rest);
|
|
171
|
+
const head = /^([^/]+)\/head\/([^/]+)\/([^/]+)\/(.+)\.(mdx|json)$/.exec(rest);
|
|
155
172
|
if (head) {
|
|
156
173
|
return {
|
|
157
174
|
branch: decodeGitBranch(head[1]!),
|
|
@@ -164,7 +181,7 @@ export function parseS3ContentKey(
|
|
|
164
181
|
}
|
|
165
182
|
|
|
166
183
|
const tree =
|
|
167
|
-
/^([^/]+)\/trees\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)\.mdx$/.exec(rest);
|
|
184
|
+
/^([^/]+)\/trees\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)\.(mdx|json)$/.exec(rest);
|
|
168
185
|
if (tree) {
|
|
169
186
|
return {
|
|
170
187
|
branch: decodeGitBranch(tree[1]!),
|
package/src/sync-schema.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
type ModelDef,
|
|
5
5
|
type ModelSnapshot,
|
|
6
6
|
} from "./model";
|
|
7
|
+
import type { BlockDef } from "./block";
|
|
7
8
|
|
|
8
9
|
export function detectGitBranch(
|
|
9
10
|
env: Record<string, string | undefined> = process.env,
|
|
@@ -20,13 +21,14 @@ export type SyncSchemaOptions = {
|
|
|
20
21
|
workspaceId: string;
|
|
21
22
|
hostToken: string;
|
|
22
23
|
models: Array<ModelDef | ModelSnapshot>;
|
|
24
|
+
blocks?: BlockDef[];
|
|
23
25
|
branch?: string;
|
|
24
26
|
siteUrl?: string;
|
|
25
27
|
};
|
|
26
28
|
|
|
27
29
|
export async function syncSchema(options: SyncSchemaOptions): Promise<void> {
|
|
28
30
|
const branch = options.branch ?? detectGitBranch() ?? "main";
|
|
29
|
-
const payload = serializeSchema(options.models as ModelDef[]);
|
|
31
|
+
const payload = serializeSchema(options.models as ModelDef[], options.blocks);
|
|
30
32
|
const body: { payload: ReturnType<typeof serializeSchema>; branch: string; siteUrl?: string } = {
|
|
31
33
|
payload,
|
|
32
34
|
branch,
|