@backmatter/core 0.0.15 → 0.0.17

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.15",
3
+ "version": "0.0.17",
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
@@ -8,6 +8,8 @@ export type BackmatterDirs = {
8
8
  models?: string;
9
9
  /** Folder with one `defineBlock` default export per file. Default: "blocks". */
10
10
  blocks?: string;
11
+ /** Folder with one in-content `defineBlock` per file. Default: "richtext". */
12
+ richtext?: string;
11
13
  /** Folder with one MDX tag component default export per file. Default: "mdx". */
12
14
  mdx?: string;
13
15
  /** Folder with one layout default export per model type. Default: "layouts". */
@@ -51,6 +53,7 @@ export type ResolvedConfig = Omit<BackmatterConfig, "dirs" | "defaultLocale"> &
51
53
  export const DEFAULT_DIRS: Required<BackmatterDirs> = {
52
54
  models: "models",
53
55
  blocks: "blocks",
56
+ richtext: "richtext",
54
57
  mdx: "mdx",
55
58
  layouts: "layouts",
56
59
  objects: "objects",
@@ -95,14 +98,38 @@ export function resolveDevBarApiUrl(
95
98
  return "";
96
99
  }
97
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
+
98
115
  function detectApiUrl(
99
116
  config: BackmatterConfig,
100
117
  env: Record<string, string | undefined>,
101
118
  workspaceId: string | undefined,
102
119
  ): string | undefined {
103
- 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;
104
122
  const trimmed = explicit?.trim();
105
- 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);
106
133
  if (workspaceId && isDeployedHost(env)) return CLOUD_API_URL;
107
134
  return undefined;
108
135
  }
@@ -159,6 +186,7 @@ export function resolveConfig(
159
186
  dirs: {
160
187
  models: config.dirs?.models ?? DEFAULT_DIRS.models,
161
188
  blocks: config.dirs?.blocks ?? DEFAULT_DIRS.blocks,
189
+ richtext: config.dirs?.richtext ?? DEFAULT_DIRS.richtext,
162
190
  mdx: config.dirs?.mdx ?? DEFAULT_DIRS.mdx,
163
191
  layouts: config.dirs?.layouts ?? DEFAULT_DIRS.layouts,
164
192
  objects: config.dirs?.objects ?? DEFAULT_DIRS.objects,
package/src/model.ts CHANGED
@@ -85,6 +85,8 @@ export type SchemaSnapshot = {
85
85
  version: number;
86
86
  models: ModelSnapshot[];
87
87
  blocks?: BlockSnapshot[];
88
+ /** In-content blocks from `richtext/`. Separate from page-builder `blocks`. */
89
+ richtext?: BlockSnapshot[];
88
90
  objects?: ObjectSnapshot[];
89
91
  };
90
92
 
@@ -178,6 +180,7 @@ export function serializeSchema(
178
180
  models: ModelDef[],
179
181
  blocks?: BlockDef[],
180
182
  objects?: ObjectDef[],
183
+ richtext?: BlockDef[],
181
184
  ): SchemaSnapshot {
182
185
  const snapshot: SchemaSnapshot = {
183
186
  version: 1,
@@ -186,6 +189,9 @@ export function serializeSchema(
186
189
  if (blocks && blocks.length > 0) {
187
190
  snapshot.blocks = blocks.map(serializeBlock);
188
191
  }
192
+ if (richtext && richtext.length > 0) {
193
+ snapshot.richtext = richtext.map(serializeBlock);
194
+ }
189
195
  if (objects && objects.length > 0) {
190
196
  snapshot.objects = objects.map(serializeObject);
191
197
  }
@@ -301,6 +307,28 @@ export function parseSchemaSnapshot(input: unknown): SchemaSnapshot {
301
307
  }
302
308
  }
303
309
 
310
+ let richtext: BlockSnapshot[] = [];
311
+ if (value.richtext !== undefined) {
312
+ if (!Array.isArray(value.richtext)) {
313
+ throw new Error("schema.richtext must be an array");
314
+ }
315
+ richtext = value.richtext.map((raw, i) => {
316
+ try {
317
+ return parseBlockSnapshot(raw);
318
+ } catch (err) {
319
+ const msg = err instanceof Error ? err.message : "invalid block";
320
+ throw new Error(`schema.richtext[${i}]: ${msg}`);
321
+ }
322
+ });
323
+ const richtextNames = new Set<string>();
324
+ for (const block of richtext) {
325
+ if (richtextNames.has(block.name)) {
326
+ throw new Error(`duplicate richtext block name: ${block.name}`);
327
+ }
328
+ richtextNames.add(block.name);
329
+ }
330
+ }
331
+
304
332
  let objects: ObjectSnapshot[] = [];
305
333
  if (value.objects !== undefined) {
306
334
  if (!Array.isArray(value.objects)) {
@@ -323,5 +351,11 @@ export function parseSchemaSnapshot(input: unknown): SchemaSnapshot {
323
351
  }
324
352
  }
325
353
 
326
- return { version, models, blocks, objects };
354
+ return {
355
+ version,
356
+ models,
357
+ blocks,
358
+ ...(richtext.length > 0 ? { richtext } : {}),
359
+ objects,
360
+ };
327
361
  }
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;
@@ -234,8 +235,10 @@ export async function runGenerate(args: CliArgs): Promise<GenerateResult> {
234
235
  root,
235
236
  modelsDir: config.dirs.models,
236
237
  blocksDir: config.dirs.blocks,
238
+ richtextDir: config.dirs.richtext,
237
239
  mdxDir: config.dirs.mdx,
238
240
  layoutsDir: config.dirs.layouts,
241
+ objectsDir: config.dirs.objects,
239
242
  });
240
243
  }
241
244
 
@@ -247,8 +250,9 @@ function renderConfigFile(sdk: string): string {
247
250
  import { defineConfig } from "${from}";
248
251
 
249
252
  // 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.
253
+ // blocks/*.tsx, in-content blocks in richtext/*.tsx, MDX tags in mdx/*.tsx,
254
+ // and content in content/{locale}/{type}/{slug}.{mdx,json} — all discovered
255
+ // automatically.
252
256
  // Set workspaceId from General settings (not an env var).
253
257
  // API URL defaults to https://api.backmatter.app in production; tokens stay env.
254
258
  export default defineConfig({
@@ -258,16 +262,18 @@ export default defineConfig({
258
262
  `;
259
263
  }
260
264
 
261
- function renderCatchAllPage(registryImport: string): string {
265
+ function renderCatchAllPage(): string {
262
266
  return `// ${CATCH_ALL_DIR}/page.tsx — mounts every model at its route, including / via indexSlug.
263
267
  import { cacheLife } from "next/cache";
264
268
  import { createCatchAll } from "@backmatter/next/routes";
265
- import { cms } from "${registryImport}";
266
- import { components } from "${registryImport}/mdx";
267
- import { blockLoaders } from "${registryImport}/blocks";
268
- import { blockFallbacks } from "${registryImport}/block-fallbacks";
269
- import { layouts } from "${registryImport}/layouts";
270
-
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";
275
+
276
+ const components = { ...richtextComponents, ...mdxComponents };
271
277
  const route = createCatchAll(cms, { components, blocks: blockLoaders, fallbacks: blockFallbacks, layouts });
272
278
 
273
279
  export const generateStaticParams = route.generateStaticParams;
@@ -298,12 +304,12 @@ export default async function Page(props: {
298
304
  `;
299
305
  }
300
306
 
301
- function renderInstrumentation(syncImport: string): string {
307
+ function renderInstrumentation(): string {
302
308
  return `// instrumentation.ts — schema sync on Node. Next also runs register() on Edge;
303
- // do not import .backmatter/sync there (it pulls Node APIs).
309
+ // do not import ${REGISTRY_SPECIFIER}/sync there (it pulls Node APIs).
304
310
  export async function register() {
305
311
  if (process.env.NEXT_RUNTIME === "nodejs") {
306
- await import("${syncImport}");
312
+ await import("${REGISTRY_SPECIFIER}/sync");
307
313
  }
308
314
  }
309
315
  `;
@@ -315,15 +321,15 @@ export function warnMissingInstrumentation(root: string): void {
315
321
  const hasSrc = existsSync(path.join(root, "src", "instrumentation.ts"));
316
322
  if (!hasRoot && !hasSrc) {
317
323
  console.warn(
318
- "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`,
319
325
  );
320
326
  }
321
327
  }
322
328
 
323
- function renderPreviewRoute(registryImport: string): string {
329
+ function renderPreviewRoute(): string {
324
330
  return `// api/backmatter/preview/route.ts — preview session enable / disable / stream / session.
325
331
  import { createPreviewRoute } from "@backmatter/next/preview-route";
326
- import { cms } from "${registryImport}";
332
+ import { cms } from "${REGISTRY_SPECIFIER}";
327
333
 
328
334
  export const { GET, POST } = createPreviewRoute(cms);
329
335
  `;
@@ -342,10 +348,10 @@ export const config = {
342
348
  `;
343
349
  }
344
350
 
345
- function renderRevalidateRoute(registryImport: string): string {
351
+ function renderRevalidateRoute(): string {
346
352
  return `// api/backmatter/revalidate/route.ts — content.committed from the BACKMATTER server.
347
353
  import { createRevalidateRoute } from "@backmatter/next/revalidate";
348
- import { cms } from "${registryImport}";
354
+ import { cms } from "${REGISTRY_SPECIFIER}";
349
355
 
350
356
  export const POST = createRevalidateRoute(cms);
351
357
  `;
@@ -381,21 +387,16 @@ export async function runInit(args: CliArgs): Promise<string[]> {
381
387
  } else if (existsSync(requiredCatchAll)) {
382
388
  notes.push(`kept existing ${path.join(appDir, "[...backmatter]", "page.tsx")}`);
383
389
  } else {
384
- const registry = path.relative(pageDir, path.join(root, ".backmatter")).split(path.sep).join("/");
385
390
  await mkdir(pageDir, { recursive: true });
386
- await writeFile(page, renderCatchAllPage(registry.startsWith(".") ? registry : `./${registry}`));
391
+ await writeFile(page, renderCatchAllPage());
387
392
  notes.push(`wrote ${path.join(appDir, CATCH_ALL_DIR, "page.tsx")}`);
388
393
  }
389
394
 
390
395
  const revalidateDir = path.join(root, appDir, "api", "backmatter", "revalidate");
391
396
  const revalidateFile = path.join(revalidateDir, "route.ts");
392
397
  if (!existsSync(revalidateFile)) {
393
- const registry = path.relative(revalidateDir, path.join(root, ".backmatter")).split(path.sep).join("/");
394
398
  await mkdir(revalidateDir, { recursive: true });
395
- await writeFile(
396
- revalidateFile,
397
- renderRevalidateRoute(registry.startsWith(".") ? registry : `./${registry}`),
398
- );
399
+ await writeFile(revalidateFile, renderRevalidateRoute());
399
400
  notes.push(`wrote ${path.join(appDir, "api/backmatter/revalidate/route.ts")}`);
400
401
  } else {
401
402
  notes.push(`kept existing ${path.join(appDir, "api/backmatter/revalidate/route.ts")}`);
@@ -404,12 +405,8 @@ export async function runInit(args: CliArgs): Promise<string[]> {
404
405
  const previewDir = path.join(root, appDir, "api", "backmatter", "preview");
405
406
  const previewFile = path.join(previewDir, "route.ts");
406
407
  if (!existsSync(previewFile)) {
407
- const registry = path.relative(previewDir, path.join(root, ".backmatter")).split(path.sep).join("/");
408
408
  await mkdir(previewDir, { recursive: true });
409
- await writeFile(
410
- previewFile,
411
- renderPreviewRoute(registry.startsWith(".") ? registry : `./${registry}`),
412
- );
409
+ await writeFile(previewFile, renderPreviewRoute());
413
410
  notes.push(`wrote ${path.join(appDir, "api/backmatter/preview/route.ts")}`);
414
411
  } else {
415
412
  notes.push(`kept existing ${path.join(appDir, "api/backmatter/preview/route.ts")}`);
@@ -443,15 +440,17 @@ export async function runInit(args: CliArgs): Promise<string[]> {
443
440
  notes.push("kept existing instrumentation.ts");
444
441
  } else {
445
442
  const dest = usesSrcApp ? srcInstrumentation : rootInstrumentation;
446
- const syncImport = usesSrcApp ? "../.backmatter/sync" : "./.backmatter/sync";
447
- await writeFile(dest, renderInstrumentation(syncImport));
443
+ await writeFile(dest, renderInstrumentation());
448
444
  notes.push(`wrote ${path.relative(root, dest)}`);
449
445
  }
446
+
447
+ const tsconfigNote = ensureRegistryTsconfig(root);
448
+ if (tsconfigNote) notes.push(tsconfigNote);
450
449
  } else {
451
450
  notes.push("no @backmatter/next dependency: skipped the catch-all route");
452
451
  }
453
452
 
454
- for (const dir of ["models", "content", "blocks", "mdx", "layouts"]) {
453
+ for (const dir of ["models", "content", "blocks", "richtext", "mdx", "layouts"]) {
455
454
  await mkdir(path.join(root, dir), { recursive: true });
456
455
  }
457
456
 
@@ -18,6 +18,8 @@ export type CreateBackmatterOptions<Models extends readonly ModelDef[] = readonl
18
18
  BackmatterConfig & {
19
19
  models: Models;
20
20
  blocks?: readonly BlockDef[];
21
+ /** In-content blocks (`richtext/*.tsx`). Synced separately from page blocks. */
22
+ richtext?: readonly BlockDef[];
21
23
  objects?: readonly ObjectDef[];
22
24
  /**
23
25
  * When true, API fetches use Next cache tags instead of `cache: "no-store"`.
@@ -67,7 +69,7 @@ function toSnapshots(models: readonly ModelDef[]): ModelSnapshot[] {
67
69
  export function createBackmatter<const Models extends readonly ModelDef[]>(
68
70
  options: CreateBackmatterOptions<Models>,
69
71
  ): BackmatterClient<Models> {
70
- const { models: modelDefs, blocks: blockDefs, objects: objectDefs, taggedApiFetch, sync = true, ...rest } = options;
72
+ const { models: modelDefs, blocks: blockDefs, richtext: richtextDefs, objects: objectDefs, taggedApiFetch, sync = true, ...rest } = options;
71
73
  const config = resolveConfig(rest);
72
74
  const models = toSnapshots(modelDefs);
73
75
  const siteRoot = config.siteRoot ?? process.cwd();
@@ -91,6 +93,7 @@ export function createBackmatter<const Models extends readonly ModelDef[]>(
91
93
  hostToken,
92
94
  models: [...modelDefs],
93
95
  blocks: blockDefs ? [...blockDefs] : undefined,
96
+ richtext: richtextDefs ? [...richtextDefs] : undefined,
94
97
  objects: objectDefs ? [...objectDefs] : undefined,
95
98
  branch: config.branch,
96
99
  siteUrl,
@@ -31,6 +31,7 @@ export type GenerateOptions = {
31
31
  outDir?: string;
32
32
  modelsDir?: string;
33
33
  blocksDir?: string;
34
+ richtextDir?: string;
34
35
  mdxDir?: string;
35
36
  layoutsDir?: string;
36
37
  objectsDir?: string;
@@ -59,6 +60,7 @@ export type GenerateResult = {
59
60
  changed: string[];
60
61
  models: ModelFile[];
61
62
  blocks: BlockFile[];
63
+ richtext: BlockFile[];
62
64
  mdx: BlockFile[];
63
65
  layouts: ModelFile[];
64
66
  objects: ModelFile[];
@@ -161,11 +163,13 @@ export function renderSyncBarrel(input: {
161
163
  outDir: string;
162
164
  models: ModelFile[];
163
165
  blocks: BlockFile[];
166
+ richtext?: BlockFile[];
164
167
  objects?: ModelFile[];
165
168
  configFile: string | null;
166
169
  sdk: string;
167
170
  }): string {
168
171
  const objects = input.objects ?? [];
172
+ const richtext = input.richtext ?? [];
169
173
  const lines = [GENERATED_HEADER, `import { createBackmatter } from "${input.sdk}";`];
170
174
  if (input.configFile) {
171
175
  lines.push(`import config from "${importSpecifier(input.outDir, input.configFile)}";`);
@@ -178,6 +182,9 @@ export function renderSyncBarrel(input: {
178
182
  for (const b of input.blocks) {
179
183
  lines.push(`import ${b.name} from "${importSpecifier(input.outDir, b.file)}";`);
180
184
  }
185
+ for (const b of richtext) {
186
+ lines.push(`import richtext${b.name} from "${importSpecifier(input.outDir, b.file)}";`);
187
+ }
181
188
  for (const object of objects) {
182
189
  lines.push(`import ${object.importName} from "${importSpecifier(input.outDir, object.file)}";`);
183
190
  }
@@ -187,10 +194,16 @@ export function renderSyncBarrel(input: {
187
194
  lines.push("");
188
195
  lines.push(`export const blocks = [${input.blocks.map((b) => b.name).join(", ")}] as const;`);
189
196
  lines.push("");
197
+ lines.push(
198
+ `export const richtext = [${richtext.map((b) => `richtext${b.name}`).join(", ")}] as const;`,
199
+ );
200
+ lines.push("");
190
201
  lines.push(`export const objects = [${objects.map((o) => o.importName).join(", ")}] as const;`);
191
202
  lines.push("");
192
203
  lines.push("/** Typed content client: cms.query.{type}, cms.getDoc, cms.listDocs. */");
193
- lines.push("export const cms = createBackmatter({ ...config, models, blocks, objects, sync: true });");
204
+ lines.push(
205
+ "export const cms = createBackmatter({ ...config, models, blocks, richtext, objects, sync: true });",
206
+ );
194
207
  return `${lines.join("\n")}\n`;
195
208
  }
196
209
 
@@ -206,6 +219,18 @@ export function renderMdxBarrel(input: { outDir: string; mdx: BlockFile[] }): st
206
219
  return `${lines.join("\n")}\n`;
207
220
  }
208
221
 
222
+ export function renderRichtextBarrel(input: { outDir: string; richtext: BlockFile[] }): string {
223
+ const lines = [GENERATED_HEADER];
224
+ for (const b of input.richtext) {
225
+ lines.push(`import ${b.name} from "${importSpecifier(input.outDir, b.file)}";`);
226
+ }
227
+ lines.push("");
228
+ lines.push("/** In-content blocks: <Image /> in MDX resolves to richtext/Image.tsx. */");
229
+ const entries = input.richtext.map((b) => `${b.name}: ${b.name}.component`);
230
+ lines.push(`export const richtextComponents = ${entries.length ? `{ ${entries.join(", ")} }` : "{}"};`);
231
+ return `${lines.join("\n")}\n`;
232
+ }
233
+
209
234
  export function renderBlocksBarrel(input: { outDir: string; blocks: BlockFile[] }): string {
210
235
  const lines = [GENERATED_HEADER, ""];
211
236
  lines.push('/** Composer loaders: JSON `{ type: "Hero" }` → dynamic import of blocks/Hero.tsx. */');
@@ -257,6 +282,7 @@ export const REGISTRY_FILES = [
257
282
  "blocks.ts",
258
283
  "block-fallbacks.ts",
259
284
  "mdx.ts",
285
+ "richtext.ts",
260
286
  "layouts.ts",
261
287
  "sync.ts",
262
288
  ] as const;
@@ -269,6 +295,10 @@ export function generate(options: GenerateOptions): GenerateResult {
269
295
  root,
270
296
  options.blocksDir ?? DEFAULT_DIRS.blocks,
271
297
  );
298
+ const { blocks: richtext, skipped: skippedRichtext } = listBlockFiles(
299
+ root,
300
+ options.richtextDir ?? DEFAULT_DIRS.richtext,
301
+ );
272
302
  const fallbacks = listBlockFallbackFiles(root, options.blocksDir ?? DEFAULT_DIRS.blocks);
273
303
  const { blocks: mdx, skipped: skippedMdx } = listMdxFiles(
274
304
  root,
@@ -285,8 +315,9 @@ export function generate(options: GenerateOptions): GenerateResult {
285
315
  "blocks.ts": renderBlocksBarrel({ outDir, blocks }),
286
316
  "block-fallbacks.ts": renderBlockFallbacksBarrel({ outDir, fallbacks }),
287
317
  "mdx.ts": renderMdxBarrel({ outDir, mdx }),
318
+ "richtext.ts": renderRichtextBarrel({ outDir, richtext }),
288
319
  "layouts.ts": renderLayoutsBarrel({ outDir, layouts }),
289
- "sync.ts": renderSyncBarrel({ outDir, models, blocks, objects, configFile, sdk }),
320
+ "sync.ts": renderSyncBarrel({ outDir, models, blocks, richtext, objects, configFile, sdk }),
290
321
  };
291
322
  const outputs: GeneratedFile[] = REGISTRY_FILES.map((name) => {
292
323
  const file = path.join(outDir, name);
@@ -304,10 +335,11 @@ export function generate(options: GenerateOptions): GenerateResult {
304
335
  changed: outputs.filter((o) => o.changed).map((o) => o.rel),
305
336
  models,
306
337
  blocks,
338
+ richtext,
307
339
  mdx,
308
340
  layouts,
309
341
  objects,
310
- skippedBlocks,
342
+ skippedBlocks: [...skippedBlocks, ...skippedRichtext],
311
343
  skippedMdx,
312
344
  configFile,
313
345
  };
package/src/node/index.ts CHANGED
@@ -32,12 +32,22 @@ export {
32
32
  renderBlocksBarrel,
33
33
  renderLayoutsBarrel,
34
34
  renderMdxBarrel,
35
+ renderRichtextBarrel,
35
36
  renderModelsBarrel,
36
37
  renderSyncBarrel,
37
38
  DEFAULT_OUT_DIR,
38
39
  REGISTRY_FILES,
39
40
  } from "./generate";
40
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";
41
51
  export {
42
52
  contentRoots,
43
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
+ }
@@ -23,6 +23,8 @@ export type SyncSchemaOptions = {
23
23
  hostToken: string;
24
24
  models: Array<ModelDef | ModelSnapshot>;
25
25
  blocks?: BlockDef[];
26
+ /** In-content blocks from the client `richtext/` directory. */
27
+ richtext?: BlockDef[];
26
28
  objects?: ObjectDef[];
27
29
  branch?: string;
28
30
  siteUrl?: string;
@@ -34,6 +36,7 @@ export async function syncSchema(options: SyncSchemaOptions): Promise<void> {
34
36
  options.models as ModelDef[],
35
37
  options.blocks,
36
38
  options.objects,
39
+ options.richtext,
37
40
  );
38
41
  const body: { payload: ReturnType<typeof serializeSchema>; branch: string; siteUrl?: string } = {
39
42
  payload,