@capacms/sdk 1.0.0-next.1 → 1.0.0-next.10

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.
Files changed (92) hide show
  1. package/CHANGELOG.md +450 -0
  2. package/README.md +1698 -193
  3. package/bin/capa-codegen.js +192 -5
  4. package/bin/capa.js +235 -0
  5. package/bin/graphql-project.js +142 -0
  6. package/bin/project-env.js +58 -0
  7. package/dist/client.d.ts +5 -0
  8. package/dist/client.js +17 -0
  9. package/dist/codegen.d.ts +55 -0
  10. package/dist/codegen.js +320 -39
  11. package/dist/config.d.ts +5 -36
  12. package/dist/config.js +47 -1
  13. package/dist/esm/image/index.d.ts +120 -0
  14. package/dist/esm/image/index.js +250 -0
  15. package/dist/esm/image/shared-params.generated.d.ts +190 -0
  16. package/dist/esm/image/shared-params.generated.js +461 -0
  17. package/dist/esm/nextjs/image-loader.d.ts +60 -0
  18. package/dist/esm/nextjs/image-loader.js +67 -0
  19. package/dist/esm/nextjs/overlay.d.ts +30 -0
  20. package/dist/esm/nextjs/overlay.js +75 -0
  21. package/dist/esm/overlay/index.d.ts +32 -0
  22. package/dist/esm/overlay/index.js +576 -0
  23. package/dist/esm/overlay/protocol.d.ts +187 -0
  24. package/dist/esm/overlay/protocol.js +240 -0
  25. package/dist/esm/package.json +4 -0
  26. package/dist/graphql-codegen.d.ts +117 -0
  27. package/dist/graphql-codegen.js +705 -0
  28. package/dist/http.js +1 -1
  29. package/dist/image/index.d.ts +120 -0
  30. package/dist/image/index.js +257 -0
  31. package/dist/image/shared-params.generated.d.ts +190 -0
  32. package/dist/image/shared-params.generated.js +471 -0
  33. package/dist/index.d.ts +2 -2
  34. package/dist/index.js +2 -1
  35. package/dist/next/attrs.d.ts +84 -10
  36. package/dist/next/attrs.js +119 -2
  37. package/dist/next/client.d.ts +176 -32
  38. package/dist/next/client.js +212 -90
  39. package/dist/next/entry-fields.d.ts +162 -0
  40. package/dist/next/entry-fields.js +2 -0
  41. package/dist/next/errors.d.ts +136 -0
  42. package/dist/next/errors.js +214 -0
  43. package/dist/next/field-names.d.ts +37 -0
  44. package/dist/next/field-names.js +145 -0
  45. package/dist/next/graphql/build.d.ts +27 -0
  46. package/dist/next/graphql/build.js +98 -0
  47. package/dist/next/graphql/documents.d.ts +67 -0
  48. package/dist/next/graphql/documents.js +35 -0
  49. package/dist/next/graphql/edit-mode.d.ts +16 -0
  50. package/dist/next/graphql/edit-mode.js +93 -0
  51. package/dist/next/graphql/filter-values.d.ts +34 -0
  52. package/dist/next/graphql/filter-values.js +96 -0
  53. package/dist/next/graphql/introspection.d.ts +89 -0
  54. package/dist/next/graphql/introspection.js +102 -0
  55. package/dist/next/graphql/plan.d.ts +115 -0
  56. package/dist/next/graphql/plan.js +531 -0
  57. package/dist/next/graphql/request.d.ts +228 -0
  58. package/dist/next/graphql/request.js +283 -0
  59. package/dist/next/graphql/rest.d.ts +66 -0
  60. package/dist/next/graphql/rest.js +502 -0
  61. package/dist/next/graphql/selection.d.ts +55 -0
  62. package/dist/next/graphql/selection.js +212 -0
  63. package/dist/next/graphql/sha256.d.ts +13 -0
  64. package/dist/next/graphql/sha256.js +86 -0
  65. package/dist/next/graphql/summary.d.ts +83 -0
  66. package/dist/next/graphql/summary.js +151 -0
  67. package/dist/next/graphql/tree-layout.d.ts +36 -0
  68. package/dist/next/graphql/tree-layout.js +20 -0
  69. package/dist/next/graphql/tree.d.ts +171 -0
  70. package/dist/next/graphql/tree.js +249 -0
  71. package/dist/next/graphql/typed.d.ts +261 -0
  72. package/dist/next/graphql/typed.js +146 -0
  73. package/dist/next/index.d.ts +30 -5
  74. package/dist/next/index.js +32 -1
  75. package/dist/next/inflate.d.ts +51 -0
  76. package/dist/next/inflate.js +243 -0
  77. package/dist/next/key-family.d.ts +31 -0
  78. package/dist/next/key-family.js +66 -0
  79. package/dist/next/select-types.d.ts +58 -5
  80. package/dist/next/system-keys.d.ts +27 -0
  81. package/dist/next/system-keys.js +42 -0
  82. package/dist/nextjs/image-loader.d.ts +60 -0
  83. package/dist/nextjs/image-loader.js +71 -0
  84. package/dist/nextjs/index.d.ts +484 -5
  85. package/dist/nextjs/index.js +688 -6
  86. package/dist/nextjs/overlay.d.ts +30 -0
  87. package/dist/nextjs/overlay.js +78 -0
  88. package/dist/overlay/index.d.ts +14 -2
  89. package/dist/overlay/index.js +282 -43
  90. package/dist/overlay/protocol.d.ts +98 -2
  91. package/dist/overlay/protocol.js +151 -4
  92. package/package.json +63 -15
@@ -1,13 +1,34 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DRAFT_COOKIE_MAX_AGE = exports.DRAFT_ROBOTS_TAG = exports.CAPA_ADMIN_ORIGIN = exports.DEFAULT_API_VERSION = exports.CAPA_ENV_ALIASES = exports.CAPA_ENV = exports.EDIT_CACHE_CONTROL = exports.DRAFT_COOKIE = exports.EDIT_HEADER = exports.VIEW_PARAM = exports.PREVIEW_PARAM = exports.EDIT_PARAM = exports.createCapaImageLoader = exports.capaImageLoader = exports.LAYOUT_PAGE = exports.gql = exports.MEDIA_TAG = exports.GRAPHQL_TAG = void 0;
3
4
  exports.withCache = withCache;
