@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 +1 -1
- package/src/api-loader.ts +2 -1
- package/src/config.ts +8 -0
- package/src/content-committed.ts +5 -1
- package/src/doc-ref.ts +7 -2
- package/src/fields.ts +49 -2
- package/src/index.ts +8 -0
- package/src/model.ts +124 -20
- package/src/node/cli.ts +9 -4
- package/src/node/create-backmatter.ts +8 -1
- package/src/node/discover.ts +42 -0
- package/src/node/fs-loader.ts +1 -1
- package/src/node/generate.ts +47 -3
- package/src/node/index.ts +3 -0
- package/src/object.ts +58 -0
- package/src/path.ts +71 -36
- package/src/query.ts +34 -3
- package/src/routes.ts +8 -2
- package/src/sync-schema.ts +10 -1
- package/src/zod.ts +26 -1
package/package.json
CHANGED
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
|
}
|
package/src/content-committed.ts
CHANGED
|
@@ -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
|
-
|
|
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", "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
throw new Error("model.path must include {locale} and {slug}");
|
|
78
|
-
}
|
|
114
|
+
assertModelPath(path, singleton);
|
|
79
115
|
if (def.route !== undefined) {
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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 (
|
|
186
|
-
throw new Error(`schema.models[${i}]
|
|
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 (
|
|
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
|
-
|
|
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,
|
|
251
|
-
// content/{locale}/{type}/{slug}.{mdx,json} — all discovered
|
|
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) => {
|
package/src/node/discover.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/node/fs-loader.ts
CHANGED
package/src/node/generate.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
|
2
|
+
import { IMPLICIT_SINGLETON_SLUG, type ModelSnapshot } from "./model";
|
|
3
3
|
|
|
4
4
|
export type PathMatch = {
|
|
5
|
-
locale
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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)}${
|
|
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)}${
|
|
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
|
-
|
|
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
|
|
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\/(
|
|
194
|
+
const head = /^([^/]+)\/head\/(.+)\.(mdx|json)$/.exec(rest);
|
|
172
195
|
if (head) {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
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
|
|
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: (
|
|
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;
|
package/src/sync-schema.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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":
|