@backmatter/core 0.0.16 → 0.0.18

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 CHANGED
@@ -1,15 +1,21 @@
1
1
  {
2
2
  "name": "@backmatter/core",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
4
4
  "description": "Experimental Backmatter SDK. APIs will break. Not ready for production.",
5
5
  "type": "module",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/MRued/backmatter.git",
12
+ "directory": "packages/core"
13
+ },
9
14
  "exports": {
10
15
  ".": "./src/index.ts",
11
16
  "./node": "./src/node/index.ts",
12
17
  "./node/create-backmatter": "./src/node/create-backmatter.ts",
18
+ "./node/registry-import": "./src/node/registry-import.ts",
13
19
  "./preview": "./src/preview.ts",
14
20
  "./generate": "./src/node/generate.ts"
15
21
  },
@@ -21,7 +27,7 @@
21
27
  "!**/test-fixtures"
22
28
  ],
23
29
  "bin": {
24
- "backmatter": "./bin/backmatter.js"
30
+ "backmatter": "bin/backmatter.js"
25
31
  },
26
32
  "scripts": {
27
33
  "test": "bun test"
package/src/config.ts CHANGED
@@ -98,14 +98,38 @@ export function resolveDevBarApiUrl(
98
98
  return "";
99
99
  }
100
100
 
101
+ function isLoopbackApiUrl(url: string): boolean {
102
+ try {
103
+ const { hostname } = new URL(url);
104
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
105
+ } catch {
106
+ return false;
107
+ }
108
+ }
109
+
110
+ /** Vercel, Netlify, Cloudflare Pages, or Railway — not a laptop `next build`. */
111
+ function onPlatformDeploy(env: Record<string, string | undefined>): boolean {
112
+ return Boolean(env.VERCEL || env.NETLIFY || env.CF_PAGES || env.RAILWAY_ENVIRONMENT);
113
+ }
114
+
101
115
  function detectApiUrl(
102
116
  config: BackmatterConfig,
103
117
  env: Record<string, string | undefined>,
104
118
  workspaceId: string | undefined,
105
119
  ): string | undefined {
106
- const explicit = config.apiUrl !== undefined ? config.apiUrl : env.BACKMATTER_API_URL;
120
+ const fromConfig = config.apiUrl !== undefined;
121
+ const explicit = fromConfig ? config.apiUrl : env.BACKMATTER_API_URL;
107
122
  const trimmed = explicit?.trim();
108
- if (trimmed) return stripTrailingSlash(trimmed);
123
+ // Next loads `.env` for every environment, so a dev loopback URL is present
124
+ // during `next build` and fails page-data collection when that API is down.
125
+ // A local production build falls through to the cloud API. An explicit
126
+ // config.apiUrl, `next dev`, and a real platform deploy keep the loopback URL.
127
+ const ignoreDevLoopback =
128
+ !fromConfig &&
129
+ Boolean(trimmed && isLoopbackApiUrl(trimmed)) &&
130
+ env.NEXT_PHASE === "phase-production-build" &&
131
+ !onPlatformDeploy(env);
132
+ if (trimmed && !ignoreDevLoopback) return stripTrailingSlash(trimmed);
109
133
  if (workspaceId && isDeployedHost(env)) return CLOUD_API_URL;
110
134
  return undefined;
111
135
  }
package/src/node/cli.ts CHANGED
@@ -17,6 +17,7 @@ import { resolveConfig, type BackmatterConfig, type ResolvedConfig } from "../co
17
17
  import { discoverModels, discoverBlocks } from "./discover";
18
18
  import { walkContentFiles } from "./fs-loader";
19
19
  import { detectSdk, findConfigFile, generate, type GenerateResult } from "./generate";
20
+ import { ensureRegistryTsconfig, REGISTRY_SPECIFIER } from "./registry-import";
20
21
 
21
22
  export type CliArgs = {
22
23
  command?: string;
@@ -261,16 +262,16 @@ export default defineConfig({
261
262
  `;
262
263
  }
263
264
 
264
- function renderCatchAllPage(registryImport: string): string {
265
+ function renderCatchAllPage(): string {
265
266
  return `// ${CATCH_ALL_DIR}/page.tsx — mounts every model at its route, including / via indexSlug.
266
267
  import { cacheLife } from "next/cache";
267
268
  import { createCatchAll } from "@backmatter/next/routes";
268
- import { cms } from "${registryImport}";
269
- import { components as mdxComponents } from "${registryImport}/mdx";
270
- import { richtextComponents } from "${registryImport}/richtext";
271
- import { blockLoaders } from "${registryImport}/blocks";
272
- import { blockFallbacks } from "${registryImport}/block-fallbacks";
273
- import { layouts } from "${registryImport}/layouts";
269
+ import { cms } from "${REGISTRY_SPECIFIER}";
270
+ import { components as mdxComponents } from "${REGISTRY_SPECIFIER}/mdx";
271
+ import { richtextComponents } from "${REGISTRY_SPECIFIER}/richtext";
272
+ import { blockLoaders } from "${REGISTRY_SPECIFIER}/blocks";
273
+ import { blockFallbacks } from "${REGISTRY_SPECIFIER}/block-fallbacks";
274
+ import { layouts } from "${REGISTRY_SPECIFIER}/layouts";
274
275
 
275
276
  const components = { ...richtextComponents, ...mdxComponents };
276
277
  const route = createCatchAll(cms, { components, blocks: blockLoaders, fallbacks: blockFallbacks, layouts });
@@ -303,12 +304,12 @@ export default async function Page(props: {
303
304
  `;
304
305
  }
305
306
 
306
- function renderInstrumentation(syncImport: string): string {
307
+ function renderInstrumentation(): string {
307
308
  return `// instrumentation.ts — schema sync on Node. Next also runs register() on Edge;
308
- // do not import .backmatter/sync there (it pulls Node APIs).
309
+ // do not import ${REGISTRY_SPECIFIER}/sync there (it pulls Node APIs).
309
310
  export async function register() {
310
311
  if (process.env.NEXT_RUNTIME === "nodejs") {
311
- await import("${syncImport}");
312
+ await import("${REGISTRY_SPECIFIER}/sync");
312
313
  }
313
314
  }
314
315
  `;
@@ -320,15 +321,15 @@ export function warnMissingInstrumentation(root: string): void {
320
321
  const hasSrc = existsSync(path.join(root, "src", "instrumentation.ts"));
321
322
  if (!hasRoot && !hasSrc) {
322
323
  console.warn(
323
- "backmatter: add instrumentation.ts that imports .backmatter/sync — block schema sync will not run without it",
324
+ `backmatter: add instrumentation.ts that imports ${REGISTRY_SPECIFIER}/sync — block schema sync will not run without it`,
324
325
  );
325
326
  }
326
327
  }
327
328
 
328
- function renderPreviewRoute(registryImport: string): string {
329
+ function renderPreviewRoute(): string {
329
330
  return `// api/backmatter/preview/route.ts — preview session enable / disable / stream / session.
330
331
  import { createPreviewRoute } from "@backmatter/next/preview-route";
331
- import { cms } from "${registryImport}";
332
+ import { cms } from "${REGISTRY_SPECIFIER}";
332
333
 
333
334
  export const { GET, POST } = createPreviewRoute(cms);
334
335
  `;
@@ -347,10 +348,10 @@ export const config = {
347
348
  `;
348
349
  }
349
350
 
350
- function renderRevalidateRoute(registryImport: string): string {
351
+ function renderRevalidateRoute(): string {
351
352
  return `// api/backmatter/revalidate/route.ts — content.committed from the BACKMATTER server.
352
353
  import { createRevalidateRoute } from "@backmatter/next/revalidate";
353
- import { cms } from "${registryImport}";
354
+ import { cms } from "${REGISTRY_SPECIFIER}";
354
355
 
355
356
  export const POST = createRevalidateRoute(cms);
356
357
  `;
@@ -386,21 +387,16 @@ export async function runInit(args: CliArgs): Promise<string[]> {
386
387
  } else if (existsSync(requiredCatchAll)) {
387
388
  notes.push(`kept existing ${path.join(appDir, "[...backmatter]", "page.tsx")}`);
388
389
  } else {
389
- const registry = path.relative(pageDir, path.join(root, ".backmatter")).split(path.sep).join("/");
390
390
  await mkdir(pageDir, { recursive: true });
391
- await writeFile(page, renderCatchAllPage(registry.startsWith(".") ? registry : `./${registry}`));
391
+ await writeFile(page, renderCatchAllPage());
392
392
  notes.push(`wrote ${path.join(appDir, CATCH_ALL_DIR, "page.tsx")}`);
393
393
  }
394
394
 
395
395
  const revalidateDir = path.join(root, appDir, "api", "backmatter", "revalidate");
396
396
  const revalidateFile = path.join(revalidateDir, "route.ts");
397
397
  if (!existsSync(revalidateFile)) {
398
- const registry = path.relative(revalidateDir, path.join(root, ".backmatter")).split(path.sep).join("/");
399
398
  await mkdir(revalidateDir, { recursive: true });
400
- await writeFile(
401
- revalidateFile,
402
- renderRevalidateRoute(registry.startsWith(".") ? registry : `./${registry}`),
403
- );
399
+ await writeFile(revalidateFile, renderRevalidateRoute());
404
400
  notes.push(`wrote ${path.join(appDir, "api/backmatter/revalidate/route.ts")}`);
405
401
  } else {
406
402
  notes.push(`kept existing ${path.join(appDir, "api/backmatter/revalidate/route.ts")}`);
@@ -409,12 +405,8 @@ export async function runInit(args: CliArgs): Promise<string[]> {
409
405
  const previewDir = path.join(root, appDir, "api", "backmatter", "preview");
410
406
  const previewFile = path.join(previewDir, "route.ts");
411
407
  if (!existsSync(previewFile)) {
412
- const registry = path.relative(previewDir, path.join(root, ".backmatter")).split(path.sep).join("/");
413
408
  await mkdir(previewDir, { recursive: true });
414
- await writeFile(
415
- previewFile,
416
- renderPreviewRoute(registry.startsWith(".") ? registry : `./${registry}`),
417
- );
409
+ await writeFile(previewFile, renderPreviewRoute());
418
410
  notes.push(`wrote ${path.join(appDir, "api/backmatter/preview/route.ts")}`);
419
411
  } else {
420
412
  notes.push(`kept existing ${path.join(appDir, "api/backmatter/preview/route.ts")}`);
@@ -448,10 +440,12 @@ export async function runInit(args: CliArgs): Promise<string[]> {
448
440
  notes.push("kept existing instrumentation.ts");
449
441
  } else {
450
442
  const dest = usesSrcApp ? srcInstrumentation : rootInstrumentation;
451
- const syncImport = usesSrcApp ? "../.backmatter/sync" : "./.backmatter/sync";
452
- await writeFile(dest, renderInstrumentation(syncImport));
443
+ await writeFile(dest, renderInstrumentation());
453
444
  notes.push(`wrote ${path.relative(root, dest)}`);
454
445
  }
446
+
447
+ const tsconfigNote = ensureRegistryTsconfig(root);
448
+ if (tsconfigNote) notes.push(tsconfigNote);
455
449
  } else {
456
450
  notes.push("no @backmatter/next dependency: skipped the catch-all route");
457
451
  }
@@ -1,15 +1,18 @@
1
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.
2
+ // Sandbox-eval convention model and object files (GitHub import). The allowed
3
+ // surface is the same one `discoverModels` / `discoverObjects` run: `defineModel`,
4
+ // `defineObject`, and `field` from @backmatter/core or @backmatter/next, plus
5
+ // `import name from "../objects/<name>"`. Never import() the customer checkout.
5
6
  import { runInNewContext } from "node:vm";
6
7
  import { defineModel, type ModelDef } from "../model";
8
+ import { defineObject, type ObjectDef } from "../object";
7
9
  import { field } from "../fields";
8
10
 
9
11
  const SOURCE_EXT = /\.(ts|tsx|js|jsx|mjs)$/;
10
12
  const IGNORED = /(\.test\.|\.spec\.|\.d\.ts$|\.stories\.)/;
11
13
  const ALLOWED_IMPORT = /^\s*import\s+\{([^}]+)\}\s+from\s+["'](@backmatter\/core|@backmatter\/next)["']\s*;?\s*$/gm;
12
14
  const ALLOWED_NAMES = new Set(["defineModel", "field"]);
15
+ const OBJECT_NAMES = new Set(["defineObject", "field"]);
13
16
  const FORBIDDEN = [
14
17
  { re: /\brequire\s*\(/, name: "require" },
15
18
  { re: /\bimport\s*\(/, name: "import" },
@@ -44,38 +47,61 @@ function stemOf(repoPath: string): string {
44
47
  return name.replace(SOURCE_EXT, "");
45
48
  }
46
49
 
47
- function stripAllowedImports(source: string): string {
50
+ function stripAllowedImports(
51
+ source: string,
52
+ allowed: Set<string> = ALLOWED_NAMES,
53
+ label = "defineModel and field",
54
+ ): string {
55
+ ALLOWED_IMPORT.lastIndex = 0;
48
56
  const names = new Set<string>();
49
57
  const stripped = source.replace(ALLOWED_IMPORT, (_full, specifiers: string) => {
50
58
  for (const part of specifiers.split(",")) {
51
59
  const name = part.trim();
52
60
  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`);
61
+ if (/\bas\b/.test(name) || !allowed.has(name)) {
62
+ throw new Error(`import "${name}" is not allowed; only ${label}`);
55
63
  }
56
64
  names.add(name);
57
65
  }
58
66
  return "";
59
67
  });
60
68
  if (/^\s*import\b/m.test(stripped)) {
61
- throw new Error("import is not allowed except defineModel/field from @backmatter/core or @backmatter/next");
69
+ throw new Error(
70
+ `import is not allowed except ${label} from @backmatter/core or @backmatter/next`,
71
+ );
62
72
  }
63
73
  return stripped;
64
74
  }
65
75
 
66
- function assertNoForbidden(source: string): void {
76
+ /** `import name from "../objects/<file>"` — the only local import a model may use. */
77
+ function takeObjectImports(source: string): {
78
+ source: string;
79
+ bindings: Array<{ local: string; stem: string }>;
80
+ } {
81
+ const bindings: Array<{ local: string; stem: string }> = [];
82
+ const re =
83
+ /^\s*import\s+([A-Za-z_$][\w$]*)\s+from\s+["']\.\.\/objects\/([A-Za-z][A-Za-z0-9]*)(?:\.(?:ts|tsx|js|jsx|mjs))?["']\s*;?\s*$/gm;
84
+ const next = source.replace(re, (_full, local: string, stem: string) => {
85
+ bindings.push({ local, stem });
86
+ return "";
87
+ });
88
+ return { source: next, bindings };
89
+ }
90
+
91
+ function assertNoForbidden(source: string, kind = "model"): void {
67
92
  for (const { re, name } of FORBIDDEN) {
68
- if (re.test(source)) throw new Error(`${name} is not allowed in model files`);
93
+ re.lastIndex = 0;
94
+ if (re.test(source)) throw new Error(`${name} is not allowed in ${kind} files`);
69
95
  }
70
96
  }
71
97
 
72
- function rewriteDefaultExport(source: string): string {
98
+ function rewriteDefaultExport(source: string, what = "defineModel({ ... })"): string {
73
99
  if (!/\bexport\s+default\b/.test(source)) {
74
- throw new Error("must export default defineModel({ ... })");
100
+ throw new Error(`must export default ${what}`);
75
101
  }
76
102
  const rewritten = source.replace(/\bexport\s+default\s+/, "return ");
77
103
  if (/\bexport\b/.test(rewritten)) {
78
- throw new Error("must export default defineModel({ ... })");
104
+ throw new Error(`must export default ${what}`);
79
105
  }
80
106
  return rewritten;
81
107
  }
@@ -85,18 +111,75 @@ function transpile(source: string): string {
85
111
  return transpiler.transformSync(source);
86
112
  }
87
113
 
114
+ function isObjectDef(value: unknown): value is ObjectDef {
115
+ if (!value || typeof value !== "object") return false;
116
+ const object = value as Record<string, unknown>;
117
+ return typeof object.name === "string" && typeof object.fields === "object" && !("type" in object);
118
+ }
119
+
120
+ /**
121
+ * Evaluate one `objects/{name}.ts` blob. Same rule as `discoverObjects`:
122
+ * the file stem must equal `object.name`.
123
+ */
124
+ export function evalObjectSource(source: string, stem: string): ObjectDef {
125
+ const body = rewriteDefaultExport(
126
+ stripAllowedImports(source, OBJECT_NAMES, "defineObject and field"),
127
+ "defineObject({ ... })",
128
+ );
129
+ assertNoForbidden(body, "object");
130
+ const wrapped = `(function () {\n${body}\n})()`;
131
+ const js = transpile(wrapped);
132
+ const sandbox = Object.create(null) as {
133
+ defineObject: typeof defineObject;
134
+ field: typeof field;
135
+ };
136
+ sandbox.defineObject = defineObject;
137
+ sandbox.field = field;
138
+ Object.freeze(sandbox);
139
+
140
+ let result: unknown;
141
+ try {
142
+ result = runInNewContext(js, sandbox, {
143
+ timeout: EVAL_TIMEOUT_MS,
144
+ filename: `${stem}.js`,
145
+ });
146
+ } catch (err) {
147
+ const message = err instanceof Error ? err.message : String(err);
148
+ throw new Error(`object "${stem}" failed to evaluate: ${message}`);
149
+ }
150
+ if (!isObjectDef(result)) {
151
+ throw new Error("must export default defineObject({ ... })");
152
+ }
153
+ if (result.name !== stem) {
154
+ throw new Error(
155
+ `defines name "${result.name}" but the file is named "${stem}"; rename one so they match`,
156
+ );
157
+ }
158
+ return result;
159
+ }
160
+
88
161
  /**
89
162
  * Evaluate one convention model file. `stem` is the file name without extension
90
- * and must equal `model.type`.
163
+ * and must equal `model.type`. `objects` supplies `import x from "../objects/<file>"`.
91
164
  */
92
- export function evalModelSource(source: string, stem: string): ModelDef {
93
- const body = rewriteDefaultExport(stripAllowedImports(source));
165
+ export function evalModelSource(
166
+ source: string,
167
+ stem: string,
168
+ objects: Readonly<Record<string, ObjectDef>> = {},
169
+ ): ModelDef {
170
+ const taken = takeObjectImports(source);
171
+ const body = rewriteDefaultExport(stripAllowedImports(taken.source));
94
172
  assertNoForbidden(body);
95
173
  const wrapped = `(function () {\n${body}\n})()`;
96
174
  const js = transpile(wrapped);
97
- const sandbox = Object.create(null) as { defineModel: typeof defineModel; field: typeof field };
175
+ const sandbox = Object.create(null) as Record<string, unknown>;
98
176
  sandbox.defineModel = defineModel;
99
177
  sandbox.field = field;
178
+ for (const binding of taken.bindings) {
179
+ const object = objects[binding.stem];
180
+ if (!object) throw new Error(`import "../objects/${binding.stem}" was not found`);
181
+ sandbox[binding.local] = object;
182
+ }
100
183
  Object.freeze(sandbox);
101
184
 
102
185
  let result: unknown;
@@ -125,6 +208,11 @@ export function evalModelsFromTree(
125
208
  files: Array<{ path: string; source: string }>,
126
209
  modelsDir = "models",
127
210
  ): ModelDef[] {
211
+ const objects: Record<string, ObjectDef> = {};
212
+ for (const file of files.filter((f) => isModelSourcePath(f.path, "objects"))) {
213
+ const stem = stemOf(file.path);
214
+ objects[stem] = evalObjectSource(file.source, stem);
215
+ }
128
216
  const models: ModelDef[] = [];
129
217
  const types = new Set<string>();
130
218
  const sorted = files
@@ -132,7 +220,7 @@ export function evalModelsFromTree(
132
220
  .sort((a, b) => a.path.localeCompare(b.path));
133
221
  for (const file of sorted) {
134
222
  const stem = stemOf(file.path);
135
- const model = evalModelSource(file.source, stem);
223
+ const model = evalModelSource(file.source, stem, objects);
136
224
  if (types.has(model.type)) {
137
225
  throw new Error(`duplicate model type "${model.type}" (${file.path})`);
138
226
  }
package/src/node/index.ts CHANGED
@@ -18,7 +18,7 @@ export {
18
18
  toIdentifier,
19
19
  } from "./discover";
20
20
  export type { BlockFile, ModelFile } from "./discover";
21
- export { evalModelSource, evalModelsFromTree, isModelSourcePath } from "./eval-model";
21
+ export { evalModelSource, evalModelsFromTree, evalObjectSource, isModelSourcePath } from "./eval-model";
22
22
  export {
23
23
  evalBlockSource,
24
24
  evalBlocksFromTree,
@@ -39,6 +39,15 @@ export {
39
39
  REGISTRY_FILES,
40
40
  } from "./generate";
41
41
  export type { GenerateOptions, GenerateResult, GeneratedFile } from "./generate";
42
+ export {
43
+ REGISTRY_SPECIFIER,
44
+ addRegistryPaths,
45
+ ensureRegistryTsconfig,
46
+ registryImportPaths,
47
+ inferTurbopackRoot,
48
+ registryTurbopackAlias,
49
+ registryWebpackAlias,
50
+ } from "./registry-import";
42
51
  export {
43
52
  contentRoots,
44
53
  formatResults,
@@ -0,0 +1,335 @@
1
+ // packages/core/src/node/registry-import.ts
2
+ // The generated client lives in `.backmatter/`. Apps import it as `backmatter`
3
+ // from any file. These helpers are the alias (`withBackmatter`) and the
4
+ // matching `tsconfig.json` `paths` (`init` and `withBackmatter`).
5
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
6
+ import path from "node:path";
7
+ import { DEFAULT_OUT_DIR, REGISTRY_FILES } from "./generate";
8
+
9
+ export const REGISTRY_SPECIFIER = "backmatter";
10
+
11
+ export type RegistryAlias = {
12
+ /** Bare import, e.g. `backmatter` or `backmatter/mdx`. */
13
+ specifier: string;
14
+ /** File name inside the out dir, e.g. `index.ts`. */
15
+ file: string;
16
+ };
17
+
18
+ export function registryAliases(): RegistryAlias[] {
19
+ return REGISTRY_FILES.map((file) => {
20
+ const stem = file.slice(0, -".ts".length);
21
+ return {
22
+ specifier: stem === "index" ? REGISTRY_SPECIFIER : `${REGISTRY_SPECIFIER}/${stem}`,
23
+ file,
24
+ };
25
+ });
26
+ }
27
+
28
+ const LOCK_FILES = ["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lock", "bun.lockb"];
29
+
30
+ /**
31
+ * Highest workspace or lockfile at or above `appRoot`, inside the git repo.
32
+ * Next uses the same files to choose `turbopack.root`. In a single-package app
33
+ * that is the app root; in this monorepo it is the repository root.
34
+ */
35
+ export function inferTurbopackRoot(appRoot: string): string {
36
+ const start = path.resolve(appRoot);
37
+ const gitRoot = findGitRoot(start);
38
+ let dir = start;
39
+ let root = start;
40
+ while (true) {
41
+ if (hasWorkspace(dir) || LOCK_FILES.some((name) => existsSync(path.join(dir, name)))) root = dir;
42
+ if (gitRoot && dir === gitRoot) break;
43
+ const parent = path.dirname(dir);
44
+ if (parent === dir) break;
45
+ dir = parent;
46
+ }
47
+ return root;
48
+ }
49
+
50
+ /**
51
+ * Turbopack `resolveAlias` values, relative to the Next app, with a `./` prefix.
52
+ * Next resolves these from the app directory even when `turbopack.root` is a
53
+ * parent workspace. A monorepo prefix (`./packages/docs/.backmatter/...`) makes
54
+ * the lookup `packages/docs/packages/docs/.backmatter`.
55
+ */
56
+ export function registryTurbopackAlias(
57
+ appRoot: string,
58
+ outDir = DEFAULT_OUT_DIR,
59
+ ): Record<string, string> {
60
+ const alias: Record<string, string> = {};
61
+ const base = path.resolve(appRoot);
62
+ for (const { specifier, file } of registryAliases()) {
63
+ const abs = path.resolve(appRoot, outDir, file);
64
+ alias[specifier] = asRelative(path.relative(base, abs).split(path.sep).join("/"));
65
+ }
66
+ return alias;
67
+ }
68
+
69
+ /**
70
+ * Webpack `resolve.alias`. Keys end in `$` so `backmatter` matches only that
71
+ * specifier, and `backmatter/mdx` stays its own entry. Values are absolute.
72
+ */
73
+ export function registryWebpackAlias(root: string, outDir = DEFAULT_OUT_DIR): Record<string, string> {
74
+ const alias: Record<string, string> = {};
75
+ for (const { specifier, file } of registryAliases()) {
76
+ alias[`${specifier}$`] = path.resolve(root, outDir, file);
77
+ }
78
+ return alias;
79
+ }
80
+
81
+ /**
82
+ * `tsconfig` `paths` for the registry. Values are relative to `baseUrl` when
83
+ * set, otherwise to the tsconfig directory (the project root).
84
+ */
85
+ export function registryImportPaths(outDir = DEFAULT_OUT_DIR, baseUrl?: string): Record<string, string[]> {
86
+ const base = !baseUrl || baseUrl === "." ? "." : baseUrl;
87
+ const from = path.resolve("/registry-root", base);
88
+ const to = path.resolve("/registry-root", outDir);
89
+ const folder = asRelative(path.relative(from, to).split(path.sep).join("/"));
90
+ const paths: Record<string, string[]> = {};
91
+ for (const { specifier, file } of registryAliases()) {
92
+ paths[specifier] = [`${folder}/${file}`];
93
+ }
94
+ return paths;
95
+ }
96
+
97
+ /** Insert missing `backmatter` paths. Returns the source unchanged when they are already present. */
98
+ export function addRegistryPaths(source: string, outDir = DEFAULT_OUT_DIR): string {
99
+ let parsed: unknown;
100
+ try {
101
+ parsed = looseParse(source);
102
+ } catch {
103
+ return source;
104
+ }
105
+ if (!isRecord(parsed)) return source;
106
+ const options = parsed.compilerOptions;
107
+ if (!isRecord(options)) return source;
108
+ const baseUrl = typeof options.baseUrl === "string" ? options.baseUrl : undefined;
109
+ const mappings = registryImportPaths(outDir, baseUrl);
110
+ const existing = isRecord(options.paths) ? options.paths : undefined;
111
+ if (options.paths !== undefined && !existing) return source;
112
+ const missing = Object.fromEntries(
113
+ Object.entries(mappings).filter(([key]) => !existing || existing[key] === undefined),
114
+ );
115
+ if (Object.keys(missing).length === 0) return source;
116
+
117
+ const compiler = findKeyBrace(source, "compilerOptions", 0, source.length);
118
+ if (!compiler) return source;
119
+ const compilerEnd = matchBrace(source, compiler.brace);
120
+ if (compilerEnd < 0) return source;
121
+
122
+ const nl = source.includes("\r\n") ? "\r\n" : "\n";
123
+ if (existing) {
124
+ const pathsBrace = findKeyBrace(source, "paths", compiler.brace + 1, compilerEnd);
125
+ if (!pathsBrace || pathsBrace.brace > compilerEnd) return source;
126
+ const indent = innerIndent(source, pathsBrace.brace);
127
+ const entries = formatEntries(missing, indent, nl);
128
+ return insertEntries(source, pathsBrace.brace, entries, indent, nl);
129
+ }
130
+
131
+ const indent = innerIndent(source, compiler.brace);
132
+ const child = `${indent} `;
133
+ const entries = formatEntries(missing, child, nl);
134
+ const block = `${indent}"paths": {${nl}${entries},${nl}${indent}}`;
135
+ return `${source.slice(0, compiler.brace + 1)}${nl}${block},${source.slice(compiler.brace + 1)}`;
136
+ }
137
+
138
+ /** Write `paths` into tsconfig.json or jsconfig.json when the registry keys are missing. */
139
+ export function ensureRegistryTsconfig(root: string, outDir = DEFAULT_OUT_DIR): string | null {
140
+ const tsconfig = path.join(root, "tsconfig.json");
141
+ const jsconfig = path.join(root, "jsconfig.json");
142
+ const file = existsSync(tsconfig) ? tsconfig : existsSync(jsconfig) ? jsconfig : null;
143
+ if (!file) return null;
144
+ const before = readFileSync(file, "utf-8");
145
+ const after = addRegistryPaths(before, outDir);
146
+ if (after === before) return null;
147
+ writeFileSync(file, after);
148
+ return `updated ${path.basename(file)}`;
149
+ }
150
+
151
+ function hasWorkspace(dir: string): boolean {
152
+ const pkgPath = path.join(dir, "package.json");
153
+ if (!existsSync(pkgPath)) return false;
154
+ try {
155
+ return JSON.parse(readFileSync(pkgPath, "utf-8")).workspaces != null;
156
+ } catch {
157
+ return false;
158
+ }
159
+ }
160
+
161
+ function findGitRoot(start: string): string | null {
162
+ let dir = start;
163
+ while (true) {
164
+ if (existsSync(path.join(dir, ".git"))) return dir;
165
+ const parent = path.dirname(dir);
166
+ if (parent === dir) return null;
167
+ dir = parent;
168
+ }
169
+ }
170
+
171
+ /** `.backmatter/...` is relative, and still needs a `./` so bundlers do not treat it as a package. */
172
+ function asRelative(rel: string): string {
173
+ if (rel.startsWith("./") || rel.startsWith("../")) return rel;
174
+ return `./${rel}`;
175
+ }
176
+
177
+ function insertEntries(source: string, open: number, entries: string, indent: string, nl: string): string {
178
+ let rest = source.slice(open + 1);
179
+ if (!/^\r?\n/.test(rest)) rest = `${nl}${indent}${rest.trimStart()}`;
180
+ return `${source.slice(0, open + 1)}${nl}${entries},${rest}`;
181
+ }
182
+
183
+ function formatEntries(mappings: Record<string, string[]>, indent: string, nl: string): string {
184
+ return Object.entries(mappings)
185
+ .map(([key, value]) => `${indent}"${key}": [${value.map((entry) => JSON.stringify(entry)).join(", ")}]`)
186
+ .join(`,${nl}`);
187
+ }
188
+
189
+ function innerIndent(source: string, open: number): string {
190
+ const rest = source.slice(open + 1);
191
+ const nl = rest.search(/\r?\n/);
192
+ if (nl !== -1) {
193
+ const after = rest.slice(nl).replace(/^\r?\n/, "");
194
+ const indent = after.match(/^[ \t]*/)?.[0] ?? "";
195
+ const next = after[indent.length];
196
+ if (indent.length > 0 && next && next !== "}") return indent;
197
+ }
198
+ const lineStart = source.lastIndexOf("\n", open) + 1;
199
+ const base = source.slice(lineStart, open).match(/^[ \t]*/)?.[0] ?? "";
200
+ return `${base} `;
201
+ }
202
+
203
+ function looseParse(source: string): unknown {
204
+ const stripped = stripJsonComments(source).replace(/,\s*([}\]])/g, "$1");
205
+ return JSON.parse(stripped);
206
+ }
207
+
208
+ function stripJsonComments(input: string): string {
209
+ let out = "";
210
+ let i = 0;
211
+ let inString = false;
212
+ let escaped = false;
213
+ while (i < input.length) {
214
+ const c = input[i]!;
215
+ const next = input[i + 1];
216
+ if (inString) {
217
+ out += c;
218
+ if (escaped) escaped = false;
219
+ else if (c === "\\") escaped = true;
220
+ else if (c === '"') inString = false;
221
+ i++;
222
+ continue;
223
+ }
224
+ if (c === '"') {
225
+ inString = true;
226
+ out += c;
227
+ i++;
228
+ continue;
229
+ }
230
+ if (c === "/" && next === "/") {
231
+ i += 2;
232
+ while (i < input.length && input[i] !== "\n") i++;
233
+ continue;
234
+ }
235
+ if (c === "/" && next === "*") {
236
+ i += 2;
237
+ while (i < input.length && !(input[i] === "*" && input[i + 1] === "/")) i++;
238
+ i += 2;
239
+ continue;
240
+ }
241
+ out += c;
242
+ i++;
243
+ }
244
+ return out;
245
+ }
246
+
247
+ function isRecord(value: unknown): value is Record<string, unknown> {
248
+ return typeof value === "object" && value !== null && !Array.isArray(value);
249
+ }
250
+
251
+ function findKeyBrace(source: string, key: string, start: number, end: number): { brace: number } | null {
252
+ const needle = `"${key}"`;
253
+ let i = start;
254
+ let inString = false;
255
+ let escaped = false;
256
+ while (i < end) {
257
+ const c = source[i]!;
258
+ const next = source[i + 1];
259
+ if (inString) {
260
+ if (escaped) escaped = false;
261
+ else if (c === "\\") escaped = true;
262
+ else if (c === '"') inString = false;
263
+ i++;
264
+ continue;
265
+ }
266
+ if (c === '"') {
267
+ if (source.startsWith(needle, i)) {
268
+ let j = i + needle.length;
269
+ while (j < end && /\s/.test(source[j]!)) j++;
270
+ if (source[j] === ":") {
271
+ j++;
272
+ while (j < end && /\s/.test(source[j]!)) j++;
273
+ if (source[j] === "{") return { brace: j };
274
+ }
275
+ }
276
+ inString = true;
277
+ i++;
278
+ continue;
279
+ }
280
+ if (c === "/" && next === "/") {
281
+ i += 2;
282
+ while (i < end && source[i] !== "\n") i++;
283
+ continue;
284
+ }
285
+ if (c === "/" && next === "*") {
286
+ i += 2;
287
+ while (i < end && !(source[i] === "*" && source[i + 1] === "/")) i++;
288
+ i += 2;
289
+ continue;
290
+ }
291
+ i++;
292
+ }
293
+ return null;
294
+ }
295
+
296
+ function matchBrace(source: string, open: number): number {
297
+ let depth = 0;
298
+ let i = open;
299
+ let inString = false;
300
+ let escaped = false;
301
+ while (i < source.length) {
302
+ const c = source[i]!;
303
+ const next = source[i + 1];
304
+ if (inString) {
305
+ if (escaped) escaped = false;
306
+ else if (c === "\\") escaped = true;
307
+ else if (c === '"') inString = false;
308
+ i++;
309
+ continue;
310
+ }
311
+ if (c === '"') {
312
+ inString = true;
313
+ i++;
314
+ continue;
315
+ }
316
+ if (c === "/" && next === "/") {
317
+ i += 2;
318
+ while (i < source.length && source[i] !== "\n") i++;
319
+ continue;
320
+ }
321
+ if (c === "/" && next === "*") {
322
+ i += 2;
323
+ while (i < source.length && !(source[i] === "*" && source[i + 1] === "/")) i++;
324
+ i += 2;
325
+ continue;
326
+ }
327
+ if (c === "{") depth++;
328
+ else if (c === "}") {
329
+ depth--;
330
+ if (depth === 0) return i;
331
+ }
332
+ i++;
333
+ }
334
+ return -1;
335
+ }