@backmatter/core 0.0.18 → 0.0.20
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/block.ts +14 -3
- package/src/config.ts +29 -0
- package/src/content-committed.ts +16 -0
- package/src/enter.ts +68 -0
- package/src/fields.ts +17 -4
- package/src/index.ts +31 -1
- package/src/model.ts +72 -2
- package/src/node/cli.ts +93 -3
- package/src/node/discover.ts +5 -0
- package/src/node/generate.ts +67 -2
- package/src/preview-channel.ts +255 -0
- package/src/preview-client.ts +291 -0
- package/src/preview-session.ts +107 -0
- package/src/preview.ts +76 -1
- package/src/source.ts +24 -0
package/package.json
CHANGED
package/src/block.ts
CHANGED
|
@@ -39,9 +39,17 @@ export function defineBlock<const D extends BlockDef>(def: D): D {
|
|
|
39
39
|
if (def.component === undefined) {
|
|
40
40
|
throw new Error("block.component is required");
|
|
41
41
|
}
|
|
42
|
+
if (RESERVED_BLOCK_PROP in def.fields) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`block.fields.${RESERVED_BLOCK_PROP} is reserved: it carries the external source record`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
42
47
|
return def;
|
|
43
48
|
}
|
|
44
49
|
|
|
50
|
+
/** Prop name the renderer uses for an external model's record. */
|
|
51
|
+
export const RESERVED_BLOCK_PROP = "data";
|
|
52
|
+
|
|
45
53
|
type Simplify<T> = { -readonly [K in keyof T]: T[K] } & {};
|
|
46
54
|
|
|
47
55
|
type ScalarOf<K extends FieldKind> = K extends "number"
|
|
@@ -58,8 +66,11 @@ type IsRequired<F> = F extends { required?: infer R }
|
|
|
58
66
|
: false
|
|
59
67
|
: false;
|
|
60
68
|
|
|
61
|
-
/**
|
|
62
|
-
|
|
69
|
+
/**
|
|
70
|
+
* TypeScript shape of a block's props, derived from `field.*` calls. Pass the
|
|
71
|
+
* record type as `Data` for blocks on an `external` model to type `props.data`.
|
|
72
|
+
*/
|
|
73
|
+
export type InferBlockProps<Fields extends Record<string, FieldDef>, Data = undefined> = Simplify<
|
|
63
74
|
{
|
|
64
75
|
[N in keyof Fields as IsRequired<Fields[N]> extends true ? N : never]: ScalarOf<
|
|
65
76
|
Fields[N]["kind"]
|
|
@@ -68,7 +79,7 @@ export type InferBlockProps<Fields extends Record<string, FieldDef>> = Simplify<
|
|
|
68
79
|
[N in keyof Fields as IsRequired<Fields[N]> extends true ? never : N]?: ScalarOf<
|
|
69
80
|
Fields[N]["kind"]
|
|
70
81
|
>;
|
|
71
|
-
}
|
|
82
|
+
} & ([Data] extends [undefined] ? {} : { data: Data })
|
|
72
83
|
>;
|
|
73
84
|
|
|
74
85
|
export function serializeBlock(block: BlockDef): BlockSnapshot {
|
package/src/config.ts
CHANGED
|
@@ -16,6 +16,8 @@ export type BackmatterDirs = {
|
|
|
16
16
|
layouts?: string;
|
|
17
17
|
/** Folder with one `defineObject` default export per file. Default: "objects". */
|
|
18
18
|
objects?: string;
|
|
19
|
+
/** Folder with one `defineSource` default export per external model type. Default: "sources". */
|
|
20
|
+
sources?: string;
|
|
19
21
|
};
|
|
20
22
|
|
|
21
23
|
export type BackmatterConfig = {
|
|
@@ -57,9 +59,12 @@ export const DEFAULT_DIRS: Required<BackmatterDirs> = {
|
|
|
57
59
|
mdx: "mdx",
|
|
58
60
|
layouts: "layouts",
|
|
59
61
|
objects: "objects",
|
|
62
|
+
sources: "sources",
|
|
60
63
|
};
|
|
61
64
|
export const DEFAULT_LOCALE = "en";
|
|
62
65
|
export const CLOUD_API_URL = "https://api.backmatter.app";
|
|
66
|
+
export const CLOUD_DASHBOARD_URL = "https://dash.backmatter.app";
|
|
67
|
+
export const LOCAL_DASHBOARD_URL = "http://localhost:5173";
|
|
63
68
|
|
|
64
69
|
function withHttps(url: string): string {
|
|
65
70
|
if (url.startsWith("http://") || url.startsWith("https://")) return url;
|
|
@@ -85,6 +90,29 @@ export function isDeployedHost(env: Record<string, string | undefined>): boolean
|
|
|
85
90
|
);
|
|
86
91
|
}
|
|
87
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Dashboard origin the dev bar frames.
|
|
95
|
+
* Explicit URL, else `BACKMATTER_DASHBOARD_URL`, else the cloud dashboard on a
|
|
96
|
+
* deployed host, else the local Vite app. A loopback URL in `.env` is ignored
|
|
97
|
+
* during a local `next build`, so the built site does not keep pointing at the laptop.
|
|
98
|
+
*/
|
|
99
|
+
export function resolveDashboardUrl(
|
|
100
|
+
explicit?: string,
|
|
101
|
+
env: Record<string, string | undefined> = envOf(),
|
|
102
|
+
): string {
|
|
103
|
+
const fromProp = explicit?.trim();
|
|
104
|
+
const fromEnv = fromProp ? undefined : env.BACKMATTER_DASHBOARD_URL?.trim();
|
|
105
|
+
const candidate = fromProp || fromEnv || "";
|
|
106
|
+
const ignoreDevLoopback =
|
|
107
|
+
!fromProp &&
|
|
108
|
+
Boolean(candidate && isLoopbackApiUrl(candidate)) &&
|
|
109
|
+
env.NEXT_PHASE === "phase-production-build" &&
|
|
110
|
+
!onPlatformDeploy(env);
|
|
111
|
+
if (candidate && !ignoreDevLoopback) return stripTrailingSlash(candidate);
|
|
112
|
+
if (isDeployedHost(env)) return CLOUD_DASHBOARD_URL;
|
|
113
|
+
return LOCAL_DASHBOARD_URL;
|
|
114
|
+
}
|
|
115
|
+
|
|
88
116
|
/** DevBar /me origin: explicit, else env, else cloud API on a deployed host. */
|
|
89
117
|
export function resolveDevBarApiUrl(
|
|
90
118
|
explicit: string | undefined,
|
|
@@ -190,6 +218,7 @@ export function resolveConfig(
|
|
|
190
218
|
mdx: config.dirs?.mdx ?? DEFAULT_DIRS.mdx,
|
|
191
219
|
layouts: config.dirs?.layouts ?? DEFAULT_DIRS.layouts,
|
|
192
220
|
objects: config.dirs?.objects ?? DEFAULT_DIRS.objects,
|
|
221
|
+
sources: config.dirs?.sources ?? DEFAULT_DIRS.sources,
|
|
193
222
|
},
|
|
194
223
|
};
|
|
195
224
|
}
|
package/src/content-committed.ts
CHANGED
|
@@ -40,6 +40,22 @@ export function listCacheTags(type?: string): string[] {
|
|
|
40
40
|
return type ? [BACKMATTER_CACHE_TAG, `backmatter:${type}`] : [BACKMATTER_CACHE_TAG];
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Cache tags a commit should expire. A normal commit expires the touched
|
|
45
|
+
* documents and their type lists. A full rebuild expires the global tag,
|
|
46
|
+
* which every cached entry carries.
|
|
47
|
+
*/
|
|
48
|
+
export function revalidateTagsForCommit(event: Pick<ContentCommittedEvent, "docs" | "full">): string[] {
|
|
49
|
+
if (event.full) return [BACKMATTER_CACHE_TAG];
|
|
50
|
+
const tags = new Set<string>();
|
|
51
|
+
for (const doc of event.docs ?? []) {
|
|
52
|
+
for (const tag of contentCacheTags(doc.locale, doc.type, doc.slug)) {
|
|
53
|
+
if (tag !== BACKMATTER_CACHE_TAG) tags.add(tag);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return [...tags];
|
|
57
|
+
}
|
|
58
|
+
|
|
43
59
|
/** Site paths the host should `revalidatePath` for these docs. */
|
|
44
60
|
export function pathsForCommittedDocs(
|
|
45
61
|
models: readonly RouteModel[],
|
package/src/enter.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// packages/core/src/enter.ts
|
|
2
|
+
// The dashboard is the identity provider. A site that needs a session sends
|
|
3
|
+
// the browser top-level to `{dashboard}/enter`, which mints a ticket from the
|
|
4
|
+
// first-party cookie and returns to `{site}/api/backmatter/preview?ticket=`.
|
|
5
|
+
|
|
6
|
+
export const ENTER_PATH = "/enter";
|
|
7
|
+
export const PREVIEW_ENTRY_PATH = "/api/backmatter/preview";
|
|
8
|
+
|
|
9
|
+
export type EnterRequest = {
|
|
10
|
+
workspaceId: string;
|
|
11
|
+
/** Absolute URL on the site to land on after the session is set. */
|
|
12
|
+
returnUrl: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function studioEnterUrl(dashboardUrl: string, req: EnterRequest): string {
|
|
16
|
+
const url = new URL(ENTER_PATH, dashboardUrl.replace(/\/+$/, "") + "/");
|
|
17
|
+
url.searchParams.set("workspace", req.workspaceId);
|
|
18
|
+
url.searchParams.set("return", req.returnUrl);
|
|
19
|
+
return url.toString();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function parseEnterSearch(search: string): EnterRequest | null {
|
|
23
|
+
const params = new URLSearchParams(search);
|
|
24
|
+
const workspaceId = params.get("workspace")?.trim() ?? "";
|
|
25
|
+
const returnUrl = params.get("return")?.trim() ?? "";
|
|
26
|
+
if (!workspaceId || !returnUrl) return null;
|
|
27
|
+
return { workspaceId, returnUrl };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type EnterTarget = {
|
|
31
|
+
/** Site origin the ticket is for. Must be one of the workspace's `siteUrls`. */
|
|
32
|
+
origin: string;
|
|
33
|
+
/** Same-origin path, with search, the ticket redirects to. */
|
|
34
|
+
redirect: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve where a return URL points, allowing only origins the workspace has
|
|
39
|
+
* declared. Returns null for a foreign origin, a bad URL, or a non-http scheme.
|
|
40
|
+
*/
|
|
41
|
+
export function resolveEnterTarget(returnUrl: string, siteUrls: readonly string[]): EnterTarget | null {
|
|
42
|
+
let url: URL;
|
|
43
|
+
try {
|
|
44
|
+
url = new URL(returnUrl);
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
|
|
49
|
+
const allowed = new Set(
|
|
50
|
+
siteUrls
|
|
51
|
+
.map((raw) => {
|
|
52
|
+
try {
|
|
53
|
+
return new URL(raw).origin;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
.filter((o): o is string => Boolean(o)),
|
|
59
|
+
);
|
|
60
|
+
if (!allowed.has(url.origin)) return null;
|
|
61
|
+
const redirect = `${url.pathname}${url.search}` || "/";
|
|
62
|
+
return { origin: url.origin, redirect };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The URL the dashboard sends the browser to once it holds a ticket. */
|
|
66
|
+
export function previewEntryUrl(origin: string, ticket: string): string {
|
|
67
|
+
return `${origin}${PREVIEW_ENTRY_PATH}?ticket=${encodeURIComponent(ticket)}`;
|
|
68
|
+
}
|
package/src/fields.ts
CHANGED
|
@@ -28,6 +28,12 @@ export type FieldDef<
|
|
|
28
28
|
object?: string;
|
|
29
29
|
/** Nested fields for object/array (inline or copied from defineObject). */
|
|
30
30
|
fields?: Record<string, FieldDef>;
|
|
31
|
+
/**
|
|
32
|
+
* Name of the Studio input that edits this field, e.g. `"color"`. The site's
|
|
33
|
+
* `defineStudio({ fields })` maps names to components; unknown names fall
|
|
34
|
+
* back to the default input for the kind.
|
|
35
|
+
*/
|
|
36
|
+
input?: string;
|
|
31
37
|
};
|
|
32
38
|
|
|
33
39
|
export type FieldSnapshot = {
|
|
@@ -36,20 +42,22 @@ export type FieldSnapshot = {
|
|
|
36
42
|
relationTo?: string;
|
|
37
43
|
object?: string;
|
|
38
44
|
fields?: Record<string, FieldSnapshot>;
|
|
45
|
+
input?: string;
|
|
39
46
|
};
|
|
40
47
|
|
|
41
48
|
type ObjectLike = { name: string; fields: Record<string, FieldDef> };
|
|
42
49
|
|
|
43
|
-
type Opts<R extends boolean> = { required?: R };
|
|
50
|
+
type Opts<R extends boolean> = { required?: R; input?: string };
|
|
44
51
|
|
|
45
52
|
function base<K extends FieldKind, R extends boolean, To extends string>(
|
|
46
53
|
kind: K,
|
|
47
|
-
opts?: { required?: R; relationTo?: To },
|
|
54
|
+
opts?: { required?: R; relationTo?: To; input?: string },
|
|
48
55
|
): FieldDef<K, R, To> {
|
|
49
56
|
return {
|
|
50
57
|
kind,
|
|
51
58
|
...(opts?.required ? { required: opts.required } : {}),
|
|
52
59
|
...(opts?.relationTo ? { relationTo: opts.relationTo } : {}),
|
|
60
|
+
...(opts?.input ? { input: opts.input } : {}),
|
|
53
61
|
} as FieldDef<K, R, To>;
|
|
54
62
|
}
|
|
55
63
|
|
|
@@ -59,10 +67,11 @@ function composite<K extends "object" | "array", R extends boolean>(
|
|
|
59
67
|
opts?: Opts<R>,
|
|
60
68
|
): FieldDef<K, R> {
|
|
61
69
|
const required = opts?.required ? { required: opts.required as R } : {};
|
|
70
|
+
const input = opts?.input ? { input: opts.input } : {};
|
|
62
71
|
if (typeof def === "string") {
|
|
63
|
-
return { kind, object: def, ...required };
|
|
72
|
+
return { kind, object: def, ...required, ...input };
|
|
64
73
|
}
|
|
65
|
-
return { kind, object: def.name, fields: def.fields, ...required };
|
|
74
|
+
return { kind, object: def.name, fields: def.fields, ...required, ...input };
|
|
66
75
|
}
|
|
67
76
|
|
|
68
77
|
export const field = {
|
|
@@ -86,6 +95,7 @@ export function serializeField(def: FieldDef): FieldSnapshot {
|
|
|
86
95
|
...(def.required ? { required: true } : {}),
|
|
87
96
|
...(def.relationTo ? { relationTo: def.relationTo } : {}),
|
|
88
97
|
...(def.object ? { object: def.object } : {}),
|
|
98
|
+
...(def.input ? { input: def.input } : {}),
|
|
89
99
|
};
|
|
90
100
|
if (def.fields) {
|
|
91
101
|
const fields: Record<string, FieldSnapshot> = {};
|
|
@@ -125,6 +135,9 @@ export function parseFieldSnapshot(input: unknown): FieldSnapshot {
|
|
|
125
135
|
if (typeof value.object === "string" && value.object.length > 0) {
|
|
126
136
|
snap.object = value.object;
|
|
127
137
|
}
|
|
138
|
+
if (typeof value.input === "string" && value.input.length > 0) {
|
|
139
|
+
snap.input = value.input;
|
|
140
|
+
}
|
|
128
141
|
if (value.fields && typeof value.fields === "object" && !Array.isArray(value.fields)) {
|
|
129
142
|
const fields: Record<string, FieldSnapshot> = {};
|
|
130
143
|
for (const [name, nested] of Object.entries(value.fields as Record<string, unknown>)) {
|
package/src/index.ts
CHANGED
|
@@ -24,7 +24,9 @@ export {
|
|
|
24
24
|
defaultModelPath,
|
|
25
25
|
DEFAULT_MODEL_PATH,
|
|
26
26
|
DEFAULT_PAGE_PATH,
|
|
27
|
+
DEFAULT_TEMPLATE_SLUG,
|
|
27
28
|
IMPLICIT_SINGLETON_SLUG,
|
|
29
|
+
isTemplateSlug,
|
|
28
30
|
serializeModel,
|
|
29
31
|
serializeSchema,
|
|
30
32
|
parseSchemaSnapshot,
|
|
@@ -40,8 +42,12 @@ export {
|
|
|
40
42
|
defineBlock,
|
|
41
43
|
serializeBlock,
|
|
42
44
|
parseBlockSnapshot,
|
|
45
|
+
RESERVED_BLOCK_PROP,
|
|
43
46
|
} from "./block";
|
|
44
47
|
|
|
48
|
+
export type { SourceDef, SourceRef } from "./source";
|
|
49
|
+
export { defineSource } from "./source";
|
|
50
|
+
|
|
45
51
|
export type { DocFormat } from "./doc-format";
|
|
46
52
|
export { modelFormat, parseDoc, serializeDoc } from "./doc-format";
|
|
47
53
|
|
|
@@ -52,9 +58,12 @@ export {
|
|
|
52
58
|
detectSiteUrl,
|
|
53
59
|
isDeployedHost,
|
|
54
60
|
resolveDevBarApiUrl,
|
|
61
|
+
resolveDashboardUrl,
|
|
55
62
|
DEFAULT_DIRS,
|
|
56
63
|
DEFAULT_LOCALE,
|
|
57
64
|
CLOUD_API_URL,
|
|
65
|
+
CLOUD_DASHBOARD_URL,
|
|
66
|
+
LOCAL_DASHBOARD_URL,
|
|
58
67
|
} from "./config";
|
|
59
68
|
|
|
60
69
|
export type { RouteMatch, RouteModel, MatchRouteOptions } from "./routes";
|
|
@@ -118,21 +127,30 @@ export {
|
|
|
118
127
|
export type { BackmatterDoc, BackmatterDocSummary } from "./doc";
|
|
119
128
|
export { createQuery } from "./query";
|
|
120
129
|
export type { Query, ModelQuery, TypedDoc, Where, Include, DocLoader } from "./query";
|
|
121
|
-
export type { PreviewCheckout, PreviewTicket } from "./preview-session";
|
|
130
|
+
export type { PreviewCheckout, PreviewSessionClaims, PreviewTicket } from "./preview-session";
|
|
122
131
|
export {
|
|
123
132
|
PREVIEW_COOKIE,
|
|
133
|
+
PREVIEW_SESSION_COOKIE,
|
|
134
|
+
PREVIEW_SESSION_QUERY,
|
|
135
|
+
PREVIEW_SESSION_STORAGE_KEY,
|
|
124
136
|
PREVIEW_ENABLE_PATH,
|
|
125
137
|
PREVIEW_QUERY,
|
|
126
138
|
PREVIEW_CHECKOUT_QUERY,
|
|
127
139
|
previewLocationWithCheckout,
|
|
128
140
|
isSafeRedirectPath,
|
|
141
|
+
mintPreviewSession,
|
|
129
142
|
mintPreviewTicket,
|
|
143
|
+
peekPreviewSessionWorkspace,
|
|
130
144
|
previewMiddlewareDecision,
|
|
131
145
|
readPreviewCookie,
|
|
146
|
+
readPreviewSession,
|
|
132
147
|
readPreviewTicket,
|
|
148
|
+
scopePreviewAccess,
|
|
149
|
+
takePreviewSessionFromSearch,
|
|
133
150
|
writePreviewCookie,
|
|
134
151
|
PREVIEW_TICKET_TTL_MS,
|
|
135
152
|
PREVIEW_COOKIE_TTL_MS,
|
|
153
|
+
PREVIEW_SESSION_TTL_MS,
|
|
136
154
|
} from "./preview-session";
|
|
137
155
|
export { signPayload, verifyPayload } from "./signed-token";
|
|
138
156
|
export { getDocFromApi, listDocsFromApi } from "./api-loader";
|
|
@@ -147,5 +165,17 @@ export {
|
|
|
147
165
|
contentCacheTags,
|
|
148
166
|
listCacheTags,
|
|
149
167
|
pathsForCommittedDocs,
|
|
168
|
+
revalidateTagsForCommit,
|
|
150
169
|
revalidateUrl,
|
|
151
170
|
} from "./content-committed";
|
|
171
|
+
|
|
172
|
+
export {
|
|
173
|
+
ENTER_PATH,
|
|
174
|
+
PREVIEW_ENTRY_PATH,
|
|
175
|
+
parseEnterSearch,
|
|
176
|
+
previewEntryUrl,
|
|
177
|
+
resolveEnterTarget,
|
|
178
|
+
studioEnterUrl,
|
|
179
|
+
type EnterRequest,
|
|
180
|
+
type EnterTarget,
|
|
181
|
+
} from "./enter";
|
package/src/model.ts
CHANGED
|
@@ -33,12 +33,51 @@ export type ModelDef = {
|
|
|
33
33
|
* in a schema may set this.
|
|
34
34
|
*/
|
|
35
35
|
indexSlug?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Slugs belong to an outside system (a catalog). The host provides
|
|
38
|
+
* `sources/{type}.ts`; a record that does not exist there is a 404. A
|
|
39
|
+
* document at the `template` slug renders every record without its own file.
|
|
40
|
+
*/
|
|
41
|
+
external?: boolean;
|
|
42
|
+
/** Reserved slug of the template document. Default `_template` when `external`. */
|
|
43
|
+
template?: string;
|
|
36
44
|
fields: Record<string, FieldDef>;
|
|
37
45
|
};
|
|
38
46
|
|
|
39
47
|
/** What `defineModel` accepts: `path` falls back to the folder convention. */
|
|
40
48
|
export type ModelInput = Omit<ModelDef, "path"> & { path?: string };
|
|
41
49
|
|
|
50
|
+
export const DEFAULT_TEMPLATE_SLUG = "_template";
|
|
51
|
+
|
|
52
|
+
/** True when `slug` is the template document of an external model. */
|
|
53
|
+
export function isTemplateSlug(
|
|
54
|
+
model: Pick<ModelDef, "type" | "external" | "template"> | null | undefined,
|
|
55
|
+
slug: string,
|
|
56
|
+
): boolean {
|
|
57
|
+
if (!model?.external) return false;
|
|
58
|
+
return slug === (model.template ?? DEFAULT_TEMPLATE_SLUG);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Validate `external` / `template` and return the template slug to store, if any. */
|
|
62
|
+
export function resolveTemplateSlug(
|
|
63
|
+
def: { external?: boolean; template?: string; singleton?: boolean; indexSlug?: string },
|
|
64
|
+
label = "model",
|
|
65
|
+
): string | undefined {
|
|
66
|
+
if (def.template !== undefined && !def.external) {
|
|
67
|
+
throw new Error(`${label}.template requires external: true`);
|
|
68
|
+
}
|
|
69
|
+
if (!def.external) return undefined;
|
|
70
|
+
if (def.singleton) throw new Error(`${label}.external cannot be combined with singleton`);
|
|
71
|
+
if (def.indexSlug !== undefined) {
|
|
72
|
+
throw new Error(`${label}.external cannot be combined with indexSlug`);
|
|
73
|
+
}
|
|
74
|
+
if (def.template === undefined) return DEFAULT_TEMPLATE_SLUG;
|
|
75
|
+
if (typeof def.template !== "string" || !def.template.trim()) {
|
|
76
|
+
throw new Error(`${label}.template must be a non-empty string`);
|
|
77
|
+
}
|
|
78
|
+
return def.template.trim();
|
|
79
|
+
}
|
|
80
|
+
|
|
42
81
|
/** Default repo path when a model does not declare one. */
|
|
43
82
|
export const DEFAULT_MODEL_PATH = "content/{locale}/{type}/{slug}.mdx";
|
|
44
83
|
export const DEFAULT_PAGE_PATH = "content/{locale}/{type}/{slug}.json";
|
|
@@ -55,6 +94,8 @@ export type ModelSnapshot = {
|
|
|
55
94
|
singleton?: boolean;
|
|
56
95
|
format?: DocFormat;
|
|
57
96
|
indexSlug?: string;
|
|
97
|
+
external?: boolean;
|
|
98
|
+
template?: string;
|
|
58
99
|
fields: Record<string, FieldSnapshot>;
|
|
59
100
|
};
|
|
60
101
|
|
|
@@ -91,12 +132,17 @@ export type SchemaSnapshot = {
|
|
|
91
132
|
};
|
|
92
133
|
|
|
93
134
|
type WithPath<D extends ModelInput> = D extends { path: string } ? D : D & { path: string };
|
|
135
|
+
type WithTemplate<D extends ModelInput> = D extends { external: true }
|
|
136
|
+
? D extends { template: string }
|
|
137
|
+
? D
|
|
138
|
+
: D & { template: string }
|
|
139
|
+
: D;
|
|
94
140
|
|
|
95
141
|
/**
|
|
96
142
|
* Validates the definition at runtime and preserves its literal types.
|
|
97
143
|
* `path` defaults to `content/{locale}/{type}/{slug}.mdx`.
|
|
98
144
|
*/
|
|
99
|
-
export function defineModel<const D extends ModelInput>(def: D): WithPath<D
|
|
145
|
+
export function defineModel<const D extends ModelInput>(def: D): WithTemplate<WithPath<D>> {
|
|
100
146
|
if (!def.type || typeof def.type !== "string") {
|
|
101
147
|
throw new Error("model.type is required");
|
|
102
148
|
}
|
|
@@ -123,7 +169,11 @@ export function defineModel<const D extends ModelInput>(def: D): WithPath<D> {
|
|
|
123
169
|
if (!def.fields || typeof def.fields !== "object") {
|
|
124
170
|
throw new Error("model.fields is required");
|
|
125
171
|
}
|
|
126
|
-
const
|
|
172
|
+
const template = resolveTemplateSlug(def);
|
|
173
|
+
const withPath = (def.path === undefined ? { ...def, path } : { ...def }) as WithTemplate<
|
|
174
|
+
WithPath<D>
|
|
175
|
+
>;
|
|
176
|
+
if (template !== undefined) (withPath as { template?: string }).template = template;
|
|
127
177
|
if (singleton && def.route === undefined) {
|
|
128
178
|
return { ...withPath, route: false };
|
|
129
179
|
}
|
|
@@ -173,6 +223,10 @@ export function serializeModel(model: ModelDef): ModelSnapshot {
|
|
|
173
223
|
if (model.singleton) snapshot.singleton = true;
|
|
174
224
|
if (model.format === "page") snapshot.format = "page";
|
|
175
225
|
if (model.indexSlug !== undefined) snapshot.indexSlug = model.indexSlug.trim();
|
|
226
|
+
if (model.external) {
|
|
227
|
+
snapshot.external = true;
|
|
228
|
+
snapshot.template = model.template ?? DEFAULT_TEMPLATE_SLUG;
|
|
229
|
+
}
|
|
176
230
|
return snapshot;
|
|
177
231
|
}
|
|
178
232
|
|
|
@@ -264,6 +318,22 @@ export function parseSchemaSnapshot(input: unknown): SchemaSnapshot {
|
|
|
264
318
|
}
|
|
265
319
|
snapshot.indexSlug = m.indexSlug.trim();
|
|
266
320
|
}
|
|
321
|
+
if (m.external !== undefined && typeof m.external !== "boolean") {
|
|
322
|
+
throw new Error(`schema.models[${i}].external must be a boolean`);
|
|
323
|
+
}
|
|
324
|
+
const template = resolveTemplateSlug(
|
|
325
|
+
{
|
|
326
|
+
external: m.external === true,
|
|
327
|
+
template: m.template as string | undefined,
|
|
328
|
+
singleton,
|
|
329
|
+
indexSlug: snapshot.indexSlug,
|
|
330
|
+
},
|
|
331
|
+
`schema.models[${i}]`,
|
|
332
|
+
);
|
|
333
|
+
if (template !== undefined) {
|
|
334
|
+
snapshot.external = true;
|
|
335
|
+
snapshot.template = template;
|
|
336
|
+
}
|
|
267
337
|
return snapshot;
|
|
268
338
|
});
|
|
269
339
|
|
package/src/node/cli.ts
CHANGED
|
@@ -30,7 +30,7 @@ export type CliArgs = {
|
|
|
30
30
|
|
|
31
31
|
const USAGE = `Usage:
|
|
32
32
|
backmatter init scaffold backmatter.config.ts, the catch-all route and .backmatter/
|
|
33
|
-
backmatter generate [--root <dir>] write .backmatter/index.ts (typed cms), blocks.ts, mdx.ts and
|
|
33
|
+
backmatter generate [--root <dir>] write .backmatter/index.ts (typed cms), blocks.ts, mdx.ts, layouts.ts and sources.ts
|
|
34
34
|
backmatter validate [--root <dir>] [--models <file>] [--json]
|
|
35
35
|
backmatter schema [--root <dir>] [--models <file>] [--out <dir>]
|
|
36
36
|
|
|
@@ -239,6 +239,7 @@ export async function runGenerate(args: CliArgs): Promise<GenerateResult> {
|
|
|
239
239
|
mdxDir: config.dirs.mdx,
|
|
240
240
|
layoutsDir: config.dirs.layouts,
|
|
241
241
|
objectsDir: config.dirs.objects,
|
|
242
|
+
sourcesDir: config.dirs.sources,
|
|
242
243
|
});
|
|
243
244
|
}
|
|
244
245
|
|
|
@@ -272,9 +273,10 @@ import { richtextComponents } from "${REGISTRY_SPECIFIER}/richtext";
|
|
|
272
273
|
import { blockLoaders } from "${REGISTRY_SPECIFIER}/blocks";
|
|
273
274
|
import { blockFallbacks } from "${REGISTRY_SPECIFIER}/block-fallbacks";
|
|
274
275
|
import { layouts } from "${REGISTRY_SPECIFIER}/layouts";
|
|
276
|
+
import { sources } from "${REGISTRY_SPECIFIER}/sources";
|
|
275
277
|
|
|
276
278
|
const components = { ...richtextComponents, ...mdxComponents };
|
|
277
|
-
const route = createCatchAll(cms, { components, blocks: blockLoaders, fallbacks: blockFallbacks, layouts });
|
|
279
|
+
const route = createCatchAll(cms, { components, blocks: blockLoaders, fallbacks: blockFallbacks, layouts, sources });
|
|
278
280
|
|
|
279
281
|
export const generateStaticParams = route.generateStaticParams;
|
|
280
282
|
|
|
@@ -348,6 +350,53 @@ export const config = {
|
|
|
348
350
|
`;
|
|
349
351
|
}
|
|
350
352
|
|
|
353
|
+
function renderCmsProxyRoute(): string {
|
|
354
|
+
return `// api/backmatter/cms/[...path]/route.ts — same-origin proxy for the preview session.
|
|
355
|
+
import { createCmsProxy } from "@backmatter/next/cms-proxy";
|
|
356
|
+
import { cms } from "${REGISTRY_SPECIFIER}";
|
|
357
|
+
|
|
358
|
+
export const { GET, POST, PUT, DELETE } = createCmsProxy(cms);
|
|
359
|
+
`;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const STUDIO_DIR = path.join("(backmatter)", "backmatter");
|
|
363
|
+
|
|
364
|
+
function renderStudioLayout(): string {
|
|
365
|
+
return `// app/(backmatter)/backmatter/layout.tsx — own root layout so the site's
|
|
366
|
+
// header, footer, fonts and global CSS do not wrap the Studio.
|
|
367
|
+
export { StudioLayout as default } from "@backmatter/next/studio";
|
|
368
|
+
`;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function renderStudioPage(studioImport: string): string {
|
|
372
|
+
return `// app/(backmatter)/backmatter/[[...path]]/page.tsx — the Backmatter Studio,
|
|
373
|
+
// mounted on this site. Editors sign in at the dashboard; the Studio talks to
|
|
374
|
+
// /api/backmatter/cms on this origin with the site session cookie.
|
|
375
|
+
import { createStudioPage } from "@backmatter/next/studio";
|
|
376
|
+
import { cms } from "${REGISTRY_SPECIFIER}";
|
|
377
|
+
import studio from "${studioImport}";
|
|
378
|
+
|
|
379
|
+
export default createStudioPage(cms, { studio });
|
|
380
|
+
`;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function renderStudioDefinition(): string {
|
|
384
|
+
return `// backmatter.studio.tsx — how the Studio looks and which inputs it uses on
|
|
385
|
+
// this site. A "use client" module: field inputs and block previews are
|
|
386
|
+
// ordinary React components bundled by the site.
|
|
387
|
+
"use client";
|
|
388
|
+
|
|
389
|
+
import { defineStudio } from "@backmatter/studio/define";
|
|
390
|
+
|
|
391
|
+
export default defineStudio({
|
|
392
|
+
// theme: { accent: "#1d4ed8" },
|
|
393
|
+
// fields: { color: ColorInput }, // field.text({ input: "color" })
|
|
394
|
+
// blocks: { Hero: { preview: HeroPreview } },
|
|
395
|
+
menu: [{ label: "Open site", href: "/" }],
|
|
396
|
+
});
|
|
397
|
+
`;
|
|
398
|
+
}
|
|
399
|
+
|
|
351
400
|
function renderRevalidateRoute(): string {
|
|
352
401
|
return `// api/backmatter/revalidate/route.ts — content.committed from the BACKMATTER server.
|
|
353
402
|
import { createRevalidateRoute } from "@backmatter/next/revalidate";
|
|
@@ -402,6 +451,16 @@ export async function runInit(args: CliArgs): Promise<string[]> {
|
|
|
402
451
|
notes.push(`kept existing ${path.join(appDir, "api/backmatter/revalidate/route.ts")}`);
|
|
403
452
|
}
|
|
404
453
|
|
|
454
|
+
const cmsDir = path.join(root, appDir, "api", "backmatter", "cms", "[...path]");
|
|
455
|
+
const cmsFile = path.join(cmsDir, "route.ts");
|
|
456
|
+
if (!existsSync(cmsFile)) {
|
|
457
|
+
await mkdir(cmsDir, { recursive: true });
|
|
458
|
+
await writeFile(cmsFile, renderCmsProxyRoute());
|
|
459
|
+
notes.push(`wrote ${path.join(appDir, "api/backmatter/cms/[...path]/route.ts")}`);
|
|
460
|
+
} else {
|
|
461
|
+
notes.push(`kept existing ${path.join(appDir, "api/backmatter/cms/[...path]/route.ts")}`);
|
|
462
|
+
}
|
|
463
|
+
|
|
405
464
|
const previewDir = path.join(root, appDir, "api", "backmatter", "preview");
|
|
406
465
|
const previewFile = path.join(previewDir, "route.ts");
|
|
407
466
|
if (!existsSync(previewFile)) {
|
|
@@ -412,6 +471,37 @@ export async function runInit(args: CliArgs): Promise<string[]> {
|
|
|
412
471
|
notes.push(`kept existing ${path.join(appDir, "api/backmatter/preview/route.ts")}`);
|
|
413
472
|
}
|
|
414
473
|
|
|
474
|
+
const studioDefinition = path.join(root, "backmatter.studio.tsx");
|
|
475
|
+
if (!existsSync(studioDefinition)) {
|
|
476
|
+
await writeFile(studioDefinition, renderStudioDefinition());
|
|
477
|
+
notes.push("wrote backmatter.studio.tsx");
|
|
478
|
+
} else {
|
|
479
|
+
notes.push("kept existing backmatter.studio.tsx");
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const studioDir = path.join(root, appDir, STUDIO_DIR);
|
|
483
|
+
const studioLayout = path.join(studioDir, "layout.tsx");
|
|
484
|
+
if (!existsSync(studioLayout)) {
|
|
485
|
+
await mkdir(studioDir, { recursive: true });
|
|
486
|
+
await writeFile(studioLayout, renderStudioLayout());
|
|
487
|
+
notes.push(`wrote ${path.join(appDir, STUDIO_DIR, "layout.tsx")}`);
|
|
488
|
+
} else {
|
|
489
|
+
notes.push(`kept existing ${path.join(appDir, STUDIO_DIR, "layout.tsx")}`);
|
|
490
|
+
}
|
|
491
|
+
const studioPageDir = path.join(studioDir, "[[...path]]");
|
|
492
|
+
const studioPage = path.join(studioPageDir, "page.tsx");
|
|
493
|
+
if (!existsSync(studioPage)) {
|
|
494
|
+
await mkdir(studioPageDir, { recursive: true });
|
|
495
|
+
const studioImport = path
|
|
496
|
+
.relative(studioPageDir, path.join(root, "backmatter.studio"))
|
|
497
|
+
.split(path.sep)
|
|
498
|
+
.join("/");
|
|
499
|
+
await writeFile(studioPage, renderStudioPage(studioImport));
|
|
500
|
+
notes.push(`wrote ${path.join(appDir, STUDIO_DIR, "[[...path]]", "page.tsx")}`);
|
|
501
|
+
} else {
|
|
502
|
+
notes.push(`kept existing ${path.join(appDir, STUDIO_DIR, "[[...path]]", "page.tsx")}`);
|
|
503
|
+
}
|
|
504
|
+
|
|
415
505
|
const proxyFile = path.join(root, "proxy.ts");
|
|
416
506
|
const srcProxy = path.join(root, "src", "proxy.ts");
|
|
417
507
|
const middlewareFile = path.join(root, "middleware.ts");
|
|
@@ -450,7 +540,7 @@ export async function runInit(args: CliArgs): Promise<string[]> {
|
|
|
450
540
|
notes.push("no @backmatter/next dependency: skipped the catch-all route");
|
|
451
541
|
}
|
|
452
542
|
|
|
453
|
-
for (const dir of ["models", "content", "blocks", "richtext", "mdx", "layouts"]) {
|
|
543
|
+
for (const dir of ["models", "content", "blocks", "richtext", "mdx", "layouts", "sources"]) {
|
|
454
544
|
await mkdir(path.join(root, dir), { recursive: true });
|
|
455
545
|
}
|
|
456
546
|
|
package/src/node/discover.ts
CHANGED
|
@@ -101,6 +101,11 @@ export function listLayoutFiles(root: string, layoutsDir = "layouts"): ModelFile
|
|
|
101
101
|
export function listObjectFiles(root: string, objectsDir = "objects"): ModelFile[] {
|
|
102
102
|
return listModelFiles(root, objectsDir);
|
|
103
103
|
}
|
|
104
|
+
|
|
105
|
+
/** `sources/{type}.ts`: one `defineSource` default export per external model type. */
|
|
106
|
+
export function listSourceDefFiles(root: string, sourcesDir = "sources"): ModelFile[] {
|
|
107
|
+
return listModelFiles(root, sourcesDir);
|
|
108
|
+
}
|
|
104
109
|
export function listBlockFiles(
|
|
105
110
|
root: string,
|
|
106
111
|
blocksDir = "blocks",
|