@backmatter/core 0.0.14 → 0.0.16

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@backmatter/core",
3
- "version": "0.0.14",
3
+ "version": "0.0.16",
4
4
  "description": "Experimental Backmatter SDK. APIs will break. Not ready for production.",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/src/api-loader.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  // packages/core/src/api-loader.ts
2
2
  import type { BackmatterDoc, BackmatterDocSummary } from "./doc";
3
+ import { encodeDocLocale } from "./path";
3
4
 
4
5
  export type PublicDocsQuery = {
5
6
  branch?: string;
@@ -60,7 +61,7 @@ export async function getDocFromApi(
60
61
  query: Pick<PublicDocsQuery, "branch" | "tags"> = {},
61
62
  ): Promise<BackmatterDoc | null> {
62
63
  const res = await fetch(
63
- publicDocsUrl(apiUrl, workspaceId, `/${locale}/${type}/${slug}`, query),
64
+ publicDocsUrl(apiUrl, workspaceId, `/${encodeDocLocale(locale)}/${type}/${slug}`, query),
64
65
  fetchInit(contentToken, query.tags),
65
66
  );
66
67
  if (res.status === 404) return null;
package/src/config.ts CHANGED
@@ -8,10 +8,14 @@ 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". */
14
16
  layouts?: string;
17
+ /** Folder with one `defineObject` default export per file. Default: "objects". */
18
+ objects?: string;
15
19
  };
16
20
 
17
21
  export type BackmatterConfig = {
@@ -49,8 +53,10 @@ export type ResolvedConfig = Omit<BackmatterConfig, "dirs" | "defaultLocale"> &
49
53
  export const DEFAULT_DIRS: Required<BackmatterDirs> = {
50
54
  models: "models",
51
55
  blocks: "blocks",
56
+ richtext: "richtext",
52
57
  mdx: "mdx",
53
58
  layouts: "layouts",
59
+ objects: "objects",
54
60
  };
55
61
  export const DEFAULT_LOCALE = "en";
56
62
  export const CLOUD_API_URL = "https://api.backmatter.app";
@@ -156,8 +162,10 @@ export function resolveConfig(
156
162
  dirs: {
157
163
  models: config.dirs?.models ?? DEFAULT_DIRS.models,
158
164
  blocks: config.dirs?.blocks ?? DEFAULT_DIRS.blocks,
165
+ richtext: config.dirs?.richtext ?? DEFAULT_DIRS.richtext,
159
166
  mdx: config.dirs?.mdx ?? DEFAULT_DIRS.mdx,
160
167
  layouts: config.dirs?.layouts ?? DEFAULT_DIRS.layouts,
168
+ objects: config.dirs?.objects ?? DEFAULT_DIRS.objects,
161
169
  },
162
170
  };
163
171
  }
@@ -30,7 +30,10 @@ export function revalidateUrl(siteUrl: string): string {
30
30
  }
31
31
 
32
32
  export function contentCacheTags(locale: string, type: string, slug: string): string[] {
33
- return [BACKMATTER_CACHE_TAG, `backmatter:${type}`, `backmatter:${locale}:${type}:${slug}`];
33
+ const identity = locale
34
+ ? `backmatter:${locale}:${type}:${slug}`
35
+ : `backmatter:${type}:${slug}`;
36
+ return [BACKMATTER_CACHE_TAG, `backmatter:${type}`, identity];
34
37
  }
35
38
 
36
39
  export function listCacheTags(type?: string): string[] {
@@ -49,6 +52,7 @@ export function pathsForCommittedDocs(
49
52
  const model = byType.get(doc.type);
50
53
  if (!model) continue;
51
54
  const path = routeFor(model, doc.locale, doc.slug);
55
+ if (!path) continue;
52
56
  if (seen.has(path)) continue;
53
57
  seen.add(path);
54
58
  paths.push(path);
package/src/doc-ref.ts CHANGED
@@ -10,11 +10,14 @@ export function parseDocRef(input: unknown): DocRef {
10
10
  throw new Error("docRef must be an object");
11
11
  }
12
12
  const value = input as Record<string, unknown>;
13
- for (const key of ["workspaceId", "locale", "type", "slug"] as const) {
13
+ for (const key of ["workspaceId", "type", "slug"] as const) {
14
14
  if (typeof value[key] !== "string" || value[key].length === 0) {
15
15
  throw new Error(`docRef.${key} is required`);
16
16
  }
17
17
  }
18
+ if (typeof value.locale !== "string") {
19
+ throw new Error("docRef.locale is required");
20
+ }
18
21
  return {
19
22
  workspaceId: value.workspaceId as string,
20
23
  locale: value.locale as string,
@@ -25,5 +28,7 @@ export function parseDocRef(input: unknown): DocRef {
25
28
 
26
29
  /** Relative content path (no workspace prefix) used under head/ and trees/{userId}/. */
27
30
  export function docRefPath(ref: DocRef): string {
28
- return `${ref.locale}/${ref.type}/${ref.slug}`;
31
+ return ref.locale
32
+ ? `${ref.locale}/${ref.type}/${ref.slug}`
33
+ : `${ref.type}/${ref.slug}`;
29
34
  }
package/src/fields.ts CHANGED
@@ -7,7 +7,9 @@ export type FieldKind =
7
7
  | "relation"
8
8
  | "number"
9
9
  | "boolean"
10
- | "blocks";
10
+ | "blocks"
11
+ | "object"
12
+ | "array";
11
13
 
12
14
  /**
13
15
  * Field definition. Generic parameters only exist for type inference
@@ -22,14 +24,22 @@ export type FieldDef<
22
24
  required?: R;
23
25
  /** For relation fields: target model type. */
24
26
  relationTo?: To;
27
+ /** For object/array fields: named object. */
28
+ object?: string;
29
+ /** Nested fields for object/array (inline or copied from defineObject). */
30
+ fields?: Record<string, FieldDef>;
25
31
  };
26
32
 
27
33
  export type FieldSnapshot = {
28
34
  kind: FieldKind;
29
35
  required?: boolean;
30
36
  relationTo?: string;
37
+ object?: string;
38
+ fields?: Record<string, FieldSnapshot>;
31
39
  };
32
40
 
41
+ type ObjectLike = { name: string; fields: Record<string, FieldDef> };
42
+
33
43
  type Opts<R extends boolean> = { required?: R };
34
44
 
35
45
  function base<K extends FieldKind, R extends boolean, To extends string>(
@@ -43,6 +53,18 @@ function base<K extends FieldKind, R extends boolean, To extends string>(
43
53
  } as FieldDef<K, R, To>;
44
54
  }
45
55
 
56
+ function composite<K extends "object" | "array", R extends boolean>(
57
+ kind: K,
58
+ def: string | ObjectLike,
59
+ opts?: Opts<R>,
60
+ ): FieldDef<K, R> {
61
+ const required = opts?.required ? { required: opts.required as R } : {};
62
+ if (typeof def === "string") {
63
+ return { kind, object: def, ...required };
64
+ }
65
+ return { kind, object: def.name, fields: def.fields, ...required };
66
+ }
67
+
46
68
  export const field = {
47
69
  text: <const R extends boolean = false>(opts?: Opts<R>) => base("text", opts),
48
70
  locale: <const R extends boolean = false>(opts?: Opts<R>) => base("locale", opts),
@@ -52,14 +74,27 @@ export const field = {
52
74
  relation: <To extends string, const R extends boolean = false>(to: To, opts?: Opts<R>) =>
53
75
  base("relation", { ...opts, relationTo: to }),
54
76
  blocks: <const R extends boolean = false>(opts?: Opts<R>) => base("blocks", opts),
77
+ object: <const R extends boolean = false>(def: string | ObjectLike, opts?: Opts<R>) =>
78
+ composite("object", def, opts),
79
+ array: <const R extends boolean = false>(def: string | ObjectLike, opts?: Opts<R>) =>
80
+ composite("array", def, opts),
55
81
  };
56
82
 
57
83
  export function serializeField(def: FieldDef): FieldSnapshot {
58
- return {
84
+ const snap: FieldSnapshot = {
59
85
  kind: def.kind,
60
86
  ...(def.required ? { required: true } : {}),
61
87
  ...(def.relationTo ? { relationTo: def.relationTo } : {}),
88
+ ...(def.object ? { object: def.object } : {}),
62
89
  };
90
+ if (def.fields) {
91
+ const fields: Record<string, FieldSnapshot> = {};
92
+ for (const [name, nested] of Object.entries(def.fields)) {
93
+ fields[name] = serializeField(nested);
94
+ }
95
+ snap.fields = fields;
96
+ }
97
+ return snap;
63
98
  }
64
99
 
65
100
  export function parseFieldSnapshot(input: unknown): FieldSnapshot {
@@ -76,6 +111,8 @@ export function parseFieldSnapshot(input: unknown): FieldSnapshot {
76
111
  "number",
77
112
  "boolean",
78
113
  "blocks",
114
+ "object",
115
+ "array",
79
116
  ];
80
117
  if (typeof kind !== "string" || !allowed.includes(kind as FieldKind)) {
81
118
  throw new Error(`invalid field.kind: ${String(kind)}`);
@@ -85,5 +122,15 @@ export function parseFieldSnapshot(input: unknown): FieldSnapshot {
85
122
  if (typeof value.relationTo === "string" && value.relationTo.length > 0) {
86
123
  snap.relationTo = value.relationTo;
87
124
  }
125
+ if (typeof value.object === "string" && value.object.length > 0) {
126
+ snap.object = value.object;
127
+ }
128
+ if (value.fields && typeof value.fields === "object" && !Array.isArray(value.fields)) {
129
+ const fields: Record<string, FieldSnapshot> = {};
130
+ for (const [name, nested] of Object.entries(value.fields as Record<string, unknown>)) {
131
+ fields[name] = parseFieldSnapshot(nested);
132
+ }
133
+ snap.fields = fields;
134
+ }
88
135
  return snap;
89
136
  }
package/src/index.ts CHANGED
@@ -9,6 +9,9 @@ export type {
9
9
  } from "./fields";
10
10
  export { field, serializeField, parseFieldSnapshot } from "./fields";
11
11
 
12
+ export type { ObjectDef, ObjectSnapshot } from "./object";
13
+ export { defineObject, serializeObject, parseObjectSnapshot } from "./object";
14
+
12
15
  export type {
13
16
  ModelDef,
14
17
  ModelInput,
@@ -21,6 +24,7 @@ export {
21
24
  defaultModelPath,
22
25
  DEFAULT_MODEL_PATH,
23
26
  DEFAULT_PAGE_PATH,
27
+ IMPLICIT_SINGLETON_SLUG,
24
28
  serializeModel,
25
29
  serializeSchema,
26
30
  parseSchemaSnapshot,
@@ -57,6 +61,7 @@ export type { RouteMatch, RouteModel, MatchRouteOptions } from "./routes";
57
61
  export {
58
62
  DEFAULT_ROUTE,
59
63
  compileRoutePattern,
64
+ isRoutable,
60
65
  matchRoute,
61
66
  normalizePathname,
62
67
  routeFor,
@@ -73,6 +78,9 @@ export {
73
78
  repoPathFromModel,
74
79
  contentExtFromPath,
75
80
  contentExtForType,
81
+ EMPTY_LOCALE_SEGMENT,
82
+ encodeDocLocale,
83
+ decodeDocLocale,
76
84
  encodeGitBranch,
77
85
  decodeGitBranch,
78
86
  s3HeadKey,
package/src/model.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  } from "./fields";
9
9
  import { parseBlockSnapshot, serializeBlock, type BlockDef, type BlockSnapshot } from "./block";
10
10
  import type { BlockInstance } from "./block";
11
+ import { parseObjectSnapshot, serializeObject, type ObjectDef, type ObjectSnapshot } from "./object";
11
12
  import type { DocFormat } from "./doc-format";
12
13
 
13
14
  export type ModelDef = {
@@ -17,8 +18,14 @@ export type ModelDef = {
17
18
  /**
18
19
  * URL template for the host catch-all route, e.g. "/blog/{slug}".
19
20
  * Defaults to "/{locale}/{type}/{slug}" when omitted (see routes.ts).
21
+ * `false` excludes the model from the catch-all. Singletons default to `false`.
20
22
  */
21
- route?: string;
23
+ route?: string | false;
24
+ /**
25
+ * At most one document for each identity the path allows (one file, or one
26
+ * file per `{locale}` when that placeholder is present).
27
+ */
28
+ singleton?: boolean;
22
29
  /** File format. Default `"mdx"`. `"page"` stores JSON with a blocks array. */
23
30
  format?: DocFormat;
24
31
  /**
@@ -44,16 +51,43 @@ export function defaultModelPath(type: string, format: DocFormat = "mdx"): strin
44
51
  export type ModelSnapshot = {
45
52
  type: string;
46
53
  path: string;
47
- route?: string;
54
+ route?: string | false;
55
+ singleton?: boolean;
48
56
  format?: DocFormat;
49
57
  indexSlug?: string;
50
58
  fields: Record<string, FieldSnapshot>;
51
59
  };
52
60
 
61
+ /** Implicit file stem when a singleton path omits `{slug}`. */
62
+ export const IMPLICIT_SINGLETON_SLUG = "index";
63
+
64
+ export function assertModelPath(path: string, singleton: boolean, label = "model.path"): void {
65
+ if (!singleton && !path.includes("{slug}")) {
66
+ throw new Error(`${label} must include {slug}`);
67
+ }
68
+ }
69
+
70
+ export function assertModelRoute(
71
+ route: string | false,
72
+ singleton: boolean,
73
+ label = "model.route",
74
+ ): void {
75
+ if (route === false) return;
76
+ if (typeof route !== "string" || !route.startsWith("/")) {
77
+ throw new Error(`${label} must start with "/"`);
78
+ }
79
+ if (!singleton && !route.includes("{slug}")) {
80
+ throw new Error(`${label} must include {slug}`);
81
+ }
82
+ }
83
+
53
84
  export type SchemaSnapshot = {
54
85
  version: number;
55
86
  models: ModelSnapshot[];
56
87
  blocks?: BlockSnapshot[];
88
+ /** In-content blocks from `richtext/`. Separate from page-builder `blocks`. */
89
+ richtext?: BlockSnapshot[];
90
+ objects?: ObjectSnapshot[];
57
91
  };
58
92
 
59
93
  type WithPath<D extends ModelInput> = D extends { path: string } ? D : D & { path: string };
@@ -69,20 +103,17 @@ export function defineModel<const D extends ModelInput>(def: D): WithPath<D> {
69
103
  if (def.format !== undefined && def.format !== "mdx" && def.format !== "page") {
70
104
  throw new Error('model.format must be "mdx" or "page"');
71
105
  }
106
+ const singleton = def.singleton === true;
107
+ if (singleton && def.indexSlug !== undefined) {
108
+ throw new Error("model.singleton cannot be combined with indexSlug");
109
+ }
72
110
  const path = def.path ?? defaultModelPath(def.type, def.format ?? "mdx");
73
111
  if (typeof path !== "string" || !path) {
74
112
  throw new Error("model.path must be a non-empty string");
75
113
  }
76
- if (!path.includes("{locale}") || !path.includes("{slug}")) {
77
- throw new Error("model.path must include {locale} and {slug}");
78
- }
114
+ assertModelPath(path, singleton);
79
115
  if (def.route !== undefined) {
80
- if (typeof def.route !== "string" || !def.route.startsWith("/")) {
81
- throw new Error('model.route must start with "/"');
82
- }
83
- if (!def.route.includes("{slug}")) {
84
- throw new Error("model.route must include {slug}");
85
- }
116
+ assertModelRoute(def.route, singleton);
86
117
  }
87
118
  if (def.indexSlug !== undefined) {
88
119
  if (typeof def.indexSlug !== "string" || !def.indexSlug.trim()) {
@@ -92,7 +123,11 @@ export function defineModel<const D extends ModelInput>(def: D): WithPath<D> {
92
123
  if (!def.fields || typeof def.fields !== "object") {
93
124
  throw new Error("model.fields is required");
94
125
  }
95
- return (def.path === undefined ? { ...def, path } : def) as WithPath<D>;
126
+ const withPath = (def.path === undefined ? { ...def, path } : { ...def }) as WithPath<D>;
127
+ if (singleton && def.route === undefined) {
128
+ return { ...withPath, route: false };
129
+ }
130
+ return withPath;
96
131
  }
97
132
 
98
133
  type Simplify<T> = { -readonly [K in keyof T]: T[K] } & {};
@@ -135,6 +170,7 @@ export function serializeModel(model: ModelDef): ModelSnapshot {
135
170
  fields,
136
171
  };
137
172
  if (model.route !== undefined) snapshot.route = model.route;
173
+ if (model.singleton) snapshot.singleton = true;
138
174
  if (model.format === "page") snapshot.format = "page";
139
175
  if (model.indexSlug !== undefined) snapshot.indexSlug = model.indexSlug.trim();
140
176
  return snapshot;
@@ -143,6 +179,8 @@ export function serializeModel(model: ModelDef): ModelSnapshot {
143
179
  export function serializeSchema(
144
180
  models: ModelDef[],
145
181
  blocks?: BlockDef[],
182
+ objects?: ObjectDef[],
183
+ richtext?: BlockDef[],
146
184
  ): SchemaSnapshot {
147
185
  const snapshot: SchemaSnapshot = {
148
186
  version: 1,
@@ -151,6 +189,12 @@ export function serializeSchema(
151
189
  if (blocks && blocks.length > 0) {
152
190
  snapshot.blocks = blocks.map(serializeBlock);
153
191
  }
192
+ if (richtext && richtext.length > 0) {
193
+ snapshot.richtext = richtext.map(serializeBlock);
194
+ }
195
+ if (objects && objects.length > 0) {
196
+ snapshot.objects = objects.map(serializeObject);
197
+ }
154
198
  return snapshot;
155
199
  }
156
200
 
@@ -177,13 +221,21 @@ export function parseSchemaSnapshot(input: unknown): SchemaSnapshot {
177
221
  if (typeof m.path !== "string" || !m.path) {
178
222
  throw new Error(`schema.models[${i}].path is required`);
179
223
  }
180
- if (!m.path.includes("{locale}") || !m.path.includes("{slug}")) {
181
- throw new Error(
182
- `schema.models[${i}].path must include {locale} and {slug}`,
183
- );
224
+ const singleton = m.singleton === true;
225
+ try {
226
+ assertModelPath(m.path, singleton, `schema.models[${i}].path`);
227
+ } catch (err) {
228
+ throw err instanceof Error ? err : new Error(String(err));
229
+ }
230
+ if (m.route !== undefined) {
231
+ try {
232
+ assertModelRoute(m.route as string | false, singleton, `schema.models[${i}].route`);
233
+ } catch (err) {
234
+ throw err instanceof Error ? err : new Error(String(err));
235
+ }
184
236
  }
185
- if (m.route !== undefined && (typeof m.route !== "string" || !m.route.startsWith("/"))) {
186
- throw new Error(`schema.models[${i}].route must be a string starting with "/"`);
237
+ if (singleton && m.indexSlug !== undefined) {
238
+ throw new Error(`schema.models[${i}]: singleton cannot be combined with indexSlug`);
187
239
  }
188
240
  if (!m.fields || typeof m.fields !== "object" || Array.isArray(m.fields)) {
189
241
  throw new Error(`schema.models[${i}].fields must be an object`);
@@ -199,7 +251,9 @@ export function parseSchemaSnapshot(input: unknown): SchemaSnapshot {
199
251
  }
200
252
  }
201
253
  const snapshot: ModelSnapshot = { type: m.type, path: m.path, fields };
202
- if (typeof m.route === "string") snapshot.route = m.route;
254
+ if (m.route === false || (singleton && m.route === undefined)) snapshot.route = false;
255
+ else if (typeof m.route === "string") snapshot.route = m.route;
256
+ if (singleton) snapshot.singleton = true;
203
257
  if (m.format === "page") snapshot.format = "page";
204
258
  else if (m.format !== undefined && m.format !== "mdx") {
205
259
  throw new Error(`schema.models[${i}].format must be "mdx" or "page"`);
@@ -253,5 +307,55 @@ export function parseSchemaSnapshot(input: unknown): SchemaSnapshot {
253
307
  }
254
308
  }
255
309
 
256
- return { version, models, blocks };
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
+
332
+ let objects: ObjectSnapshot[] = [];
333
+ if (value.objects !== undefined) {
334
+ if (!Array.isArray(value.objects)) {
335
+ throw new Error("schema.objects must be an array");
336
+ }
337
+ objects = value.objects.map((raw, i) => {
338
+ try {
339
+ return parseObjectSnapshot(raw);
340
+ } catch (err) {
341
+ const msg = err instanceof Error ? err.message : "invalid object";
342
+ throw new Error(`schema.objects[${i}]: ${msg}`);
343
+ }
344
+ });
345
+ const objectNames = new Set<string>();
346
+ for (const object of objects) {
347
+ if (objectNames.has(object.name)) {
348
+ throw new Error(`duplicate object name: ${object.name}`);
349
+ }
350
+ objectNames.add(object.name);
351
+ }
352
+ }
353
+
354
+ return {
355
+ version,
356
+ models,
357
+ blocks,
358
+ ...(richtext.length > 0 ? { richtext } : {}),
359
+ objects,
360
+ };
257
361
  }
package/src/node/cli.ts CHANGED
@@ -234,8 +234,10 @@ export async function runGenerate(args: CliArgs): Promise<GenerateResult> {
234
234
  root,
235
235
  modelsDir: config.dirs.models,
236
236
  blocksDir: config.dirs.blocks,
237
+ richtextDir: config.dirs.richtext,
237
238
  mdxDir: config.dirs.mdx,
238
239
  layoutsDir: config.dirs.layouts,
240
+ objectsDir: config.dirs.objects,
239
241
  });
240
242
  }
241
243
 
@@ -247,8 +249,9 @@ function renderConfigFile(sdk: string): string {
247
249
  import { defineConfig } from "${from}";
248
250
 
249
251
  // 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
+ // blocks/*.tsx, in-content blocks in richtext/*.tsx, MDX tags in mdx/*.tsx,
253
+ // and content in content/{locale}/{type}/{slug}.{mdx,json} — all discovered
254
+ // automatically.
252
255
  // Set workspaceId from General settings (not an env var).
253
256
  // API URL defaults to https://api.backmatter.app in production; tokens stay env.
254
257
  export default defineConfig({
@@ -263,11 +266,13 @@ function renderCatchAllPage(registryImport: string): string {
263
266
  import { cacheLife } from "next/cache";
264
267
  import { createCatchAll } from "@backmatter/next/routes";
265
268
  import { cms } from "${registryImport}";
266
- import { components } from "${registryImport}/mdx";
269
+ import { components as mdxComponents } from "${registryImport}/mdx";
270
+ import { richtextComponents } from "${registryImport}/richtext";
267
271
  import { blockLoaders } from "${registryImport}/blocks";
268
272
  import { blockFallbacks } from "${registryImport}/block-fallbacks";
269
273
  import { layouts } from "${registryImport}/layouts";
270
274
 
275
+ const components = { ...richtextComponents, ...mdxComponents };
271
276
  const route = createCatchAll(cms, { components, blocks: blockLoaders, fallbacks: blockFallbacks, layouts });
272
277
 
273
278
  export const generateStaticParams = route.generateStaticParams;
@@ -451,7 +456,7 @@ export async function runInit(args: CliArgs): Promise<string[]> {
451
456
  notes.push("no @backmatter/next dependency: skipped the catch-all route");
452
457
  }
453
458
 
454
- for (const dir of ["models", "content", "blocks", "mdx", "layouts"]) {
459
+ for (const dir of ["models", "content", "blocks", "richtext", "mdx", "layouts"]) {
455
460
  await mkdir(path.join(root, dir), { recursive: true });
456
461
  }
457
462
 
@@ -1,6 +1,7 @@
1
1
  // packages/core/src/node/create-backmatter.ts
2
2
  import type { ModelDef, ModelSnapshot } from "../model";
3
3
  import type { BlockDef } from "../block";
4
+ import type { ObjectDef } from "../object";
4
5
  import { getDocFromApi, listDocsFromApi } from "../api-loader";
5
6
  import { getDocFromFs, listDocsFromFs } from "./fs-loader";
6
7
  import { createQuery, type DocLoader, type Query } from "../query";
@@ -17,6 +18,9 @@ export type CreateBackmatterOptions<Models extends readonly ModelDef[] = readonl
17
18
  BackmatterConfig & {
18
19
  models: Models;
19
20
  blocks?: readonly BlockDef[];
21
+ /** In-content blocks (`richtext/*.tsx`). Synced separately from page blocks. */
22
+ richtext?: readonly BlockDef[];
23
+ objects?: readonly ObjectDef[];
20
24
  /**
21
25
  * When true, API fetches use Next cache tags instead of `cache: "no-store"`.
22
26
  * `@backmatter/next` turns this on.
@@ -55,6 +59,7 @@ function toSnapshots(models: readonly ModelDef[]): ModelSnapshot[] {
55
59
  fields: m.fields as ModelSnapshot["fields"],
56
60
  };
57
61
  if (m.route !== undefined) snapshot.route = m.route;
62
+ if (m.singleton) snapshot.singleton = true;
58
63
  if (m.format === "page") snapshot.format = "page";
59
64
  if (m.indexSlug !== undefined) snapshot.indexSlug = m.indexSlug;
60
65
  return snapshot;
@@ -64,7 +69,7 @@ function toSnapshots(models: readonly ModelDef[]): ModelSnapshot[] {
64
69
  export function createBackmatter<const Models extends readonly ModelDef[]>(
65
70
  options: CreateBackmatterOptions<Models>,
66
71
  ): BackmatterClient<Models> {
67
- const { models: modelDefs, blocks: blockDefs, taggedApiFetch, sync = true, ...rest } = options;
72
+ const { models: modelDefs, blocks: blockDefs, richtext: richtextDefs, objects: objectDefs, taggedApiFetch, sync = true, ...rest } = options;
68
73
  const config = resolveConfig(rest);
69
74
  const models = toSnapshots(modelDefs);
70
75
  const siteRoot = config.siteRoot ?? process.cwd();
@@ -88,6 +93,8 @@ export function createBackmatter<const Models extends readonly ModelDef[]>(
88
93
  hostToken,
89
94
  models: [...modelDefs],
90
95
  blocks: blockDefs ? [...blockDefs] : undefined,
96
+ richtext: richtextDefs ? [...richtextDefs] : undefined,
97
+ objects: objectDefs ? [...objectDefs] : undefined,
91
98
  branch: config.branch,
92
99
  siteUrl,
93
100
  }).catch((err) => {
@@ -7,6 +7,7 @@ import { existsSync, readdirSync, statSync } from "node:fs";
7
7
  import path from "node:path";
8
8
  import { pathToFileURL } from "node:url";
9
9
  import type { ModelDef } from "../model";
10
+ import type { ObjectDef } from "../object";
10
11
 
11
12
  export type ModelFile = {
12
13
  /** Absolute path. */
@@ -96,6 +97,10 @@ export function listBlockFallbackFiles(
96
97
  export function listLayoutFiles(root: string, layoutsDir = "layouts"): ModelFile[] {
97
98
  return listModelFiles(root, layoutsDir);
98
99
  }
100
+
101
+ export function listObjectFiles(root: string, objectsDir = "objects"): ModelFile[] {
102
+ return listModelFiles(root, objectsDir);
103
+ }
99
104
  export function listBlockFiles(
100
105
  root: string,
101
106
  blocksDir = "blocks",
@@ -189,3 +194,40 @@ export async function discoverBlocks(
189
194
  }
190
195
  return blocks;
191
196
  }
197
+
198
+ function isObjectDef(value: unknown): value is ObjectDef {
199
+ if (!value || typeof value !== "object") return false;
200
+ const m = value as Record<string, unknown>;
201
+ return typeof m.name === "string" && typeof m.fields === "object" && !("component" in m) && !("type" in m);
202
+ }
203
+
204
+ /**
205
+ * Import every object file. The file stem must equal `object.name`.
206
+ */
207
+ export async function discoverObjects(
208
+ root: string,
209
+ objectsDir = "objects",
210
+ ): Promise<ObjectDef[]> {
211
+ const objects: ObjectDef[] = [];
212
+ const names = new Set<string>();
213
+ for (const { file, stem } of listObjectFiles(root, objectsDir)) {
214
+ const rel = path.relative(root, file);
215
+ const mod = (await import(
216
+ /* webpackIgnore: true */ /* turbopackIgnore: true */ pathToFileURL(file).href
217
+ )) as { default?: unknown };
218
+ if (!isObjectDef(mod.default)) {
219
+ throw new Error(`${rel} must \`export default defineObject({ ... })\``);
220
+ }
221
+ if (mod.default.name !== stem) {
222
+ throw new Error(
223
+ `${rel} defines name "${mod.default.name}" but the file is named "${stem}"; rename one so they match`,
224
+ );
225
+ }
226
+ if (names.has(mod.default.name)) {
227
+ throw new Error(`duplicate object name "${mod.default.name}" (${rel})`);
228
+ }
229
+ names.add(mod.default.name);
230
+ objects.push(mod.default);
231
+ }
232
+ return objects;
233
+ }
@@ -63,7 +63,7 @@ export async function listDocsFromFs(
63
63
  }
64
64
  }
65
65
  docs.push({
66
- locale: hit.locale,
66
+ locale: hit.locale ?? "",
67
67
  type: hit.type,
68
68
  slug: hit.slug,
69
69
  frontmatter,
@@ -11,6 +11,7 @@ import {
11
11
  listLayoutFiles,
12
12
  listMdxFiles,
13
13
  listModelFiles,
14
+ listObjectFiles,
14
15
  type BlockFile,
15
16
  type ModelFile,
16
17
  } from "./discover";
@@ -30,8 +31,10 @@ export type GenerateOptions = {
30
31
  outDir?: string;
31
32
  modelsDir?: string;
32
33
  blocksDir?: string;
34
+ richtextDir?: string;
33
35
  mdxDir?: string;
34
36
  layoutsDir?: string;
37
+ objectsDir?: string;
35
38
  /** Module that exports `createBackmatter`. Auto-detected from package.json when omitted. */
36
39
  sdk?: string;
37
40
  /** Write to disk. Default: true. `false` only renders (used by the bundler loader). */
@@ -57,8 +60,10 @@ export type GenerateResult = {
57
60
  changed: string[];
58
61
  models: ModelFile[];
59
62
  blocks: BlockFile[];
63
+ richtext: BlockFile[];
60
64
  mdx: BlockFile[];
61
65
  layouts: ModelFile[];
66
+ objects: ModelFile[];
62
67
  skippedBlocks: string[];
63
68
  skippedMdx: string[];
64
69
  /** Absolute path of `backmatter.config.*` when present. */
@@ -158,9 +163,13 @@ export function renderSyncBarrel(input: {
158
163
  outDir: string;
159
164
  models: ModelFile[];
160
165
  blocks: BlockFile[];
166
+ richtext?: BlockFile[];
167
+ objects?: ModelFile[];
161
168
  configFile: string | null;
162
169
  sdk: string;
163
170
  }): string {
171
+ const objects = input.objects ?? [];
172
+ const richtext = input.richtext ?? [];
164
173
  const lines = [GENERATED_HEADER, `import { createBackmatter } from "${input.sdk}";`];
165
174
  if (input.configFile) {
166
175
  lines.push(`import config from "${importSpecifier(input.outDir, input.configFile)}";`);
@@ -173,14 +182,28 @@ export function renderSyncBarrel(input: {
173
182
  for (const b of input.blocks) {
174
183
  lines.push(`import ${b.name} from "${importSpecifier(input.outDir, b.file)}";`);
175
184
  }
185
+ for (const b of richtext) {
186
+ lines.push(`import richtext${b.name} from "${importSpecifier(input.outDir, b.file)}";`);
187
+ }
188
+ for (const object of objects) {
189
+ lines.push(`import ${object.importName} from "${importSpecifier(input.outDir, object.file)}";`);
190
+ }
176
191
  lines.push("");
177
192
  lines.push(`export const models = [${input.models.map((m) => m.importName).join(", ")}] as const;`);
178
193
  lines.push("export type Models = typeof models;");
179
194
  lines.push("");
180
195
  lines.push(`export const blocks = [${input.blocks.map((b) => b.name).join(", ")}] as const;`);
181
196
  lines.push("");
197
+ lines.push(
198
+ `export const richtext = [${richtext.map((b) => `richtext${b.name}`).join(", ")}] as const;`,
199
+ );
200
+ lines.push("");
201
+ lines.push(`export const objects = [${objects.map((o) => o.importName).join(", ")}] as const;`);
202
+ lines.push("");
182
203
  lines.push("/** Typed content client: cms.query.{type}, cms.getDoc, cms.listDocs. */");
183
- lines.push("export const cms = createBackmatter({ ...config, models, blocks, sync: true });");
204
+ lines.push(
205
+ "export const cms = createBackmatter({ ...config, models, blocks, richtext, objects, sync: true });",
206
+ );
184
207
  return `${lines.join("\n")}\n`;
185
208
  }
186
209
 
@@ -196,6 +219,18 @@ export function renderMdxBarrel(input: { outDir: string; mdx: BlockFile[] }): st
196
219
  return `${lines.join("\n")}\n`;
197
220
  }
198
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
+
199
234
  export function renderBlocksBarrel(input: { outDir: string; blocks: BlockFile[] }): string {
200
235
  const lines = [GENERATED_HEADER, ""];
201
236
  lines.push('/** Composer loaders: JSON `{ type: "Hero" }` → dynamic import of blocks/Hero.tsx. */');
@@ -247,6 +282,7 @@ export const REGISTRY_FILES = [
247
282
  "blocks.ts",
248
283
  "block-fallbacks.ts",
249
284
  "mdx.ts",
285
+ "richtext.ts",
250
286
  "layouts.ts",
251
287
  "sync.ts",
252
288
  ] as const;
@@ -259,12 +295,17 @@ export function generate(options: GenerateOptions): GenerateResult {
259
295
  root,
260
296
  options.blocksDir ?? DEFAULT_DIRS.blocks,
261
297
  );
298
+ const { blocks: richtext, skipped: skippedRichtext } = listBlockFiles(
299
+ root,
300
+ options.richtextDir ?? DEFAULT_DIRS.richtext,
301
+ );
262
302
  const fallbacks = listBlockFallbackFiles(root, options.blocksDir ?? DEFAULT_DIRS.blocks);
263
303
  const { blocks: mdx, skipped: skippedMdx } = listMdxFiles(
264
304
  root,
265
305
  options.mdxDir ?? DEFAULT_DIRS.mdx,
266
306
  );
267
307
  const layouts = listLayoutFiles(root, options.layoutsDir ?? DEFAULT_DIRS.layouts);
308
+ const objects = listObjectFiles(root, options.objectsDir ?? DEFAULT_DIRS.objects);
268
309
  const sdk = options.sdk ?? detectSdk(root);
269
310
  const configFile = findConfigFile(root);
270
311
  const write = options.write ?? true;
@@ -274,8 +315,9 @@ export function generate(options: GenerateOptions): GenerateResult {
274
315
  "blocks.ts": renderBlocksBarrel({ outDir, blocks }),
275
316
  "block-fallbacks.ts": renderBlockFallbacksBarrel({ outDir, fallbacks }),
276
317
  "mdx.ts": renderMdxBarrel({ outDir, mdx }),
318
+ "richtext.ts": renderRichtextBarrel({ outDir, richtext }),
277
319
  "layouts.ts": renderLayoutsBarrel({ outDir, layouts }),
278
- "sync.ts": renderSyncBarrel({ outDir, models, blocks, configFile, sdk }),
320
+ "sync.ts": renderSyncBarrel({ outDir, models, blocks, richtext, objects, configFile, sdk }),
279
321
  };
280
322
  const outputs: GeneratedFile[] = REGISTRY_FILES.map((name) => {
281
323
  const file = path.join(outDir, name);
@@ -293,9 +335,11 @@ export function generate(options: GenerateOptions): GenerateResult {
293
335
  changed: outputs.filter((o) => o.changed).map((o) => o.rel),
294
336
  models,
295
337
  blocks,
338
+ richtext,
296
339
  mdx,
297
340
  layouts,
298
- skippedBlocks,
341
+ objects,
342
+ skippedBlocks: [...skippedBlocks, ...skippedRichtext],
299
343
  skippedMdx,
300
344
  configFile,
301
345
  };
package/src/node/index.ts CHANGED
@@ -8,11 +8,13 @@ export { getDocFromFs, listDocsFromFs, walkMdxFiles, walkContentFiles } from "./
8
8
  export {
9
9
  discoverModels,
10
10
  discoverBlocks,
11
+ discoverObjects,
11
12
  listBlockFallbackFiles,
12
13
  listBlockFiles,
13
14
  listLayoutFiles,
14
15
  listMdxFiles,
15
16
  listModelFiles,
17
+ listObjectFiles,
16
18
  toIdentifier,
17
19
  } from "./discover";
18
20
  export type { BlockFile, ModelFile } from "./discover";
@@ -30,6 +32,7 @@ export {
30
32
  renderBlocksBarrel,
31
33
  renderLayoutsBarrel,
32
34
  renderMdxBarrel,
35
+ renderRichtextBarrel,
33
36
  renderModelsBarrel,
34
37
  renderSyncBarrel,
35
38
  DEFAULT_OUT_DIR,
package/src/object.ts ADDED
@@ -0,0 +1,58 @@
1
+ // packages/core/src/object.ts
2
+ import {
3
+ type FieldDef,
4
+ type FieldSnapshot,
5
+ parseFieldSnapshot,
6
+ serializeField,
7
+ } from "./fields";
8
+
9
+ export type ObjectDef = {
10
+ name: string;
11
+ fields: Record<string, FieldDef>;
12
+ };
13
+
14
+ export type ObjectSnapshot = {
15
+ name: string;
16
+ fields: Record<string, FieldSnapshot>;
17
+ };
18
+
19
+ const NAME = /^[A-Za-z][A-Za-z0-9]*$/;
20
+
21
+ export function defineObject<const D extends ObjectDef>(def: D): D {
22
+ if (!def.name || typeof def.name !== "string") {
23
+ throw new Error("object.name is required");
24
+ }
25
+ if (!NAME.test(def.name)) {
26
+ throw new Error('object.name must be an identifier (e.g. "link")');
27
+ }
28
+ if (!def.fields || typeof def.fields !== "object") {
29
+ throw new Error("object.fields is required");
30
+ }
31
+ return def;
32
+ }
33
+
34
+ export function serializeObject(object: ObjectDef): ObjectSnapshot {
35
+ const fields: Record<string, FieldSnapshot> = {};
36
+ for (const [name, def] of Object.entries(object.fields)) {
37
+ fields[name] = serializeField(def);
38
+ }
39
+ return { name: object.name, fields };
40
+ }
41
+
42
+ export function parseObjectSnapshot(input: unknown): ObjectSnapshot {
43
+ if (!input || typeof input !== "object") {
44
+ throw new Error("object must be an object");
45
+ }
46
+ const value = input as Record<string, unknown>;
47
+ if (typeof value.name !== "string" || !value.name) {
48
+ throw new Error("object.name is required");
49
+ }
50
+ if (!value.fields || typeof value.fields !== "object" || Array.isArray(value.fields)) {
51
+ throw new Error("object.fields must be an object");
52
+ }
53
+ const fields: Record<string, FieldSnapshot> = {};
54
+ for (const [name, fieldRaw] of Object.entries(value.fields as Record<string, unknown>)) {
55
+ fields[name] = parseFieldSnapshot(fieldRaw);
56
+ }
57
+ return { name: value.name, fields };
58
+ }
package/src/path.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  // packages/core/src/path.ts
2
- import type { ModelSnapshot } from "./model";
2
+ import { IMPLICIT_SINGLETON_SLUG, type ModelSnapshot } from "./model";
3
3
 
4
4
  export type PathMatch = {
5
- locale: string;
5
+ locale?: string;
6
6
  slug: string;
7
7
  type: string;
8
8
  };
@@ -31,16 +31,22 @@ export function matchModelPath(
31
31
  const normalized = repoPath.replace(/^\/+/, "");
32
32
  const re = compilePathPattern(model.path);
33
33
  const m = re.exec(normalized);
34
- if (!m?.groups?.locale || !m.groups.slug) return null;
34
+ if (!m) return null;
35
+ const hasLocale = model.path.includes("{locale}");
36
+ const hasSlug = model.path.includes("{slug}");
37
+ if (hasLocale && !m.groups?.locale) return null;
38
+ if (hasSlug && !m.groups?.slug) return null;
35
39
  // Strip trailing extension from slug if the template baked it into the capture.
36
- let slug = m.groups.slug;
40
+ let slug = hasSlug ? m.groups!.slug! : IMPLICIT_SINGLETON_SLUG;
37
41
  const ext = contentExtFromPath(model.path);
38
42
  const suffix = `.${ext}`;
39
- if (model.path.endsWith(suffix) && slug.endsWith(suffix)) {
43
+ if (hasSlug && model.path.endsWith(suffix) && slug.endsWith(suffix)) {
40
44
  slug = slug.slice(0, -suffix.length);
41
45
  }
42
46
  if (!slug) return null;
43
- return { locale: m.groups.locale, slug, type: model.type };
47
+ const match: PathMatch = { slug, type: model.type };
48
+ if (hasLocale) match.locale = m.groups!.locale;
49
+ return match;
44
50
  }
45
51
 
46
52
  export function matchAnyModelPath(
@@ -69,12 +75,12 @@ export function contentExtForType(
69
75
 
70
76
  export function repoPathFromModel(
71
77
  model: Pick<ModelSnapshot, "path">,
72
- locale: string,
78
+ locale: string | undefined,
73
79
  slug: string,
74
80
  ): string {
75
- return model.path
76
- .replaceAll("{locale}", locale)
77
- .replaceAll("{slug}", slug);
81
+ let out = model.path;
82
+ if (locale !== undefined) out = out.replaceAll("{locale}", locale);
83
+ return out.replaceAll("{slug}", slug);
78
84
  }
79
85
 
80
86
  // ---------------------------------------------------------------------------
@@ -115,38 +121,55 @@ export function s3TreePrefix(
115
121
  return `${branchRoot(workspaceId, branch)}/trees/${encodeURIComponent(userId)}/`;
116
122
  }
117
123
 
124
+ /** URL stand-in when a model path has no `{locale}` — not a language code. */
125
+ export const EMPTY_LOCALE_SEGMENT = "-";
126
+
127
+ export function encodeDocLocale(locale: string | undefined): string {
128
+ return locale ? locale : EMPTY_LOCALE_SEGMENT;
129
+ }
130
+
131
+ export function decodeDocLocale(segment: string): string {
132
+ return segment === EMPTY_LOCALE_SEGMENT ? "" : segment;
133
+ }
134
+
135
+ function contentLeaf(type: string, slug: string, ext: string, locale?: string): string {
136
+ const leaf = `${type}/${slug}.${ext}`;
137
+ return locale ? `${locale}/${leaf}` : leaf;
138
+ }
139
+
118
140
  export function s3HeadKey(
119
141
  workspaceId: string,
120
142
  branch: string,
121
- locale: string,
143
+ locale: string | undefined,
122
144
  type: string,
123
145
  slug: string,
124
146
  ext = "mdx",
125
147
  ): string {
126
- return `${s3HeadPrefix(workspaceId, branch)}${locale}/${type}/${slug}.${ext}`;
148
+ return `${s3HeadPrefix(workspaceId, branch)}${contentLeaf(type, slug, ext, locale || undefined)}`;
127
149
  }
128
150
 
129
151
  export function s3TreeKey(
130
152
  workspaceId: string,
131
153
  branch: string,
132
154
  userId: string,
133
- locale: string,
155
+ locale: string | undefined,
134
156
  type: string,
135
157
  slug: string,
136
158
  ext = "mdx",
137
159
  ): string {
138
- return `${s3TreePrefix(workspaceId, branch, userId)}${locale}/${type}/${slug}.${ext}`;
160
+ return `${s3TreePrefix(workspaceId, branch, userId)}${contentLeaf(type, slug, ext, locale || undefined)}`;
139
161
  }
140
162
 
141
163
  /** HEAD blob sha for conflict checks. */
142
164
  export function s3ShaMetaKey(
143
165
  workspaceId: string,
144
166
  branch: string,
145
- locale: string,
167
+ locale: string | undefined,
146
168
  type: string,
147
169
  slug: string,
148
170
  ): string {
149
- return `${branchRoot(workspaceId, branch)}/meta/shas/${locale}/${type}/${slug}.json`;
171
+ const leaf = locale ? `${locale}/${type}/${slug}.json` : `${type}/${slug}.json`;
172
+ return `${branchRoot(workspaceId, branch)}/meta/shas/${leaf}`;
150
173
  }
151
174
 
152
175
  export type ParsedContentKey = {
@@ -154,7 +177,7 @@ export type ParsedContentKey = {
154
177
  source: ContentSource;
155
178
  /** Owner of the working tree; null for head keys. */
156
179
  userId: string | null;
157
- locale: string;
180
+ locale?: string;
158
181
  type: string;
159
182
  slug: string;
160
183
  };
@@ -168,29 +191,41 @@ export function parseS3ContentKey(
168
191
  if (!key.startsWith(prefix)) return null;
169
192
  const rest = key.slice(prefix.length);
170
193
 
171
- const head = /^([^/]+)\/head\/([^/]+)\/([^/]+)\/(.+)\.(mdx|json)$/.exec(rest);
194
+ const head = /^([^/]+)\/head\/(.+)\.(mdx|json)$/.exec(rest);
172
195
  if (head) {
173
- return {
174
- branch: decodeGitBranch(head[1]!),
175
- source: "head",
176
- userId: null,
177
- locale: head[2]!,
178
- type: head[3]!,
179
- slug: head[4]!,
180
- };
196
+ const parsed = parseContentLeaf(head[2]!);
197
+ if (parsed) {
198
+ return {
199
+ branch: decodeGitBranch(head[1]!),
200
+ source: "head",
201
+ userId: null,
202
+ ...parsed,
203
+ };
204
+ }
181
205
  }
182
206
 
183
- const tree =
184
- /^([^/]+)\/trees\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)\.(mdx|json)$/.exec(rest);
207
+ const tree = /^([^/]+)\/trees\/([^/]+)\/(.+)\.(mdx|json)$/.exec(rest);
185
208
  if (tree) {
186
- return {
187
- branch: decodeGitBranch(tree[1]!),
188
- source: "tree",
189
- userId: decodeURIComponent(tree[2]!),
190
- locale: tree[3]!,
191
- type: tree[4]!,
192
- slug: tree[5]!,
193
- };
209
+ const parsed = parseContentLeaf(tree[3]!);
210
+ if (parsed) {
211
+ return {
212
+ branch: decodeGitBranch(tree[1]!),
213
+ source: "tree",
214
+ userId: decodeURIComponent(tree[2]!),
215
+ ...parsed,
216
+ };
217
+ }
218
+ }
219
+ return null;
220
+ }
221
+
222
+ function parseContentLeaf(leaf: string): { locale?: string; type: string; slug: string } | null {
223
+ const parts = leaf.split("/");
224
+ if (parts.length === 3) {
225
+ return { locale: parts[0], type: parts[1]!, slug: parts[2]! };
226
+ }
227
+ if (parts.length === 2) {
228
+ return { type: parts[0]!, slug: parts[1]! };
194
229
  }
195
230
  return null;
196
231
  }
package/src/query.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // packages/core/src/query.ts
2
2
  import type { BackmatterDoc, BackmatterDocSummary } from "./doc";
3
3
  import type { FieldKind } from "./fields";
4
- import type { InferFrontmatter, ModelDef } from "./model";
4
+ import { IMPLICIT_SINGLETON_SLUG, type InferFrontmatter, type ModelDef } from "./model";
5
5
  import { normalizeFrontmatter } from "./zod";
6
6
 
7
7
  /** Loader the query layer sits on: the FS or API loaders behind createBackmatter. */
@@ -50,8 +50,20 @@ export type Resolved<
50
50
  : never;
51
51
  };
52
52
 
53
+ type PathHasLocale<M extends ModelDef> = M["path"] extends `${string}{locale}${string}`
54
+ ? true
55
+ : false;
56
+
57
+ type ModelGet<M extends ModelDef> = M["singleton"] extends true
58
+ ? PathHasLocale<M> extends true
59
+ ? (locale: string) => Promise<TypedDoc<M> | null>
60
+ : () => Promise<TypedDoc<M> | null>
61
+ : PathHasLocale<M> extends true
62
+ ? (locale: string, slug: string) => Promise<TypedDoc<M> | null>
63
+ : (slug: string) => Promise<TypedDoc<M> | null>;
64
+
53
65
  export type ModelQuery<M extends ModelDef, Models extends readonly ModelDef[]> = {
54
- get(locale: string, slug: string): Promise<TypedDoc<M> | null>;
66
+ get: ModelGet<M>;
55
67
  findMany<I extends Include<M> = {}>(opts?: {
56
68
  locale?: string;
57
69
  where?: Where<M>;
@@ -75,6 +87,22 @@ export function createQuery<const Models extends readonly ModelDef[]>(
75
87
  ): Query<Models> {
76
88
  const byType = new Map<string, ModelDef>(models.map((m) => [m.type, m]));
77
89
 
90
+ function identityFor(type: string, a?: string, b?: string): { locale: string; slug: string } {
91
+ const model = byType.get(type);
92
+ const localized = model?.path.includes("{locale}") ?? true;
93
+ const singleton = model?.singleton === true;
94
+ if (singleton) {
95
+ return {
96
+ locale: localized ? (a ?? "") : "",
97
+ slug: model?.path.includes("{slug}") ? (localized ? (b ?? IMPLICIT_SINGLETON_SLUG) : (a ?? IMPLICIT_SINGLETON_SLUG)) : IMPLICIT_SINGLETON_SLUG,
98
+ };
99
+ }
100
+ return {
101
+ locale: localized ? (a ?? "") : "",
102
+ slug: localized ? (b ?? "") : (a ?? ""),
103
+ };
104
+ }
105
+
78
106
  async function get(type: string, locale: string, slug: string): Promise<BackmatterDoc | null> {
79
107
  const doc = await loader.getDoc(locale, type, slug);
80
108
  if (!doc) return null;
@@ -157,7 +185,10 @@ export function createQuery<const Models extends readonly ModelDef[]>(
157
185
  const query: Record<string, unknown> = {};
158
186
  for (const m of models) {
159
187
  query[m.type] = {
160
- get: (locale: string, slug: string) => get(m.type, locale, slug),
188
+ get: (a?: string, b?: string) => {
189
+ const id = identityFor(m.type, a, b);
190
+ return get(m.type, id.locale, id.slug);
191
+ },
161
192
  findMany: (opts?: RawOpts) => findMany(m.type, opts),
162
193
  };
163
194
  }
package/src/routes.ts CHANGED
@@ -6,6 +6,10 @@ export const DEFAULT_ROUTE = "/{locale}/{type}/{slug}";
6
6
 
7
7
  export type RouteModel = Pick<ModelSnapshot, "type" | "route" | "indexSlug">;
8
8
 
9
+ export function isRoutable(model: Pick<RouteModel, "route">): boolean {
10
+ return model.route !== false;
11
+ }
12
+
9
13
  export type RouteMatch = {
10
14
  type: string;
11
15
  locale: string;
@@ -21,11 +25,13 @@ export type MatchRouteOptions = {
21
25
 
22
26
  /** The model's route template with `{type}` already substituted. */
23
27
  export function routeTemplate(model: RouteModel): string {
28
+ if (model.route === false) return "";
24
29
  return (model.route ?? DEFAULT_ROUTE).replaceAll("{type}", model.type);
25
30
  }
26
31
 
27
32
  /** Build the public URL path for a document, e.g. "/en/blog/hello". */
28
33
  export function routeFor(model: RouteModel, locale: string, slug: string): string {
34
+ if (model.route === false) return "";
29
35
  if (model.indexSlug && slug === model.indexSlug) return "/";
30
36
  return routeTemplate(model)
31
37
  .replaceAll("{locale}", encodeURIComponent(locale))
@@ -87,13 +93,13 @@ export function matchRoute(
87
93
  ): RouteMatch | null {
88
94
  const target = normalizePathname(pathname);
89
95
  if (target === "/") {
90
- const index = models.find((model) => model.indexSlug);
96
+ const index = models.find((model) => model.indexSlug && isRoutable(model));
91
97
  if (index?.indexSlug) {
92
98
  return { type: index.type, locale: options.defaultLocale, slug: index.indexSlug };
93
99
  }
94
100
  return null;
95
101
  }
96
- for (const model of sortByRouteSpecificity(models)) {
102
+ for (const model of sortByRouteSpecificity(models.filter(isRoutable))) {
97
103
  const template = routeTemplate(model);
98
104
  const m = compileRoutePattern(template).exec(target);
99
105
  if (!m?.groups?.slug) continue;
@@ -5,6 +5,7 @@ import {
5
5
  type ModelSnapshot,
6
6
  } from "./model";
7
7
  import type { BlockDef } from "./block";
8
+ import type { ObjectDef } from "./object";
8
9
 
9
10
  export function detectGitBranch(
10
11
  env: Record<string, string | undefined> = process.env,
@@ -22,13 +23,21 @@ export type SyncSchemaOptions = {
22
23
  hostToken: string;
23
24
  models: Array<ModelDef | ModelSnapshot>;
24
25
  blocks?: BlockDef[];
26
+ /** In-content blocks from the client `richtext/` directory. */
27
+ richtext?: BlockDef[];
28
+ objects?: ObjectDef[];
25
29
  branch?: string;
26
30
  siteUrl?: string;
27
31
  };
28
32
 
29
33
  export async function syncSchema(options: SyncSchemaOptions): Promise<void> {
30
34
  const branch = options.branch ?? detectGitBranch() ?? "main";
31
- const payload = serializeSchema(options.models as ModelDef[], options.blocks);
35
+ const payload = serializeSchema(
36
+ options.models as ModelDef[],
37
+ options.blocks,
38
+ options.objects,
39
+ options.richtext,
40
+ );
32
41
  const body: { payload: ReturnType<typeof serializeSchema>; branch: string; siteUrl?: string } = {
33
42
  payload,
34
43
  branch,
package/src/zod.ts CHANGED
@@ -5,14 +5,19 @@ import type { Frontmatter } from "./mdx";
5
5
  import type { ModelSnapshot } from "./model";
6
6
 
7
7
  /** Primitive a field kind is stored as in frontmatter; used in error messages. */
8
- export function expectedTypeFor(kind: FieldKind): "string" | "number" | "boolean" | "array" {
8
+ export function expectedTypeFor(
9
+ kind: FieldKind,
10
+ ): "string" | "number" | "boolean" | "array" | "object" {
9
11
  switch (kind) {
10
12
  case "number":
11
13
  return "number";
12
14
  case "boolean":
13
15
  return "boolean";
14
16
  case "blocks":
17
+ case "array":
15
18
  return "array";
19
+ case "object":
20
+ return "object";
16
21
  case "text":
17
22
  case "locale":
18
23
  case "image":
@@ -33,7 +38,10 @@ export function exampleValueFor(kind: FieldKind): unknown {
33
38
  case "boolean":
34
39
  return false;
35
40
  case "blocks":
41
+ case "array":
36
42
  return [];
43
+ case "object":
44
+ return {};
37
45
  case "image":
38
46
  return "https://example.com/image.jpg";
39
47
  case "locale":
@@ -49,6 +57,15 @@ export function exampleValueFor(kind: FieldKind): unknown {
49
57
  }
50
58
  }
51
59
 
60
+ function objectZod(field: FieldSnapshot): z.ZodType {
61
+ if (!field.fields) return z.record(z.string(), z.unknown());
62
+ const shape: Record<string, z.ZodType> = {};
63
+ for (const [name, nested] of Object.entries(field.fields)) {
64
+ shape[name] = fieldZod(nested);
65
+ }
66
+ return z.looseObject(shape);
67
+ }
68
+
52
69
  function fieldZod(field: FieldSnapshot): z.ZodType {
53
70
  if (field.kind === "blocks") {
54
71
  const item = z.object({
@@ -59,6 +76,14 @@ function fieldZod(field: FieldSnapshot): z.ZodType {
59
76
  const arr = field.required ? z.array(item).min(1) : z.array(item).optional();
60
77
  return arr;
61
78
  }
79
+ if (field.kind === "object") {
80
+ const obj = objectZod(field);
81
+ return field.required ? obj : obj.optional();
82
+ }
83
+ if (field.kind === "array") {
84
+ const arr = z.array(objectZod(field));
85
+ return field.required ? arr : arr.optional();
86
+ }
62
87
  let base: z.ZodType;
63
88
  switch (expectedTypeFor(field.kind)) {
64
89
  case "number":