@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.
@@ -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
+ }
@@ -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
- parseMdx,
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.endsWith(".mdx")) {
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 walkMdxFiles(siteRoot);
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 = parseMdx(raw);
86
+ const parsed = parseDoc(raw, modelFormat(model));
80
87
  return {
81
88
  locale,
82
89
  type,
@@ -1,11 +1,11 @@
1
1
  // packages/core/src/node/generate.ts
2
- // Writes the `.llmcms/` registry: a models barrel + typed `cms` client, and a
3
- // blocks barrel. Pure filesystem (no TS import) so it can run inside
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
- lines.push("/** MDX component map: <Hero /> in content resolves to blocks/Hero.tsx. */");
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(root, options.blocksDir ?? DEFAULT_DIRS.blocks);
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
- skippedBlocks: skipped,
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 .mdx from slug if the template baked the extension into the capture.
35
+ // Strip trailing extension from slug if the template baked it into the capture.
36
36
  let slug = m.groups.slug;
37
- if (model.path.endsWith(".mdx") && slug.endsWith(".mdx")) {
38
- slug = slug.slice(0, -".mdx".length);
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}.mdx`;
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}.mdx`;
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]!),
@@ -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,