@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.
@@ -0,0 +1,33 @@
1
+ /** The flat body, or an SDK result wrapping one. */
2
+ export interface FlatResponse {
3
+ data: unknown;
4
+ included: Record<string, Record<string, unknown>>;
5
+ /** The select the request sent, when the SDK made it. */
6
+ select?: string;
7
+ }
8
+ /** One level of a select: `*` or not, and the names it wrote, in order. */
9
+ interface Level {
10
+ star: boolean;
11
+ items: Array<{
12
+ name: string;
13
+ expand: Level | null;
14
+ }>;
15
+ }
16
+ /**
17
+ * The select grammar, read only as far as `inflate` needs it: names, nesting
18
+ * and `*`. Modifiers (`limit:`, `sort:`, `after:`) decide which rows the API
19
+ * returned, which the response already reflects, so they are skipped. The API
20
+ * validated the select before answering, so this parser trusts its shape and
21
+ * throws a `TypeError` only on text it cannot split at all.
22
+ */
23
+ export declare function parseSelectLevels(select: string): Level;
24
+ /**
25
+ * The tree shape of a flat response: `data` with every expansion nested back
26
+ * in, `included` and `select` removed, everything else (`page`, `meta`,
27
+ * `cacheTags`) carried over as it was.
28
+ *
29
+ * `select` is what the request sent; it defaults to `response.select`, which
30
+ * an SDK flat read fills in. A request with no select expanded nothing.
31
+ */
32
+ export declare function inflate<R extends FlatResponse>(response: R, select?: string): Omit<R, "included" | "select">;
33
+ export {};
@@ -0,0 +1,229 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseSelectLevels = parseSelectLevels;
4
+ exports.inflate = inflate;
5
+ /**
6
+ * inflate.ts — turn a `shape=flat` response back into the `shape=tree` one.
7
+ *
8
+ * A flat response carries every expanded entry once, in `included`, keyed by
9
+ * model namespace and then id, and every relation as a `{ id, model }`
10
+ * reference. `inflate` walks the SAME select the request sent and puts each
11
+ * referenced entry back where the tree would have nested it, holding exactly
12
+ * the system keys and fields that path selected. The result is deep-equal to
13
+ * the `shape=tree` body for the same request (amendment 16 of the api-next spec
14
+ * names the one exception).
15
+ *
16
+ * WHY IT NEEDS THE SELECT. An included entry holds the UNION of every path that
17
+ * reached it, and an entry in `data` also carries what any relation path asked
18
+ * of it. Only the select says which of those fields belong at which place in
19
+ * the tree, and which references were expanded. A result from this SDK's
20
+ * `shape: "flat"` read carries the select it sent (`response.select`), so
21
+ * `inflate(response)` is enough; for a body fetched by hand, pass the select
22
+ * you sent as the second argument. A request that sent no select expanded
23
+ * nothing, and `inflate` returns its data unchanged.
24
+ *
25
+ * COPIES, NEVER SHARED INSTANCES. Every entry in the result is a new object,
26
+ * and so is every value inside it: the same author reached from twenty
27
+ * articles comes back as twenty equal, independent objects, exactly as the
28
+ * tree response parses. Mutating one never changes another, the input is never
29
+ * modified, and `JSON.stringify` of the result cannot meet a cycle.
30
+ *
31
+ * CYCLES END WHERE THE SELECT ENDS. `a` related to `b` related to `a` is
32
+ * inflated to the depth the select wrote (at most four levels, the API's cap)
33
+ * and no further: the walk follows the select, never the references, so it
34
+ * cannot loop. A reference the select did not expand stays a reference.
35
+ */
36
+ const attrs_1 = require("./attrs");
37
+ /** The system keys, in the order the API prints them. */
38
+ const SYSTEM_KEYS = [
39
+ "id",
40
+ "model",
41
+ "status",
42
+ "createdAt",
43
+ "updatedAt",
44
+ "publishedAt",
45
+ "version",
46
+ "folder",
47
+ "tags",
48
+ ];
49
+ const ALWAYS_KEYS = new Set(["id", "model", "status"]);
50
+ const SYSTEM_KEY_SET = new Set(SYSTEM_KEYS);
51
+ /**
52
+ * The select grammar, read only as far as `inflate` needs it: names, nesting
53
+ * and `*`. Modifiers (`limit:`, `sort:`, `after:`) decide which rows the API
54
+ * returned, which the response already reflects, so they are skipped. The API
55
+ * validated the select before answering, so this parser trusts its shape and
56
+ * throws a `TypeError` only on text it cannot split at all.
57
+ */
58
+ function parseSelectLevels(select) {
59
+ let pos = 0;
60
+ const fail = () => {
61
+ throw new TypeError(`@capacms/sdk/next: inflate could not read the select ${JSON.stringify(select)}.`);
62
+ };
63
+ const level = () => {
64
+ const out = { star: false, items: [] };
65
+ for (;;) {
66
+ const start = pos;
67
+ while (pos < select.length && !",()".includes(select[pos]))
68
+ pos += 1;
69
+ const token = select.slice(start, pos);
70
+ if (select[pos] === "(") {
71
+ if (token === "")
72
+ fail();
73
+ pos += 1;
74
+ const inner = level();
75
+ if (select[pos] !== ")")
76
+ fail();
77
+ pos += 1;
78
+ out.items.push({ name: token, expand: inner });
79
+ }
80
+ else if (token === "*") {
81
+ out.star = true;
82
+ }
83
+ else if (token.includes(":")) {
84
+ // A modifier: `limit:2`, `sort:-name`, `after:<cursor>`.
85
+ }
86
+ else if (token !== "") {
87
+ out.items.push({ name: token, expand: null });
88
+ }
89
+ else {
90
+ fail();
91
+ }
92
+ if (select[pos] !== ",")
93
+ return out;
94
+ pos += 1;
95
+ }
96
+ };
97
+ const root = level();
98
+ if (pos !== select.length)
99
+ fail();
100
+ return root;
101
+ }
102
+ // ----------------------------------------------------------------- walk ----
103
+ function clone(value) {
104
+ if (Array.isArray(value))
105
+ return value.map(clone);
106
+ if (value && typeof value === "object") {
107
+ const out = {};
108
+ for (const [key, inner] of Object.entries(value))
109
+ out[key] = clone(inner);
110
+ return out;
111
+ }
112
+ return value;
113
+ }
114
+ function isReference(value) {
115
+ return (!!value &&
116
+ typeof value === "object" &&
117
+ !Array.isArray(value) &&
118
+ typeof value.id === "string" &&
119
+ !("fields" in value));
120
+ }
121
+ function resolve(index, ref) {
122
+ if (ref.missing)
123
+ return null;
124
+ const fromIncluded = ref.model ? index.included[ref.model]?.[ref.id] : undefined;
125
+ if (fromIncluded && typeof fromIncluded === "object")
126
+ return fromIncluded;
127
+ return index.data.get(ref.id) ?? null;
128
+ }
129
+ /**
130
+ * One entry as the tree holds it at a place whose select is `level`. `null`
131
+ * is "no select was sent", which is every key and every field, unexpanded.
132
+ */
133
+ function project(entry, level, index) {
134
+ const fields = (entry.fields ?? {});
135
+ const all = level === null || level.star;
136
+ // A name is a FIELD when the entry has a field of that name, otherwise a
137
+ // system key: a model field shadows a system key of the same name (spec
138
+ // 3.3), and a path that selected the field is exactly what put it here.
139
+ const wanted = new Set(ALWAYS_KEYS);
140
+ if (level) {
141
+ for (const item of level.items) {
142
+ if (!(item.name in fields) && SYSTEM_KEY_SET.has(item.name))
143
+ wanted.add(item.name);
144
+ }
145
+ }
146
+ const out = {};
147
+ for (const key of SYSTEM_KEYS) {
148
+ if (!(key in entry))
149
+ continue;
150
+ if (all || wanted.has(key))
151
+ out[key] = clone(entry[key]);
152
+ }
153
+ const expansions = new Map();
154
+ const names = [];
155
+ if (level) {
156
+ for (const item of level.items) {
157
+ if (item.expand)
158
+ expansions.set(item.name, item.expand);
159
+ if (item.name in fields && !all)
160
+ names.push(item.name);
161
+ }
162
+ }
163
+ if (all)
164
+ names.push(...Object.keys(fields));
165
+ const outFields = {};
166
+ for (const name of names) {
167
+ const expand = expansions.get(name);
168
+ outFields[name] = expand ? expandValue(fields[name], expand, index) : clone(fields[name]);
169
+ }
170
+ out.fields = outFields;
171
+ if ((0, attrs_1.isEditEntry)(entry)) {
172
+ Object.defineProperty(out, attrs_1.CAPA_EDIT, { value: true, enumerable: false, configurable: true });
173
+ }
174
+ return out;
175
+ }
176
+ /** A reference, or an array relation's `{ items, pageInfo }`, put back in place. */
177
+ function expandValue(value, level, index) {
178
+ if (isReference(value)) {
179
+ const target = resolve(index, value);
180
+ return target ? project(target, level, index) : clone(value);
181
+ }
182
+ if (value && typeof value === "object" && Array.isArray(value.items)) {
183
+ const list = value;
184
+ const out = {};
185
+ for (const [key, inner] of Object.entries(list)) {
186
+ out[key] =
187
+ key === "items"
188
+ ? list.items.map((item) => expandValue(item, level, index))
189
+ : clone(inner);
190
+ }
191
+ return out;
192
+ }
193
+ return clone(value);
194
+ }
195
+ /**
196
+ * The tree shape of a flat response: `data` with every expansion nested back
197
+ * in, `included` and `select` removed, everything else (`page`, `meta`,
198
+ * `cacheTags`) carried over as it was.
199
+ *
200
+ * `select` is what the request sent; it defaults to `response.select`, which
201
+ * an SDK flat read fills in. A request with no select expanded nothing.
202
+ */
203
+ function inflate(response, select) {
204
+ if (!response || typeof response !== "object" || !("included" in response)) {
205
+ throw new TypeError("@capacms/sdk/next: inflate takes a shape=flat response, which carries included.");
206
+ }
207
+ const text = select ?? response.select;
208
+ const level = text === undefined || text === null ? null : parseSelectLevels(text);
209
+ const rows = Array.isArray(response.data) ? response.data : [response.data];
210
+ const index = { included: response.included ?? {}, data: new Map() };
211
+ for (const row of rows) {
212
+ if (row && typeof row === "object" && typeof row.id === "string") {
213
+ index.data.set(row.id, row);
214
+ }
215
+ }
216
+ const inflateRow = (row) => row && typeof row === "object" ? project(row, level, index) : row;
217
+ const out = {};
218
+ for (const [key, value] of Object.entries(response)) {
219
+ if (key === "included" || key === "select")
220
+ continue;
221
+ if (key === "data") {
222
+ out.data = Array.isArray(value) ? value.map(inflateRow) : inflateRow(value);
223
+ }
224
+ else {
225
+ out[key] = value;
226
+ }
227
+ }
228
+ return out;
229
+ }
@@ -36,4 +36,21 @@ export type SelectItem<T> = "*" | ScalarKeys<T> | {
36
36
  }[RelationKeys<T>];
