@backmatter/core 0.0.13 → 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 +1 -1
- package/src/api-loader.ts +2 -1
- package/src/block.ts +2 -3
- package/src/config.ts +4 -0
- package/src/content-committed.ts +5 -1
- package/src/doc-ref.ts +7 -2
- package/src/fields.ts +49 -5
- package/src/index.ts +8 -0
- package/src/model.ts +92 -23
- package/src/node/create-backmatter.ts +5 -1
- package/src/node/discover.ts +42 -0
- package/src/node/fs-loader.ts +1 -1
- package/src/node/generate.ts +14 -2
- package/src/node/index.ts +2 -0
- package/src/object.ts +58 -0
- package/src/path.ts +71 -36
- package/src/query.ts +35 -4
- package/src/routes.ts +8 -2
- package/src/sync-schema.ts +7 -1
- package/src/zod.ts +26 -4
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/block.ts
CHANGED
|
@@ -94,9 +94,8 @@ export function parseBlockSnapshot(input: unknown): BlockSnapshot {
|
|
|
94
94
|
for (const [name, fieldRaw] of Object.entries(value.fields as Record<string, unknown>)) {
|
|
95
95
|
try {
|
|
96
96
|
fields[name] = parseFieldSnapshot(fieldRaw);
|
|
97
|
-
} catch
|
|
98
|
-
|
|
99
|
-
throw new Error(`block.fields.${name}: ${msg}`);
|
|
97
|
+
} catch {
|
|
98
|
+
// Drop unknown kinds so a stored snapshot still loads.
|
|
100
99
|
}
|
|
101
100
|
}
|
|
102
101
|
return { name: value.name, fields };
|
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
|
}
|
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
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
export type FieldKind =
|
|
4
4
|
| "text"
|
|
5
|
-
| "slug"
|
|
6
5
|
| "locale"
|
|
7
6
|
| "image"
|
|
8
7
|
| "relation"
|
|
9
8
|
| "number"
|
|
10
9
|
| "boolean"
|
|
11
|
-
| "blocks"
|
|
10
|
+
| "blocks"
|
|
11
|
+
| "object"
|
|
12
|
+
| "array";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Field definition. Generic parameters only exist for type inference
|
|
@@ -23,14 +24,22 @@ export type FieldDef<
|
|
|
23
24
|
required?: R;
|
|
24
25
|
/** For relation fields: target model type. */
|
|
25
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>;
|
|
26
31
|
};
|
|
27
32
|
|
|
28
33
|
export type FieldSnapshot = {
|
|
29
34
|
kind: FieldKind;
|
|
30
35
|
required?: boolean;
|
|
31
36
|
relationTo?: string;
|
|
37
|
+
object?: string;
|
|
38
|
+
fields?: Record<string, FieldSnapshot>;
|
|
32
39
|
};
|
|
33
40
|
|
|
41
|
+
type ObjectLike = { name: string; fields: Record<string, FieldDef> };
|
|
42
|
+
|
|
34
43
|
type Opts<R extends boolean> = { required?: R };
|
|
35
44
|
|
|
36
45
|
function base<K extends FieldKind, R extends boolean, To extends string>(
|
|
@@ -44,9 +53,20 @@ function base<K extends FieldKind, R extends boolean, To extends string>(
|
|
|
44
53
|
} as FieldDef<K, R, To>;
|
|
45
54
|
}
|
|
46
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
|
+
|
|
47
68
|
export const field = {
|
|
48
69
|
text: <const R extends boolean = false>(opts?: Opts<R>) => base("text", opts),
|
|
49
|
-
slug: <const R extends boolean = false>(opts?: Opts<R>) => base("slug", opts),
|
|
50
70
|
locale: <const R extends boolean = false>(opts?: Opts<R>) => base("locale", opts),
|
|
51
71
|
image: <const R extends boolean = false>(opts?: Opts<R>) => base("image", opts),
|
|
52
72
|
number: <const R extends boolean = false>(opts?: Opts<R>) => base("number", opts),
|
|
@@ -54,14 +74,27 @@ export const field = {
|
|
|
54
74
|
relation: <To extends string, const R extends boolean = false>(to: To, opts?: Opts<R>) =>
|
|
55
75
|
base("relation", { ...opts, relationTo: to }),
|
|
56
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),
|
|
57
81
|
};
|
|
58
82
|
|
|
59
83
|
export function serializeField(def: FieldDef): FieldSnapshot {
|
|
60
|
-
|
|
84
|
+
const snap: FieldSnapshot = {
|
|
61
85
|
kind: def.kind,
|
|
62
86
|
...(def.required ? { required: true } : {}),
|
|
63
87
|
...(def.relationTo ? { relationTo: def.relationTo } : {}),
|
|
88
|
+
...(def.object ? { object: def.object } : {}),
|
|
64
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;
|
|
65
98
|
}
|
|
66
99
|
|
|
67
100
|
export function parseFieldSnapshot(input: unknown): FieldSnapshot {
|
|
@@ -72,13 +105,14 @@ export function parseFieldSnapshot(input: unknown): FieldSnapshot {
|
|
|
72
105
|
const kind = value.kind;
|
|
73
106
|
const allowed: FieldKind[] = [
|
|
74
107
|
"text",
|
|
75
|
-
"slug",
|
|
76
108
|
"locale",
|
|
77
109
|
"image",
|
|
78
110
|
"relation",
|
|
79
111
|
"number",
|
|
80
112
|
"boolean",
|
|
81
113
|
"blocks",
|
|
114
|
+
"object",
|
|
115
|
+
"array",
|
|
82
116
|
];
|
|
83
117
|
if (typeof kind !== "string" || !allowed.includes(kind as FieldKind)) {
|
|
84
118
|
throw new Error(`invalid field.kind: ${String(kind)}`);
|
|
@@ -88,5 +122,15 @@ export function parseFieldSnapshot(input: unknown): FieldSnapshot {
|
|
|
88
122
|
if (typeof value.relationTo === "string" && value.relationTo.length > 0) {
|
|
89
123
|
snap.relationTo = value.relationTo;
|
|
90
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
|
+
}
|
|
91
135
|
return snap;
|
|
92
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
|
-
|
|
77
|
-
throw new Error("model.path must include {locale} and {slug}");
|
|
78
|
-
}
|
|
112
|
+
assertModelPath(path, singleton);
|
|
79
113
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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));
|
|
223
|
+
}
|
|
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
|
+
}
|
|
184
230
|
}
|
|
185
|
-
if (
|
|
186
|
-
throw new Error(`schema.models[${i}]
|
|
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`);
|
|
@@ -194,13 +240,14 @@ export function parseSchemaSnapshot(input: unknown): SchemaSnapshot {
|
|
|
194
240
|
)) {
|
|
195
241
|
try {
|
|
196
242
|
fields[name] = parseFieldSnapshot(fieldRaw);
|
|
197
|
-
} catch
|
|
198
|
-
|
|
199
|
-
throw new Error(`schema.models[${i}].fields.${name}: ${msg}`);
|
|
243
|
+
} catch {
|
|
244
|
+
// Drop unknown kinds (e.g. leftover slug) so a stored snapshot still loads.
|
|
200
245
|
}
|
|
201
246
|
}
|
|
202
247
|
const snapshot: ModelSnapshot = { type: m.type, path: m.path, fields };
|
|
203
|
-
if (
|
|
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;
|
|
204
251
|
if (m.format === "page") snapshot.format = "page";
|
|
205
252
|
else if (m.format !== undefined && m.format !== "mdx") {
|
|
206
253
|
throw new Error(`schema.models[${i}].format must be "mdx" or "page"`);
|
|
@@ -254,5 +301,27 @@ export function parseSchemaSnapshot(input: unknown): SchemaSnapshot {
|
|
|
254
301
|
}
|
|
255
302
|
}
|
|
256
303
|
|
|
257
|
-
|
|
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 };
|
|
258
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) => {
|
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";
|
|
@@ -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
|
|
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. */
|
|
@@ -19,7 +19,7 @@ type KeysOfKind<M extends ModelDef, K extends FieldKind> = {
|
|
|
19
19
|
[N in keyof M["fields"]]: M["fields"][N]["kind"] extends K ? N : never;
|
|
20
20
|
}[keyof M["fields"]];
|
|
21
21
|
|
|
22
|
-
type ScalarKind = "text" | "
|
|
22
|
+
type ScalarKind = "text" | "locale" | "number" | "boolean";
|
|
23
23
|
|
|
24
24
|
/** Equality filters on slug and scalar frontmatter fields. */
|
|
25
25
|
export type Where<M extends ModelDef> = Partial<
|
|
@@ -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,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(
|
|
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,16 +5,20 @@ 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
|
-
case "slug":
|
|
18
22
|
case "locale":
|
|
19
23
|
case "image":
|
|
20
24
|
case "relation":
|
|
@@ -34,11 +38,12 @@ export function exampleValueFor(kind: FieldKind): unknown {
|
|
|
34
38
|
case "boolean":
|
|
35
39
|
return false;
|
|
36
40
|
case "blocks":
|
|
41
|
+
case "array":
|
|
37
42
|
return [];
|
|
43
|
+
case "object":
|
|
44
|
+
return {};
|
|
38
45
|
case "image":
|
|
39
46
|
return "https://example.com/image.jpg";
|
|
40
|
-
case "slug":
|
|
41
|
-
return "example-slug";
|
|
42
47
|
case "locale":
|
|
43
48
|
return "en";
|
|
44
49
|
case "relation":
|
|
@@ -52,6 +57,15 @@ export function exampleValueFor(kind: FieldKind): unknown {
|
|
|
52
57
|
}
|
|
53
58
|
}
|
|
54
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
|
+
|
|
55
69
|
function fieldZod(field: FieldSnapshot): z.ZodType {
|
|
56
70
|
if (field.kind === "blocks") {
|
|
57
71
|
const item = z.object({
|
|
@@ -62,6 +76,14 @@ function fieldZod(field: FieldSnapshot): z.ZodType {
|
|
|
62
76
|
const arr = field.required ? z.array(item).min(1) : z.array(item).optional();
|
|
63
77
|
return arr;
|
|
64
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
|
+
}
|
|
65
87
|
let base: z.ZodType;
|
|
66
88
|
switch (expectedTypeFor(field.kind)) {
|
|
67
89
|
case "number":
|