@capacms/sdk 1.0.0-next.2 → 1.0.0-next.4

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.
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_API_VERSION = exports.CAPA_ENV = exports.EDIT_CACHE_CONTROL = exports.DRAFT_COOKIE = exports.EDIT_HEADER = exports.EDIT_PARAM = exports.LAYOUT_PAGE = void 0;
3
4
  exports.withCache = withCache;
4
5
  exports.tagsFor = tagsFor;
5
6
  exports.revalidateFromWebhook = revalidateFromWebhook;
@@ -7,7 +8,16 @@ exports.draftClient = draftClient;
7
8
  exports.routeOf = routeOf;
8
9
  exports.preview = preview;
9
10
  exports.pagesFor = pagesFor;
11
+ exports.editMode = editMode;
12
+ exports.resolveEditRequest = resolveEditRequest;
13
+ exports.getPublishedClient = getPublishedClient;
14
+ exports.getCapaClient = getCapaClient;
15
+ exports.safeSitePath = safeSitePath;
16
+ exports.createPreviewRoute = createPreviewRoute;
17
+ exports.exitPreviewRoute = exitPreviewRoute;
18
+ exports.capaMiddleware = capaMiddleware;
10
19
  const next_1 = require("../next");
20
+ Object.defineProperty(exports, "LAYOUT_PAGE", { enumerable: true, get: function () { return next_1.LAYOUT_PAGE; } });
11
21
  /** Add Next.js fetch-cache options without importing `next/*`. */
