@backmatter/next 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.
@@ -0,0 +1,138 @@
1
+ // packages/next/src/preview-route.ts
2
+ import {
3
+ PREVIEW_COOKIE,
4
+ PREVIEW_COOKIE_TTL_MS,
5
+ readPreviewCookie,
6
+ readPreviewTicket,
7
+ writePreviewCookie,
8
+ type PreviewCheckout,
9
+ } from "@backmatter/core";
10
+ import { previewStreamUrl } from "@backmatter/core/preview";
11
+ import type { BackmatterClient } from "@backmatter/core/node/create-backmatter";
12
+
13
+ export type PreviewRouteDeps = {
14
+ enableDraft?: () => void | Promise<void>;
15
+ disableDraft?: () => void | Promise<void>;
16
+ now?: () => number;
17
+ };
18
+
19
+ function cookieBase(secure: boolean, maxAge: number, value: string) {
20
+ const parts = [
21
+ `${PREVIEW_COOKIE}=${value}`,
22
+ "Path=/",
23
+ "HttpOnly",
24
+ "SameSite=Lax",
25
+ `Max-Age=${maxAge}`,
26
+ ];
27
+ if (secure) parts.push("Secure");
28
+ return parts.join("; ");
29
+ }
30
+
31
+ async function nextDraft(enable: boolean): Promise<void> {
32
+ const mod = (await import("next/headers" as string)) as {
33
+ draftMode: () => Promise<{ enable: () => void; disable: () => void }>;
34
+ };
35
+ const draft = await mod.draftMode();
36
+ if (enable) draft.enable();
37
+ else draft.disable();
38
+ }
39
+
40
+ function cookieFrom(req: Request): string | undefined {
41
+ const header = req.headers.get("cookie");
42
+ if (!header) return undefined;
43
+ for (const part of header.split(";")) {
44
+ const [name, ...rest] = part.trim().split("=");
45
+ if (name === PREVIEW_COOKIE) return rest.join("=");
46
+ }
47
+ return undefined;
48
+ }
49
+
50
+ export function createPreviewRoute(
51
+ cms: Pick<BackmatterClient, "config">,
52
+ deps: PreviewRouteDeps = {},
53
+ ) {
54
+ const secret = cms.config.previewToken;
55
+ const now = deps.now ?? Date.now;
56
+
57
+ const GET = async (req: Request): Promise<Response> => {
58
+ const url = new URL(req.url);
59
+ const secure = url.protocol === "https:";
60
+
61
+ if (url.searchParams.get("session") === "1") {
62
+ const checkout = secret ? await readPreviewCookie(secret, cookieFrom(req)) : null;
63
+ if (!checkout) return Response.json({ mode: "live" });
64
+ return Response.json({
65
+ mode: "preview",
66
+ ...checkout,
67
+ });
68
+ }
69
+
70
+ if (url.searchParams.get("stream") === "1") {
71
+ if (!secret || !cms.config.apiUrl || !cms.config.workspaceId) {
72
+ return new Response("preview not configured", { status: 503 });
73
+ }
74
+ const checkout = await readPreviewCookie(secret, cookieFrom(req));
75
+ if (!checkout) return new Response("preview session missing", { status: 401 });
76
+ const locale = url.searchParams.get("locale") ?? cms.config.defaultLocale;
77
+ const type = url.searchParams.get("type") ?? "";
78
+ const slug = url.searchParams.get("slug") ?? "";
79
+ if (!type || !slug) return new Response("missing type or slug", { status: 400 });
80
+ const upstream = previewStreamUrl(
81
+ { apiUrl: cms.config.apiUrl, workspaceId: cms.config.workspaceId, previewToken: secret },
82
+ { locale, type, slug, ...checkout },
83
+ );
84
+ const res = await fetch(upstream);
85
+ return new Response(res.body, {
86
+ status: res.status,
87
+ headers: {
88
+ "Content-Type": "text/event-stream",
89
+ "Cache-Control": "no-store",
90
+ },
91
+ });
92
+ }
93
+
94
+ const ticket = url.searchParams.get("ticket")?.trim();
95
+ if (!ticket) return Response.json({ error: "missing_ticket" }, { status: 400 });
96
+ if (!secret) return Response.json({ error: "preview_not_configured" }, { status: 503 });
97
+ const payload = await readPreviewTicket(secret, ticket);
98
+ if (!payload || payload.workspaceId !== cms.config.workspaceId) {
99
+ return Response.json({ error: "invalid_ticket" }, { status: 401 });
100
+ }
101
+ const value = await writePreviewCookie(secret, {
102
+ branch: payload.branch,
103
+ tree: payload.tree,
104
+ exp: now() + PREVIEW_COOKIE_TTL_MS,
105
+ });
106
+ await (deps.enableDraft ?? (() => nextDraft(true)))();
107
+ const location = new URL(payload.redirect, url.origin).toString();
108
+ return new Response(null, {
109
+ status: 302,
110
+ headers: {
111
+ Location: location,
112
+ "Set-Cookie": cookieBase(secure, Math.floor(PREVIEW_COOKIE_TTL_MS / 1000), value),
113
+ },
114
+ });
115
+ };
116
+
117
+ const POST = async (req: Request): Promise<Response> => {
118
+ await (deps.disableDraft ?? (() => nextDraft(false)))();
119
+ const secure = new URL(req.url).protocol === "https:";
120
+ return new Response(null, {
121
+ status: 204,
122
+ headers: {
123
+ "Set-Cookie": cookieBase(secure, 0, ""),
124
+ },
125
+ });
126
+ };
127
+
128
+ return { GET, POST };
129
+ }
130
+
131
+ export async function loadPreviewCheckout(
132
+ secret: string | undefined,
133
+ cookieHeader: string | undefined,
134
+ ): Promise<PreviewCheckout | null> {
135
+ if (!secret) return null;
136
+ const fake = new Request("http://local", { headers: cookieHeader ? { cookie: cookieHeader } : {} });
137
+ return readPreviewCookie(secret, cookieFrom(fake));
138
+ }
@@ -0,0 +1,15 @@
1
+ // packages/next/src/preview-ui.ts
2
+ import { createElement, type ReactNode } from "react";
3
+
4
+ export function previewUnavailable(message?: string): ReactNode {
5
+ return createElement(
6
+ "section",
7
+ null,
8
+ createElement("h1", null, "Preview unavailable"),
9
+ createElement(
10
+ "p",
11
+ null,
12
+ message ?? "This preview session expired or could not load.",
13
+ ),
14
+ );
15
+ }
package/src/preview.ts ADDED
@@ -0,0 +1,4 @@
1
+ // packages/next/src/preview.ts
2
+ // Client-safe re-export of the preview client (`fetchPreviewDoc`,
3
+ // `subscribePreview`, hotkeys). Implementation lives in `@backmatter/core/preview`.
4
+ export * from "@backmatter/core/preview";
package/src/proxy.ts ADDED
@@ -0,0 +1,24 @@
1
+ // packages/next/src/proxy.ts
2
+ // Next 16 file convention is `proxy.ts` exporting `proxy` (was `middleware.ts`).
3
+ import { NextResponse, type NextRequest } from "next/server";
4
+ import { PREVIEW_COOKIE, previewMiddlewareDecision } from "@backmatter/core";
5
+
6
+ export function backmatterProxy(request: NextRequest): NextResponse | null {
7
+ const draft = Boolean(request.cookies.get(PREVIEW_COOKIE)?.value);
8
+ const decision = previewMiddlewareDecision(request.nextUrl, draft);
9
+ if (decision.type === "redirect") {
10
+ return NextResponse.redirect(new URL(decision.location, request.url));
11
+ }
12
+ if (decision.type === "headers") {
13
+ const res = NextResponse.next();
14
+ for (const [name, value] of Object.entries(decision.headers)) {
15
+ res.headers.set(name, value);
16
+ }
17
+ return res;
18
+ }
19
+ return null;
20
+ }
21
+
22
+ export function proxy(request: NextRequest) {
23
+ return backmatterProxy(request) ?? NextResponse.next();
24
+ }
@@ -0,0 +1,64 @@
1
+ // packages/next/src/revalidate.ts
2
+ // POST /api/backmatter/revalidate — the only Next-specific piece of content.committed.
3
+ // The central server POSTs here after S3 head/ is updated; we never talk to GitHub.
4
+ import { timingSafeEqual } from "node:crypto";
5
+ import {
6
+ CONTENT_COMMITTED_EVENT,
7
+ BACKMATTER_CACHE_TAG,
8
+ pathsForCommittedDocs,
9
+ type ContentCommittedEvent,
10
+ } from "@backmatter/core";
11
+ import type { BackmatterClient } from "@backmatter/core/node/create-backmatter";
12
+
13
+ export type RevalidateCache = {
14
+ revalidatePath: (path: string, type?: "layout" | "page") => void;
15
+ revalidateTag: (tag: string) => void;
16
+ };
17
+
18
+ function extractBearer(header: string | null): string | null {
19
+ if (!header?.startsWith("Bearer ")) return null;
20
+ const token = header.slice("Bearer ".length).trim();
21
+ return token.length > 0 ? token : null;
22
+ }
23
+
24
+ function tokensEqual(a: string, b: string): boolean {
25
+ const ab = Buffer.from(a);
26
+ const bb = Buffer.from(b);
27
+ if (ab.length !== bb.length) return false;
28
+ return timingSafeEqual(ab, bb);
29
+ }
30
+
31
+ async function loadNextCache(): Promise<RevalidateCache> {
32
+ const mod = (await import("next/cache" as string)) as RevalidateCache;
33
+ return { revalidatePath: mod.revalidatePath, revalidateTag: mod.revalidateTag };
34
+ }
35
+
36
+ export function createRevalidateRoute(
37
+ cms: Pick<BackmatterClient, "models" | "config">,
38
+ cache?: RevalidateCache,
39
+ ) {
40
+ return async (req: Request): Promise<Response> => {
41
+ const expected = cms.config.hostToken;
42
+ const token = extractBearer(req.headers.get("authorization"));
43
+ if (!expected || !token || !tokensEqual(token, expected)) {
44
+ return Response.json({ error: "unauthorized" }, { status: 401 });
45
+ }
46
+
47
+ let body: ContentCommittedEvent;
48
+ try {
49
+ body = (await req.json()) as ContentCommittedEvent;
50
+ } catch {
51
+ return Response.json({ error: "invalid_json" }, { status: 400 });
52
+ }
53
+ if (body?.event !== CONTENT_COMMITTED_EVENT) {
54
+ return Response.json({ error: "invalid_event" }, { status: 400 });
55
+ }
56
+
57
+ const { revalidatePath, revalidateTag } = cache ?? (await loadNextCache());
58
+ const paths = pathsForCommittedDocs(cms.models, body.docs ?? []);
59
+ for (const path of paths) revalidatePath(path);
60
+ revalidateTag(BACKMATTER_CACHE_TAG);
61
+ if (body.full) revalidatePath("/", "layout");
62
+ return Response.json({ ok: true, paths });
63
+ };
64
+ }
package/src/routes.ts ADDED
@@ -0,0 +1,226 @@
1
+ // packages/next/src/routes.ts
2
+ // `createCatchAll(cms)` powers the one route file a host adds:
3
+ // app/[[...backmatter]]/page.tsx. It maps the URL to a document via each model's
4
+ // `route` template (including `/` via indexSlug), loads it and renders MDX
5
+ // or JSON pages. An optional catch-all is required so `/` is a CMS route.
6
+ import { Suspense, createElement, type ReactNode } from "react";
7
+ import { matchRoute, modelFormat, PREVIEW_COOKIE, readPreviewCookie, routeSegments, type BackmatterDoc, type ModelDef } from "@backmatter/core";
8
+ import type { BackmatterClient } from "@backmatter/core/node/create-backmatter";
9
+ import { fetchPreviewDoc } from "@backmatter/core/preview";
10
+ import { renderDoc, type MdxComponents } from "./mdx-compile";
11
+ import { getCachedDoc, registerCachedDocGet } from "./cached-doc";
12
+ import { BlockSkeleton, renderPage, type BlockComponents, type BlockFallbacks } from "./page-render";
13
+ import { PreviewBody } from "./preview-live";
14
+ import { PreviewPageReloader } from "./preview-refresh";
15
+ import { previewUnavailable } from "./preview-ui";
16
+
17
+ export const CATCH_ALL_PARAM = "backmatter";
18
+
19
+ export type CatchAllParams = { [CATCH_ALL_PARAM]?: string[] };
20
+
21
+ export type DocLayout = (doc: BackmatterDoc, body: ReactNode) => ReactNode;
22
+
23
+ export type CatchAllOptions = {
24
+ /** MDX component map, usually the generated `components`. */
25
+ components?: MdxComponents;
26
+ /** Composer map, usually the generated `blocks`. */
27
+ blocks?: BlockComponents;
28
+ /** Per-block Suspense fallbacks for page-format documents. */
29
+ fallbacks?: BlockFallbacks;
30
+ /** Per-type wrappers, usually the generated `layouts`. */
31
+ layouts?: Record<string, DocLayout>;
32
+ /** Overrides `cms.config.locales`. */
33
+ locales?: readonly string[];
34
+ /** Overrides `cms.config.defaultLocale`. */
35
+ defaultLocale?: string;
36
+ /**
37
+ * Wrap the rendered body (layout, title, metadata). Default: the body only.
38
+ */
39
+ render?: (doc: BackmatterDoc, body: ReactNode) => ReactNode;
40
+ /** Hook for tests; defaults to Next's `notFound()`. */
41
+ notFound?: () => never;
42
+ /**
43
+ * Test/host injection. When omitted, the catch-all reads Next draftMode
44
+ * and the signed preview cookie. Never call cookies() on the live path.
45
+ */
46
+ preview?: {
47
+ enabled: boolean;
48
+ checkout?: { branch?: string; tree?: string };
49
+ getDoc?: (ref: {
50
+ locale: string;
51
+ type: string;
52
+ slug: string;
53
+ branch?: string;
54
+ tree?: string;
55
+ }) => Promise<BackmatterDoc | null>;
56
+ };
57
+ };
58
+
59
+ export type CatchAllMetadata = { title?: string };
60
+
61
+ export type CatchAllRoute = {
62
+ Page: (props: { params: Promise<CatchAllParams> }) => ReactNode;
63
+ CmsRoute: (props: { params: Promise<CatchAllParams> | CatchAllParams }) => Promise<ReactNode>;
64
+ generateStaticParams: () => Promise<CatchAllParams[]>;
65
+ generateMetadata: (props: {
66
+ params: Promise<CatchAllParams>;
67
+ }) => Promise<CatchAllMetadata>;
68
+ /** Resolve URL segments to a document identity (null → 404). */
69
+ resolve: (segments: readonly string[] | undefined) => ReturnType<typeof matchRoute>;
70
+ };
71
+
72
+ // `next` is an optional peer: the `as string` keeps tsc from resolving the
73
+ // module (so this package type-checks with or without `next`, including when
74
+ // symlinked into a host), while SWC/webpack still see a literal specifier.
75
+ async function nextNotFound(): Promise<never> {
76
+ const mod = (await import("next/navigation" as string)) as { notFound: () => never };
77
+ return mod.notFound();
78
+ }
79
+
80
+ type PreviewState =
81
+ | { enabled: false }
82
+ | { enabled: true; checkout: { branch?: string; tree?: string }; error?: string };
83
+
84
+ async function resolvePreviewState(
85
+ cms: Pick<BackmatterClient, "config">,
86
+ injected: CatchAllOptions["preview"],
87
+ ): Promise<PreviewState> {
88
+ if (injected) {
89
+ if (!injected.enabled) return { enabled: false };
90
+ return { enabled: true, checkout: injected.checkout ?? {} };
91
+ }
92
+ try {
93
+ const mod = (await import("next/headers" as string)) as {
94
+ draftMode: () => Promise<{ isEnabled: boolean }>;
95
+ cookies: () => Promise<{ get: (name: string) => { value: string } | undefined }>;
96
+ };
97
+ const { isEnabled } = await mod.draftMode();
98
+ if (!isEnabled) return { enabled: false };
99
+ const secret = cms.config.previewToken;
100
+ if (!secret) return { enabled: true, checkout: {}, error: "preview_not_configured" };
101
+ const raw = (await mod.cookies()).get(PREVIEW_COOKIE)?.value;
102
+ const checkout = await readPreviewCookie(secret, raw);
103
+ if (!checkout) return { enabled: true, checkout: {}, error: "invalid_session" };
104
+ return { enabled: true, checkout };
105
+ } catch {
106
+ return { enabled: false };
107
+ }
108
+ }
109
+
110
+ export function createCatchAll<Models extends readonly ModelDef[]>(
111
+ cms: BackmatterClient<Models>,
112
+ options: CatchAllOptions = {},
113
+ ): CatchAllRoute {
114
+ registerCachedDocGet((locale, type, slug) => cms.getDoc(locale, type, slug));
115
+ const models = cms.models as readonly ModelDef[];
116
+ const byType = new Map(models.map((m) => [m.type, m]));
117
+ const matchOptions = {
118
+ defaultLocale: options.defaultLocale ?? cms.config.defaultLocale,
119
+ locales: options.locales ?? cms.config.locales,
120
+ };
121
+
122
+ const resolve: CatchAllRoute["resolve"] = (segments) => {
123
+ const pathname = `/${(segments ?? []).join("/")}`;
124
+ return matchRoute(models, pathname, matchOptions);
125
+ };
126
+
127
+ const generateStaticParams: CatchAllRoute["generateStaticParams"] = async () => {
128
+ const docs = await cms.listDocs();
129
+ const params: CatchAllParams[] = [];
130
+ for (const doc of docs) {
131
+ const model = byType.get(doc.type);
132
+ if (!model) continue;
133
+ params.push({ [CATCH_ALL_PARAM]: routeSegments(model, doc.locale, doc.slug) });
134
+ }
135
+ return params;
136
+ };
137
+
138
+ const PageFallback = BlockSkeleton;
139
+
140
+ const CmsRoute: CatchAllRoute["CmsRoute"] = async ({ params }) => {
141
+ const resolved = await params;
142
+ const hit = resolve(resolved[CATCH_ALL_PARAM]);
143
+ const fail = options.notFound ?? nextNotFound;
144
+ if (!hit) return fail();
145
+ const preview = await resolvePreviewState(cms, options.preview);
146
+ let doc: BackmatterDoc | null;
147
+ if (preview.enabled) {
148
+ if (preview.error) return previewUnavailable(preview.error);
149
+ try {
150
+ const getDoc =
151
+ options.preview?.getDoc ??
152
+ ((ref) =>
153
+ fetchPreviewDoc(
154
+ {
155
+ apiUrl: cms.config.apiUrl ?? "",
156
+ workspaceId: cms.config.workspaceId ?? "",
157
+ previewToken: cms.config.previewToken ?? "",
158
+ },
159
+ ref,
160
+ ));
161
+ doc = await getDoc({
162
+ locale: hit.locale,
163
+ type: hit.type,
164
+ slug: hit.slug,
165
+ ...preview.checkout,
166
+ });
167
+ } catch (err) {
168
+ return previewUnavailable(err instanceof Error ? err.message : "Failed to load");
169
+ }
170
+ } else {
171
+ doc = await getCachedDoc(hit.locale, hit.type, hit.slug);
172
+ }
173
+ if (!doc) return fail();
174
+ const model = byType.get(hit.type);
175
+ const isPage = Boolean(model && modelFormat(model) === "page");
176
+ const rendered = isPage
177
+ ? renderPage(doc, options.blocks, options.fallbacks)
178
+ : await renderDoc(doc, { components: options.components });
179
+ const body = isPage
180
+ ? rendered
181
+ : preview.enabled
182
+ ? createElement(
183
+ PreviewBody,
184
+ { doc, components: options.components },
185
+ rendered,
186
+ )
187
+ : rendered;
188
+ const withLive =
189
+ preview.enabled && isPage
190
+ ? createElement(
191
+ "div",
192
+ null,
193
+ createElement(PreviewPageReloader, {
194
+ locale: doc.locale,
195
+ type: doc.type,
196
+ slug: doc.slug,
197
+ }),
198
+ body,
199
+ )
200
+ : body;
201
+ const layout = options.layouts?.[hit.type];
202
+ const laidOut = layout ? layout(doc, withLive) : withLive;
203
+ if (options.render) return options.render(doc, laidOut);
204
+ if (layout || isPage) return laidOut;
205
+ return createElement("article", null, laidOut);
206
+ };
207
+
208
+ const Page: CatchAllRoute["Page"] = (props) =>
209
+ createElement(
210
+ Suspense,
211
+ { fallback: createElement(PageFallback) },
212
+ createElement(CmsRoute, props),
213
+ );
214
+
215
+ const generateMetadata: CatchAllRoute["generateMetadata"] = async ({ params }) => {
216
+ const resolved = await params;
217
+ const hit = resolve(resolved[CATCH_ALL_PARAM]);
218
+ if (!hit) return {};
219
+ const doc = await getCachedDoc(hit.locale, hit.type, hit.slug);
220
+ if (!doc) return {};
221
+ const title = doc.frontmatter.title;
222
+ return { title: typeof title === "string" ? title : hit.slug };
223
+ };
224
+
225
+ return { Page, CmsRoute, generateStaticParams, generateMetadata, resolve };
226
+ }