@backmatter/core 0.0.6
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/bin/backmatter.js +2 -0
- package/package.json +36 -0
- package/src/api-loader.ts +72 -0
- package/src/block.ts +103 -0
- package/src/coerce.ts +74 -0
- package/src/config.ts +125 -0
- package/src/content-committed.ts +57 -0
- package/src/doc-format.ts +32 -0
- package/src/doc-ref.ts +29 -0
- package/src/doc.ts +18 -0
- package/src/fields.ts +92 -0
- package/src/index.ts +132 -0
- package/src/mdx.ts +44 -0
- package/src/model.ts +258 -0
- package/src/node/cli.ts +505 -0
- package/src/node/create-backmatter.ts +130 -0
- package/src/node/discover.ts +191 -0
- package/src/node/eval-block.ts +202 -0
- package/src/node/eval-model.ts +143 -0
- package/src/node/fs-loader.ts +96 -0
- package/src/node/generate.ts +265 -0
- package/src/node/index.ts +50 -0
- package/src/path.ts +196 -0
- package/src/preview-hotkeys.ts +71 -0
- package/src/preview-session.ts +131 -0
- package/src/preview.ts +205 -0
- package/src/query.ts +152 -0
- package/src/routes.ts +110 -0
- package/src/signed-token.ts +63 -0
- package/src/sync-schema.ts +52 -0
- package/src/validate.ts +149 -0
- package/src/zod.ts +111 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// packages/core/src/node/discover.ts
|
|
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.
|
|
6
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { pathToFileURL } from "node:url";
|
|
9
|
+
import type { ModelDef } from "../model";
|
|
10
|
+
|
|
11
|
+
export type ModelFile = {
|
|
12
|
+
/** Absolute path. */
|
|
13
|
+
file: string;
|
|
14
|
+
/** File stem, also the expected `model.type`. */
|
|
15
|
+
stem: string;
|
|
16
|
+
/** Valid JS identifier derived from the stem, used in generated imports. */
|
|
17
|
+
importName: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type BlockFile = {
|
|
21
|
+
file: string;
|
|
22
|
+
/** File stem: block name or MDX tag name. */
|
|
23
|
+
name: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const SOURCE_EXT = /\.(ts|tsx|js|jsx|mjs)$/;
|
|
27
|
+
const IGNORED = /(\.test\.|\.spec\.|\.d\.ts$|\.stories\.)/;
|
|
28
|
+
|
|
29
|
+
export function toIdentifier(stem: string): string {
|
|
30
|
+
const camel = stem
|
|
31
|
+
.split(/[^A-Za-z0-9]+/)
|
|
32
|
+
.filter(Boolean)
|
|
33
|
+
.map((part, i) => (i === 0 ? part : part[0]!.toUpperCase() + part.slice(1)))
|
|
34
|
+
.join("");
|
|
35
|
+
const safe = camel.replace(/^[^A-Za-z_$]/, (c) => `_${c}`);
|
|
36
|
+
return safe || "_";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function listSourceFiles(dir: string): string[] {
|
|
40
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) return [];
|
|
41
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
42
|
+
.filter((e) => e.isFile() && SOURCE_EXT.test(e.name) && !IGNORED.test(e.name))
|
|
43
|
+
.filter((e) => !e.name.startsWith("_") && !e.name.startsWith("."))
|
|
44
|
+
.map((e) => path.join(dir, e.name))
|
|
45
|
+
.sort();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function listModelFiles(root: string, modelsDir = "models"): ModelFile[] {
|
|
49
|
+
const files = listSourceFiles(path.resolve(root, modelsDir));
|
|
50
|
+
const seen = new Map<string, string>();
|
|
51
|
+
return files.map((file) => {
|
|
52
|
+
const stem = path.basename(file).replace(SOURCE_EXT, "");
|
|
53
|
+
const importName = toIdentifier(stem);
|
|
54
|
+
const clash = seen.get(importName);
|
|
55
|
+
if (clash) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`models/${path.basename(clash)} and models/${path.basename(file)} both map to "${importName}"`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
seen.set(importName, file);
|
|
61
|
+
return { file, stem, importName };
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const JSX_TAG = /^[A-Z][A-Za-z0-9]*$/;
|
|
66
|
+
const FALLBACK_STEM = /^([A-Z][A-Za-z0-9]*)\.fallback$/;
|
|
67
|
+
|
|
68
|
+
function listPascalCaseFiles(
|
|
69
|
+
root: string,
|
|
70
|
+
dir: string,
|
|
71
|
+
): { files: BlockFile[]; skipped: string[] } {
|
|
72
|
+
const files: BlockFile[] = [];
|
|
73
|
+
const skipped: string[] = [];
|
|
74
|
+
for (const file of listSourceFiles(path.resolve(root, dir))) {
|
|
75
|
+
const name = path.basename(file).replace(SOURCE_EXT, "");
|
|
76
|
+
if (FALLBACK_STEM.test(name)) continue;
|
|
77
|
+
if (JSX_TAG.test(name)) files.push({ file, name });
|
|
78
|
+
else skipped.push(path.relative(root, file));
|
|
79
|
+
}
|
|
80
|
+
return { files, skipped };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function listBlockFallbackFiles(
|
|
84
|
+
root: string,
|
|
85
|
+
blocksDir = "blocks",
|
|
86
|
+
): BlockFile[] {
|
|
87
|
+
const files: BlockFile[] = [];
|
|
88
|
+
for (const file of listSourceFiles(path.resolve(root, blocksDir))) {
|
|
89
|
+
const stem = path.basename(file).replace(SOURCE_EXT, "");
|
|
90
|
+
const match = FALLBACK_STEM.exec(stem);
|
|
91
|
+
if (match) files.push({ file, name: match[1]! });
|
|
92
|
+
}
|
|
93
|
+
return files.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function listLayoutFiles(root: string, layoutsDir = "layouts"): ModelFile[] {
|
|
97
|
+
return listModelFiles(root, layoutsDir);
|
|
98
|
+
}
|
|
99
|
+
export function listBlockFiles(
|
|
100
|
+
root: string,
|
|
101
|
+
blocksDir = "blocks",
|
|
102
|
+
): { blocks: BlockFile[]; skipped: string[] } {
|
|
103
|
+
const { files, skipped } = listPascalCaseFiles(root, blocksDir);
|
|
104
|
+
return { blocks: files, skipped };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** MDX tag components. */
|
|
108
|
+
export function listMdxFiles(
|
|
109
|
+
root: string,
|
|
110
|
+
mdxDir = "mdx",
|
|
111
|
+
): { blocks: BlockFile[]; skipped: string[] } {
|
|
112
|
+
const { files, skipped } = listPascalCaseFiles(root, mdxDir);
|
|
113
|
+
return { blocks: files, skipped };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isBlockDef(value: unknown): boolean {
|
|
117
|
+
if (!value || typeof value !== "object") return false;
|
|
118
|
+
const m = value as Record<string, unknown>;
|
|
119
|
+
return typeof m.name === "string" && typeof m.fields === "object" && "component" in m;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function isModelDef(value: unknown): value is ModelDef {
|
|
123
|
+
if (!value || typeof value !== "object") return false;
|
|
124
|
+
const m = value as Record<string, unknown>;
|
|
125
|
+
return typeof m.type === "string" && typeof m.path === "string" && typeof m.fields === "object";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Import every model file and return its default export. The file stem must
|
|
130
|
+
* equal `model.type` so `content/{locale}/{type}/` and `models/{type}.ts` line up.
|
|
131
|
+
*/
|
|
132
|
+
export async function discoverModels(root: string, modelsDir = "models"): Promise<ModelDef[]> {
|
|
133
|
+
const models: ModelDef[] = [];
|
|
134
|
+
const types = new Set<string>();
|
|
135
|
+
for (const { file, stem } of listModelFiles(root, modelsDir)) {
|
|
136
|
+
const rel = path.relative(root, file);
|
|
137
|
+
// CLI-only runtime import. The ignore hints stop webpack/Turbopack from
|
|
138
|
+
// trying to bundle an expression request when this module ends up in a
|
|
139
|
+
// host app graph via `@backmatter/core/node`.
|
|
140
|
+
const mod = (await import(
|
|
141
|
+
/* webpackIgnore: true */ /* turbopackIgnore: true */ pathToFileURL(file).href
|
|
142
|
+
)) as { default?: unknown };
|
|
143
|
+
if (!isModelDef(mod.default)) {
|
|
144
|
+
throw new Error(`${rel} must \`export default defineModel({ ... })\``);
|
|
145
|
+
}
|
|
146
|
+
if (mod.default.type !== stem) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`${rel} defines type "${mod.default.type}" but the file is named "${stem}"; rename one so they match`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
if (types.has(mod.default.type)) {
|
|
152
|
+
throw new Error(`duplicate model type "${mod.default.type}" (${rel})`);
|
|
153
|
+
}
|
|
154
|
+
types.add(mod.default.type);
|
|
155
|
+
models.push(mod.default);
|
|
156
|
+
}
|
|
157
|
+
return models;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Import every block file. The file stem must equal `block.name`.
|
|
162
|
+
*/
|
|
163
|
+
export async function discoverBlocks(
|
|
164
|
+
root: string,
|
|
165
|
+
blocksDir = "blocks",
|
|
166
|
+
): Promise<Array<{ name: string; fields: Record<string, unknown>; component: unknown }>> {
|
|
167
|
+
const blocks: Array<{ name: string; fields: Record<string, unknown>; component: unknown }> = [];
|
|
168
|
+
const names = new Set<string>();
|
|
169
|
+
const { blocks: files } = listBlockFiles(root, blocksDir);
|
|
170
|
+
for (const { file, name } of files) {
|
|
171
|
+
const rel = path.relative(root, file);
|
|
172
|
+
const mod = (await import(
|
|
173
|
+
/* webpackIgnore: true */ /* turbopackIgnore: true */ pathToFileURL(file).href
|
|
174
|
+
)) as { default?: unknown };
|
|
175
|
+
if (!isBlockDef(mod.default)) {
|
|
176
|
+
throw new Error(`${rel} must \`export default defineBlock({ ... })\``);
|
|
177
|
+
}
|
|
178
|
+
const def = mod.default as { name: string; fields: Record<string, unknown>; component: unknown };
|
|
179
|
+
if (def.name !== name) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`${rel} defines name "${def.name}" but the file is named "${name}"; rename one so they match`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
if (names.has(def.name)) {
|
|
185
|
+
throw new Error(`duplicate block name "${def.name}" (${rel})`);
|
|
186
|
+
}
|
|
187
|
+
names.add(def.name);
|
|
188
|
+
blocks.push(def);
|
|
189
|
+
}
|
|
190
|
+
return blocks;
|
|
191
|
+
}
|
|
@@ -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+["'](@backmatter\/core|@backmatter\/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 @backmatter/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 @backmatter/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 @backmatter/core or @backmatter/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+["'](@backmatter\/core|@backmatter\/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 @backmatter/core or @backmatter/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
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// packages/core/src/node/fs-loader.ts
|
|
2
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
matchAnyModelPath,
|
|
6
|
+
modelFormat,
|
|
7
|
+
parseDoc,
|
|
8
|
+
repoPathFromModel,
|
|
9
|
+
type ModelSnapshot,
|
|
10
|
+
} from "../index";
|
|
11
|
+
import type { BackmatterDoc, BackmatterDocSummary } from "../doc";
|
|
12
|
+
|
|
13
|
+
const SKIP_DIRS = new Set(["node_modules", ".next", ".git"]);
|
|
14
|
+
|
|
15
|
+
const CONTENT_EXT = /\.(mdx|json)$/;
|
|
16
|
+
|
|
17
|
+
export async function walkMdxFiles(rootDir: string): Promise<string[]> {
|
|
18
|
+
return walkContentFiles(rootDir);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function walkContentFiles(rootDir: string): Promise<string[]> {
|
|
22
|
+
const out: string[] = [];
|
|
23
|
+
|
|
24
|
+
async function walk(dir: string) {
|
|
25
|
+
let entries;
|
|
26
|
+
try {
|
|
27
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
28
|
+
} catch {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
for (const entry of entries) {
|
|
32
|
+
if (entry.isDirectory() && SKIP_DIRS.has(entry.name)) continue;
|
|
33
|
+
const full = path.join(dir, entry.name);
|
|
34
|
+
if (entry.isDirectory()) {
|
|
35
|
+
await walk(full);
|
|
36
|
+
} else if (entry.isFile() && CONTENT_EXT.test(entry.name)) {
|
|
37
|
+
out.push(full);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
await walk(rootDir);
|
|
43
|
+
return out.sort();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function listDocsFromFs(
|
|
47
|
+
siteRoot: string,
|
|
48
|
+
models: ModelSnapshot[],
|
|
49
|
+
): Promise<BackmatterDocSummary[]> {
|
|
50
|
+
const files = await walkContentFiles(siteRoot);
|
|
51
|
+
const docs: BackmatterDocSummary[] = [];
|
|
52
|
+
for (const full of files) {
|
|
53
|
+
const rel = path.relative(siteRoot, full).split(path.sep).join("/");
|
|
54
|
+
const hit = matchAnyModelPath(models, rel);
|
|
55
|
+
if (!hit) continue;
|
|
56
|
+
docs.push({
|
|
57
|
+
locale: hit.locale,
|
|
58
|
+
type: hit.type,
|
|
59
|
+
slug: hit.slug,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return docs.sort((a, b) => {
|
|
63
|
+
const ak = `${a.locale}/${a.type}/${a.slug}`;
|
|
64
|
+
const bk = `${b.locale}/${b.type}/${b.slug}`;
|
|
65
|
+
return ak.localeCompare(bk);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function getDocFromFs(
|
|
70
|
+
siteRoot: string,
|
|
71
|
+
models: ModelSnapshot[],
|
|
72
|
+
locale: string,
|
|
73
|
+
type: string,
|
|
74
|
+
slug: string,
|
|
75
|
+
): Promise<BackmatterDoc | null> {
|
|
76
|
+
const model = models.find((m) => m.type === type);
|
|
77
|
+
if (!model) return null;
|
|
78
|
+
const rel = repoPathFromModel(model, locale, slug);
|
|
79
|
+
const full = path.join(siteRoot, rel);
|
|
80
|
+
let raw: string;
|
|
81
|
+
try {
|
|
82
|
+
raw = await readFile(full, "utf-8");
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
const parsed = parseDoc(raw, modelFormat(model));
|
|
87
|
+
return {
|
|
88
|
+
locale,
|
|
89
|
+
type,
|
|
90
|
+
slug,
|
|
91
|
+
source: "head",
|
|
92
|
+
frontmatter: parsed.frontmatter,
|
|
93
|
+
body: parsed.body,
|
|
94
|
+
raw,
|
|
95
|
+
};
|
|
96
|
+
}
|