37
37
  /** A typed form of the `/api/entries` `select` grammar. */
38
38
  export type Select<T> = ReadonlyArray<SelectItem<T>>;
39
+ type Depth = [never, 0, 1, 2, 3, 4];
40
+ /** The target types one select item expands, and everything below them. */
41
+ type ItemTargets<T, I, D extends number> = [D] extends [never] ? never : I extends string ? never : {
42
+ [K in Extract<keyof I, RelationKeys<T>>]: RelationTarget<T[K]> | ValueTargets<RelationTarget<T[K]>, I[K], Depth[D]>;
43
+ }[Extract<keyof I, RelationKeys<T>>];
44
+ type ValueTargets<R, V, D extends number> = V extends "*" ? never : V extends {
45
+ select: infer S;
46
+ } ? ExpandedTargetsAt<R, S, D> : ExpandedTargetsAt<R, V, D>;
47
+ type ExpandedTargetsAt<T, S, D extends number> = S extends ReadonlyArray<infer I> ? ItemTargets<T, I, D> : never;
48
+ /**
49
+ * Every entry type a select EXPANDS, at any depth: what `included` can hold
50
+ * for a `shape: "flat"` read. `[{ author: ["name"] }]` on an article is
51
+ * `Author`; a select given as a string cannot be read by the type system and is
52
+ * `Record<string, unknown>`. Bounded at the API's four levels, so a model that
53
+ * relates to itself does not recurse forever.
54
+ */
55
+ export type ExpandedTargets<T, S> = S extends string ? Record<string, unknown> : [ExpandedTargetsAt<T, S, 4>] extends [never] ? never : ExpandedTargetsAt<T, S, 4>;
39
56
  export {};
