@backmatter/core 0.0.14 → 0.0.15

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.15",
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
@@ -12,6 +12,8 @@ export type BackmatterDirs = {
12
12
  mdx?: string;
13
13
  /** Folder with one layout default export per model type. Default: "layouts". */
14
14
  layouts?: string;
15
+ /** Folder with one `defineObject` default export per file. Default: "objects". */
16
+ objects?: string;
15
17
  };
16
18
 
17
19
  export type BackmatterConfig = {
@@ -51,6 +53,7 @@ export const DEFAULT_DIRS: Required<BackmatterDirs> = {
51
53
  blocks: "blocks",
52
54
  mdx: "mdx",
53
55
  layouts: "layouts",
56
+ objects: "objects",
54
57
  };
55
58
  export const DEFAULT_LOCALE = "en";
56
59
  export const CLOUD_API_URL = "https://api.backmatter.app";
@@ -158,6 +161,7 @@ export function resolveConfig(
158
161
  blocks: config.dirs?.blocks ?? DEFAULT_DIRS.blocks,
159
162
  mdx: config.dirs?.mdx ?? DEFAULT_DIRS.mdx,
160
163
  layouts: config.dirs?.layouts ?? DEFAULT_DIRS.layouts,
164
+ objects: config.dirs?.objects ?? DEFAULT_DIRS.objects,
161
165
  },
162
166
  };
163
167
  }
@@ -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,41 @@ 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
+ objects?: ObjectSnapshot[];
57
89
  };
58
90
 
59
91
  type WithPath<D extends ModelInput> = D extends { path: string } ? D : D & { path: string };
@@ -69,20 +101,17 @@ export function defineModel<const D extends ModelInput>(def: D): WithPath<D> {
69
101
  if (def.format !== undefined && def.format !== "mdx" && def.format !== "page") {
70
102
  throw new Error('model.format must be "mdx" or "page"');
71
103
  }
104
+ const singleton = def.singleton === true;
105
+ if (singleton && def.indexSlug !== undefined) {
106
+ throw new Error("model.singleton cannot be combined with indexSlug");
107
+ }
72
108
  const path = def.path ?? defaultModelPath(def.type, def.format ?? "mdx");
73
109
  if (typeof path !== "string" || !path) {
74
110
  throw new Error("model.path must be a non-empty string");
75
111
  }
76
- if (!path.includes("{locale}") || !path.includes("{slug}")) {
77
- throw new Error("model.path must include {locale} and {slug}");
78
- }
112
+ assertModelPath(path, singleton);
79
113
  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
- }
114
+ assertModelRoute(def.route, singleton);
86
115
  }
