@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
package/src/node/cli.ts
ADDED
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// packages/core/src/node/cli.ts
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
import {
|
|
8
|
+
frontmatterJsonSchema,
|
|
9
|
+
serializeModel,
|
|
10
|
+
serializeBlock,
|
|
11
|
+
validateContentTree,
|
|
12
|
+
type ContentFileResult,
|
|
13
|
+
type ModelDef,
|
|
14
|
+
type ModelSnapshot,
|
|
15
|
+
} from "../index";
|
|
16
|
+
import { resolveConfig, type BackmatterConfig, type ResolvedConfig } from "../config";
|
|
17
|
+
import { discoverModels, discoverBlocks } from "./discover";
|
|
18
|
+
import { walkContentFiles } from "./fs-loader";
|
|
19
|
+
import { detectSdk, findConfigFile, generate, type GenerateResult } from "./generate";
|
|
20
|
+
|
|
21
|
+
export type CliArgs = {
|
|
22
|
+
command?: string;
|
|
23
|
+
root: string;
|
|
24
|
+
/** Optional barrel module exporting `models`; default is folder discovery. */
|
|
25
|
+
models?: string;
|
|
26
|
+
out: string;
|
|
27
|
+
json: boolean;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const USAGE = `Usage:
|
|
31
|
+
backmatter init scaffold backmatter.config.ts, the catch-all route and .backmatter/
|
|
32
|
+
backmatter generate [--root <dir>] write .backmatter/index.ts (typed cms), blocks.ts, mdx.ts and layouts.ts
|
|
33
|
+
backmatter validate [--root <dir>] [--models <file>] [--json]
|
|
34
|
+
backmatter schema [--root <dir>] [--models <file>] [--out <dir>]
|
|
35
|
+
|
|
36
|
+
Defaults: --root . --out .backmatter/schemas
|
|
37
|
+
Models are discovered from models/*.ts (one \`export default defineModel(...)\` per
|
|
38
|
+
file, file name = model type). Pass --models <file> to use a module that exports
|
|
39
|
+
\`models\` instead. Importing your code sets BACKMATTER_CLI=1 so createBackmatter never
|
|
40
|
+
syncs the schema from the CLI.`;
|
|
41
|
+
|
|
42
|
+
export function parseArgs(argv: string[]): CliArgs {
|
|
43
|
+
const args: CliArgs = {
|
|
44
|
+
command: undefined,
|
|
45
|
+
root: ".",
|
|
46
|
+
models: undefined,
|
|
47
|
+
out: ".backmatter/schemas",
|
|
48
|
+
json: false,
|
|
49
|
+
};
|
|
50
|
+
for (let i = 0; i < argv.length; i++) {
|
|
51
|
+
const arg = argv[i]!;
|
|
52
|
+
if (arg === "--root") {
|
|
53
|
+
const value = argv[++i];
|
|
54
|
+
if (value === undefined || value.startsWith("--")) {
|
|
55
|
+
throw new Error("usage: missing value for --root");
|
|
56
|
+
}
|
|
57
|
+
args.root = value;
|
|
58
|
+
} else if (arg === "--models") {
|
|
59
|
+
const value = argv[++i];
|
|
60
|
+
if (value === undefined || value.startsWith("--")) {
|
|
61
|
+
throw new Error("usage: missing value for --models");
|
|
62
|
+
}
|
|
63
|
+
args.models = value;
|
|
64
|
+
} else if (arg === "--out") {
|
|
65
|
+
const value = argv[++i];
|
|
66
|
+
if (value === undefined || value.startsWith("--")) {
|
|
67
|
+
throw new Error("usage: missing value for --out");
|
|
68
|
+
}
|
|
69
|
+
args.out = value;
|
|
70
|
+
} else if (arg === "--json") {
|
|
71
|
+
args.json = true;
|
|
72
|
+
} else if (arg.startsWith("--")) {
|
|
73
|
+
throw new Error(`usage: unknown flag ${arg}`);
|
|
74
|
+
} else if (args.command === undefined) {
|
|
75
|
+
args.command = arg;
|
|
76
|
+
} else {
|
|
77
|
+
throw new Error("usage: unexpected positional argument");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return args;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Run `fn` with BACKMATTER_CLI=1 so imported host code never syncs the schema. */
|
|
84
|
+
async function withCliFlag<T>(fn: () => Promise<T>): Promise<T> {
|
|
85
|
+
const prevCli = process.env.BACKMATTER_CLI;
|
|
86
|
+
process.env.BACKMATTER_CLI = "1";
|
|
87
|
+
try {
|
|
88
|
+
return await fn();
|
|
89
|
+
} finally {
|
|
90
|
+
if (prevCli === undefined) delete process.env.BACKMATTER_CLI;
|
|
91
|
+
else process.env.BACKMATTER_CLI = prevCli;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Import `backmatter.config.ts` (default export) when present; `{}` otherwise. */
|
|
96
|
+
export async function loadConfig(root: string): Promise<ResolvedConfig> {
|
|
97
|
+
const file = findConfigFile(root);
|
|
98
|
+
if (!file) return resolveConfig({});
|
|
99
|
+
const mod = await withCliFlag(
|
|
100
|
+
() =>
|
|
101
|
+
import(/* webpackIgnore: true */ /* turbopackIgnore: true */ pathToFileURL(file).href) as Promise<{ default?: unknown; config?: unknown }>,
|
|
102
|
+
);
|
|
103
|
+
const candidate = mod.default ?? mod.config;
|
|
104
|
+
if (candidate !== undefined && (typeof candidate !== "object" || candidate === null)) {
|
|
105
|
+
throw new Error(`${path.basename(file)} must \`export default defineConfig({ ... })\``);
|
|
106
|
+
}
|
|
107
|
+
return resolveConfig((candidate ?? {}) as BackmatterConfig);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Models from the `models/` folder (default) or from an explicit barrel module
|
|
112
|
+
* that exports `models` / a default array.
|
|
113
|
+
*/
|
|
114
|
+
export async function loadModels(root: string, modelsPath?: string): Promise<ModelDef[]> {
|
|
115
|
+
if (modelsPath === undefined) {
|
|
116
|
+
const config = await loadConfig(root);
|
|
117
|
+
return withCliFlag(() => discoverModels(root, config.dirs.models));
|
|
118
|
+
}
|
|
119
|
+
const full = path.resolve(root, modelsPath);
|
|
120
|
+
const mod = await withCliFlag(
|
|
121
|
+
() =>
|
|
122
|
+
import(/* webpackIgnore: true */ /* turbopackIgnore: true */ pathToFileURL(full).href) as Promise<{ models?: unknown; default?: unknown }>,
|
|
123
|
+
);
|
|
124
|
+
const candidate = mod.models ?? mod.default;
|
|
125
|
+
if (!Array.isArray(candidate)) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`${modelsPath} must export \`models\` (array of defineModel results) or a default array`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return candidate as ModelDef[];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Static directory roots the models' path templates live under, e.g.
|
|
135
|
+
* `content/{locale}/blog/{slug}.mdx` → `content`. Roots that are sub-paths of
|
|
136
|
+
* another root are dropped; a template with no static leading segment yields ".".
|
|
137
|
+
*/
|
|
138
|
+
export function contentRoots(models: ModelSnapshot[]): string[] {
|
|
139
|
+
const candidates = new Set<string>();
|
|
140
|
+
for (const model of models) {
|
|
141
|
+
const segments = model.path.split("/").slice(0, -1); // drop the filename segment
|
|
142
|
+
const leading: string[] = [];
|
|
143
|
+
for (const segment of segments) {
|
|
144
|
+
if (segment.includes("{")) break;
|
|
145
|
+
leading.push(segment);
|
|
146
|
+
}
|
|
147
|
+
candidates.add(leading.length > 0 ? leading.join("/") : ".");
|
|
148
|
+
}
|
|
149
|
+
const list = [...candidates];
|
|
150
|
+
return list
|
|
151
|
+
.filter(
|
|
152
|
+
(root) =>
|
|
153
|
+
!list.some(
|
|
154
|
+
(other) =>
|
|
155
|
+
other !== root && (other === "." || root.startsWith(`${other}/`)),
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
.sort();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function runValidate(
|
|
162
|
+
args: CliArgs,
|
|
163
|
+
): Promise<{ results: ContentFileResult[]; roots: string[]; exitCode: 0 | 1 }> {
|
|
164
|
+
const root = path.resolve(args.root);
|
|
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
|
+
}
|
|
174
|
+
const roots = contentRoots(models);
|
|
175
|
+
const files: string[] = [];
|
|
176
|
+
for (const contentRoot of roots) {
|
|
177
|
+
const dir = path.join(root, contentRoot);
|
|
178
|
+
const info = await stat(dir).catch(() => null);
|
|
179
|
+
if (!info?.isDirectory()) continue; // missing roots are simply skipped
|
|
180
|
+
files.push(...(await walkContentFiles(dir)));
|
|
181
|
+
}
|
|
182
|
+
files.sort();
|
|
183
|
+
const inputs = await Promise.all(
|
|
184
|
+
files.map(async (full) => ({
|
|
185
|
+
path: path.relative(root, full).split(path.sep).join("/"),
|
|
186
|
+
raw: await readFile(full, "utf-8"),
|
|
187
|
+
})),
|
|
188
|
+
);
|
|
189
|
+
const results = validateContentTree({ models, files: inputs, catalog });
|
|
190
|
+
return { results, roots, exitCode: results.some((r) => !r.ok) ? 1 : 0 };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function formatResults(results: ContentFileResult[]): string[] {
|
|
194
|
+
const lines: string[] = [];
|
|
195
|
+
for (const r of results) {
|
|
196
|
+
if (!r.ok) {
|
|
197
|
+
for (const err of r.errors) lines.push(`${r.path}: ${err}`);
|
|
198
|
+
} else if ("skipped" in r) {
|
|
199
|
+
lines.push(`${r.path}: no matching model (skipped)`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return lines;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function runSchema(args: CliArgs): Promise<string[]> {
|
|
206
|
+
const root = path.resolve(args.root);
|
|
207
|
+
const models = (await loadModels(root, args.models)).map(serializeModel);
|
|
208
|
+
const outDir = path.resolve(root, args.out);
|
|
209
|
+
await mkdir(outDir, { recursive: true });
|
|
210
|
+
const written: string[] = [];
|
|
211
|
+
for (const model of models) {
|
|
212
|
+
const file = path.join(outDir, `${model.type}.json`);
|
|
213
|
+
await writeFile(file, `${JSON.stringify(frontmatterJsonSchema(model), null, 2)}\n`);
|
|
214
|
+
written.push(path.relative(root, file).split(path.sep).join("/"));
|
|
215
|
+
}
|
|
216
|
+
return written;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function runGenerate(args: CliArgs): Promise<GenerateResult> {
|
|
220
|
+
const root = path.resolve(args.root);
|
|
221
|
+
// Codegen must not depend on the host's dependencies being installed yet
|
|
222
|
+
// (e.g. right after `init`); fall back to default folders if the config
|
|
223
|
+
// cannot be imported.
|
|
224
|
+
let config: ResolvedConfig;
|
|
225
|
+
try {
|
|
226
|
+
config = await loadConfig(root);
|
|
227
|
+
} catch (err) {
|
|
228
|
+
console.error(
|
|
229
|
+
`backmatter: could not load backmatter.config (${err instanceof Error ? err.message : String(err)}); using default folders`,
|
|
230
|
+
);
|
|
231
|
+
config = resolveConfig({});
|
|
232
|
+
}
|
|
233
|
+
return generate({
|
|
234
|
+
root,
|
|
235
|
+
modelsDir: config.dirs.models,
|
|
236
|
+
blocksDir: config.dirs.blocks,
|
|
237
|
+
mdxDir: config.dirs.mdx,
|
|
238
|
+
layoutsDir: config.dirs.layouts,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const CATCH_ALL_DIR = "[[...backmatter]]";
|
|
243
|
+
|
|
244
|
+
function renderConfigFile(sdk: string): string {
|
|
245
|
+
const from = sdk === "@backmatter/next" ? "@backmatter/next" : "@backmatter/core";
|
|
246
|
+
return `// backmatter.config.ts
|
|
247
|
+
import { defineConfig } from "${from}";
|
|
248
|
+
|
|
249
|
+
// General settings only. Models live in models/*.ts, composer blocks in
|
|
250
|
+
// blocks/*.tsx, MDX tags in mdx/*.tsx, and content in
|
|
251
|
+
// content/{locale}/{type}/{slug}.{mdx,json} — all discovered automatically.
|
|
252
|
+
// workspaceId / apiUrl / tokens fall back to BACKMATTER_* env vars when omitted.
|
|
253
|
+
export default defineConfig({
|
|
254
|
+
defaultLocale: "en",
|
|
255
|
+
});
|
|
256
|
+
`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function renderCatchAllPage(registryImport: string): string {
|
|
260
|
+
return `// ${CATCH_ALL_DIR}/page.tsx — mounts every model at its route, including / via indexSlug.
|
|
261
|
+
import { createCatchAll } from "@backmatter/next/routes";
|
|
262
|
+
import { cms } from "${registryImport}";
|
|
263
|
+
import { components } from "${registryImport}/mdx";
|
|
264
|
+
import { blockLoaders } from "${registryImport}/blocks";
|
|
265
|
+
import { blockFallbacks } from "${registryImport}/block-fallbacks";
|
|
266
|
+
import { layouts } from "${registryImport}/layouts";
|
|
267
|
+
|
|
268
|
+
const route = createCatchAll(cms, { components, blocks: blockLoaders, fallbacks: blockFallbacks, layouts });
|
|
269
|
+
|
|
270
|
+
export const generateStaticParams = route.generateStaticParams;
|
|
271
|
+
export const generateMetadata = route.generateMetadata;
|
|
272
|
+
export default route.Page;
|
|
273
|
+
`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function renderInstrumentation(syncImport: string): string {
|
|
277
|
+
return `// instrumentation.ts — schema sync on Node. Next also runs register() on Edge;
|
|
278
|
+
// do not import .backmatter/sync there (it pulls Node APIs).
|
|
279
|
+
export async function register() {
|
|
280
|
+
if (process.env.NEXT_RUNTIME === "nodejs") {
|
|
281
|
+
await import("${syncImport}");
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function warnMissingInstrumentation(root: string): void {
|
|
288
|
+
if (detectSdk(root) !== "@backmatter/next") return;
|
|
289
|
+
const hasRoot = existsSync(path.join(root, "instrumentation.ts"));
|
|
290
|
+
const hasSrc = existsSync(path.join(root, "src", "instrumentation.ts"));
|
|
291
|
+
if (!hasRoot && !hasSrc) {
|
|
292
|
+
console.warn(
|
|
293
|
+
"backmatter: add instrumentation.ts that imports .backmatter/sync — block schema sync will not run without it",
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function renderPreviewRoute(registryImport: string): string {
|
|
299
|
+
return `// api/backmatter/preview/route.ts — preview session enable / disable / stream / session.
|
|
300
|
+
import { createPreviewRoute } from "@backmatter/next/preview-route";
|
|
301
|
+
import { cms } from "${registryImport}";
|
|
302
|
+
|
|
303
|
+
export const { GET, POST } = createPreviewRoute(cms);
|
|
304
|
+
`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function renderProxy(): string {
|
|
308
|
+
return `// proxy.ts — preview session (draft cookie, noindex, ticket entry).
|
|
309
|
+
// Next 16: \`config\` must be declared in this file, not re-exported.
|
|
310
|
+
export { proxy } from "@backmatter/next/proxy";
|
|
311
|
+
|
|
312
|
+
export const config = {
|
|
313
|
+
matcher: [
|
|
314
|
+
"/((?!_next/static|_next/image|favicon.ico|.*\\\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js)$).*)",
|
|
315
|
+
],
|
|
316
|
+
};
|
|
317
|
+
`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function renderRevalidateRoute(registryImport: string): string {
|
|
321
|
+
return `// api/backmatter/revalidate/route.ts — content.committed from the BACKMATTER server.
|
|
322
|
+
import { createRevalidateRoute } from "@backmatter/next/revalidate";
|
|
323
|
+
import { cms } from "${registryImport}";
|
|
324
|
+
|
|
325
|
+
export const POST = createRevalidateRoute(cms);
|
|
326
|
+
`;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export async function runInit(args: CliArgs): Promise<string[]> {
|
|
330
|
+
const root = path.resolve(args.root);
|
|
331
|
+
const sdk = detectSdk(root);
|
|
332
|
+
const notes: string[] = [];
|
|
333
|
+
|
|
334
|
+
if (!findConfigFile(root)) {
|
|
335
|
+
await writeFile(path.join(root, "backmatter.config.ts"), renderConfigFile(sdk));
|
|
336
|
+
notes.push("wrote backmatter.config.ts");
|
|
337
|
+
} else {
|
|
338
|
+
notes.push("kept existing backmatter.config.*");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const gitignore = path.join(root, ".gitignore");
|
|
342
|
+
const current = existsSync(gitignore) ? await readFile(gitignore, "utf-8") : "";
|
|
343
|
+
if (!current.split("\n").some((line) => line.trim() === ".backmatter/" || line.trim() === ".backmatter")) {
|
|
344
|
+
const prefix = current.length && !current.endsWith("\n") ? "\n" : "";
|
|
345
|
+
await appendFile(gitignore, `${prefix}# backmatter generated registry\n.backmatter/\n`);
|
|
346
|
+
notes.push("added .backmatter/ to .gitignore");
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (sdk === "@backmatter/next") {
|
|
350
|
+
const appDir = existsSync(path.join(root, "src", "app")) ? path.join("src", "app") : "app";
|
|
351
|
+
const pageDir = path.join(root, appDir, CATCH_ALL_DIR);
|
|
352
|
+
const page = path.join(pageDir, "page.tsx");
|
|
353
|
+
const requiredCatchAll = path.join(root, appDir, "[...backmatter]", "page.tsx");
|
|
354
|
+
if (existsSync(page)) {
|
|
355
|
+
notes.push(`kept existing ${path.join(appDir, CATCH_ALL_DIR, "page.tsx")}`);
|
|
356
|
+
} else if (existsSync(requiredCatchAll)) {
|
|
357
|
+
notes.push(`kept existing ${path.join(appDir, "[...backmatter]", "page.tsx")}`);
|
|
358
|
+
} else {
|
|
359
|
+
const registry = path.relative(pageDir, path.join(root, ".backmatter")).split(path.sep).join("/");
|
|
360
|
+
await mkdir(pageDir, { recursive: true });
|
|
361
|
+
await writeFile(page, renderCatchAllPage(registry.startsWith(".") ? registry : `./${registry}`));
|
|
362
|
+
notes.push(`wrote ${path.join(appDir, CATCH_ALL_DIR, "page.tsx")}`);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const revalidateDir = path.join(root, appDir, "api", "backmatter", "revalidate");
|
|
366
|
+
const revalidateFile = path.join(revalidateDir, "route.ts");
|
|
367
|
+
if (!existsSync(revalidateFile)) {
|
|
368
|
+
const registry = path.relative(revalidateDir, path.join(root, ".backmatter")).split(path.sep).join("/");
|
|
369
|
+
await mkdir(revalidateDir, { recursive: true });
|
|
370
|
+
await writeFile(
|
|
371
|
+
revalidateFile,
|
|
372
|
+
renderRevalidateRoute(registry.startsWith(".") ? registry : `./${registry}`),
|
|
373
|
+
);
|
|
374
|
+
notes.push(`wrote ${path.join(appDir, "api/backmatter/revalidate/route.ts")}`);
|
|
375
|
+
} else {
|
|
376
|
+
notes.push(`kept existing ${path.join(appDir, "api/backmatter/revalidate/route.ts")}`);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const previewDir = path.join(root, appDir, "api", "backmatter", "preview");
|
|
380
|
+
const previewFile = path.join(previewDir, "route.ts");
|
|
381
|
+
if (!existsSync(previewFile)) {
|
|
382
|
+
const registry = path.relative(previewDir, path.join(root, ".backmatter")).split(path.sep).join("/");
|
|
383
|
+
await mkdir(previewDir, { recursive: true });
|
|
384
|
+
await writeFile(
|
|
385
|
+
previewFile,
|
|
386
|
+
renderPreviewRoute(registry.startsWith(".") ? registry : `./${registry}`),
|
|
387
|
+
);
|
|
388
|
+
notes.push(`wrote ${path.join(appDir, "api/backmatter/preview/route.ts")}`);
|
|
389
|
+
} else {
|
|
390
|
+
notes.push(`kept existing ${path.join(appDir, "api/backmatter/preview/route.ts")}`);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const proxyFile = path.join(root, "proxy.ts");
|
|
394
|
+
const srcProxy = path.join(root, "src", "proxy.ts");
|
|
395
|
+
const middlewareFile = path.join(root, "middleware.ts");
|
|
396
|
+
const srcMiddleware = path.join(root, "src", "middleware.ts");
|
|
397
|
+
const existingProxy = existsSync(proxyFile)
|
|
398
|
+
? proxyFile
|
|
399
|
+
: existsSync(srcProxy)
|
|
400
|
+
? srcProxy
|
|
401
|
+
: existsSync(middlewareFile)
|
|
402
|
+
? middlewareFile
|
|
403
|
+
: existsSync(srcMiddleware)
|
|
404
|
+
? srcMiddleware
|
|
405
|
+
: null;
|
|
406
|
+
if (!existingProxy) {
|
|
407
|
+
const dest = existsSync(path.join(root, "src", "app")) ? srcProxy : proxyFile;
|
|
408
|
+
await writeFile(dest, renderProxy());
|
|
409
|
+
notes.push(`wrote ${path.relative(root, dest)}`);
|
|
410
|
+
} else {
|
|
411
|
+
notes.push(`kept existing ${path.relative(root, existingProxy)}`);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const rootInstrumentation = path.join(root, "instrumentation.ts");
|
|
415
|
+
const srcInstrumentation = path.join(root, "src", "instrumentation.ts");
|
|
416
|
+
const usesSrcApp = existsSync(path.join(root, "src", "app"));
|
|
417
|
+
if (existsSync(rootInstrumentation) || existsSync(srcInstrumentation)) {
|
|
418
|
+
notes.push("kept existing instrumentation.ts");
|
|
419
|
+
} else {
|
|
420
|
+
const dest = usesSrcApp ? srcInstrumentation : rootInstrumentation;
|
|
421
|
+
const syncImport = usesSrcApp ? "../.backmatter/sync" : "./.backmatter/sync";
|
|
422
|
+
await writeFile(dest, renderInstrumentation(syncImport));
|
|
423
|
+
notes.push(`wrote ${path.relative(root, dest)}`);
|
|
424
|
+
}
|
|
425
|
+
} else {
|
|
426
|
+
notes.push("no @backmatter/next dependency: skipped the catch-all route");
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
for (const dir of ["models", "content", "blocks", "mdx", "layouts"]) {
|
|
430
|
+
await mkdir(path.join(root, dir), { recursive: true });
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const result = await runGenerate(args);
|
|
434
|
+
notes.push(...result.files.map((f) => `wrote ${f}`));
|
|
435
|
+
return notes;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function main(argv: string[]): Promise<number> {
|
|
439
|
+
let args: CliArgs;
|
|
440
|
+
try {
|
|
441
|
+
args = parseArgs(argv);
|
|
442
|
+
} catch (err) {
|
|
443
|
+
if (err instanceof Error && err.message.startsWith("usage:")) {
|
|
444
|
+
console.error(err.message);
|
|
445
|
+
console.error(USAGE);
|
|
446
|
+
return 2;
|
|
447
|
+
}
|
|
448
|
+
console.error(`backmatter: ${err instanceof Error ? err.message : String(err)}`);
|
|
449
|
+
return 2;
|
|
450
|
+
}
|
|
451
|
+
try {
|
|
452
|
+
if (args.command === "validate") {
|
|
453
|
+
const { results, roots, exitCode } = await runValidate(args);
|
|
454
|
+
if (results.length === 0) {
|
|
455
|
+
console.error(
|
|
456
|
+
`backmatter: no .mdx or .json files found under ${roots.join(", ")} (check --root)`,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
if (args.json) {
|
|
460
|
+
console.log(JSON.stringify(results, null, 2));
|
|
461
|
+
} else {
|
|
462
|
+
for (const line of formatResults(results)) console.log(line);
|
|
463
|
+
const checked = results.filter((r) => !("skipped" in r)).length;
|
|
464
|
+
console.log(
|
|
465
|
+
exitCode === 0
|
|
466
|
+
? `backmatter: ${checked} document(s) valid`
|
|
467
|
+
: `backmatter: validation failed`,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
return exitCode;
|
|
471
|
+
}
|
|
472
|
+
if (args.command === "schema") {
|
|
473
|
+
const written = await runSchema(args);
|
|
474
|
+
for (const file of written) console.log(`wrote ${file}`);
|
|
475
|
+
return 0;
|
|
476
|
+
}
|
|
477
|
+
if (args.command === "generate") {
|
|
478
|
+
const result = await runGenerate(args);
|
|
479
|
+
for (const file of result.files) {
|
|
480
|
+
console.log(`${result.changed.includes(file) ? "wrote" : "unchanged"} ${file}`);
|
|
481
|
+
}
|
|
482
|
+
for (const skipped of result.skippedBlocks) {
|
|
483
|
+
console.error(`backmatter: skipped ${skipped} (block file names must be PascalCase)`);
|
|
484
|
+
}
|
|
485
|
+
for (const skipped of result.skippedMdx) {
|
|
486
|
+
console.error(`backmatter: skipped ${skipped} (MDX file names must be PascalCase)`);
|
|
487
|
+
}
|
|
488
|
+
warnMissingInstrumentation(path.resolve(args.root));
|
|
489
|
+
return 0;
|
|
490
|
+
}
|
|
491
|
+
if (args.command === "init") {
|
|
492
|
+
for (const note of await runInit(args)) console.log(note);
|
|
493
|
+
return 0;
|
|
494
|
+
}
|
|
495
|
+
console.error(USAGE);
|
|
496
|
+
return 2;
|
|
497
|
+
} catch (err) {
|
|
498
|
+
console.error(`backmatter: ${err instanceof Error ? err.message : String(err)}`);
|
|
499
|
+
return 2;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (import.meta.main) {
|
|
504
|
+
main(process.argv.slice(2)).then((code) => process.exit(code));
|
|
505
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// packages/core/src/node/create-backmatter.ts
|
|
2
|
+
import type { ModelDef, ModelSnapshot } from "../model";
|
|
3
|
+
import type { BlockDef } from "../block";
|
|
4
|
+
import { getDocFromApi, listDocsFromApi } from "../api-loader";
|
|
5
|
+
import { getDocFromFs, listDocsFromFs } from "./fs-loader";
|
|
6
|
+
import { createQuery, type DocLoader, type Query } from "../query";
|
|
7
|
+
import { syncSchema } from "../sync-schema";
|
|
8
|
+
import type { BackmatterDoc, BackmatterDocSummary } from "../doc";
|
|
9
|
+
import { resolveConfig, type BackmatterConfig, type ResolvedConfig } from "../config";
|
|
10
|
+
import { contentCacheTags, listCacheTags } from "../content-committed";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `BackmatterConfig` (usually spread from `backmatter.config.ts`) plus the discovered
|
|
14
|
+
* models. Unset fields fall back to `BACKMATTER_*` env vars.
|
|
15
|
+
*/
|
|
16
|
+
export type CreateBackmatterOptions<Models extends readonly ModelDef[] = readonly ModelDef[]> =
|
|
17
|
+
BackmatterConfig & {
|
|
18
|
+
models: Models;
|
|
19
|
+
blocks?: readonly BlockDef[];
|
|
20
|
+
/**
|
|
21
|
+
* When true, API fetches use Next cache tags instead of `cache: "no-store"`.
|
|
22
|
+
* `@backmatter/next` turns this on.
|
|
23
|
+
*/
|
|
24
|
+
taggedApiFetch?: boolean;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type BackmatterClient<Models extends readonly ModelDef[] = readonly ModelDef[]> = {
|
|
28
|
+
listDocs: (filter?: {
|
|
29
|
+
type?: string;
|
|
30
|
+
locale?: string;
|
|
31
|
+
}) => Promise<BackmatterDocSummary[]>;
|
|
32
|
+
getDoc: (
|
|
33
|
+
locale: string,
|
|
34
|
+
type: string,
|
|
35
|
+
slug: string,
|
|
36
|
+
) => Promise<BackmatterDoc | null>;
|
|
37
|
+
/** Typed access keyed by model.type: cms.query.blog.get / findMany */
|
|
38
|
+
query: Query<Models>;
|
|
39
|
+
/** The registered models, e.g. for route matching. */
|
|
40
|
+
models: Models;
|
|
41
|
+
/** Effective config after env fallbacks. */
|
|
42
|
+
config: ResolvedConfig;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function toSnapshots(models: readonly ModelDef[]): ModelSnapshot[] {
|
|
46
|
+
return models.map((m) => {
|
|
47
|
+
const snapshot: ModelSnapshot = {
|
|
48
|
+
type: m.type,
|
|
49
|
+
path: m.path,
|
|
50
|
+
fields: m.fields as ModelSnapshot["fields"],
|
|
51
|
+
};
|
|
52
|
+
if (m.route !== undefined) snapshot.route = m.route;
|
|
53
|
+
if (m.format === "page") snapshot.format = "page";
|
|
54
|
+
if (m.indexSlug !== undefined) snapshot.indexSlug = m.indexSlug;
|
|
55
|
+
return snapshot;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createBackmatter<const Models extends readonly ModelDef[]>(
|
|
60
|
+
options: CreateBackmatterOptions<Models>,
|
|
61
|
+
): BackmatterClient<Models> {
|
|
62
|
+
const { models: modelDefs, blocks: blockDefs, taggedApiFetch, ...rest } = options;
|
|
63
|
+
const config = resolveConfig(rest);
|
|
64
|
+
const models = toSnapshots(modelDefs);
|
|
65
|
+
const siteRoot = config.siteRoot ?? process.cwd();
|
|
66
|
+
const useApi = Boolean(config.apiUrl && config.workspaceId);
|
|
67
|
+
const { workspaceId, apiUrl, contentToken, hostToken, branch, siteUrl } = config;
|
|
68
|
+
|
|
69
|
+
if (apiUrl && !workspaceId) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
"backmatter: workspaceId is required when apiUrl is set (backmatter.config.ts or BACKMATTER_WORKSPACE_ID)",
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// backmatter CLI imports the config to read models; never sync from there.
|
|
76
|
+
if (apiUrl && hostToken && workspaceId) {
|
|
77
|
+
if (process.env.BACKMATTER_CLI === "1") {
|
|
78
|
+
console.warn("backmatter: schema sync skipped (BACKMATTER_CLI=1)");
|
|
79
|
+
} else {
|
|
80
|
+
void syncSchema({
|
|
81
|
+
apiUrl,
|
|
82
|
+
workspaceId,
|
|
83
|
+
hostToken,
|
|
84
|
+
models: [...modelDefs],
|
|
85
|
+
blocks: blockDefs ? [...blockDefs] : undefined,
|
|
86
|
+
branch: config.branch,
|
|
87
|
+
siteUrl,
|
|
88
|
+
}).catch((err) => {
|
|
89
|
+
console.error("backmatter schema sync failed", err);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const loader: DocLoader = {
|
|
95
|
+
async listDocs(filter) {
|
|
96
|
+
const docs = useApi
|
|
97
|
+
? await listDocsFromApi(apiUrl!, workspaceId!, contentToken, {
|
|
98
|
+
branch,
|
|
99
|
+
type: filter?.type,
|
|
100
|
+
locale: filter?.locale,
|
|
101
|
+
tags: taggedApiFetch ? listCacheTags(filter?.type) : undefined,
|
|
102
|
+
})
|
|
103
|
+
: await listDocsFromFs(siteRoot, models);
|
|
104
|
+
|
|
105
|
+
return docs.filter((d) => {
|
|
106
|
+
if (filter?.type && d.type !== filter.type) return false;
|
|
107
|
+
if (filter?.locale && d.locale !== filter.locale) return false;
|
|
108
|
+
return true;
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
async getDoc(locale, type, slug) {
|
|
113
|
+
if (useApi) {
|
|
114
|
+
return getDocFromApi(apiUrl!, workspaceId!, contentToken, locale, type, slug, {
|
|
115
|
+
branch,
|
|
116
|
+
tags: taggedApiFetch ? contentCacheTags(locale, type, slug) : undefined,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return getDocFromFs(siteRoot, models, locale, type, slug);
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
listDocs: loader.listDocs,
|
|
125
|
+
getDoc: loader.getDoc,
|
|
126
|
+
query: createQuery(modelDefs, loader),
|
|
127
|
+
models: modelDefs,
|
|
128
|
+
config,
|
|
129
|
+
};
|
|
130
|
+
}
|