@@ -1,4 +1,4 @@
1
- import { type CapaNextClient, type CapaNextConfig, type PagesResource, type PreviewClaim } from "../next";
1
+ import { LAYOUT_PAGE, type CapaNextClient, type CapaNextConfig, type PagesResource, type PreviewClaim } from "../next";
2
2
  export interface CacheOptions {
3
3
  tags?: string[];
4
4
  revalidate?: number | false;
@@ -50,4 +50,179 @@ export declare function preview(token: string, client: CapaNextClient): Promise<
50
50
  * this and never hold the whole client.
51
51
  */
52
52
  export declare function pagesFor(client: CapaNextClient): PagesResource;
53
+ export { LAYOUT_PAGE };
53
54
  export type { PreviewClaim };
55
+ /**
56
+ * The query parameter that turns edit mode on for one request without draft
57
+ * content: `?capa-edit=<token>`, where the token is a Capa preview token. The
58
+ * Capa editor's Published view sends it so the published page is still
59
+ * clickable. It never switches the site to draft data.
60
+ */
61
+ export declare const EDIT_PARAM = "capa-edit";
62
+ /**
63
+ * The request header `resolveEditRequest` sets once a `capa-edit` token has
64
+ * been verified, for `editMode()` to read. Any copy a browser sent is removed
65
+ * first, so it cannot be forged from outside.
66
+ */
67
+ export declare const EDIT_HEADER = "x-capa-edit";
68
+ /** The cookie Next's `draftMode().enable()` sets. */
69
+ export declare const DRAFT_COOKIE = "__prerender_bypass";
70
+ /** What every edit-mode response must send: never cached, never shared. */
71
+ export declare const EDIT_CACHE_CONTROL = "private, no-store";
72
+ interface HeaderReader {
73
+ get(name: string): string | null;
74
+ }
75
+ /**
76
+ * Whether this request renders in edit mode: Next draft mode is on, or the
77
+ * request carried a verified `capa-edit` token (see `resolveEditRequest`).
78
+ *
79
+ * Pass Next's own functions; this package imports nothing from `next`:
80
+ *
81
+ * import { draftMode, headers } from "next/headers";
82
+ * const edit = await editMode({ draftMode, headers });
83
+ * const client = createClient({ ...config, editMode: edit });
84
+ *
85
+ * A client built with `editMode: edit` marks what it reads, and `capaAttrs`
86
+ * then tags those entries and only those.
87
+ */
88
+ export declare function editMode(input: {
89
+ draftMode: () => {
90
+ isEnabled: boolean;
91
+ } | Promise<{
92
+ isEnabled: boolean;
93
+ }>;
94
+ headers: () => HeaderReader | Promise<HeaderReader>;
95
+ }): Promise<boolean>;
96
+ export interface EditRequest {
97
+ /** True when the page must render in edit mode. */
98
+ edit: boolean;
99
+ /** True when a `capa-edit` token was presented and Capa accepted it. */
100
+ verified: boolean;
101
+ /**
102
+ * The request headers to forward: `x-capa-edit` removed, then set to `1`
103
+ * when the token verified. Hand them to `NextResponse.next({ request: { headers } })`.
104
+ */
105
+ headers: Headers;
106
+ /** `EDIT_CACHE_CONTROL` in edit mode, otherwise null. Set it on the response. */
107
+ cacheControl: string | null;
108
+ }
109
+ /**
110
+ * The middleware half of edit mode.
111
+ *
112
+ * export async function middleware(request: NextRequest) {
113
+ * const edit = await resolveEditRequest(request, publishedClient());
114
+ * const response = NextResponse.next({ request: { headers: edit.headers } });
115
+ * if (edit.cacheControl) response.headers.set("Cache-Control", edit.cacheControl);
116
+ * return response;
117
+ * }
118
+ *
119
+ * The token is checked with Capa (`client.preview`), exactly as a preview link
120
+ * is: the site never holds the signing key. A bad, expired or unverifiable
121
+ * token means not in edit mode; it never throws, because a broken edit link
122
+ * must still render the public page.
123
+ */
124
+ export declare function resolveEditRequest(request: {
125
+ url: string | URL;
126
+ headers: Headers;
127
+ cookies?: {
128
+ has(name: string): boolean;
129
+ };
130
+ }, client: Pick<CapaNextClient, "preview">): Promise<EditRequest>;
131
+ /** Where the env-driven helpers read their settings (M6). */
132
+ export declare const CAPA_ENV: {
133
+ readonly baseUrl: "CAPA_API_URL";
134
+ readonly apiKey: "CAPA_KEY";
135
+ readonly draftKey: "CAPA_DRAFT_KEY";
136
+ readonly version: "CAPA_API_VERSION";
137
+ };
138
+ export declare const DEFAULT_API_VERSION = "2026-10-01";
139
+ type DraftModeFn = () => {
140
+ isEnabled: boolean;
141
+ enable?: () => void;
142
+ disable?: () => void;
143
+ } | Promise<{
144
+ isEnabled: boolean;
145
+ enable?: () => void;
146
+ disable?: () => void;
147
+ }>;
148
+ /** The published-key client from env: what verifying a token needs. */
149
+ export declare function getPublishedClient(overrides?: Partial<CapaNextConfig>): CapaNextClient;
150
+ /**
151
+ * The client for this request, from env: the draft key under draft mode,
152
+ * otherwise the published key, and `editMode` worked out for you.
153
+ *
154
+ * import { draftMode, headers } from "next/headers";
155
+ * const capa = await getCapaClient({ draftMode, headers });
156
+ */
157
+ export declare function getCapaClient(input: {
158
+ draftMode: DraftModeFn;
159
+ headers: () => HeaderReader | Promise<HeaderReader>;
160
+ config?: Partial<CapaNextConfig>;
161
+ }): Promise<CapaNextClient>;
162
+ /** Only a path on this site: never `//elsewhere.example` or a full URL. */
163
+ export declare function safeSitePath(value: string | null | undefined): string;
164
+ /**
165
+ * `app/api/capa/preview/route.ts`:
166
+ *
167
+ * import { draftMode } from "next/headers";
168
+ * import { redirect } from "next/navigation";
169
+ * export const GET = createPreviewRoute({ draftMode, redirect });
170
+ *
171
+ * Checks the token with Capa (the site never holds the signing key), turns
172
+ * draft mode on and lands on the entry's page. A bad or expired token lands on
173
+ * the page without draft mode and `?preview=expired`; Capa unreachable gives
174
+ * `?preview=unavailable`.
175
+ */
176
+ export declare function createPreviewRoute(input: {
177
+ draftMode: DraftModeFn;
178
+ redirect: (url: string) => never | void;
179
+ client?: () => Pick<CapaNextClient, "preview">;
180
+ /** Runs after draft mode is enabled, before the redirect (cookie tweaks). */
181
+ onEnable?: () => void | Promise<void>;
182
+ }): (request: Request) => Promise<Response | void>;
183
+ /** `app/api/capa/exit/route.ts`: `export const GET = exitPreviewRoute({ draftMode, redirect });` */
184
+ export declare function exitPreviewRoute(input: {
185
+ draftMode: DraftModeFn;
186
+ redirect: (url: string) => never | void;
187
+ }): (request: Request) => Promise<Response | void>;
188
+ interface MiddlewareRequest {
189
+ url: string;
190
+ headers: Headers;
191
+ nextUrl: URL & {
192
+ clone(): URL;
193
+ };
194
+ cookies: {
195
+ getAll(): Array<{
196
+ name: string;
197
+ value: string;
198
+ }>;
199
+ };
200
+ }
201
+ interface NextResponseLike {
202
+ next(init?: {
203
+ request?: {
204
+ headers?: Headers;
205
+ };
206
+ }): {
207
+ headers: Headers;
208
+ };
209
+ rewrite(url: URL): unknown;
210
+ }
211
+ /**
212
+ * `middleware.ts` in one line:
213
+ *
214
+ * import { NextResponse } from "next/server";
215
+ * export const middleware = capaMiddleware({ NextResponse });
216
+ *
217
+ * - `?capa-preview=<token>` on any page goes to `previewRoute`, which turns
218
+ * draft mode on and comes back;
219
+ * - `?capa-view=published` renders without the draft cookie, for the editor's
220
+ * Published view;
221
+ * - `?capa-edit=<token>` turns edit mode on (ids and overlay, published data);
222
+ * - every edit-mode response is `private, no-store`.
223
+ */
224
+ export declare function capaMiddleware(input: {
225
+ NextResponse: NextResponseLike;
226
+ previewRoute?: string;
227
+ client?: () => Pick<CapaNextClient, "preview">;
228
+ }): (request: MiddlewareRequest) => Promise<unknown>;