5
+ exports.modelTag = modelTag;
4
6
  exports.tagsFor = tagsFor;
5
7
  exports.revalidateFromWebhook = revalidateFromWebhook;
6
8
  exports.draftClient = draftClient;
7
9
  exports.routeOf = routeOf;
8
10
  exports.preview = preview;
9
11
  exports.pagesFor = pagesFor;
12
+ exports.editMode = editMode;
13
+ exports.resolveEditRequest = resolveEditRequest;
14
+ exports.getPublishedClient = getPublishedClient;
15
+ exports.getCapaClient = getCapaClient;
16
+ exports.graphql = graphql;
17
+ exports.safeSitePath = safeSitePath;
18
+ exports.frameAncestors = frameAncestors;
19
+ exports.draftHeaders = draftHeaders;
20
+ exports.capaHeaders = capaHeaders;
21
+ exports.frameDraftCookie = frameDraftCookie;
22
+ exports.clearDraftCookie = clearDraftCookie;
23
+ exports.createPreviewRoute = createPreviewRoute;
24
+ exports.exitPreviewRoute = exitPreviewRoute;
25
+ exports.capaMiddleware = capaMiddleware;
10
26
  const next_1 = require("../next");
27
+ Object.defineProperty(exports, "gql", { enumerable: true, get: function () { return next_1.gql; } });
28
+ Object.defineProperty(exports, "LAYOUT_PAGE", { enumerable: true, get: function () { return next_1.LAYOUT_PAGE; } });
29
+ const client_1 = require("../next/client");
30
+ const sha256_1 = require("../next/graphql/sha256");
31
+ const key_family_1 = require("../next/key-family");
11
32
  /** Add Next.js fetch-cache options without importing `next/*`. */
