@backmatter/core 0.0.6
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/bin/backmatter.js +2 -0
- package/package.json +36 -0
- package/src/api-loader.ts +72 -0
- package/src/block.ts +103 -0
- package/src/coerce.ts +74 -0
- package/src/config.ts +125 -0
- package/src/content-committed.ts +57 -0
- package/src/doc-format.ts +32 -0
- package/src/doc-ref.ts +29 -0
- package/src/doc.ts +18 -0
- package/src/fields.ts +92 -0
- package/src/index.ts +132 -0
- package/src/mdx.ts +44 -0
- package/src/model.ts +258 -0
- package/src/node/cli.ts +505 -0
- package/src/node/create-backmatter.ts +130 -0
- package/src/node/discover.ts +191 -0
- package/src/node/eval-block.ts +202 -0
- package/src/node/eval-model.ts +143 -0
- package/src/node/fs-loader.ts +96 -0
- package/src/node/generate.ts +265 -0
- package/src/node/index.ts +50 -0
- package/src/path.ts +196 -0
- package/src/preview-hotkeys.ts +71 -0
- package/src/preview-session.ts +131 -0
- package/src/preview.ts +205 -0
- package/src/query.ts +152 -0
- package/src/routes.ts +110 -0
- package/src/signed-token.ts +63 -0
- package/src/sync-schema.ts +52 -0
- package/src/validate.ts +149 -0
- package/src/zod.ts +111 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// packages/core/src/preview-session.ts
|
|
2
|
+
import { signPayload, verifyPayload } from "./signed-token";
|
|
3
|
+
|
|
4
|
+
export const PREVIEW_COOKIE = "backmatter_preview";
|
|
5
|
+
export const PREVIEW_QUERY = "backmatter_preview";
|
|
6
|
+
export const PREVIEW_ENABLE_PATH = "/api/backmatter/preview";
|
|
7
|
+
export const PREVIEW_TICKET_TTL_MS = 10 * 60 * 1000;
|
|
8
|
+
export const PREVIEW_COOKIE_TTL_MS = 12 * 60 * 60 * 1000;
|
|
9
|
+
|
|
10
|
+
export type PreviewCheckout = {
|
|
11
|
+
branch?: string;
|
|
12
|
+
tree?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type PreviewTicket = {
|
|
16
|
+
v: 1;
|
|
17
|
+
workspaceId: string;
|
|
18
|
+
redirect: string;
|
|
19
|
+
branch?: string;
|
|
20
|
+
tree?: string;
|
|
21
|
+
exp: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type PreviewCookiePayload = {
|
|
25
|
+
v: 1;
|
|
26
|
+
branch?: string;
|
|
27
|
+
tree?: string;
|
|
28
|
+
exp: number;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function isSafeRedirectPath(path: string): boolean {
|
|
32
|
+
if (!path.startsWith("/") || path.startsWith("//")) return false;
|
|
33
|
+
if (path.includes("://")) return false;
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function mintPreviewTicket(
|
|
38
|
+
secret: string,
|
|
39
|
+
input: {
|
|
40
|
+
workspaceId: string;
|
|
41
|
+
redirect: string;
|
|
42
|
+
branch?: string;
|
|
43
|
+
tree?: string;
|
|
44
|
+
exp: number;
|
|
45
|
+
},
|
|
46
|
+
): Promise<string> {
|
|
47
|
+
if (!isSafeRedirectPath(input.redirect)) {
|
|
48
|
+
throw new Error("preview ticket redirect must be a same-origin path");
|
|
49
|
+
}
|
|
50
|
+
const payload: PreviewTicket = {
|
|
51
|
+
v: 1,
|
|
52
|
+
workspaceId: input.workspaceId,
|
|
53
|
+
redirect: input.redirect,
|
|
54
|
+
exp: input.exp,
|
|
55
|
+
...(input.branch ? { branch: input.branch } : {}),
|
|
56
|
+
...(input.tree ? { tree: input.tree } : {}),
|
|
57
|
+
};
|
|
58
|
+
return signPayload(secret, payload);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function asTicket(value: unknown): PreviewTicket | null {
|
|
62
|
+
if (!value || typeof value !== "object") return null;
|
|
63
|
+
const v = value as Record<string, unknown>;
|
|
64
|
+
if (v.v !== 1 || typeof v.workspaceId !== "string" || typeof v.redirect !== "string") {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
if (typeof v.exp !== "number" || v.exp <= Date.now()) return null;
|
|
68
|
+
if (!isSafeRedirectPath(v.redirect)) return null;
|
|
69
|
+
return {
|
|
70
|
+
v: 1,
|
|
71
|
+
workspaceId: v.workspaceId,
|
|
72
|
+
redirect: v.redirect,
|
|
73
|
+
exp: v.exp,
|
|
74
|
+
...(typeof v.branch === "string" && v.branch ? { branch: v.branch } : {}),
|
|
75
|
+
...(typeof v.tree === "string" && v.tree ? { tree: v.tree } : {}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function readPreviewTicket(secret: string, token: string): Promise<PreviewTicket | null> {
|
|
80
|
+
return asTicket(await verifyPayload(secret, token));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function writePreviewCookie(
|
|
84
|
+
secret: string,
|
|
85
|
+
input: PreviewCheckout & { exp: number },
|
|
86
|
+
): Promise<string> {
|
|
87
|
+
const payload: PreviewCookiePayload = {
|
|
88
|
+
v: 1,
|
|
89
|
+
exp: input.exp,
|
|
90
|
+
...(input.branch ? { branch: input.branch } : {}),
|
|
91
|
+
...(input.tree ? { tree: input.tree } : {}),
|
|
92
|
+
};
|
|
93
|
+
return signPayload(secret, payload);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function readPreviewCookie(
|
|
97
|
+
secret: string,
|
|
98
|
+
token: string | undefined,
|
|
99
|
+
): Promise<PreviewCheckout | null> {
|
|
100
|
+
if (!token) return null;
|
|
101
|
+
const value = await verifyPayload(secret, token);
|
|
102
|
+
if (!value || typeof value !== "object") return null;
|
|
103
|
+
const v = value as Record<string, unknown>;
|
|
104
|
+
if (v.v !== 1 || typeof v.exp !== "number" || v.exp <= Date.now()) return null;
|
|
105
|
+
return {
|
|
106
|
+
...(typeof v.branch === "string" && v.branch ? { branch: v.branch } : {}),
|
|
107
|
+
...(typeof v.tree === "string" && v.tree ? { tree: v.tree } : {}),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export type PreviewMiddlewareDecision =
|
|
112
|
+
| { type: "redirect"; location: string }
|
|
113
|
+
| { type: "headers"; headers: Record<string, string> }
|
|
114
|
+
| { type: "next" };
|
|
115
|
+
|
|
116
|
+
export const PREVIEW_CACHE_HEADERS = {
|
|
117
|
+
"Cache-Control": "private, no-store",
|
|
118
|
+
"X-Robots-Tag": "noindex",
|
|
119
|
+
} as const;
|
|
120
|
+
|
|
121
|
+
/** Edge-safe: no CMS API. Ticket query → enable route; draft → no-store. */
|
|
122
|
+
export function previewMiddlewareDecision(url: URL, draftEnabled: boolean): PreviewMiddlewareDecision {
|
|
123
|
+
const ticket = url.searchParams.get(PREVIEW_QUERY)?.trim();
|
|
124
|
+
if (ticket) {
|
|
125
|
+
return { type: "redirect", location: `${PREVIEW_ENABLE_PATH}?ticket=${encodeURIComponent(ticket)}` };
|
|
126
|
+
}
|
|
127
|
+
if (draftEnabled) {
|
|
128
|
+
return { type: "headers", headers: { ...PREVIEW_CACHE_HEADERS } };
|
|
129
|
+
}
|
|
130
|
+
return { type: "next" };
|
|
131
|
+
}
|
package/src/preview.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// packages/core/src/preview.ts
|
|
2
|
+
import type { BackmatterDoc } from "./doc";
|
|
3
|
+
|
|
4
|
+
export type PreviewDocRef = {
|
|
5
|
+
locale: string;
|
|
6
|
+
type: string;
|
|
7
|
+
slug: string;
|
|
8
|
+
/** Editor checkout branch. Omit to read the workspace default branch. */
|
|
9
|
+
branch?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Working tree owner (the editing user's id). With it the preview shows that
|
|
12
|
+
* user's uncommitted copy and streams their saves; without it, branch HEAD.
|
|
13
|
+
*/
|
|
14
|
+
tree?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type PreviewChangeEvent = PreviewDocRef & {
|
|
18
|
+
updatedAt: string;
|
|
19
|
+
frontmatter?: Record<string, unknown>;
|
|
20
|
+
body?: string;
|
|
21
|
+
raw?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type PreviewClientOptions = {
|
|
25
|
+
apiUrl: string;
|
|
26
|
+
workspaceId: string;
|
|
27
|
+
previewToken: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export {
|
|
31
|
+
installPreviewHotkeys,
|
|
32
|
+
parsePreviewHotkey,
|
|
33
|
+
previewHotkeyFromKeyboardEvent,
|
|
34
|
+
} from "./preview-hotkeys";
|
|
35
|
+
export type { PreviewHotkeyMessage } from "./preview-hotkeys";
|
|
36
|
+
|
|
37
|
+
function baseUrl(apiUrl: string) {
|
|
38
|
+
return apiUrl.replace(/\/$/, "");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** First non-empty value from a Next.js `searchParams` field. */
|
|
42
|
+
function queryValue(value: string | string[] | undefined): string | undefined {
|
|
43
|
+
const raw = Array.isArray(value) ? value[0] : value;
|
|
44
|
+
const trimmed = raw?.trim();
|
|
45
|
+
return trimmed ? trimmed : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Read `branch` and `tree` off the host preview URL so the snapshot and SSE
|
|
50
|
+
* match the editor checkout. Empty values mean branch HEAD (no live stream).
|
|
51
|
+
*/
|
|
52
|
+
export function previewCheckoutFromQuery(query: {
|
|
53
|
+
branch?: string | string[];
|
|
54
|
+
tree?: string | string[];
|
|
55
|
+
}): Pick<PreviewDocRef, "branch" | "tree"> {
|
|
56
|
+
const branch = queryValue(query.branch);
|
|
57
|
+
const tree = queryValue(query.tree);
|
|
58
|
+
return {
|
|
59
|
+
...(branch ? { branch } : {}),
|
|
60
|
+
...(tree ? { tree } : {}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type DevMode = {
|
|
65
|
+
mode: "preview" | "live";
|
|
66
|
+
branch?: string;
|
|
67
|
+
tree?: string;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export function resolveDevMode(session: {
|
|
71
|
+
enabled: boolean;
|
|
72
|
+
branch?: string;
|
|
73
|
+
tree?: string;
|
|
74
|
+
}): DevMode {
|
|
75
|
+
return {
|
|
76
|
+
mode: session.enabled ? "preview" : "live",
|
|
77
|
+
...(session.branch ? { branch: session.branch } : {}),
|
|
78
|
+
...(session.tree ? { tree: session.tree } : {}),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function fetchHostPreviewSession(
|
|
83
|
+
origin = "",
|
|
84
|
+
): Promise<{ enabled: boolean; branch?: string; tree?: string }> {
|
|
85
|
+
try {
|
|
86
|
+
const res = await fetch(`${origin.replace(/\/$/, "")}/api/backmatter/preview?session=1`, {
|
|
87
|
+
credentials: "same-origin",
|
|
88
|
+
cache: "no-store",
|
|
89
|
+
});
|
|
90
|
+
if (!res.ok) return { enabled: false };
|
|
91
|
+
const data = (await res.json()) as {
|
|
92
|
+
mode?: string;
|
|
93
|
+
branch?: string;
|
|
94
|
+
tree?: string;
|
|
95
|
+
};
|
|
96
|
+
if (data.mode !== "preview") return { enabled: false };
|
|
97
|
+
return {
|
|
98
|
+
enabled: true,
|
|
99
|
+
...(typeof data.branch === "string" && data.branch ? { branch: data.branch } : {}),
|
|
100
|
+
...(typeof data.tree === "string" && data.tree ? { tree: data.tree } : {}),
|
|
101
|
+
};
|
|
102
|
+
} catch {
|
|
103
|
+
return { enabled: false };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function clearHostPreviewSession(origin = ""): Promise<boolean> {
|
|
108
|
+
try {
|
|
109
|
+
const res = await fetch(`${origin.replace(/\/$/, "")}/api/backmatter/preview`, {
|
|
110
|
+
method: "POST",
|
|
111
|
+
credentials: "same-origin",
|
|
112
|
+
cache: "no-store",
|
|
113
|
+
});
|
|
114
|
+
return res.ok;
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type CmsSessionUser = { id: string; email: string; name: string };
|
|
121
|
+
|
|
122
|
+
/** Cookie session on the CMS API. Fail closed: empty URL, 401, errors → null. */
|
|
123
|
+
export async function fetchCmsSession(
|
|
124
|
+
apiUrl: string,
|
|
125
|
+
): Promise<CmsSessionUser | null> {
|
|
126
|
+
const base = baseUrl(apiUrl.trim());
|
|
127
|
+
if (!base) return null;
|
|
128
|
+
try {
|
|
129
|
+
const res = await fetch(`${base}/me`, {
|
|
130
|
+
credentials: "include",
|
|
131
|
+
cache: "no-store",
|
|
132
|
+
});
|
|
133
|
+
if (!res.ok) return null;
|
|
134
|
+
const data = (await res.json()) as { user?: CmsSessionUser };
|
|
135
|
+
if (!data.user?.id) return null;
|
|
136
|
+
return data.user;
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function previewParams(options: PreviewClientOptions, ref: PreviewDocRef) {
|
|
143
|
+
const params = new URLSearchParams({ token: options.previewToken });
|
|
144
|
+
if (ref.branch) params.set("branch", ref.branch);
|
|
145
|
+
if (ref.tree) params.set("tree", ref.tree);
|
|
146
|
+
return params;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function docPath(options: PreviewClientOptions, ref: PreviewDocRef) {
|
|
150
|
+
return `${baseUrl(options.apiUrl)}/preview/workspaces/${options.workspaceId}/docs/${ref.locale}/${ref.type}/${ref.slug}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function previewDocRequestUrl(
|
|
154
|
+
options: PreviewClientOptions,
|
|
155
|
+
ref: PreviewDocRef,
|
|
156
|
+
): string {
|
|
157
|
+
return `${docPath(options, ref)}?${previewParams(options, ref)}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function previewStreamUrl(
|
|
161
|
+
options: PreviewClientOptions,
|
|
162
|
+
ref: PreviewDocRef,
|
|
163
|
+
): string {
|
|
164
|
+
return `${docPath(options, ref)}/stream?${previewParams(options, ref)}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function fetchPreviewDoc(
|
|
168
|
+
options: PreviewClientOptions,
|
|
169
|
+
ref: PreviewDocRef,
|
|
170
|
+
): Promise<BackmatterDoc | null> {
|
|
171
|
+
const url = previewDocRequestUrl(options, ref);
|
|
172
|
+
const res = await fetch(url, { cache: "no-store" });
|
|
173
|
+
if (res.status === 404) return null;
|
|
174
|
+
if (!res.ok) throw new Error(`preview getDoc failed: ${res.status}`);
|
|
175
|
+
const data = (await res.json()) as { doc: BackmatterDoc };
|
|
176
|
+
return data.doc;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Browser EventSource subscribe to one working tree's saves; returns unsubscribe. */
|
|
180
|
+
export function subscribePreview(
|
|
181
|
+
options: PreviewClientOptions,
|
|
182
|
+
ref: PreviewDocRef,
|
|
183
|
+
onEvent: (event: PreviewChangeEvent) => void,
|
|
184
|
+
): () => void {
|
|
185
|
+
if (typeof EventSource === "undefined") {
|
|
186
|
+
throw new Error("subscribePreview requires a browser EventSource");
|
|
187
|
+
}
|
|
188
|
+
const source = new EventSource(previewStreamUrl(options, ref));
|
|
189
|
+
|
|
190
|
+
const onChange = (ev: MessageEvent) => {
|
|
191
|
+
try {
|
|
192
|
+
const data = JSON.parse(String(ev.data)) as PreviewChangeEvent;
|
|
193
|
+
onEvent(data);
|
|
194
|
+
} catch {
|
|
195
|
+
// ignore malformed
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
source.addEventListener("change", onChange as EventListener);
|
|
200
|
+
|
|
201
|
+
return () => {
|
|
202
|
+
source.removeEventListener("change", onChange as EventListener);
|
|
203
|
+
source.close();
|
|
204
|
+
};
|
|
205
|
+
}
|
package/src/query.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// packages/core/src/query.ts
|
|
2
|
+
import type { BackmatterDoc, BackmatterDocSummary } from "./doc";
|
|
3
|
+
import type { FieldKind } from "./fields";
|
|
4
|
+
import type { InferFrontmatter, ModelDef } from "./model";
|
|
5
|
+
import { normalizeFrontmatter } from "./zod";
|
|
6
|
+
|
|
7
|
+
/** Loader the query layer sits on: the FS or API loaders behind createBackmatter. */
|
|
8
|
+
export type DocLoader = {
|
|
9
|
+
listDocs(filter?: { type?: string; locale?: string }): Promise<BackmatterDocSummary[]>;
|
|
10
|
+
getDoc(locale: string, type: string, slug: string): Promise<BackmatterDoc | null>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type TypedDoc<M extends ModelDef> = Omit<BackmatterDoc, "type" | "frontmatter"> & {
|
|
14
|
+
type: M["type"];
|
|
15
|
+
frontmatter: InferFrontmatter<M>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type KeysOfKind<M extends ModelDef, K extends FieldKind> = {
|
|
19
|
+
[N in keyof M["fields"]]: M["fields"][N]["kind"] extends K ? N : never;
|
|
20
|
+
}[keyof M["fields"]];
|
|
21
|
+
|
|
22
|
+
type ScalarKind = "text" | "slug" | "locale" | "number" | "boolean";
|
|
23
|
+
|
|
24
|
+
/** Equality filters on slug and scalar frontmatter fields. */
|
|
25
|
+
export type Where<M extends ModelDef> = Partial<
|
|
26
|
+
{ slug: string } & Pick<
|
|
27
|
+
InferFrontmatter<M>,
|
|
28
|
+
Extract<KeysOfKind<M, ScalarKind>, keyof InferFrontmatter<M>>
|
|
29
|
+
>
|
|
30
|
+
>;
|
|
31
|
+
|
|
32
|
+
/** Relation fields that may be resolved (depth 1, same locale). */
|
|
33
|
+
export type Include<M extends ModelDef> = Partial<Record<KeysOfKind<M, "relation">, true>>;
|
|
34
|
+
|
|
35
|
+
type RelationTarget<
|
|
36
|
+
M extends ModelDef,
|
|
37
|
+
N extends keyof M["fields"],
|
|
38
|
+
Models extends readonly ModelDef[],
|
|
39
|
+
> = M["fields"][N] extends { relationTo?: infer To }
|
|
40
|
+
? Extract<Models[number], { type: Exclude<To, undefined> }>
|
|
41
|
+
: never;
|
|
42
|
+
|
|
43
|
+
export type Resolved<
|
|
44
|
+
M extends ModelDef,
|
|
45
|
+
Models extends readonly ModelDef[],
|
|
46
|
+
I extends Include<M>,
|
|
47
|
+
> = {
|
|
48
|
+
[N in keyof I as I[N] extends true ? N : never]: N extends keyof M["fields"]
|
|
49
|
+
? TypedDoc<RelationTarget<M, N, Models>> | null
|
|
50
|
+
: never;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type ModelQuery<M extends ModelDef, Models extends readonly ModelDef[]> = {
|
|
54
|
+
get(locale: string, slug: string): Promise<TypedDoc<M> | null>;
|
|
55
|
+
findMany<I extends Include<M> = {}>(opts?: {
|
|
56
|
+
locale?: string;
|
|
57
|
+
where?: Where<M>;
|
|
58
|
+
include?: I;
|
|
59
|
+
}): Promise<Array<TypedDoc<M> & Resolved<M, Models, I>>>;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export type Query<Models extends readonly ModelDef[]> = {
|
|
63
|
+
[M in Models[number] as M["type"]]: ModelQuery<M, Models>;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
type RawOpts = {
|
|
67
|
+
locale?: string;
|
|
68
|
+
where?: Record<string, unknown>;
|
|
69
|
+
include?: Record<string, boolean | undefined>;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export function createQuery<const Models extends readonly ModelDef[]>(
|
|
73
|
+
models: Models,
|
|
74
|
+
loader: DocLoader,
|
|
75
|
+
): Query<Models> {
|
|
76
|
+
const byType = new Map<string, ModelDef>(models.map((m) => [m.type, m]));
|
|
77
|
+
|
|
78
|
+
async function get(type: string, locale: string, slug: string): Promise<BackmatterDoc | null> {
|
|
79
|
+
const doc = await loader.getDoc(locale, type, slug);
|
|
80
|
+
if (!doc) return null;
|
|
81
|
+
return { ...doc, frontmatter: normalizeFrontmatter(doc.frontmatter) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function matchesWhere(doc: BackmatterDoc, where: Record<string, unknown>): boolean {
|
|
85
|
+
for (const [key, expected] of Object.entries(where)) {
|
|
86
|
+
if (expected === undefined) continue;
|
|
87
|
+
const actual = key === "slug" ? doc.slug : doc.frontmatter[key];
|
|
88
|
+
if (actual !== expected) return false;
|
|
89
|
+
}
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function findMany(type: string, opts?: RawOpts): Promise<BackmatterDoc[]> {
|
|
94
|
+
const model = byType.get(type);
|
|
95
|
+
const whereSlug = opts?.where?.slug;
|
|
96
|
+
|
|
97
|
+
let docs: BackmatterDoc[];
|
|
98
|
+
// Truthy gate: the loaders' locale filter is truthiness-based, so a falsy
|
|
99
|
+
// locale ("") must fall back to the list path like an omitted one.
|
|
100
|
+
if (typeof whereSlug === "string" && opts?.locale) {
|
|
101
|
+
// Slug fast-path: identity lookup instead of list + load fan-out.
|
|
102
|
+
const doc = await get(type, opts.locale, whereSlug);
|
|
103
|
+
docs = doc ? [doc] : [];
|
|
104
|
+
} else {
|
|
105
|
+
const summaries = await loader.listDocs({ type, locale: opts?.locale });
|
|
106
|
+
const loaded = await Promise.all(summaries.map((s) => get(type, s.locale, s.slug)));
|
|
107
|
+
docs = loaded.filter((d): d is BackmatterDoc => d !== null);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const filtered = opts?.where ? docs.filter((d) => matchesWhere(d, opts.where!)) : docs;
|
|
111
|
+
const include = opts?.include;
|
|
112
|
+
if (!include || !model) return filtered;
|
|
113
|
+
|
|
114
|
+
// Memoize relation loads per call: N posts sharing one author load it once.
|
|
115
|
+
const relationCache = new Map<string, Promise<BackmatterDoc | null>>();
|
|
116
|
+
const getCached = (target: string, locale: string, slug: string) => {
|
|
117
|
+
const key = `${target}:${locale}:${slug}`;
|
|
118
|
+
let promise = relationCache.get(key);
|
|
119
|
+
if (!promise) {
|
|
120
|
+
promise = get(target, locale, slug);
|
|
121
|
+
relationCache.set(key, promise);
|
|
122
|
+
}
|
|
123
|
+
return promise;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
return Promise.all(
|
|
127
|
+
filtered.map(async (doc) => {
|
|
128
|
+
const extra: Record<string, BackmatterDoc | null> = {};
|
|
129
|
+
for (const [name, on] of Object.entries(include)) {
|
|
130
|
+
if (!on) continue;
|
|
131
|
+
const field = model.fields[name];
|
|
132
|
+
const target = field?.kind === "relation" ? field.relationTo : undefined;
|
|
133
|
+
const slug = doc.frontmatter[name];
|
|
134
|
+
extra[name] =
|
|
135
|
+
target && typeof slug === "string" && slug.length > 0
|
|
136
|
+
? await getCached(target, doc.locale, slug)
|
|
137
|
+
: null;
|
|
138
|
+
}
|
|
139
|
+
return { ...doc, ...extra };
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const query: Record<string, unknown> = {};
|
|
145
|
+
for (const m of models) {
|
|
146
|
+
query[m.type] = {
|
|
147
|
+
get: (locale: string, slug: string) => get(m.type, locale, slug),
|
|
148
|
+
findMany: (opts?: RawOpts) => findMany(m.type, opts),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return query as Query<Models>;
|
|
152
|
+
}
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// packages/core/src/routes.ts
|
|
2
|
+
// Pure URL ↔ document mapping for the host catch-all route. No framework code.
|
|
3
|
+
import type { ModelSnapshot } from "./model";
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_ROUTE = "/{locale}/{type}/{slug}";
|
|
6
|
+
|
|
7
|
+
export type RouteModel = Pick<ModelSnapshot, "type" | "route" | "indexSlug">;
|
|
8
|
+
|
|
9
|
+
export type RouteMatch = {
|
|
10
|
+
type: string;
|
|
11
|
+
locale: string;
|
|
12
|
+
slug: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type MatchRouteOptions = {
|
|
16
|
+
/** Locale for routes without a `{locale}` placeholder. */
|
|
17
|
+
defaultLocale: string;
|
|
18
|
+
/** When set, `{locale}` only matches one of these values. */
|
|
19
|
+
locales?: readonly string[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** The model's route template with `{type}` already substituted. */
|
|
23
|
+
export function routeTemplate(model: RouteModel): string {
|
|
24
|
+
return (model.route ?? DEFAULT_ROUTE).replaceAll("{type}", model.type);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Build the public URL path for a document, e.g. "/en/blog/hello". */
|
|
28
|
+
export function routeFor(model: RouteModel, locale: string, slug: string): string {
|
|
29
|
+
if (model.indexSlug && slug === model.indexSlug) return "/";
|
|
30
|
+
return routeTemplate(model)
|
|
31
|
+
.replaceAll("{locale}", encodeURIComponent(locale))
|
|
32
|
+
.replaceAll("{slug}", slug.split("/").map(encodeURIComponent).join("/"));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** URL segments for Next `generateStaticParams` (["en", "blog", "hello"]). */
|
|
36
|
+
export function routeSegments(model: RouteModel, locale: string, slug: string): string[] {
|
|
37
|
+
return routeFor(model, locale, slug)
|
|
38
|
+
.split("/")
|
|
39
|
+
.filter(Boolean)
|
|
40
|
+
.map(decodeURIComponent);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function escapeRegex(s: string): string {
|
|
44
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function compileRoutePattern(template: string): RegExp {
|
|
48
|
+
const parts = template.split(/(\{locale\}|\{slug\})/g);
|
|
49
|
+
let pattern = "^";
|
|
50
|
+
for (const part of parts) {
|
|
51
|
+
if (part === "{locale}") pattern += "(?<locale>[^/]+)";
|
|
52
|
+
else if (part === "{slug}") pattern += "(?<slug>[^/]+)";
|
|
53
|
+
else pattern += escapeRegex(part);
|
|
54
|
+
}
|
|
55
|
+
return new RegExp(`${pattern}/?$`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function specificity(template: string): [staticSegments: number, placeholders: number] {
|
|
59
|
+
const segments = template.split("/").filter(Boolean);
|
|
60
|
+
const placeholders = segments.filter((s) => s.includes("{")).length;
|
|
61
|
+
return [segments.length - placeholders, placeholders];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Most static segments first, then fewer placeholders, then declaration order. */
|
|
65
|
+
export function sortByRouteSpecificity<M extends RouteModel>(models: readonly M[]): M[] {
|
|
66
|
+
return models
|
|
67
|
+
.map((model, index) => ({ model, index, spec: specificity(routeTemplate(model)) }))
|
|
68
|
+
.sort((a, b) => {
|
|
69
|
+
if (a.spec[0] !== b.spec[0]) return b.spec[0] - a.spec[0];
|
|
70
|
+
if (a.spec[1] !== b.spec[1]) return a.spec[1] - b.spec[1];
|
|
71
|
+
return a.index - b.index;
|
|
72
|
+
})
|
|
73
|
+
.map((entry) => entry.model);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function normalizePathname(pathname: string): string {
|
|
77
|
+
let out = pathname.startsWith("/") ? pathname : `/${pathname}`;
|
|
78
|
+
if (out.length > 1) out = out.replace(/\/+$/, "");
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Resolve a URL path to a document identity, or null when no model claims it. */
|
|
83
|
+
export function matchRoute(
|
|
84
|
+
models: readonly RouteModel[],
|
|
85
|
+
pathname: string,
|
|
86
|
+
options: MatchRouteOptions,
|
|
87
|
+
): RouteMatch | null {
|
|
88
|
+
const target = normalizePathname(pathname);
|
|
89
|
+
if (target === "/") {
|
|
90
|
+
const index = models.find((model) => model.indexSlug);
|
|
91
|
+
if (index?.indexSlug) {
|
|
92
|
+
return { type: index.type, locale: options.defaultLocale, slug: index.indexSlug };
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
for (const model of sortByRouteSpecificity(models)) {
|
|
97
|
+
const template = routeTemplate(model);
|
|
98
|
+
const m = compileRoutePattern(template).exec(target);
|
|
99
|
+
if (!m?.groups?.slug) continue;
|
|
100
|
+
let locale = options.defaultLocale;
|
|
101
|
+
if (template.includes("{locale}")) {
|
|
102
|
+
const raw = m.groups.locale;
|
|
103
|
+
if (!raw) continue;
|
|
104
|
+
locale = decodeURIComponent(raw);
|
|
105
|
+
if (options.locales && !options.locales.includes(locale)) continue;
|
|
106
|
+
}
|
|
107
|
+
return { type: model.type, locale, slug: decodeURIComponent(m.groups.slug) };
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// packages/core/src/signed-token.ts
|
|
2
|
+
// HMAC-SHA256 tokens for Edge + Node (Web Crypto). Payload is JSON.
|
|
3
|
+
|
|
4
|
+
function bytesToB64Url(bytes: Uint8Array): string {
|
|
5
|
+
let bin = "";
|
|
6
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
7
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function b64UrlToBytes(value: string): Uint8Array | null {
|
|
11
|
+
try {
|
|
12
|
+
const padded = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
13
|
+
const pad = padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4));
|
|
14
|
+
const bin = atob(padded + pad);
|
|
15
|
+
const out = new Uint8Array(bin.length);
|
|
16
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
17
|
+
return out;
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function hmacSha256(secret: string, message: string): Promise<Uint8Array> {
|
|
24
|
+
const key = await crypto.subtle.importKey(
|
|
25
|
+
"raw",
|
|
26
|
+
new TextEncoder().encode(secret),
|
|
27
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
28
|
+
false,
|
|
29
|
+
["sign"],
|
|
30
|
+
);
|
|
31
|
+
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message));
|
|
32
|
+
return new Uint8Array(sig);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function timingEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
36
|
+
if (a.length !== b.length) return false;
|
|
37
|
+
let diff = 0;
|
|
38
|
+
for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!;
|
|
39
|
+
return diff === 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function signPayload(secret: string, payload: unknown): Promise<string> {
|
|
43
|
+
const body = bytesToB64Url(new TextEncoder().encode(JSON.stringify(payload)));
|
|
44
|
+
const sig = bytesToB64Url(await hmacSha256(secret, body));
|
|
45
|
+
return `${body}.${sig}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function verifyPayload(secret: string, token: string): Promise<unknown | null> {
|
|
49
|
+
const dot = token.lastIndexOf(".");
|
|
50
|
+
if (dot <= 0) return null;
|
|
51
|
+
const body = token.slice(0, dot);
|
|
52
|
+
const sig = b64UrlToBytes(token.slice(dot + 1));
|
|
53
|
+
if (!sig) return null;
|
|
54
|
+
const expected = await hmacSha256(secret, body);
|
|
55
|
+
if (!timingEqual(sig, expected)) return null;
|
|
56
|
+
const json = b64UrlToBytes(body);
|
|
57
|
+
if (!json) return null;
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(new TextDecoder().decode(json));
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|