87
116
  if (def.indexSlug !== undefined) {
88
117
  if (typeof def.indexSlug !== "string" || !def.indexSlug.trim()) {
@@ -92,7 +121,11 @@ export function defineModel<const D extends ModelInput>(def: D): WithPath<D> {
92
121
  if (!def.fields || typeof def.fields !== "object") {
93
122
  throw new Error("model.fields is required");
94
123
  }
95
- return (def.path === undefined ? { ...def, path } : def) as WithPath<D>;
124
+ const withPath = (def.path === undefined ? { ...def, path } : { ...def }) as WithPath<D>;
125
+ if (singleton && def.route === undefined) {
126
+ return { ...withPath, route: false };
127
+ }
128
+ return withPath;
96
129
  }
97
130
 
98
131
  type Simplify<T> = { -readonly [K in keyof T]: T[K] } & {};
@@ -135,6 +168,7 @@ export function serializeModel(model: ModelDef): ModelSnapshot {
135
168
  fields,
136
169
  };
137
170
  if (model.route !== undefined) snapshot.route = model.route;
171
+ if (model.singleton) snapshot.singleton = true;
138
172
  if (model.format === "page") snapshot.format = "page";
139
173
  if (model.indexSlug !== undefined) snapshot.indexSlug = model.indexSlug.trim();
140
174
  return snapshot;
@@ -143,6 +177,7 @@ export function serializeModel(model: ModelDef): ModelSnapshot {
143
177
  export function serializeSchema(
144
178
  models: ModelDef[],
145
179
  blocks?: BlockDef[],
180
+ objects?: ObjectDef[],
146
181
  ): SchemaSnapshot {
147
182
  const snapshot: SchemaSnapshot = {
148
183
  version: 1,
@@ -151,6 +186,9 @@ export function serializeSchema(
151
186
  if (blocks && blocks.length > 0) {
152
187
  snapshot.blocks = blocks.map(serializeBlock);
153
188
  }
189
+ if (objects && objects.length > 0) {
190
+ snapshot.objects = objects.map(serializeObject);
191
+ }
154
192
  return snapshot;
155
193
  }
156
194
 
@@ -177,13 +215,21 @@ export function parseSchemaSnapshot(input: unknown): SchemaSnapshot {
177
215
  if (typeof m.path !== "string" || !m.path) {
178
216
  throw new Error(`schema.models[${i}].path is required`);
179
217
  }
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
- );
218
+ const singleton = m.singleton === true;
219
+ try {
220
+ assertModelPath(m.path, singleton, `schema.models[${i}].path`);
221
+ } catch (err) {
222
+ throw err instanceof Error ? err : new Error(String(err));
184
223
  }
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 "/"`);
224
+ if (m.route !== undefined) {
225
+ try {
226
+ assertModelRoute(m.route as string | false, singleton, `schema.models[${i}].route`);
227
+ } catch (err) {
228
+ throw err instanceof Error ? err : new Error(String(err));
229
+ }
230
+ }
231
+ if (singleton && m.indexSlug !== undefined) {
232
+ throw new Error(`schema.models[${i}]: singleton cannot be combined with indexSlug`);
187
233
  }
188
234
  if (!m.fields || typeof m.fields !== "object" || Array.isArray(m.fields)) {
189
235
  throw new Error(`schema.models[${i}].fields must be an object`);
@@ -199,7 +245,9 @@ export function parseSchemaSnapshot(input: unknown): SchemaSnapshot {
199
245
  }
200
246
  }
201
247
  const snapshot: ModelSnapshot = { type: m.type, path: m.path, fields };
202
- if (typeof m.route === "string") snapshot.route = m.route;
248
+ if (m.route === false || (singleton && m.route === undefined)) snapshot.route = false;
249
+ else if (typeof m.route === "string") snapshot.route = m.route;
250
+ if (singleton) snapshot.singleton = true;
203
251
  if (m.format === "page") snapshot.format = "page";
204
252
  else if (m.format !== undefined && m.format !== "mdx") {
205
253
  throw new Error(`schema.models[${i}].format must be "mdx" or "page"`);
@@ -253,5 +301,27 @@ export function parseSchemaSnapshot(input: unknown): SchemaSnapshot {
253
301
  }
254
302
  }
255
303
 
256
- return { version, models, blocks };
304
+ let objects: ObjectSnapshot[] = [];
305
+ if (value.objects !== undefined) {
306
+ if (!Array.isArray(value.objects)) {
307
+ throw new Error("schema.objects must be an array");
308
+ }
309
+ objects = value.objects.map((raw, i) => {
310
+ try {
311
+ return parseObjectSnapshot(raw);
312
+ } catch (err) {
313
+ const msg = err instanceof Error ? err.message : "invalid object";
314
+ throw new Error(`schema.objects[${i}]: ${msg}`);
315
+ }
316
+ });
317
+ const objectNames = new Set<string>();
318
+ for (const object of objects) {
319
+ if (objectNames.has(object.name)) {
320
+ throw new Error(`duplicate object name: ${object.name}`);
321
+ }
322
+ objectNames.add(object.name);
323
+ }
324
+ }
325
+
326
+ return { version, models, blocks, objects };
257
327
  }
@@ -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,7 @@ export type CreateBackmatterOptions<Models extends readonly ModelDef[] = readonl
17
18
  BackmatterConfig & {
18
19
  models: Models;
19
20
  blocks?: readonly BlockDef[];
21
+ objects?: readonly ObjectDef[];
20
22
  /**
21
23
  * When true, API fetches use Next cache tags instead of `cache: "no-store"`.
22
24
  * `@backmatter/next` turns this on.
@@ -55,6 +57,7 @@ function toSnapshots(models: readonly ModelDef[]): ModelSnapshot[] {
55
57
  fields: m.fields as ModelSnapshot["fields"],
56
58
  };
57
59
  if (m.route !== undefined) snapshot.route = m.route;
60
+ if (m.singleton) snapshot.singleton = true;
58
61
  if (m.format === "page") snapshot.format = "page";
59
62
  if (m.indexSlug !== undefined) snapshot.indexSlug = m.indexSlug;
60
63
  return snapshot;
@@ -64,7 +67,7 @@ function toSnapshots(models: readonly ModelDef[]): ModelSnapshot[] {
64
67
  export function createBackmatter<const Models extends readonly ModelDef[]>(
65
68
  options: CreateBackmatterOptions<Models>,
66
69
  ): BackmatterClient<Models> {
67
- const { models: modelDefs, blocks: blockDefs, taggedApiFetch, sync = true, ...rest } = options;
70
+ const { models: modelDefs, blocks: blockDefs, objects: objectDefs, taggedApiFetch, sync = true, ...rest } = options;
68
71
  const config = resolveConfig(rest);
69
72
  const models = toSnapshots(modelDefs);
70
73
  const siteRoot = config.siteRoot ?? process.cwd();
@@ -88,6 +91,7 @@ export function createBackmatter<const Models extends readonly ModelDef[]>(
88
91
  hostToken,
89
92
  models: [...modelDefs],
90
93
  blocks: blockDefs ? [...blockDefs] : undefined,
94
+ objects: objectDefs ? [...objectDefs] : undefined,
91
95
  branch: config.branch,
92
96
  siteUrl,
93
97
  }).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";
@@ -32,6 +33,7 @@ export type GenerateOptions = {
32
33
  blocksDir?: string;
33
34
  mdxDir?: string;
34
35
  layoutsDir?: string;
36
+ objectsDir?: string;
35
37
  /** Module that exports `createBackmatter`. Auto-detected from package.json when omitted. */
36
38
  sdk?: string;
37
39
  /** Write to disk. Default: true. `false` only renders (used by the bundler loader). */
@@ -59,6 +61,7 @@ export type GenerateResult = {
59
61
  blocks: BlockFile[];
60
62
  mdx: BlockFile[];
61
63
  layouts: ModelFile[];
64
+ objects: ModelFile[];
62
65
  skippedBlocks: string[];
63
66
  skippedMdx: string[];
64
67
  /** Absolute path of `backmatter.config.*` when present. */
@@ -158,9 +161,11 @@ export function renderSyncBarrel(input: {
158
161
  outDir: string;
159
162
  models: ModelFile[];
160
163
  blocks: BlockFile[];
164
+ objects?: ModelFile[];
161
165
  configFile: string | null;
162
166
  sdk: string;
163
167
  }): string {
168
+ const objects = input.objects ?? [];
164
169
  const lines = [GENERATED_HEADER, `import { createBackmatter } from "${input.sdk}";`];
165
170
  if (input.configFile) {
166
171
  lines.push(`import config from "${importSpecifier(input.outDir, input.configFile)}";`);
@@ -173,14 +178,19 @@ export function renderSyncBarrel(input: {
173
178
  for (const b of input.blocks) {
174
179
  lines.push(`import ${b.name} from "${importSpecifier(input.outDir, b.file)}";`);
175
180
  }
181
+ for (const object of objects) {
182
+ lines.push(`import ${object.importName} from "${importSpecifier(input.outDir, object.file)}";`);
183
+ }
176
184
  lines.push("");
177
185
  lines.push(`export const models = [${input.models.map((m) => m.importName).join(", ")}] as const;`);
178
186
  lines.push("export type Models = typeof models;");
179
187
  lines.push("");
180
188
  lines.push(`export const blocks = [${input.blocks.map((b) => b.name).join(", ")}] as const;`);
181
189
  lines.push("");
190
+ lines.push(`export const objects = [${objects.map((o) => o.importName).join(", ")}] as const;`);
191
+ lines.push("");
182
192
  lines.push("/** Typed content client: cms.query.{type}, cms.getDoc, cms.listDocs. */");
183
- lines.push("export const cms = createBackmatter({ ...config, models, blocks, sync: true });");
193
+ lines.push("export const cms = createBackmatter({ ...config, models, blocks, objects, sync: true });");
184
194
  return `${lines.join("\n")}\n`;
185
195
  }
186
196
 
@@ -265,6 +275,7 @@ export function generate(options: GenerateOptions): GenerateResult {
265
275
  options.mdxDir ?? DEFAULT_DIRS.mdx,
266
276
  );
267
277
  const layouts = listLayoutFiles(root, options.layoutsDir ?? DEFAULT_DIRS.layouts);
278
+ const objects = listObjectFiles(root, options.objectsDir ?? DEFAULT_DIRS.objects);
268
279
  const sdk = options.sdk ?? detectSdk(root);
269
280
  const configFile = findConfigFile(root);
270
281
  const write = options.write ?? true;
@@ -275,7 +286,7 @@ export function generate(options: GenerateOptions): GenerateResult {
275
286
  "block-fallbacks.ts": renderBlockFallbacksBarrel({ outDir, fallbacks }),
276
287
  "mdx.ts": renderMdxBarrel({ outDir, mdx }),
277
288
  "layouts.ts": renderLayoutsBarrel({ outDir, layouts }),
278
- "sync.ts": renderSyncBarrel({ outDir, models, blocks, configFile, sdk }),
289
+ "sync.ts": renderSyncBarrel({ outDir, models, blocks, objects, configFile, sdk }),
279
290
  };
280
291
  const outputs: GeneratedFile[] = REGISTRY_FILES.map((name) => {
281
292
  const file = path.join(outDir, name);
@@ -295,6 +306,7 @@ export function generate(options: GenerateOptions): GenerateResult {
295
306
  blocks,
296
307
  mdx,
297
308
  layouts,
309
+ objects,
298
310
  skippedBlocks,
299
311
  skippedMdx,
300
312
  configFile,
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";
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,18 @@ export type SyncSchemaOptions = {
22
23
  hostToken: string;
23
24
  models: Array<ModelDef | ModelSnapshot>;
24
25
  blocks?: BlockDef[];
26
+ objects?: ObjectDef[];
25
27
  branch?: string;
26
28
  siteUrl?: string;
27
29
  };
28
30
 
29
31
  export async function syncSchema(options: SyncSchemaOptions): Promise<void> {
30
32
  const branch = options.branch ?? detectGitBranch() ?? "main";
31
- const payload = serializeSchema(options.models as ModelDef[], options.blocks);
33
+ const payload = serializeSchema(
34
+ options.models as ModelDef[],
35
+ options.blocks,
36
+ options.objects,
37
+ );
32
38
  const body: { payload: ReturnType<typeof serializeSchema>; branch: string; siteUrl?: string } = {
33
39
  payload,
34
40
  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":