12
33
  function withCache(fetchImpl, options) {
13
34
  return (async (input, init = {}) => {
@@ -21,6 +42,39 @@ function withCache(fetchImpl, options) {
21
42
  return fetchImpl(input, { ...init, next });
22
43
  });
23
44
  }
45
+ /**
46
+ * The Next.js cache tag for every read of a model, by its namespace. A GraphQL
47
+ * query names its models by namespace (`articles`), not by id, so this is the
48
+ * tag a GraphQL read is cached under, and `revalidateFromWebhook` revalidates
49
+ * it when an entry of that model changes.
50
+ */
51
+ function modelTag(namespace) {
52
+ return `capa:model:${namespace}`;
53
+ }
54
+ /**
55
+ * The tag `graphql()` keeps a read under when it gives a `revalidate` and no
56
+ * `tags`, and that `revalidateFromWebhook` revalidates on every content
57
+ * change: such a page is never stale after a publish, at the price of
58
+ * refreshing on any publish. Name the models it reads with
59
+ * `tagsFor({ namespace })` to refresh it only when one of those changes. A
60
+ * read with neither `tags` nor `revalidate` is not kept at all.
61
+ */
62
+ exports.GRAPHQL_TAG = "capa:graphql";
63
+ /**
64
+ * The tag `tagsFor({ namespace })` adds beside its model tags, and that
65
+ * `revalidateFromWebhook` revalidates on a media event: a GraphQL read shows
66
+ * a file's URL and alt text whichever models it names, so editing a file in
67
+ * the media library refreshes it.
68
+ */
69
+ exports.MEDIA_TAG = "capa:media";
70
+ /**
71
+ * Next.js cache tags for a read. `model`, `entry`, `key` and `tenant` are the
72
+ * API's surrogate keys (`m:`, `e:`, `k:`, `t:`), by id; `namespace` is one
73
+ * `capa:model:<namespace>` tag per model a GraphQL query reads, which
74
+ * `capa-codegen --graphql` lists as `<Name>Models`, and `MEDIA_TAG`, since
75
+ * the query may show a file from the media library. Each pairs with
76
+ * `revalidateFromWebhook`.
77
+ */
24
78
  function tagsFor(input) {
25
79
  const tags = [];
26
80
  if (input.model)
@@ -31,9 +85,26 @@ function tagsFor(input) {
31
85
  tags.push(`k:${input.key}`);
32
86
  if (input.tenant)
33
87
  tags.push(`t:${input.tenant}`);
88
+ const namespaces = typeof input.namespace === "string" ? [input.namespace] : (input.namespace ?? []);
89
+ for (const namespace of namespaces)
90
+ if (namespace)
91
+ tags.push(modelTag(namespace));
92
+ if (namespaces.some(Boolean))
93
+ tags.push(exports.MEDIA_TAG);
34
94
  return tags;
35
95
  }
36
- /** Revalidate the concrete entry and model identities carried by a webhook. */
96
+ /**
97
+ * Revalidate every tag a webhook's entry and model can be cached under: the
98
+ * entry (`e:`), the model by id (`m:`) and by namespace (`capa:model:`, the
99
+ * tag a GraphQL read uses). The namespace is `data.modelNamespace` on
100
+ * `instance.published`, `instance.unpublished` and `model.published`, and
101
+ * `data.namespace` on the other events (docs/WEBHOOKS.md). Any of those, and
102
+ * a `media.` event, also revalidates `GRAPHQL_TAG`, the tag of a `graphql()`
103
+ * read that named no tags. A `media.` event (a file's alt text, name or
104
+ * visibility changed, or the file went) revalidates the file's own key
105
+ * (`f:<fileId>`, as REST's `Surrogate-Key` names it) and `MEDIA_TAG`, which
106
+ * every read tagged by namespace carries, since it may show that file.
107
+ */
37
108
  async function revalidateFromWebhook(input) {
38
109
  const data = input.payload?.data ?? input.payload;
39
110
  const tags = [];
@@ -42,14 +113,34 @@ async function revalidateFromWebhook(input) {
42
113
  tags.push(`e:${entryId}`);
43
114
  if (typeof data?.modelId === "string" && data.modelId)
44
115
  tags.push(`m:${data.modelId}`);
116
+ const namespace = data?.modelNamespace ?? data?.namespace;
117
+ if (typeof namespace === "string" && namespace)
118
+ tags.push(modelTag(namespace));
119
+ const media = typeof input.payload?.type === "string" && input.payload.type.startsWith("media.");
120
+ if (media) {
121
+ if (typeof data?.fileId === "string" && data.fileId)
122
+ tags.push(`f:${data.fileId}`);
123
+ tags.push(exports.MEDIA_TAG);
124
+ }
125
+ if (tags.length > 0 || media)
126
+ tags.push(exports.GRAPHQL_TAG);
45
127
  const unique = [...new Set(tags)];
46
128
  for (const tag of unique)
47
129
  await input.revalidateTag(tag);
48
130
  return unique;
49
131
  }
50
- /** Select a client using server-only draft state supplied by the caller. */
132
+ /**
133
+ * Select a client using server-only draft state supplied by the caller. Pass
134
+ * codegen's `CapaQuery` to type the builder, as with `createClient`:
135
+ * `draftClient<CapaQuery>({ ... })`. `production` may hold the legacy key a
136
+ * site already has; `draft` needs a `cap_` key, and a legacy one throws a
137
+ * `TypeError` when draft mode selects it.
138
+ */
51
139
  async function draftClient(input) {
52
- return (0, next_1.createClient)((await input.isDraft()) ? input.draft : input.production);
140
+ if (!(await input.isDraft()))
141
+ return (0, next_1.createClient)(input.production);
142
+ (0, key_family_1.requireDraftKey)(input.draft?.apiKey, "the draft config");
143
+ return (0, next_1.createClient)(input.draft);
53
144
  }
54
145
  // ------------------------------------------------------------------ pages ---
55
146
  /**
@@ -64,6 +155,16 @@ async function draftClient(input) {
64
155
  * src/app/(marketing)/pricing/page.tsx -> /pricing
65
156
  * pages/blog/[slug].tsx -> /blog/[slug]
66
157
  * app/page.tsx -> /
158
+ * app/layout.tsx, app/blog/template.tsx -> (layout)
159
+ *
160
+ * A LAYOUT IS NOT A PAGE. `layout.*` and `template.*` render around every page
161
+ * below them, and a layout is not told which one: Next hands it no pathname.
162
+ * So its reads cannot be charged to the page being rendered, and charging them
163
+ * to the route its own file sits at is wrong: a root layout's Site singleton
164
+ * and nav would all be recorded as reads of `/`. `routeOf` returns
165
+ * `LAYOUT_PAGE` (`"(layout)"`) for these files instead, and a read that names
166
+ * it sends no `Capa-Page` at all. Pass `routeOf(import.meta.url)` from a layout
167
+ * exactly as from a page and it does the right thing.
67
168
  *
68
169
  * WHAT IS DROPPED, and why each one:
69
170
  *
@@ -74,8 +175,7 @@ async function draftClient(input) {
74
175
  * parallel slots `@modal` the same
75
176
  * the `(.)` intercept marker an intercepting route at the SAME
76
177
  * level renders the segment beside it
77
- * `page.*`, `layout.*`, `route.*`, leaf files, not segments
78
- * `default.*`, `template.*`
178
+ * `page.*`, `route.*`, `default.*` leaf files, not segments
79
179
  * `index` in the pages router the folder IS the route
80
180
  * the extension never in a URL
81
181
  *
@@ -87,7 +187,9 @@ async function draftClient(input) {
87
187
  * case the message says to pass the page string yourself.
88
188
  */
89
189
  const ROUTER_ROOTS = new Set(["app", "pages"]);
90
- const LEAF_FILES = new Set(["page", "layout", "route", "default", "template"]);
190
+ const LEAF_FILES = new Set(["page", "route", "default"]);
191
+ /** Files that render around many routes, so they name none. */
192
+ const LAYOUT_FILES = new Set(["layout", "template"]);
91
193
  /** `(..)photo`, `(..)(..)photo`, `(...)photo`: the URL is not here. */
92
194
  const OUTER_INTERCEPT = /^(\(\.\.\.\)|(\(\.\.\))+)/;
93
195
  function routeOf(file) {
@@ -131,6 +233,9 @@ function routeOf(file) {
131
233
  const dot = part.lastIndexOf(".");
132
234
  if (dot > 0)
133
235
  part = part.slice(0, dot);
236
+ // Only in the app router: `pages/layout.tsx` is a page at `/layout`.
237
+ if (parts[rootIndex] === "app" && LAYOUT_FILES.has(part))
238
+ return next_1.LAYOUT_PAGE;
134
239
  if (LEAF_FILES.has(part))
135
240
  continue;
136
241
  // The pages router: `pages/blog/index.tsx` is `/blog`.
@@ -176,3 +281,580 @@ async function preview(token, client) {
176
281
  function pagesFor(client) {
177
282
  return client.pages;
178
283
  }
284
+ // next/image: let Capa's CDN resize. `@capacms/sdk/nextjs/image-loader` is the
285
+ // same loader as a default export, the entry to name in `images.loaderFile`.
286
+ var image_loader_1 = require("./image-loader");
287
+ Object.defineProperty(exports, "capaImageLoader", { enumerable: true, get: function () { return image_loader_1.capaImageLoader; } });
288
+ Object.defineProperty(exports, "createCapaImageLoader", { enumerable: true, get: function () { return image_loader_1.createCapaImageLoader; } });
289
+ // -------------------------------------------------------------- edit mode ---
290
+ /**
291
+ * The query parameter that turns edit mode on for one request without draft
292
+ * content: `?capa-edit=<token>`, where the token is a Capa preview token. The
293
+ * Capa editor's Published view sends it so the published page is still
294
+ * clickable. It never switches the site to draft data.
295
+ */
296
+ exports.EDIT_PARAM = "capa-edit";
297
+ /** The query parameter a Capa preview link carries: `?capa-preview=<token>`. */
298
+ exports.PREVIEW_PARAM = "capa-preview";
299
+ /** The query parameter of the editor's Published view: `?capa-view=published`. */
300
+ exports.VIEW_PARAM = "capa-view";
301
+ /**
302
+ * The request header `resolveEditRequest` sets once a `capa-edit` token has
303
+ * been verified, for `editMode()` to read. Any copy a browser sent is removed
304
+ * first, so it cannot be forged from outside.
305
+ */
306
+ exports.EDIT_HEADER = "x-capa-edit";
307
+ /** The cookie Next's `draftMode().enable()` sets. */
308
+ exports.DRAFT_COOKIE = "__prerender_bypass";
309
+ /** What every edit-mode response must send: never cached, never shared. */
310
+ exports.EDIT_CACHE_CONTROL = "private, no-store";
311
+ /**
312
+ * Whether this request renders in edit mode: Next draft mode is on, or the
313
+ * request carried a verified `capa-edit` token (see `resolveEditRequest`).
314
+ *
315
+ * Pass Next's own functions; this package imports nothing from `next`:
316
+ *
317
+ * import { draftMode, headers } from "next/headers";
318
+ * const edit = await editMode({ draftMode, headers });
319
+ * const client = createClient({ ...config, editMode: edit });
320
+ *
321
+ * A client built with `editMode: edit` marks what it reads, and `capaAttrs`
322
+ * then tags those entries and only those.
323
+ */
324
+ async function editMode(input) {
325
+ const [draft, headers] = await Promise.all([input.draftMode(), input.headers()]);
326
+ return draft.isEnabled === true || verifiedEdit(headers);
327
+ }
328
+ /** Whether `resolveEditRequest` verified a `capa-edit` token for this request. */
329
+ function verifiedEdit(headers) {
330
+ return headers.get(exports.EDIT_HEADER) === "1";
331
+ }
332
+ /**
333
+ * The middleware half of edit mode.
334
+ *
335
+ * export async function middleware(request: NextRequest) {
336
+ * const edit = await resolveEditRequest(request, publishedClient());
337
+ * const response = NextResponse.next({ request: { headers: edit.headers } });
338
+ * if (edit.cacheControl) response.headers.set("Cache-Control", edit.cacheControl);
339
+ * if (edit.robotsTag) response.headers.set("X-Robots-Tag", edit.robotsTag);
340
+ * return response;
341
+ * }
342
+ *
343
+ * The token is checked with Capa (`client.preview`), exactly as a preview link
344
+ * is: the site never holds the signing key. A bad, expired or unverifiable
345
+ * token means not in edit mode; it never throws, because a broken edit link
346
+ * must still render the public page.
347
+ */
348
+ async function resolveEditRequest(request, client) {
349
+ const headers = new Headers(request.headers);
350
+ headers.delete(exports.EDIT_HEADER);
351
+ const token = new URL(String(request.url)).searchParams.get(exports.EDIT_PARAM);
352
+ let verified = false;
353
+ if (token) {
354
+ try {
355
+ verified = (await client.preview(token)) !== null;
356
+ }
357
+ catch {
358
+ verified = false;
359
+ }
360
+ }
361
+ if (verified)
362
+ headers.set(exports.EDIT_HEADER, "1");
363
+ const draft = request.cookies?.has(exports.DRAFT_COOKIE) ??
364
+ (headers.get("cookie") ?? "").split(/;\s*/).some((c) => c.startsWith(`${exports.DRAFT_COOKIE}=`));
365
+ const edit = verified || draft;
366
+ return {
367
+ edit,
368
+ verified,
369
+ headers,
370
+ cacheControl: edit ? exports.EDIT_CACHE_CONTROL : null,
371
+ robotsTag: edit ? exports.DRAFT_ROBOTS_TAG : null,
372
+ };
373
+ }
374
+ // ------------------------------------------------- five-minute integration ---
375
+ /**
376
+ * Where the env-driven helpers read their settings (M6): the one pair of
377
+ * names the whole product uses (the MCP server, `capa-codegen`, `capa
378
+ * persist`, every curl example in the API docs).
379
+ */
380
+ exports.CAPA_ENV = {
381
+ baseUrl: "CAPA_API_URL",
382
+ apiKey: "CAPA_KEY",
383
+ draftKey: "CAPA_DRAFT_KEY",
384
+ version: "CAPA_API_VERSION",
385
+ };
386
+ /** Older names that still work, read only when the name above is unset. */
387
+ exports.CAPA_ENV_ALIASES = {
388
+ CAPA_API_URL: "CAPA_BASE_URL",
389
+ CAPA_KEY: "CAPA_API_KEY",
390
+ };
391
+ exports.DEFAULT_API_VERSION = "2026-10-01";
392
+ function readOne(name) {
393
+ const env = globalThis.process?.env;
394
+ const value = env?.[name];
395
+ return value === undefined || value === "" ? undefined : value;
396
+ }
397
+ function readEnv(name) {
398
+ const alias = exports.CAPA_ENV_ALIASES[name];
399
+ return readOne(name) ?? (alias ? readOne(alias) : undefined);
400
+ }
401
+ function requireEnv(name) {
402
+ const value = readEnv(name);
403
+ if (value)
404
+ return value;
405
+ const alias = exports.CAPA_ENV_ALIASES[name];
406
+ throw new Error(`@capacms/sdk/nextjs: ${name} is not set${alias ? ` (${alias} also works)` : ""}.`);
407
+ }
408
+ /**
409
+ * A client config from env, `key` naming the env var for the key. A setting
410
+ * `config` gives is used as given and its env var is never read, so a full
411
+ * `config` needs no env at all.
412
+ */
413
+ function envConfig(config, key) {
414
+ return {
415
+ ...config,
416
+ baseUrl: config?.baseUrl ?? requireEnv(exports.CAPA_ENV.baseUrl),
417
+ apiKey: config?.apiKey ?? requireEnv(key),
418
+ version: config?.version ?? readEnv(exports.CAPA_ENV.version) ?? exports.DEFAULT_API_VERSION,
419
+ };
420
+ }
421
+ /** The published or the draft config from env; the draft key must be a `cap_` key. */
422
+ function modeConfig(config, draft) {
423
+ if (!draft)
424
+ return envConfig(config, exports.CAPA_ENV.apiKey);
425
+ const settings = envConfig(config, exports.CAPA_ENV.draftKey);
426
+ (0, key_family_1.requireDraftKey)(settings.apiKey, config?.apiKey !== undefined ? "config.apiKey" : exports.CAPA_ENV.draftKey);
427
+ return settings;
428
+ }
429
+ /**
430
+ * The published-key client from env: what verifying a token needs. `overrides`
431
+ * win over env, and pass codegen's `CapaQuery` to type the builder:
432
+ * `getPublishedClient<CapaQuery>()`.
433
+ */
434
+ function getPublishedClient(overrides = {}) {
435
+ return (0, next_1.createClient)(envConfig(overrides, exports.CAPA_ENV.apiKey));
436
+ }
437
+ /**
438
+ * The client for this request, from env: the draft key under draft mode,
439
+ * otherwise the published key, and `editMode` worked out for you.
440
+ *
441
+ * import { draftMode, headers } from "next/headers";
442
+ * const capa = await getCapaClient<CapaQuery>({ draftMode, headers });
443
+ * const { data } = await capa.graphql.query(selection, { tags: tagsFor({ namespace: "articles" }) });
444
+ *
445
+ * `CapaQuery` (from `capa-codegen --graphql`) types the builder, as with
446
+ * `createClient`; leave it out for an untyped client. `config` wins over env.
447
+ *
448
+ * Its REST reads are sent as `createClient` sends them, which Next does not
449
+ * keep. Its GraphQL reads, a document or the builder, are sent the same way
450
+ * unless a call gives `tags` or `revalidate`, so a publish shows up on both
451
+ * alike. Given either, a read is kept in Next's data cache exactly as
452
+ * `graphql()` keeps it: a published read with no errors, never a draft or an
453
+ * edit-mode page.
454
+ */
455
+ async function getCapaClient(input) {
456
+ const draft = (await input.draftMode()).isEnabled === true;
457
+ const edit = await editMode({ draftMode: input.draftMode, headers: input.headers });
458
+ const settings = modeConfig(input.config, draft);
459
+ const client = (0, next_1.createClient)({ editMode: edit, ...settings });
460
+ const read = { draft, edit, settings, given: input.unstable_cache };
461
+ const graphql = (0, client_1.graphqlClient)((document, variables, options = {}) => {
462
+ const { tags, revalidate, ...call } = options;
463
+ return readInNext(read, document, variables, call, { tags, revalidate });
464
+ });
465
+ return { ...client, graphql };
466
+ }
467
+ let loadedUnstableCache;
468
+ /**
469
+ * Next's own `unstable_cache`, loaded on first use, or null where `next/cache`
470
+ * cannot be loaded (Next not installed). The package never imports `next/*`
471
+ * at the top, so a Node script can import this file without Next.
472
+ */
473
+ function nextUnstableCache() {
474
+ if (loadedUnstableCache === undefined) {
475
+ try {
476
+ loadedUnstableCache = require("next/cache").unstable_cache ?? null;
477
+ }
478
+ catch {
479
+ loadedUnstableCache = null;
480
+ }
481
+ }
482
+ return loadedUnstableCache;
483
+ }
484
+ /** Thrown inside `unstable_cache` so that a result with errors is never stored; caught at once. */
485
+ class NotCached extends Error {
486
+ result;
487
+ constructor(result) {
488
+ super(`@capacms/sdk/nextjs: this GraphQL read answered with errors, so it was not cached: ${result.errors[0]?.message ?? "unknown error"}`);
489
+ this.result = result;
490
+ this.name = "NotCached";
491
+ }
492
+ }
493
+ /**
494
+ * What a cached read is keyed by: everything that changes its answer. The key
495
+ * is hashed, so the API key never reaches the cache's index.
496
+ */
497
+ async function cacheKeyOf(settings, document, variables, call) {
498
+ return [
499
+ "@capacms/sdk/nextjs graphql",
500
+ settings.baseUrl,
501
+ await (0, sha256_1.sha256Hex)(settings.apiKey),
502
+ settings.version ?? "",
503
+ String(settings.contract ?? ""),
504
+ settings.schemaChecksum ?? "",
505
+ document,
506
+ JSON.stringify(variables ?? null),
507
+ call.operationName ?? "",
508
+ call.persisted ? "persisted" : "",
509
+ call.method ?? "",
510
+ ];
511
+ }
512
+ async function graphql(document, variables, options = {}) {
513
+ const { tags, revalidate, draft: draftOption, draftMode, headers, config, unstable_cache: given, ...call } = options;
514
+ const draft = draftOption ?? (draftMode ? (await draftMode()).isEnabled === true : false);
515
+ const edit = draft || (headers ? verifiedEdit(await headers()) : false);
516
+ const read = { draft, edit, settings: modeConfig(config, draft), given };
517
+ return readInNext(read, document, variables, call, { tags, revalidate });
518
+ }
519
+ /**
520
+ * Whether a read said anything about Next's cache: tags to keep it under, or a
521
+ * `revalidate`. One that said neither is sent as the REST reads are, so a
522
+ * publish shows up on both the same way.
523
+ */
524
+ function namesCaching({ tags, revalidate }) {
525
+ return (tags !== undefined && tags.length > 0) || revalidate !== undefined;
526
+ }
527
+ /**
528
+ * One GraphQL read in Next, for `graphql()` and `getCapaClient`.
529
+ *
530
+ * With no `tags` and no `revalidate` it is sent exactly as `entries.list` and
531
+ * `entries.get` send theirs: a plain fetch with no `cache` and no `next`, which
532
+ * Next does not keep, so the page shows a publish on its next render whether
533
+ * it reads by REST or by GraphQL. Given `tags` or a `revalidate`, Next's data
534
+ * cache holds a published read with no errors, and the fetch under it is never
535
+ * cached. A draft, an edit-mode read (its entries carry marks a cached copy
536
+ * would lose) and `revalidate: 0` are not cached.
537
+ */
538
+ async function readInNext(read, document, variables, call, options) {
539
+ const { draft, edit, settings, given } = read;
540
+ if (!draft && !edit && !namesCaching(options)) {
541
+ return (0, next_1.createClient)({ editMode: false, ...settings }).graphql(document, variables, call);
542
+ }
543
+ // `tags: []` names no tag, so the read is kept under `GRAPHQL_TAG` like one
544
+ // with no tags: kept under none, nothing could ever revalidate it.
545
+ const tags = options.tags && options.tags.length > 0 ? options.tags : undefined;
546
+ const revalidate = options.revalidate;
547
+ const baseFetch = settings.fetch ?? globalThis.fetch;
548
+ const cache = given ?? nextUnstableCache();
549
+ if (cache && !draft) {
550
+ if (edit || revalidate === 0) {
551
+ return (0, next_1.createClient)({ editMode: edit, ...settings, fetch: withCache(baseFetch, { revalidate: 0 }) }).graphql(document, variables, call);
552
+ }
553
+ const client = (0, next_1.createClient)({ editMode: false, ...settings, fetch: baseFetch });
554
+ let sent = false;
555
+ const send = async () => {
556
+ sent = true;
557
+ const result = await client.graphql(document, variables, call);
558
+ if (result.errors.length > 0)
559
+ throw new NotCached(result);
560
+ return result;
561
+ };
562
+ const keyParts = await cacheKeyOf(settings, document, variables, call);
563
+ const cacheOptions = { tags: tags ?? [exports.GRAPHQL_TAG] };
564
+ if (revalidate !== undefined)
565
+ cacheOptions.revalidate = revalidate;
566
+ try {
567
+ return await cache(send, keyParts, cacheOptions)();
568
+ }
569
+ catch (error) {
570
+ if (error instanceof NotCached)
571
+ return error.result;
572
+ // Next's own `unstable_cache` throws before it reads anything outside a
573
+ // Next request (a script, a test, a build step with no cache): the read
574
+ // is then sent as below. One passed in throws as it did.
575
+ if (given || sent)
576
+ throw error;
577
+ }
578
+ }
579
+ const client = (0, next_1.createClient)({
580
+ editMode: edit,
581
+ ...settings,
582
+ fetch: draft
583
+ ? withCache(baseFetch, { revalidate: 0 })
584
+ : withCache(baseFetch, { tags: tags ?? [exports.GRAPHQL_TAG], revalidate }),
585
+ });
586
+ return client.graphql(document, variables, call);
587
+ }
588
+ /** Only a path on this site: never `//elsewhere.example` or a full URL. */
589
+ function safeSitePath(value) {
590
+ if (!value || !value.startsWith("/") || value.startsWith("//"))
591
+ return "/";
592
+ return value;
593
+ }
594
+ // -------------------------------------------------------- draft responses ---
595
+ /** The Capa admin's origin: what frames a draft in the editor, unless a site names another. */
596
+ exports.CAPA_ADMIN_ORIGIN = "https://app.capacms.com";
597
+ /** `X-Robots-Tag` on every draft and edit-mode response: a draft is never indexed, nor its links followed. */
598
+ exports.DRAFT_ROBOTS_TAG = "noindex, nofollow";
599
+ /** How long the draft cookie lasts, in seconds: one hour, as a preview token does. */
600
+ exports.DRAFT_COOKIE_MAX_AGE = 3600;
601
+ /**
602
+ * `frame-ancestors 'self' <origins>`, the CSP directive that lets the Capa
603
+ * editor frame a draft and nothing else frame it. Each origin is a scheme and
604
+ * a host, with a port when it has one, and is written as the URL parser
605
+ * normalises it, so no `;` or quote can reach the policy. Anything else, a
606
+ * path or a query included, throws a `TypeError`.
607
+ */
608
+ function frameAncestors(adminOrigins = [exports.CAPA_ADMIN_ORIGIN]) {
609
+ const origins = new Set();
610
+ for (const value of adminOrigins)
611
+ origins.add(originOf(value));
612
+ return ["frame-ancestors 'self'", ...origins].join(" ");
613
+ }
614
+ function originOf(value) {
615
+ let url = null;
616
+ try {
617
+ url = typeof value === "string" ? new URL(value) : null;
618
+ }
619
+ catch {
620
+ url = null;
621
+ }
622
+ const bare = url !== null &&
623
+ (url.protocol === "https:" || url.protocol === "http:") &&
624
+ url.pathname === "/" &&
625
+ url.search === "" &&
626
+ url.hash === "" &&
627
+ url.username === "" &&
628
+ url.password === "";
629
+ if (!bare) {
630
+ throw new TypeError(`@capacms/sdk/nextjs: ${JSON.stringify(value)} is not an origin. Pass a scheme and a host, such as ${exports.CAPA_ADMIN_ORIGIN}.`);
631
+ }
632
+ return url.origin;
633
+ }
634
+ /**
635
+ * The headers every draft response carries: `X-Robots-Tag: noindex, nofollow`,
636
+ * and `Content-Security-Policy: frame-ancestors 'self' https://app.capacms.com`
637
+ * (or the `adminOrigins` given). Throws for an origin that is not one.
638
+ */
639
+ function draftHeaders(options = {}) {
640
+ return {
641
+ "X-Robots-Tag": exports.DRAFT_ROBOTS_TAG,
642
+ "Content-Security-Policy": frameAncestors(options.adminOrigins),
643
+ };
644
+ }
645
+ /**
646
+ * Rules for `next.config`'s `headers()` that send `draftHeaders` on draft
647
+ * responses only:
648
+ *
649
+ * // next.config.mjs
650
+ * import { capaHeaders } from "@capacms/sdk/nextjs";
651
+ * export default { async headers() { return [...capaHeaders()]; } };
652
+ *
653
+ * A rule matches a request carrying the draft cookie, or a `capa-preview`,
654
+ * `capa-edit` or `capa-view` query. A visitor's request carries none of
655
+ * them, so its response, cached or not, is exactly what it was. Next checks
656
+ * the cookie by name, not by value, so a forged cookie only adds these
657
+ * headers to the forger's own response.
658
+ *
659
+ * A site that sends its own `frame-ancestors` or `X-Frame-Options` must leave
660
+ * them off draft responses (`missing: [{ type: "cookie", key: DRAFT_COOKIE }]`
661
+ * on its rule): a browser applies every CSP it is sent, so the strictest wins.
662
+ */
663
+ function capaHeaders(options = {}) {
664
+ const headers = () => Object.entries(draftHeaders(options)).map(([key, value]) => ({ key, value }));
665
+ const conditions = [
666
+ { type: "cookie", key: exports.DRAFT_COOKIE },
667
+ { type: "query", key: exports.PREVIEW_PARAM },
668
+ { type: "query", key: exports.EDIT_PARAM },
669
+ { type: "query", key: exports.VIEW_PARAM },
670
+ ];
671
+ return conditions.map((condition) => ({ source: "/:path*", has: [condition], headers: headers() }));
672
+ }
673
+ /** The attributes a draft cookie needs to be sent inside the Capa editor's cross-site frame. */
674
+ const FRAMED = { path: "/", httpOnly: true, secure: true, sameSite: "none", partitioned: true };
675
+ function draftCookieMaxAge(maxAge = exports.DRAFT_COOKIE_MAX_AGE) {
676
+ if (typeof maxAge !== "number" || !Number.isInteger(maxAge) || maxAge <= 0) {
677
+ throw new TypeError(`@capacms/sdk/nextjs: maxAge must be a whole number of seconds above 0, and got ${String(maxAge)}.`);
678
+ }
679
+ return maxAge;
680
+ }
681
+ /**
682
+ * Re-set the cookie `draftMode().enable()` just set, so the Capa editor can
683
+ * use it: `HttpOnly; Secure; SameSite=None; Partitioned; Path=/` and
684
+ * `Max-Age` one hour (`maxAge` seconds). Next sets it with no `Max-Age`, so
685
+ * it unlocks every draft on the site until the browser closes, and without
686
+ * `Partitioned`, which Safari 26.2 and later need to send a cookie into a
687
+ * cross-site frame. Call it after `enable()`, in the same route handler.
688
+ * Resolves false, and sets nothing, when there is no draft cookie to re-set.
689
+ */
690
+ async function frameDraftCookie(cookies, options = {}) {
691
+ const maxAge = draftCookieMaxAge(options.maxAge);
692
+ const jar = await cookies();
693
+ const current = jar.get(exports.DRAFT_COOKIE);
694
+ if (!current?.value)
695
+ return false;
696
+ jar.set({ name: exports.DRAFT_COOKIE, value: current.value, ...FRAMED, maxAge });
697
+ return true;
698
+ }
699
+ /**
700
+ * Delete the draft cookie `frameDraftCookie` set. A partitioned cookie is
701
+ * deleted only by a `Set-Cookie` that is partitioned too, which
702
+ * `draftMode().disable()` is not. Call it after `disable()`; it replaces
703
+ * `disable()`'s own deletion, since a response sets one cookie per name. A
704
+ * draft cookie set without `Partitioned`, before a site used
705
+ * `frameDraftCookie`, ends when the browser closes, as it always did.
706
+ */
707
+ async function clearDraftCookie(cookies) {
708
+ (await cookies()).set({ name: exports.DRAFT_COOKIE, value: "", ...FRAMED, expires: new Date(0) });
709
+ }
710
+ /**
711
+ * The preview and exit routes' own redirect. Next's `redirect()` throws and
712
+ * answers for the route, so it cannot carry headers; this one is a plain
713
+ * `Response`, to which Next appends every cookie the route set. It sets no
714
+ * cookie itself: Next keeps one per name, and the response's own would win.
715
+ */
716
+ function routeRedirect(location, headers) {
717
+ return new Response(null, {
718
+ status: 307,
719
+ headers: { Location: location, "Cache-Control": exports.EDIT_CACHE_CONTROL, "Referrer-Policy": "no-referrer", ...headers },
720
+ });
721
+ }
722
+ /**
723
+ * `app/api/capa/preview/route.ts`:
724
+ *
725
+ * import { cookies, draftMode } from "next/headers";
726
+ * export const GET = createPreviewRoute({ draftMode, cookies });
727
+ *
728
+ * Checks the token with Capa (the site never holds the signing key), turns
729
+ * draft mode on and lands on the entry's page. A bad or expired token lands on
730
+ * the page without draft mode and `?preview=expired`; Capa unreachable gives
731
+ * `?preview=unavailable`. The token is checked with any key the site holds,
732
+ * its legacy key included (`getPublishedClient`, from `CAPA_KEY`).
733
+ *
734
+ * Given `cookies`, the draft cookie is re-set by `frameDraftCookie`: one hour,
735
+ * and sent inside the editor's frame. With no `redirect`, the route answers
736
+ * with its own 307, marked `X-Robots-Tag: noindex, nofollow`,
737
+ * `Referrer-Policy: no-referrer` (the token is in the URL),
738
+ * `Cache-Control: private, no-store` and the editor's `frame-ancestors`.
739
+ * Given Next's `redirect`, it is called instead, as before, and the redirect
740
+ * carries none of those.
741
+ */
742
+ function createPreviewRoute(input) {
743
+ // Checked here, so a bad origin or maxAge fails where the route is built.
744
+ const headers = draftHeaders({ adminOrigins: input.adminOrigins });
745
+ const maxAge = draftCookieMaxAge(input.maxAge);
746
+ const go = (location) => input.redirect ? input.redirect(location) : routeRedirect(location, headers);
747
+ return async (request) => {
748
+ const url = new URL(request.url);
749
+ const token = url.searchParams.get("token") ?? url.searchParams.get(exports.PREVIEW_PARAM) ?? "";
750
+ // Reached two ways: directly, or through the middleware's rewrite of a page
751
+ // URL carrying `?capa-preview=`, where Next may hand over the ORIGINAL URL.
752
+ // Then the page itself is the path.
753
+ const path = safeSitePath(url.searchParams.get("path") ?? (url.searchParams.has(exports.PREVIEW_PARAM) ? url.pathname : null));
754
+ let claim = null;
755
+ let failed = false;
756
+ try {
757
+ claim = await (input.client ?? getPublishedClient)().preview(token);
758
+ }
759
+ catch {
760
+ failed = true;
761
+ }
762
+ const draft = await input.draftMode();
763
+ if (!claim) {
764
+ draft.disable?.();
765
+ if (input.cookies)
766
+ await clearDraftCookie(input.cookies);
767
+ return go(`${path}?preview=${failed ? "unavailable" : "expired"}`);
768
+ }
769
+ draft.enable?.();
770
+ if (input.cookies)
771
+ await frameDraftCookie(input.cookies, { maxAge });
772
+ await input.onEnable?.();
773
+ return go(safeSitePath(claim.path ?? path));
774
+ };
775
+ }
776
+ /**
777
+ * `app/api/capa/exit/route.ts`:
778
+ *
779
+ * import { cookies, draftMode } from "next/headers";
780
+ * export const GET = exitPreviewRoute({ draftMode, cookies });
781
+ *
782
+ * Turns draft mode off and lands on `?path=`. Given `cookies`, the framed
783
+ * cookie `createPreviewRoute` set is deleted too. With no `redirect`, the route
784
+ * answers with its own 307, marked noindex and never cached.
785
+ */
786
+ function exitPreviewRoute(input) {
787
+ return async (request) => {
788
+ (await input.draftMode()).disable?.();
789
+ if (input.cookies)
790
+ await clearDraftCookie(input.cookies);
791
+ const location = safeSitePath(new URL(request.url).searchParams.get("path"));
792
+ if (input.redirect)
793
+ return input.redirect(location);
794
+ return routeRedirect(location, { "X-Robots-Tag": exports.DRAFT_ROBOTS_TAG });
795
+ };
796
+ }
797
+ /**
798
+ * `middleware.ts` in one line:
799
+ *
800
+ * import { NextResponse } from "next/server";
801
+ * export const middleware = capaMiddleware({ NextResponse });
802
+ *
803
+ * - `?capa-preview=<token>` on any page goes to `previewRoute`, which turns
804
+ * draft mode on and comes back;
805
+ * - `?capa-view=published` renders without the draft cookie, for the editor's
806
+ * Published view;
807
+ * - `?capa-edit=<token>` turns edit mode on (ids and overlay, published data);
808
+ * - every edit-mode response is `private, no-store`, and it and every request
809
+ * carrying `capa-edit` or `capa-view` is `X-Robots-Tag: noindex, nofollow`.
810
+ *
811
+ * Give it a `matcher` so a visitor's request never runs it (Next reads
812
+ * `config` from the file itself, so it is written out there):
813
+ *
814
+ * export const config = {
815
+ * matcher: [
816
+ * { source: "/:path*", has: [{ type: "query", key: "capa-preview" }] },
817
+ * { source: "/:path*", has: [{ type: "query", key: "capa-edit" }] },
818
+ * { source: "/:path*", has: [{ type: "query", key: "capa-view" }] },
819
+ * { source: "/:path*", has: [{ type: "cookie", key: "__prerender_bypass" }] },
820
+ * ],
821
+ * };
822
+ */
823
+ function capaMiddleware(input) {
824
+ const previewRoute = input.previewRoute ?? "/api/capa/preview";
825
+ return async (request) => {
826
+ const token = request.nextUrl.searchParams.get(exports.PREVIEW_PARAM);
827
+ if (token) {
828
+ const target = request.nextUrl.clone();
829
+ target.pathname = previewRoute;
830
+ target.search = "";
831
+ target.searchParams.set("token", token);
832
+ target.searchParams.set("path", request.nextUrl.pathname);
833
+ return input.NextResponse.rewrite(target);
834
+ }
835
+ const headers = new Headers(request.headers);
836
+ if (request.nextUrl.searchParams.get(exports.VIEW_PARAM) === "published") {
837
+ const cookies = request.cookies
838
+ .getAll()
839
+ .filter((cookie) => cookie.name !== exports.DRAFT_COOKIE)
840
+ .map((cookie) => `${cookie.name}=${encodeURIComponent(cookie.value)}`)
841
+ .join("; ");
842
+ if (cookies)
843
+ headers.set("cookie", cookies);
844
+ else
845
+ headers.delete("cookie");
846
+ }
847
+ // The client is built only when a token needs checking, so a missing env
848
+ // value cannot break every page of the site.
849
+ const edit = await resolveEditRequest({ url: request.url, headers }, { preview: (t) => (input.client ?? getPublishedClient)().preview(t) });
850
+ const response = input.NextResponse.next({ request: { headers: edit.headers } });
851
+ if (edit.cacheControl)
852
+ response.headers.set("Cache-Control", edit.cacheControl);
853
+ // A capa- link is the editor's, verified or not, and never a page to index.
854
+ const capaLink = request.nextUrl.searchParams.has(exports.EDIT_PARAM) || request.nextUrl.searchParams.has(exports.VIEW_PARAM);
855
+ const robots = edit.robotsTag ?? (capaLink ? exports.DRAFT_ROBOTS_TAG : null);
856
+ if (robots)
857
+ response.headers.set("X-Robots-Tag", robots);
858
+ return response;
859
+ };
860
+ }