12
22
  function withCache(fetchImpl, options) {
13
23
  return (async (input, init = {}) => {
@@ -64,6 +74,16 @@ async function draftClient(input) {
64
74
  * src/app/(marketing)/pricing/page.tsx -> /pricing
65
75
  * pages/blog/[slug].tsx -> /blog/[slug]
66
76
  * app/page.tsx -> /
77
+ * app/layout.tsx, app/blog/template.tsx -> (layout)
78
+ *
79
+ * A LAYOUT IS NOT A PAGE. `layout.*` and `template.*` render around every page
80
+ * below them, and a layout is not told which one: Next hands it no pathname.
81
+ * So its reads cannot be charged to the page being rendered, and charging them
82
+ * to the route its own file sits at is wrong: a root layout's Site singleton
83
+ * and nav would all be recorded as reads of `/`. `routeOf` returns
84
+ * `LAYOUT_PAGE` (`"(layout)"`) for these files instead, and a read that names
85
+ * it sends no `Capa-Page` at all. Pass `routeOf(import.meta.url)` from a layout
86
+ * exactly as from a page and it does the right thing.
67
87
  *
68
88
  * WHAT IS DROPPED, and why each one:
69
89
  *
@@ -74,8 +94,7 @@ async function draftClient(input) {
74
94
  * parallel slots `@modal` the same
75
95
  * the `(.)` intercept marker an intercepting route at the SAME
76
96
  * level renders the segment beside it
77
- * `page.*`, `layout.*`, `route.*`, leaf files, not segments
78
- * `default.*`, `template.*`
97
+ * `page.*`, `route.*`, `default.*` leaf files, not segments
79
98
  * `index` in the pages router the folder IS the route
80
99
  * the extension never in a URL
81
100
  *
@@ -87,7 +106,9 @@ async function draftClient(input) {
87
106
  * case the message says to pass the page string yourself.
88
107
  */
89
108
  const ROUTER_ROOTS = new Set(["app", "pages"]);
90
- const LEAF_FILES = new Set(["page", "layout", "route", "default", "template"]);
109
+ const LEAF_FILES = new Set(["page", "route", "default"]);
110
+ /** Files that render around many routes, so they name none. */
111
+ const LAYOUT_FILES = new Set(["layout", "template"]);
91
112
  /** `(..)photo`, `(..)(..)photo`, `(...)photo`: the URL is not here. */
92
113
  const OUTER_INTERCEPT = /^(\(\.\.\.\)|(\(\.\.\))+)/;
93
114
  function routeOf(file) {
@@ -131,6 +152,9 @@ function routeOf(file) {
131
152
  const dot = part.lastIndexOf(".");
132
153
  if (dot > 0)
133
154
  part = part.slice(0, dot);
155
+ // Only in the app router: `pages/layout.tsx` is a page at `/layout`.
156
+ if (parts[rootIndex] === "app" && LAYOUT_FILES.has(part))
157
+ return next_1.LAYOUT_PAGE;
134
158
  if (LEAF_FILES.has(part))
135
159
  continue;
136
160
  // The pages router: `pages/blog/index.tsx` is `/blog`.
@@ -176,3 +200,217 @@ async function preview(token, client) {
176
200
  function pagesFor(client) {
177
201
  return client.pages;
178
202
  }
203
+ // -------------------------------------------------------------- edit mode ---
204
+ /**
205
+ * The query parameter that turns edit mode on for one request without draft
206
+ * content: `?capa-edit=<token>`, where the token is a Capa preview token. The
207
+ * Capa editor's Published view sends it so the published page is still
208
+ * clickable. It never switches the site to draft data.
209
+ */
210
+ exports.EDIT_PARAM = "capa-edit";
211
+ /**
212
+ * The request header `resolveEditRequest` sets once a `capa-edit` token has
213
+ * been verified, for `editMode()` to read. Any copy a browser sent is removed
214
+ * first, so it cannot be forged from outside.
215
+ */
216
+ exports.EDIT_HEADER = "x-capa-edit";
217
+ /** The cookie Next's `draftMode().enable()` sets. */
218
+ exports.DRAFT_COOKIE = "__prerender_bypass";
219
+ /** What every edit-mode response must send: never cached, never shared. */
220
+ exports.EDIT_CACHE_CONTROL = "private, no-store";
221
+ /**
222
+ * Whether this request renders in edit mode: Next draft mode is on, or the
223
+ * request carried a verified `capa-edit` token (see `resolveEditRequest`).
224
+ *
225
+ * Pass Next's own functions; this package imports nothing from `next`:
226
+ *
227
+ * import { draftMode, headers } from "next/headers";
228
+ * const edit = await editMode({ draftMode, headers });
229
+ * const client = createClient({ ...config, editMode: edit });
230
+ *
231
+ * A client built with `editMode: edit` marks what it reads, and `capaAttrs`
232
+ * then tags those entries and only those.
233
+ */
234
+ async function editMode(input) {
235
+ const [draft, headers] = await Promise.all([input.draftMode(), input.headers()]);
236
+ return draft.isEnabled === true || headers.get(exports.EDIT_HEADER) === "1";
237
+ }
238
+ /**
239
+ * The middleware half of edit mode.
240
+ *
241
+ * export async function middleware(request: NextRequest) {
242
+ * const edit = await resolveEditRequest(request, publishedClient());
243
+ * const response = NextResponse.next({ request: { headers: edit.headers } });
244
+ * if (edit.cacheControl) response.headers.set("Cache-Control", edit.cacheControl);
245
+ * return response;
246
+ * }
247
+ *
248
+ * The token is checked with Capa (`client.preview`), exactly as a preview link
249
+ * is: the site never holds the signing key. A bad, expired or unverifiable
250
+ * token means not in edit mode; it never throws, because a broken edit link
251
+ * must still render the public page.
252
+ */
253
+ async function resolveEditRequest(request, client) {
254
+ const headers = new Headers(request.headers);
255
+ headers.delete(exports.EDIT_HEADER);
256
+ const token = new URL(String(request.url)).searchParams.get(exports.EDIT_PARAM);
257
+ let verified = false;
258
+ if (token) {
259
+ try {
260
+ verified = (await client.preview(token)) !== null;
261
+ }
262
+ catch {
263
+ verified = false;
264
+ }
265
+ }
266
+ if (verified)
267
+ headers.set(exports.EDIT_HEADER, "1");
268
+ const draft = request.cookies?.has(exports.DRAFT_COOKIE) ??
269
+ (headers.get("cookie") ?? "").split(/;\s*/).some((c) => c.startsWith(`${exports.DRAFT_COOKIE}=`));
270
+ const edit = verified || draft;
271
+ return { edit, verified, headers, cacheControl: edit ? exports.EDIT_CACHE_CONTROL : null };
272
+ }
273
+ // ------------------------------------------------- five-minute integration ---
274
+ /** Where the env-driven helpers read their settings (M6). */
275
+ exports.CAPA_ENV = {
276
+ baseUrl: "CAPA_API_URL",
277
+ apiKey: "CAPA_KEY",
278
+ draftKey: "CAPA_DRAFT_KEY",
279
+ version: "CAPA_API_VERSION",
280
+ };
281
+ exports.DEFAULT_API_VERSION = "2026-10-01";
282
+ function readEnv(name) {
283
+ const env = globalThis.process?.env;
284
+ const value = env?.[name];
285
+ return value === undefined || value === "" ? undefined : value;
286
+ }
287
+ function requireEnv(name) {
288
+ const value = readEnv(name);
289
+ if (!value)
290
+ throw new Error(`@capacms/sdk/nextjs: ${name} is not set.`);
291
+ return value;
292
+ }
293
+ /** The published-key client from env: what verifying a token needs. */
294
+ function getPublishedClient(overrides = {}) {
295
+ return (0, next_1.createClient)({
296
+ baseUrl: requireEnv(exports.CAPA_ENV.baseUrl),
297
+ apiKey: requireEnv(exports.CAPA_ENV.apiKey),
298
+ version: readEnv(exports.CAPA_ENV.version) ?? exports.DEFAULT_API_VERSION,
299
+ ...overrides,
300
+ });
301
+ }
302
+ /**
303
+ * The client for this request, from env: the draft key under draft mode,
304
+ * otherwise the published key, and `editMode` worked out for you.
305
+ *
306
+ * import { draftMode, headers } from "next/headers";
307
+ * const capa = await getCapaClient({ draftMode, headers });
308
+ */
309
+ async function getCapaClient(input) {
310
+ const draft = (await input.draftMode()).isEnabled === true;
311
+ const edit = await editMode({ draftMode: input.draftMode, headers: input.headers });
312
+ return (0, next_1.createClient)({
313
+ baseUrl: requireEnv(exports.CAPA_ENV.baseUrl),
314
+ apiKey: requireEnv(draft ? exports.CAPA_ENV.draftKey : exports.CAPA_ENV.apiKey),
315
+ version: readEnv(exports.CAPA_ENV.version) ?? exports.DEFAULT_API_VERSION,
316
+ editMode: edit,
317
+ ...input.config,
318
+ });
319
+ }
320
+ /** Only a path on this site: never `//elsewhere.example` or a full URL. */
321
+ function safeSitePath(value) {
322
+ if (!value || !value.startsWith("/") || value.startsWith("//"))
323
+ return "/";
324
+ return value;
325
+ }
326
+ /**
327
+ * `app/api/capa/preview/route.ts`:
328
+ *
329
+ * import { draftMode } from "next/headers";
330
+ * import { redirect } from "next/navigation";
331
+ * export const GET = createPreviewRoute({ draftMode, redirect });
332
+ *
333
+ * Checks the token with Capa (the site never holds the signing key), turns
334
+ * draft mode on and lands on the entry's page. A bad or expired token lands on
335
+ * the page without draft mode and `?preview=expired`; Capa unreachable gives
336
+ * `?preview=unavailable`.
337
+ */
338
+ function createPreviewRoute(input) {
339
+ return async (request) => {
340
+ const url = new URL(request.url);
341
+ const token = url.searchParams.get("token") ?? url.searchParams.get("capa-preview") ?? "";
342
+ // Reached two ways: directly, or through the middleware's rewrite of a page
343
+ // URL carrying `?capa-preview=`, where Next may hand over the ORIGINAL URL.
344
+ // Then the page itself is the path.
345
+ const path = safeSitePath(url.searchParams.get("path") ?? (url.searchParams.has("capa-preview") ? url.pathname : null));
346
+ let claim = null;
347
+ let failed = false;
348
+ try {
349
+ claim = await (input.client ?? getPublishedClient)().preview(token);
350
+ }
351
+ catch {
352
+ failed = true;
353
+ }
354
+ const draft = await input.draftMode();
355
+ if (!claim) {
356
+ draft.disable?.();
357
+ return input.redirect(`${path}?preview=${failed ? "unavailable" : "expired"}`);
358
+ }
359
+ draft.enable?.();
360
+ await input.onEnable?.();
361
+ return input.redirect(safeSitePath(claim.path ?? path));
362
+ };
363
+ }
364
+ /** `app/api/capa/exit/route.ts`: `export const GET = exitPreviewRoute({ draftMode, redirect });` */
365
+ function exitPreviewRoute(input) {
366
+ return async (request) => {
367
+ (await input.draftMode()).disable?.();
368
+ return input.redirect(safeSitePath(new URL(request.url).searchParams.get("path")));
369
+ };
370
+ }
371
+ /**
372
+ * `middleware.ts` in one line:
373
+ *
374
+ * import { NextResponse } from "next/server";
375
+ * export const middleware = capaMiddleware({ NextResponse });
376
+ *
377
+ * - `?capa-preview=<token>` on any page goes to `previewRoute`, which turns
378
+ * draft mode on and comes back;
379
+ * - `?capa-view=published` renders without the draft cookie, for the editor's
380
+ * Published view;
381
+ * - `?capa-edit=<token>` turns edit mode on (ids and overlay, published data);
382
+ * - every edit-mode response is `private, no-store`.
383
+ */
384
+ function capaMiddleware(input) {
385
+ const previewRoute = input.previewRoute ?? "/api/capa/preview";
386
+ return async (request) => {
387
+ const token = request.nextUrl.searchParams.get("capa-preview");
388
+ if (token) {
389
+ const target = request.nextUrl.clone();
390
+ target.pathname = previewRoute;
391
+ target.search = "";
392
+ target.searchParams.set("token", token);
393
+ target.searchParams.set("path", request.nextUrl.pathname);
394
+ return input.NextResponse.rewrite(target);
395
+ }
396
+ const headers = new Headers(request.headers);
397
+ if (request.nextUrl.searchParams.get("capa-view") === "published") {
398
+ const cookies = request.cookies
399
+ .getAll()
400
+ .filter((cookie) => cookie.name !== exports.DRAFT_COOKIE)
401
+ .map((cookie) => `${cookie.name}=${encodeURIComponent(cookie.value)}`)
402
+ .join("; ");
403
+ if (cookies)
404
+ headers.set("cookie", cookies);
405
+ else
406
+ headers.delete("cookie");
407
+ }
408
+ // The client is built only when a token needs checking, so a missing env
409
+ // value cannot break every page of the site.
410
+ const edit = await resolveEditRequest({ url: request.url, headers }, { preview: (t) => (input.client ?? getPublishedClient)().preview(t) });
411
+ const response = input.NextResponse.next({ request: { headers: edit.headers } });
412
+ if (edit.cacheControl)
413
+ response.headers.set("Cache-Control", edit.cacheControl);
414
+ return response;
415
+ };
416
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capacms/sdk",
3
- "version": "1.0.0-next.2",
3
+ "version": "1.0.0-next.4",
4
4
  "license": "UNLICENSED",
5
5
  "repository": {
6
6
  "type": "git",
@@ -71,6 +71,6 @@
71
71
  "scripts": {
72
72
  "build": "tsc -p tsconfig.json",
73
73
  "typecheck": "tsc -p tsconfig.json --noEmit",
74
- "test": "tsc -p tsconfig.json && node --test test/codegen.test.js test/client.test.js test/next-client.test.js test/nextjs.test.js test/webhooks.test.js test/attrs.test.js test/overlay.test.js && tsc -p test/types/tsconfig.consumer.json --noEmit"
74
+ "test": "tsc -p tsconfig.json && node --test test/codegen.test.js test/client.test.js test/next-client.test.js test/nextjs.test.js test/webhooks.test.js test/attrs.test.js test/overlay.test.js test/inflate.test.js && tsc -p test/types/tsconfig.consumer.json --noEmit"
75
75
  }
76
76
  }