@8ux-co/eelzap 0.10.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/next.d.ts CHANGED
@@ -59,6 +59,13 @@ interface ZapBootOptions {
59
59
 
60
60
  type ZapPreviewProps = ZapBootOptions;
61
61
 
62
+ /**
63
+ * The path to redirect to, or null when it could leave the site. Returned
64
+ * as path + query + hash, never absolute, so the `Location` stays on the
65
+ * host the browser asked.
66
+ */
67
+ declare function safeRedirectPath(raw: string | null, requestUrl: string): string | null;
68
+
62
69
  /**
63
70
  * `@8ux-co/eelzap/next` — the draft-mode route, `isZapPreview()` and the
64
71
  * preview boot for a Next.js site (zap-cms-v2 §2.5 "Draft values", §2.7, §3.5).
@@ -68,21 +75,29 @@ type ZapPreviewProps = ZapBootOptions;
68
75
  * import { cookies, draftMode } from 'next/headers'
69
76
  * import { createDraftModeRoute } from '@8ux-co/eelzap/next'
70
77
  *
71
- * export const GET = createDraftModeRoute({
78
+ * export const POST = createDraftModeRoute({
72
79
  * siteKey: 'verdeorigen',
73
80
  * apiKey: process.env.ZAP_SECRET_KEY!,
74
81
  * draftMode,
75
82
  * cookies,
76
83
  * })
84
+ * export const GET = POST
77
85
  * ```
78
86
  *
79
87
  * and `<ZapPreview siteKey="verdeorigen" />` in the root layout, which tells
80
88
  * Zap's editor the route's path (`zap:ready`, §2.5); the editor joins it to the
81
- * frame's verified origin and loads `{origin}{route}?token=…&path=…`.
89
+ * frame's verified origin. Clients advertising `fragment-token` opt into
90
+ * `{origin}{route}#token=…&path=…`; older clients keep query URLs.
91
+ * GET serves a tiny page and a same-origin script that clears the fragment
92
+ * and submits a form POST.
93
+ * Fragments never travel in HTTP request URLs or site request logs.
82
94
  *
83
95
  * ## What the route does, in order
84
96
  *
85
- * 1. Reads `token` and `path` from the query. A token not shaped like Zap's
97
+ * 1. Requires same-origin evidence for POST (Sec-Fetch-Site or Origin), then
98
+ * reads `token` and `path` from the form body. Legacy GET query tokens
99
+ * remain supported. A token not shaped like
100
+ * Zap's
86
101
  * (`zpt_` + 43 base64url characters) is refused (401) without a request.
87
102
  * 2. Refuses a `path` that could leave the site: it must start with one `/`,
88
103
  * carry no backslash or control character, resolve to this request's own
@@ -106,7 +121,8 @@ type ZapPreviewProps = ZapBootOptions;
106
121
  * Chrome keeps a partitioned (CHIPS) cookie under third-party cookie
107
122
  * restrictions. Safari blocks frame cookies regardless; the client's
108
123
  * cookieless fallback covers it (`previewHeaders` → `getValidPreviewToken`).
109
- * 5. Redirects (307) to `path`, WITHOUT the token, `Cache-Control: no-store`,
124
+ * 5. Redirects (303 after POST; 307 for legacy GET) to `path`, without the
125
+ * token, `Cache-Control: no-store`,
110
126
  * `Referrer-Policy: no-referrer`.
111
127
  *
112
128
  * `createDraftModeExitRoute` ends the preview: both cookies expired, same
@@ -151,13 +167,7 @@ interface DraftModeRouteOptions {
151
167
  /** Injected in specs. */
152
168
  fetch?: typeof fetch;
153
169
  }
154
- type DraftModeRefusal = 'invalid_token' | 'invalid_path' | 'token_refused' | 'wrong_site' | 'zap_unreachable';
155
- /**
156
- * The path to redirect to, or null when it could leave the site. Returned
157
- * as path + query + hash, never absolute, so the `Location` stays on the
158
- * host the browser asked.
159
- */
160
- declare function safeRedirectPath(raw: string | null, requestUrl: string): string | null;
170
+ type DraftModeRefusal = 'cross_origin' | 'invalid_token' | 'invalid_path' | 'token_refused' | 'wrong_site' | 'zap_unreachable';
161
171
  declare function createDraftModeRoute(options: DraftModeRouteOptions): (request: Request) => Promise<Response>;
162
172
  interface DraftModeExitLike {
163
173
  disable(): void | Promise<void>;
package/dist/next.js CHANGED
@@ -8,6 +8,36 @@ function isPreviewTokenShape(value) {
8
8
  return typeof value === "string" && PREVIEW_TOKEN_RE.test(value);
9
9
  }
10
10
 
11
+ // src/preview/draft-path.ts
12
+ var UNSAFE_PATH_CHARS = /[\u0000-\u001f\u007f\\]/;
13
+ function safeRedirectPath(raw, requestUrl) {
14
+ const value = raw ?? "/";
15
+ if (value.length === 0 || value.length > 2048) return null;
16
+ if (!value.startsWith("/") || value.startsWith("//")) return null;
17
+ if (UNSAFE_PATH_CHARS.test(value)) return null;
18
+ let base;
19
+ let target;
20
+ try {
21
+ base = new URL(requestUrl);
22
+ target = new URL(value, base);
23
+ } catch {
24
+ return null;
25
+ }
26
+ if (target.origin !== base.origin) return null;
27
+ if (target.pathname.startsWith("//")) return null;
28
+ let decoded = value;
29
+ for (let i = 0; i < value.length; i++) {
30
+ if (/zpt_/i.test(decoded)) return null;
31
+ const next = decoded.replace(
32
+ /%([0-9a-f]{2})/gi,
33
+ (_, hex) => String.fromCharCode(Number.parseInt(hex, 16))
34
+ );
35
+ if (next === decoded) break;
36
+ decoded = next;
37
+ }
38
+ return `${target.pathname}${target.search}${target.hash}`;
39
+ }
40
+
11
41
  // src/next.ts
12
42
  var PREVIEW_TOKEN_COOKIE = "__zap_preview";
13
43
  var NEXT_DRAFT_COOKIE = "__prerender_bypass";
@@ -21,35 +51,16 @@ var NO_STORE = { "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" }
21
51
  function refuse(status, error) {
22
52
  return Response.json({ error }, { status, headers: NO_STORE });
23
53
  }
24
- function redirect(path, cookies) {
54
+ function redirect(path, cookies, status = 307) {
25
55
  const headers = new Headers({ Location: path, ...NO_STORE });
26
56
  for (const cookie of cookies) headers.append("Set-Cookie", cookie);
27
- return new Response(null, { status: 307, headers });
57
+ return new Response(null, { status, headers });
28
58
  }
29
59
  function requireKeys(options) {
30
60
  if (!options?.siteKey || !options.apiKey) {
31
61
  throw new Error("eelzap/next: siteKey and apiKey (the site API key) are required");
32
62
  }
33
63
  }
34
- var UNSAFE_PATH_CHARS = /[\u0000-\u001f\u007f\\]/;
35
- function safeRedirectPath(raw, requestUrl) {
36
- const value = raw ?? "/";
37
- if (value.length === 0 || value.length > 2048) return null;
38
- if (!value.startsWith("/") || value.startsWith("//")) return null;
39
- if (UNSAFE_PATH_CHARS.test(value)) return null;
40
- let base;
41
- let target;
42
- try {
43
- base = new URL(requestUrl);
44
- target = new URL(value, base);
45
- } catch {
46
- return null;
47
- }
48
- if (target.origin !== base.origin) return null;
49
- if (target.pathname.startsWith("//")) return null;
50
- if (/zpt_/i.test(target.search) || /zpt_/i.test(target.hash)) return null;
51
- return `${target.pathname}${target.search}${target.hash}`;
52
- }
53
64
  function cookieHeader(name, value, maxAge) {
54
65
  const parts = [`${name}=${value}`, "Path=/", "HttpOnly", "Secure", "SameSite=None", "Partitioned"];
55
66
  if (maxAge !== void 0) parts.push(`Max-Age=${maxAge}`);
@@ -82,13 +93,60 @@ async function validateWithZap(token, options) {
82
93
  const key = body?.site?.key;
83
94
  return typeof key === "string" ? { ok: true, siteKey: key, expiresAt: readExpiry(body?.expiresAt ?? body?.token?.expiresAt) } : { ok: false, status: 502, code: null };
84
95
  }
96
+ var FRAGMENT_EXCHANGE_SCRIPT = `(() => {
97
+ const params = new URLSearchParams(location.hash.slice(1));
98
+ history.replaceState(null, '', location.pathname);
99
+ const token = params.get('token');
100
+ if (!/^zpt_[A-Za-z0-9_-]{43}$/.test(token || '')) return;
101
+ const form = document.createElement('form');
102
+ form.method = 'POST';
103
+ form.action = location.pathname;
104
+ for (const [name, value] of [['token', token], ['path', params.get('path') ?? '/']]) {
105
+ const input = document.createElement('input');
106
+ input.type = 'hidden';
107
+ input.name = name;
108
+ input.value = value;
109
+ form.appendChild(input);
110
+ }
111
+ document.body.appendChild(form);
112
+ form.submit();
113
+ })();`;
114
+ function fragmentExchangeScript() {
115
+ return new Response(FRAGMENT_EXCHANGE_SCRIPT, {
116
+ headers: { ...NO_STORE, "Content-Type": "application/javascript; charset=utf-8" }
117
+ });
118
+ }
119
+ function fragmentExchangePage() {
120
+ const html = `<!doctype html><html><head><meta charset="utf-8"><title>Zap preview</title></head><body>
121
+ <script src="?script=1"></script><noscript>JavaScript is required to enter Zap preview.</noscript></body></html>`;
122
+ return new Response(html, {
123
+ headers: {
124
+ ...NO_STORE,
125
+ "Content-Type": "text/html; charset=utf-8",
126
+ "Content-Security-Policy": "default-src 'none'; script-src 'self'; form-action 'self'; base-uri 'none'"
127
+ }
128
+ });
129
+ }
85
130
  function createDraftModeRoute(options) {
86
131
  requireKeys(options);
87
- return async function GET(request) {
88
- const url = new URL(request.url);
89
- const token = url.searchParams.get("token");
132
+ const exchange = async (request) => {
133
+ let params;
134
+ if (request.method === "POST") {
135
+ const sameOrigin = request.headers.get("sec-fetch-site") === "same-origin" || request.headers.get("origin") === new URL(request.url).origin;
136
+ if (!sameOrigin) return refuse(403, "cross_origin");
137
+ try {
138
+ params = await request.formData();
139
+ } catch {
140
+ return refuse(401, "invalid_token");
141
+ }
142
+ } else {
143
+ params = new URL(request.url).searchParams;
144
+ }
145
+ const token = params.get("token");
90
146
  if (!isPreviewTokenShape(token)) return refuse(401, "invalid_token");
91
- const path = safeRedirectPath(url.searchParams.get("path"), request.url);
147
+ const rawPath = params.get("path");
148
+ if (rawPath !== null && typeof rawPath !== "string") return refuse(400, "invalid_path");
149
+ const path = safeRedirectPath(rawPath, request.url);
92
150
  if (path === null) return refuse(400, "invalid_path");
93
151
  let verdict;
94
152
  try {
@@ -108,7 +166,17 @@ function createDraftModeRoute(options) {
108
166
  if (bypass && /^[A-Za-z0-9._~-]+$/.test(bypass)) {
109
167
  cookies.push(cookieHeader(NEXT_DRAFT_COOKIE, bypass, PREVIEW_COOKIE_MAX_AGE));
110
168
  }
111
- return redirect(path, cookies);
169
+ return redirect(path, cookies, request.method === "POST" ? 303 : 307);
170
+ };
171
+ return async function draftModeRoute(request) {
172
+ if (request.method === "GET") {
173
+ const params = new URL(request.url).searchParams;
174
+ if (params.get("script") === "1") return fragmentExchangeScript();
175
+ if (!params.has("token")) return fragmentExchangePage();
176
+ return exchange(request);
177
+ }
178
+ if (request.method === "POST") return exchange(request);
179
+ return new Response(null, { status: 405, headers: { ...NO_STORE, Allow: "GET, POST" } });
112
180
  };
113
181
  }
114
182
  function createDraftModeExitRoute(options) {
package/dist/next.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/next.ts","../src/refs.ts"],"sourcesContent":["import { createElement } from 'react'\n\nimport { ZapPreview as ClientZapPreview, type ZapPreviewProps } from './boot/component'\nimport { isPreviewTokenShape } from './refs'\n\n/**\n * `@8ux-co/eelzap/next` — the draft-mode route, `isZapPreview()` and the\n * preview boot for a Next.js site (zap-cms-v2 §2.5 \"Draft values\", §2.7, §3.5).\n *\n * ```ts\n * // app/api/zap-preview/route.ts\n * import { cookies, draftMode } from 'next/headers'\n * import { createDraftModeRoute } from '@8ux-co/eelzap/next'\n *\n * export const GET = createDraftModeRoute({\n * siteKey: 'verdeorigen',\n * apiKey: process.env.ZAP_SECRET_KEY!,\n * draftMode,\n * cookies,\n * })\n * ```\n *\n * and `<ZapPreview siteKey=\"verdeorigen\" />` in the root layout, which tells\n * Zap's editor the route's path (`zap:ready`, §2.5); the editor joins it to the\n * frame's verified origin and loads `{origin}{route}?token=…&path=…`.\n *\n * ## What the route does, in order\n *\n * 1. Reads `token` and `path` from the query. A token not shaped like Zap's\n * (`zpt_` + 43 base64url characters) is refused (401) without a request.\n * 2. Refuses a `path` that could leave the site: it must start with one `/`,\n * carry no backslash or control character, resolve to this request's own\n * origin, and carry no token itself (400). An open redirect on the site's\n * own domain is the classic draft-mode hole; this closes it.\n * 3. Validates the token WITH ZAP (`GET /api/public/v1/preview/token`),\n * sending the site's own API key beside it, and Zap checks the token was\n * minted for the site that key belongs to. Without that binding, any Zap\n * account could mint a token for its own site and switch YOUR site into\n * draft mode, with YOUR server rendering THEIR drafts on your domain. The\n * site key alone cannot carry it: it is unique within a workspace only, so\n * anyone can name a site `verdeorigen` in a workspace of their own (P7\n * audit). The answer's site key is compared too. An unknown, expired or\n * revoked token, or another site's, is refused (401 / 403); Zap\n * unreachable is 502. Nothing is enabled on a refusal.\n * 4. Enables Next's draft mode and stores the token in its own cookie, so the\n * server can read drafts with it (`getValidPreviewToken`, which checks it\n * with Zap again; `getPreviewToken` only checks its shape). Both cookies are\n * `SameSite=None; Secure; Partitioned; HttpOnly`: the page renders inside\n * Zap's frame, a third-party context where anything else is dropped, and\n * Chrome keeps a partitioned (CHIPS) cookie under third-party cookie\n * restrictions. Safari blocks frame cookies regardless; the client's\n * cookieless fallback covers it (`previewHeaders` → `getValidPreviewToken`).\n * 5. Redirects (307) to `path`, WITHOUT the token, `Cache-Control: no-store`,\n * `Referrer-Policy: no-referrer`.\n *\n * `createDraftModeExitRoute` ends the preview: both cookies expired, same\n * attributes, and a redirect through the same `safeRedirectPath`.\n *\n * Next is not imported: `draftMode` and `cookies` are passed in, so the\n * helper works with any Next version that has them and is testable alone.\n */\n\n/** The cookie the token travels in after the exchange. */\nexport const PREVIEW_TOKEN_COOKIE = '__zap_preview'\n/** Next's own draft-mode cookie, re-issued partitioned. */\nexport const NEXT_DRAFT_COOKIE = '__prerender_bypass'\n/** The header the client's cookieless fallback sends (`previewHeaders`). */\nexport const PREVIEW_TOKEN_HEADER = 'x-zap-preview-token'\nexport const DEFAULT_ZAP_ORIGIN = 'https://zap.eel.software'\nexport const TOKEN_VALIDATION_PATH = '/api/public/v1/preview/token'\n/** The header the site's own API key travels in to the validation call. */\nexport const SITE_API_KEY_HEADER = 'X-Zap-Site-Api-Key'\n/** A preview token lives ten minutes (§3.5); its cookie no longer. */\nconst PREVIEW_COOKIE_MAX_AGE = 600\nconst VALIDATION_TIMEOUT_MS = 5000\n\ninterface DraftModeLike {\n enable(): void | Promise<void>\n}\n\ninterface CookieStoreLike {\n get(name: string): { value: string } | undefined\n}\n\nexport interface DraftModeRouteOptions {\n /** The Zap site's key: a token minted for any other site is refused. */\n siteKey: string\n /**\n * One of the site's own API keys (the secret key the server already reads\n * with, or its public key). Zap refuses a token minted for any other site\n * than this key's. Server-side only; it is sent to Zap and nowhere else.\n */\n apiKey: string\n /** `draftMode` from `next/headers`. */\n draftMode: () => DraftModeLike | Promise<DraftModeLike>\n /** `cookies` from `next/headers`, to re-issue Next's draft cookie partitioned. */\n cookies?: () => CookieStoreLike | Promise<CookieStoreLike>\n /** Where Zap answers. Default `https://zap.eel.software`. */\n zapOrigin?: string\n /** Injected in specs. */\n fetch?: typeof fetch\n}\n\nexport type DraftModeRefusal =\n | 'invalid_token'\n | 'invalid_path'\n | 'token_refused'\n | 'wrong_site'\n | 'zap_unreachable'\n\nconst NO_STORE = { 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' }\n\nfunction refuse(status: number, error: DraftModeRefusal): Response {\n return Response.json({ error }, { status, headers: NO_STORE })\n}\n\n/** A 307 to `path` (already `safeRedirectPath`ed) that sets `cookies`. */\nfunction redirect(path: string, cookies: string[]): Response {\n const headers = new Headers({ Location: path, ...NO_STORE })\n for (const cookie of cookies) headers.append('Set-Cookie', cookie)\n return new Response(null, { status: 307, headers })\n}\n\nfunction requireKeys(options: { siteKey?: string; apiKey?: string } | undefined): void {\n if (!options?.siteKey || !options.apiKey) {\n throw new Error('eelzap/next: siteKey and apiKey (the site API key) are required')\n }\n}\n\n// eslint-disable-next-line no-control-regex\nconst UNSAFE_PATH_CHARS = /[\\u0000-\\u001f\\u007f\\\\]/\n\n/**\n * The path to redirect to, or null when it could leave the site. Returned\n * as path + query + hash, never absolute, so the `Location` stays on the\n * host the browser asked.\n */\nexport function safeRedirectPath(raw: string | null, requestUrl: string): string | null {\n const value = raw ?? '/'\n if (value.length === 0 || value.length > 2048) return null\n if (!value.startsWith('/') || value.startsWith('//')) return null\n if (UNSAFE_PATH_CHARS.test(value)) return null\n let base: URL\n let target: URL\n try {\n base = new URL(requestUrl)\n target = new URL(value, base)\n } catch {\n return null\n }\n if (target.origin !== base.origin) return null\n // Dot segments can collapse into a leading `//` (`/.//evil.example`,\n // `/a/..//evil.example`), and a `Location` of `//evil.example` is\n // protocol-relative: another host. The resolved path must not start so.\n if (target.pathname.startsWith('//')) return null\n if (/zpt_/i.test(target.search) || /zpt_/i.test(target.hash)) return null\n return `${target.pathname}${target.search}${target.hash}`\n}\n\nfunction cookieHeader(name: string, value: string, maxAge?: number): string {\n const parts = [`${name}=${value}`, 'Path=/', 'HttpOnly', 'Secure', 'SameSite=None', 'Partitioned']\n if (maxAge !== undefined) parts.push(`Max-Age=${maxAge}`)\n return parts.join('; ')\n}\n\ntype ZapValidationOptions = Pick<DraftModeRouteOptions, 'apiKey' | 'zapOrigin' | 'fetch'>\n\ntype ZapVerdict =\n | { ok: true; siteKey: string; expiresAt: number | null }\n | { ok: false; status: number; code: string | null }\n\n/** An expiry Zap may answer beside the site (ISO string or epoch ms), else null. */\nfunction readExpiry(value: unknown): number | null {\n const at = typeof value === 'string' ? Date.parse(value) : value\n return typeof at === 'number' && Number.isFinite(at) ? at : null\n}\n\n/** Ask Zap whether the token is live, and for which site. */\nasync function validateWithZap(token: string, options: ZapValidationOptions): Promise<ZapVerdict> {\n const doFetch = options.fetch ?? fetch\n const origin = new URL(options.zapOrigin ?? DEFAULT_ZAP_ORIGIN).origin\n const response = await doFetch(`${origin}${TOKEN_VALIDATION_PATH}`, {\n method: 'GET',\n headers: {\n Authorization: `Bearer ${token}`,\n [SITE_API_KEY_HEADER]: options.apiKey,\n Accept: 'application/json',\n },\n cache: 'no-store',\n redirect: 'error',\n signal: AbortSignal.timeout(VALIDATION_TIMEOUT_MS),\n })\n if (!response.ok) {\n const body = (await response.json().catch(() => null)) as { error?: { code?: unknown } } | null\n const code = typeof body?.error?.code === 'string' ? body.error.code : null\n return { ok: false, status: response.status, code }\n }\n const body = (await response.json().catch(() => null)) as {\n site?: { key?: unknown }\n expiresAt?: unknown\n token?: { expiresAt?: unknown }\n } | null\n const key = body?.site?.key\n return typeof key === 'string'\n ? { ok: true, siteKey: key, expiresAt: readExpiry(body?.expiresAt ?? body?.token?.expiresAt) }\n : { ok: false, status: 502, code: null }\n}\n\nexport function createDraftModeRoute(options: DraftModeRouteOptions) {\n requireKeys(options)\n return async function GET(request: Request): Promise<Response> {\n const url = new URL(request.url)\n const token = url.searchParams.get('token')\n if (!isPreviewTokenShape(token)) return refuse(401, 'invalid_token')\n const path = safeRedirectPath(url.searchParams.get('path'), request.url)\n if (path === null) return refuse(400, 'invalid_path')\n\n let verdict: ZapVerdict\n try {\n verdict = await validateWithZap(token, options)\n } catch {\n return refuse(502, 'zap_unreachable')\n }\n if (!verdict.ok) {\n if (verdict.status >= 500) return refuse(502, 'zap_unreachable')\n return verdict.code === 'WRONG_SITE'\n ? refuse(403, 'wrong_site')\n : refuse(401, 'token_refused')\n }\n if (verdict.siteKey !== options.siteKey) return refuse(403, 'wrong_site')\n\n const draft = await options.draftMode()\n await draft.enable()\n\n const cookies = [cookieHeader(PREVIEW_TOKEN_COOKIE, token, PREVIEW_COOKIE_MAX_AGE)]\n // Next set its own draft cookie when `enable()` ran, without\n // `Partitioned`; the same value again, partitioned, is the copy Chrome\n // keeps inside a third-party frame.\n const bypass = options.cookies ? (await options.cookies()).get(NEXT_DRAFT_COOKIE)?.value : null\n if (bypass && /^[A-Za-z0-9._~-]+$/.test(bypass)) {\n // No longer than the token: past it the page would sit in draft mode\n // with nothing to read drafts with.\n cookies.push(cookieHeader(NEXT_DRAFT_COOKIE, bypass, PREVIEW_COOKIE_MAX_AGE))\n }\n return redirect(path, cookies)\n }\n}\n\ninterface DraftModeExitLike {\n disable(): void | Promise<void>\n}\n\nexport interface DraftModeExitRouteOptions {\n /** `draftMode` from `next/headers`. */\n draftMode: () => DraftModeExitLike | Promise<DraftModeExitLike>\n}\n\n/**\n * The route that ends a preview: `createDraftModeRoute`'s twin.\n *\n * ```ts\n * // app/api/zap-preview/exit/route.ts\n * import { draftMode } from 'next/headers'\n * import { createDraftModeExitRoute } from '@8ux-co/eelzap/next'\n *\n * export const GET = createDraftModeExitRoute({ draftMode })\n * ```\n *\n * Disables Next's draft mode, then expires BOTH cookies the enable route set,\n * with the same `SameSite=None; Secure; Partitioned; HttpOnly; Path=/`: a\n * partitioned cookie is only replaced by a partitioned one, so `disable()`\n * alone (Next's unpartitioned expiry) would leave the framed copy alive.\n * Redirects (307) to `?path=` when `safeRedirectPath` accepts it, else to `/`.\n *\n * Mount it at `{draftRoute}/exit` (`/api/zap-preview/exit` by default): the\n * live-site toolbar's sign-out calls exactly that path to drop the draft\n * cookie a draft session left (ADR 041 amendment 2026-10-06). Without it,\n * signing out still revokes the session's tokens at Zap, and the cookie\n * expires within its ten minutes.\n */\nexport function createDraftModeExitRoute(options: DraftModeExitRouteOptions) {\n if (!options?.draftMode) throw new Error('eelzap/next: draftMode is required')\n return async function GET(request: Request): Promise<Response> {\n const url = new URL(request.url)\n const path = safeRedirectPath(url.searchParams.get('path'), request.url) ?? '/'\n const draft = await options.draftMode()\n await draft.disable()\n return redirect(path, [\n cookieHeader(NEXT_DRAFT_COOKIE, '', 0),\n cookieHeader(PREVIEW_TOKEN_COOKIE, '', 0),\n ])\n }\n}\n\ninterface HeaderSource {\n get(name: string): string | null | undefined\n}\n\n/**\n * The preview token for this request, from the client's header (the\n * cookieless fallback) or the cookie the draft-mode route set; null when\n * neither carries a well-formed one. Pass it to the server SDK as the\n * credential (`createClient({ apiKey: token })`), which reads drafts with it.\n *\n * ```ts\n * import { cookies, headers } from 'next/headers'\n * const token = getPreviewToken({ headers: await headers(), cookies: await cookies() })\n * ```\n */\nexport function getPreviewToken(\n source: Request | { headers?: HeaderSource; cookies?: CookieStoreLike },\n): string | null {\n const request = source instanceof Request\n const fromHeader = source.headers?.get(PREVIEW_TOKEN_HEADER)\n if (isPreviewTokenShape(fromHeader)) return fromHeader\n const fromCookie = request\n ? readCookie(source.headers.get('cookie'), PREVIEW_TOKEN_COOKIE)\n : source.cookies?.get(PREVIEW_TOKEN_COOKIE)?.value\n return isPreviewTokenShape(fromCookie) ? fromCookie : null\n}\n\nexport interface ValidPreviewTokenOptions {\n /** The Zap site's key: a token minted for any other site is refused. */\n siteKey: string\n /** One of the site's own API keys, as for `createDraftModeRoute`. Server-side only. */\n apiKey: string\n /** Where Zap answers. Default `https://zap.eel.software`. */\n zapOrigin?: string\n /** Injected in specs. */\n fetch?: typeof fetch\n}\n\n/** A confirmed token is trusted this long at most, less when it expires sooner. */\nconst VALID_TOKEN_CACHE_MS = 60_000\n/** A refused token is re-asked after this long. */\nconst REFUSED_TOKEN_CACHE_MS = 10_000\nconst TOKEN_CACHE_MAX = 500\nconst tokenCache = new Map<string, { valid: boolean; until: number }>()\n\nfunction rememberVerdict(key: string, valid: boolean, until: number): void {\n tokenCache.delete(key)\n // Full: drop the oldest (a Map iterates in insertion order).\n if (tokenCache.size >= TOKEN_CACHE_MAX) tokenCache.delete(tokenCache.keys().next().value!)\n tokenCache.set(key, { valid, until })\n}\n\n/** Test-only: forget every cached verdict. */\nexport function __resetPreviewTokenCacheForTests(): void {\n tokenCache.clear()\n}\n\n/**\n * `getPreviewToken`, checked with Zap: the token for this request only when\n * Zap confirms it is live AND was minted for this site, else null.\n *\n * `getPreviewToken` accepts any well-formed `x-zap-preview-token` header, and\n * anyone can send one: a token minted for their own Zap site, or one that\n * expired or was revoked. **Use this on every request that serves drafts**,\n * so a stranger's token never makes your server render drafts. It asks\n * `GET /api/public/v1/preview/token` exactly as the draft-mode route does\n * (the token as the bearer, your API key beside it) and compares the site\n * key Zap answers with `siteKey`.\n *\n * Verdicts are cached in memory per token, site and key, at most 500 of\n * them: a confirmed token for 60 seconds or until the expiry Zap answers,\n * whichever comes first; a refused one for 10 seconds. Zap unreachable is\n * null and not cached. The token is never logged.\n *\n * ```ts\n * import { cookies, headers } from 'next/headers'\n * const token = await getValidPreviewToken(\n * { headers: await headers(), cookies: await cookies() },\n * { siteKey: 'verdeorigen', apiKey: process.env.ZAP_SECRET_KEY! },\n * )\n * ```\n */\nexport async function getValidPreviewToken(\n source: Request | { headers?: HeaderSource; cookies?: CookieStoreLike },\n options: ValidPreviewTokenOptions,\n): Promise<string | null> {\n requireKeys(options)\n const token = getPreviewToken(source)\n if (token === null) return null\n\n // Keyed by everything the verdict depends on: one process may serve\n // several sites, and a token valid for one is not valid for another.\n const key = `${options.zapOrigin}\\n${options.siteKey}\\n${options.apiKey}\\n${token}`\n const now = Date.now()\n const cached = tokenCache.get(key)\n if (cached && cached.until > now) return cached.valid ? token : null\n\n let verdict: ZapVerdict\n try {\n verdict = await validateWithZap(token, options)\n } catch {\n return null\n }\n if (!verdict.ok) {\n if (verdict.status < 500) rememberVerdict(key, false, now + REFUSED_TOKEN_CACHE_MS)\n return null\n }\n if (verdict.siteKey !== options.siteKey) {\n rememberVerdict(key, false, now + REFUSED_TOKEN_CACHE_MS)\n return null\n }\n if (verdict.expiresAt !== null && verdict.expiresAt <= now) {\n rememberVerdict(key, false, now + REFUSED_TOKEN_CACHE_MS)\n return null\n }\n const until = Math.min(now + VALID_TOKEN_CACHE_MS, verdict.expiresAt ?? Number.POSITIVE_INFINITY)\n rememberVerdict(key, true, until)\n return token\n}\n\nfunction readCookie(header: string | null, name: string): string | null {\n if (!header) return null\n for (const part of header.split(';')) {\n const [key, ...rest] = part.trim().split('=')\n if (key === name) return rest.join('=')\n }\n return null\n}\n\n/**\n * The path `createDraftModeRoute` is documented at, and the one `ZapPreview`\n * announces to Zap's editor unless told otherwise (§2.5): mount the route at\n * `app/api/zap-preview/route.ts`, or pass `draftRoute` with its path.\n */\nexport const DEFAULT_DRAFT_ROUTE = '/api/zap-preview'\n\n/**\n * The preview boot for a Next.js site (§2.7): render it once, in the root\n * layout. It announces the draft-mode route (`/api/zap-preview` unless\n * `draftRoute` says otherwise; `null` for none) and loads the overlay only in\n * a preview session. Pass `preview={(await draftMode()).isEnabled}` so a\n * draft-mode page in its own tab boots too.\n *\n * ```tsx\n * import { draftMode } from 'next/headers'\n * import { ZapPreview } from '@8ux-co/eelzap/next'\n *\n * <ZapPreview siteKey=\"verdeorigen\" siteId=\"…uuid…\" preview={(await draftMode()).isEnabled} />\n * ```\n *\n * A server component that renders the client boot from `@8ux-co/eelzap/react`.\n */\nexport function ZapPreview(props: ZapPreviewProps) {\n return createElement(ClientZapPreview, {\n ...props,\n draftRoute: props.draftRoute === undefined ? DEFAULT_DRAFT_ROUTE : props.draftRoute,\n })\n}\n\n/**\n * True when this request carries a preview token in the shape Zap mints (the\n * client's header or the draft-mode cookie): a cheap hint for rendering\n * choices (skip a cache, render `ZapPreview` with `preview`). It does NOT\n * prove the token is live or yours; serve drafts only with\n * `getValidPreviewToken`.\n */\nexport function isZapPreview(\n source: Request | { headers?: HeaderSource; cookies?: CookieStoreLike },\n): boolean {\n return getPreviewToken(source) !== null\n}\n","/**\n * The record and field grammar of tags (`.docs/proposals/zap-cms-v2.md` §2.4),\n * in a module of its own so the light entries (`./fields`, `./react`, the\n * boot) can name records without pulling in the preview protocol.\n * No imports, no side effects.\n */\n\n/**\n * A record reference in its canonical string form: `collectionKey/slug` for an\n * entry, `doc:documentKey` for a document (the tag grammar, §2.4).\n */\nexport type RecordRef = string\n\n/** Zap's field key pattern. */\nexport const FIELD_KEY_RE = /^[a-z][a-z0-9_]{0,127}$/\n/** Entry and document references; keys and slugs are `[a-z0-9]+(?:-[a-z0-9]+)*`. */\nconst ENTRY_REF_RE = /^([a-z0-9]+(?:-[a-z0-9]+)*)\\/([a-z0-9]+(?:-[a-z0-9]+)*)$/\nconst DOC_REF_RE = /^doc:([a-z0-9]+(?:-[a-z0-9]+)*)$/\nconst MAX_REF_LENGTH = 300\n\nexport type ParsedRecordRef =\n | { kind: 'entry'; collection: string; slug: string }\n | { kind: 'document'; key: string }\n\nexport function parseRecordRef(value: unknown): ParsedRecordRef | null {\n if (typeof value !== 'string' || value.length > MAX_REF_LENGTH) return null\n const doc = DOC_REF_RE.exec(value)\n if (doc) return { kind: 'document', key: doc[1]! }\n const entry = ENTRY_REF_RE.exec(value)\n if (entry) return { kind: 'entry', collection: entry[1]!, slug: entry[2]! }\n return null\n}\n\nexport function isRecordRef(value: unknown): value is RecordRef {\n return parseRecordRef(value) !== null\n}\n\n/** `zpt_` + 32 bytes in base64url, as Zap mints it (zap-cms-v2 §3.5). */\nexport const PREVIEW_TOKEN_RE = /^zpt_[A-Za-z0-9_-]{43}$/\n\n/** A preview token in the shape Zap mints; says nothing about whether it is live. */\nexport function isPreviewTokenShape(value: unknown): value is string {\n return typeof value === 'string' && PREVIEW_TOKEN_RE.test(value)\n}\n\nexport function isFieldKey(value: unknown): value is string {\n return typeof value === 'string' && FIELD_KEY_RE.test(value)\n}\n\n/**\n * What `zapAttrs` and `onValues` accept: a record reference string\n * (`blog/hola`, `doc:home`) or a delivery API entry (an item carries\n * `collection.key` and `slug`, a document `key`).\n */\nexport type EntryLike =\n | RecordRef\n | {\n slug?: string | null\n key?: string | null\n collection?: { key?: string | null } | null\n localizedSlugs?: Record<string, string | null | undefined> | null\n }\n\n/** Every reference an entry answers to (default slug first, then localized ones). */\nexport function recordRefsOf(entry: EntryLike | null | undefined): RecordRef[] {\n if (!entry) return []\n if (typeof entry === 'string') return isRecordRef(entry) ? [entry] : []\n const refs: RecordRef[] = []\n const add = (ref: string) => {\n if (isRecordRef(ref) && !refs.includes(ref)) refs.push(ref)\n }\n const collection = entry.collection?.key\n if (collection) {\n if (entry.slug) add(`${collection}/${entry.slug}`)\n for (const slug of Object.values(entry.localizedSlugs ?? {})) {\n if (slug) add(`${collection}/${slug}`)\n }\n } else if (entry.key) {\n add(`doc:${entry.key}`)\n }\n return refs\n}\n\n/**\n * The tag for one field, as attributes to spread on the element that renders\n * it: `{ 'data-zap': 'blog/hola#title' }`. Empty when the entry or the field\n * key is not one Zap can name, so a bad call never breaks the page.\n */\nexport function zapAttrs(entry: EntryLike, fieldKey: string): { 'data-zap'?: string } {\n const [ref] = recordRefsOf(entry)\n if (!ref || !isFieldKey(fieldKey)) return {}\n return { 'data-zap': `${ref}#${fieldKey}` }\n}\n"],"mappings":"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","names":["body"]}
1
+ {"version":3,"sources":["../src/next.ts","../src/refs.ts","../src/preview/draft-path.ts"],"sourcesContent":["import { createElement } from 'react'\n\nimport { ZapPreview as ClientZapPreview, type ZapPreviewProps } from './boot/component'\nimport { isPreviewTokenShape } from './refs'\nimport { safeRedirectPath } from './preview/draft-path'\n\nexport { safeRedirectPath } from './preview/draft-path'\n\n/**\n * `@8ux-co/eelzap/next` — the draft-mode route, `isZapPreview()` and the\n * preview boot for a Next.js site (zap-cms-v2 §2.5 \"Draft values\", §2.7, §3.5).\n *\n * ```ts\n * // app/api/zap-preview/route.ts\n * import { cookies, draftMode } from 'next/headers'\n * import { createDraftModeRoute } from '@8ux-co/eelzap/next'\n *\n * export const POST = createDraftModeRoute({\n * siteKey: 'verdeorigen',\n * apiKey: process.env.ZAP_SECRET_KEY!,\n * draftMode,\n * cookies,\n * })\n * export const GET = POST\n * ```\n *\n * and `<ZapPreview siteKey=\"verdeorigen\" />` in the root layout, which tells\n * Zap's editor the route's path (`zap:ready`, §2.5); the editor joins it to the\n * frame's verified origin. Clients advertising `fragment-token` opt into\n * `{origin}{route}#token=…&path=…`; older clients keep query URLs.\n * GET serves a tiny page and a same-origin script that clears the fragment\n * and submits a form POST.\n * Fragments never travel in HTTP request URLs or site request logs.\n *\n * ## What the route does, in order\n *\n * 1. Requires same-origin evidence for POST (Sec-Fetch-Site or Origin), then\n * reads `token` and `path` from the form body. Legacy GET query tokens\n * remain supported. A token not shaped like\n * Zap's\n * (`zpt_` + 43 base64url characters) is refused (401) without a request.\n * 2. Refuses a `path` that could leave the site: it must start with one `/`,\n * carry no backslash or control character, resolve to this request's own\n * origin, and carry no token itself (400). An open redirect on the site's\n * own domain is the classic draft-mode hole; this closes it.\n * 3. Validates the token WITH ZAP (`GET /api/public/v1/preview/token`),\n * sending the site's own API key beside it, and Zap checks the token was\n * minted for the site that key belongs to. Without that binding, any Zap\n * account could mint a token for its own site and switch YOUR site into\n * draft mode, with YOUR server rendering THEIR drafts on your domain. The\n * site key alone cannot carry it: it is unique within a workspace only, so\n * anyone can name a site `verdeorigen` in a workspace of their own (P7\n * audit). The answer's site key is compared too. An unknown, expired or\n * revoked token, or another site's, is refused (401 / 403); Zap\n * unreachable is 502. Nothing is enabled on a refusal.\n * 4. Enables Next's draft mode and stores the token in its own cookie, so the\n * server can read drafts with it (`getValidPreviewToken`, which checks it\n * with Zap again; `getPreviewToken` only checks its shape). Both cookies are\n * `SameSite=None; Secure; Partitioned; HttpOnly`: the page renders inside\n * Zap's frame, a third-party context where anything else is dropped, and\n * Chrome keeps a partitioned (CHIPS) cookie under third-party cookie\n * restrictions. Safari blocks frame cookies regardless; the client's\n * cookieless fallback covers it (`previewHeaders` → `getValidPreviewToken`).\n * 5. Redirects (303 after POST; 307 for legacy GET) to `path`, without the\n * token, `Cache-Control: no-store`,\n * `Referrer-Policy: no-referrer`.\n *\n * `createDraftModeExitRoute` ends the preview: both cookies expired, same\n * attributes, and a redirect through the same `safeRedirectPath`.\n *\n * Next is not imported: `draftMode` and `cookies` are passed in, so the\n * helper works with any Next version that has them and is testable alone.\n */\n\n/** The cookie the token travels in after the exchange. */\nexport const PREVIEW_TOKEN_COOKIE = '__zap_preview'\n/** Next's own draft-mode cookie, re-issued partitioned. */\nexport const NEXT_DRAFT_COOKIE = '__prerender_bypass'\n/** The header the client's cookieless fallback sends (`previewHeaders`). */\nexport const PREVIEW_TOKEN_HEADER = 'x-zap-preview-token'\nexport const DEFAULT_ZAP_ORIGIN = 'https://zap.eel.software'\nexport const TOKEN_VALIDATION_PATH = '/api/public/v1/preview/token'\n/** The header the site's own API key travels in to the validation call. */\nexport const SITE_API_KEY_HEADER = 'X-Zap-Site-Api-Key'\n/** A preview token lives ten minutes (§3.5); its cookie no longer. */\nconst PREVIEW_COOKIE_MAX_AGE = 600\nconst VALIDATION_TIMEOUT_MS = 5000\n\ninterface DraftModeLike {\n enable(): void | Promise<void>\n}\n\ninterface CookieStoreLike {\n get(name: string): { value: string } | undefined\n}\n\nexport interface DraftModeRouteOptions {\n /** The Zap site's key: a token minted for any other site is refused. */\n siteKey: string\n /**\n * One of the site's own API keys (the secret key the server already reads\n * with, or its public key). Zap refuses a token minted for any other site\n * than this key's. Server-side only; it is sent to Zap and nowhere else.\n */\n apiKey: string\n /** `draftMode` from `next/headers`. */\n draftMode: () => DraftModeLike | Promise<DraftModeLike>\n /** `cookies` from `next/headers`, to re-issue Next's draft cookie partitioned. */\n cookies?: () => CookieStoreLike | Promise<CookieStoreLike>\n /** Where Zap answers. Default `https://zap.eel.software`. */\n zapOrigin?: string\n /** Injected in specs. */\n fetch?: typeof fetch\n}\n\nexport type DraftModeRefusal =\n | 'cross_origin'\n | 'invalid_token'\n | 'invalid_path'\n | 'token_refused'\n | 'wrong_site'\n | 'zap_unreachable'\n\nconst NO_STORE = { 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' }\n\nfunction refuse(status: number, error: DraftModeRefusal): Response {\n return Response.json({ error }, { status, headers: NO_STORE })\n}\n\n/** A redirect to `path` (already `safeRedirectPath`ed) that sets `cookies`. */\nfunction redirect(path: string, cookies: string[], status = 307): Response {\n const headers = new Headers({ Location: path, ...NO_STORE })\n for (const cookie of cookies) headers.append('Set-Cookie', cookie)\n return new Response(null, { status, headers })\n}\n\nfunction requireKeys(options: { siteKey?: string; apiKey?: string } | undefined): void {\n if (!options?.siteKey || !options.apiKey) {\n throw new Error('eelzap/next: siteKey and apiKey (the site API key) are required')\n }\n}\n\nfunction cookieHeader(name: string, value: string, maxAge?: number): string {\n const parts = [`${name}=${value}`, 'Path=/', 'HttpOnly', 'Secure', 'SameSite=None', 'Partitioned']\n if (maxAge !== undefined) parts.push(`Max-Age=${maxAge}`)\n return parts.join('; ')\n}\n\ntype ZapValidationOptions = Pick<DraftModeRouteOptions, 'apiKey' | 'zapOrigin' | 'fetch'>\n\ntype ZapVerdict =\n | { ok: true; siteKey: string; expiresAt: number | null }\n | { ok: false; status: number; code: string | null }\n\n/** An expiry Zap may answer beside the site (ISO string or epoch ms), else null. */\nfunction readExpiry(value: unknown): number | null {\n const at = typeof value === 'string' ? Date.parse(value) : value\n return typeof at === 'number' && Number.isFinite(at) ? at : null\n}\n\n/** Ask Zap whether the token is live, and for which site. */\nasync function validateWithZap(token: string, options: ZapValidationOptions): Promise<ZapVerdict> {\n const doFetch = options.fetch ?? fetch\n const origin = new URL(options.zapOrigin ?? DEFAULT_ZAP_ORIGIN).origin\n const response = await doFetch(`${origin}${TOKEN_VALIDATION_PATH}`, {\n method: 'GET',\n headers: {\n Authorization: `Bearer ${token}`,\n [SITE_API_KEY_HEADER]: options.apiKey,\n Accept: 'application/json',\n },\n cache: 'no-store',\n redirect: 'error',\n signal: AbortSignal.timeout(VALIDATION_TIMEOUT_MS),\n })\n if (!response.ok) {\n const body = (await response.json().catch(() => null)) as { error?: { code?: unknown } } | null\n const code = typeof body?.error?.code === 'string' ? body.error.code : null\n return { ok: false, status: response.status, code }\n }\n const body = (await response.json().catch(() => null)) as {\n site?: { key?: unknown }\n expiresAt?: unknown\n token?: { expiresAt?: unknown }\n } | null\n const key = body?.site?.key\n return typeof key === 'string'\n ? { ok: true, siteKey: key, expiresAt: readExpiry(body?.expiresAt ?? body?.token?.expiresAt) }\n : { ok: false, status: 502, code: null }\n}\n\n/** Static script: the token is read only in the browser, never reflected by the server. */\nconst FRAGMENT_EXCHANGE_SCRIPT = `(() => {\n const params = new URLSearchParams(location.hash.slice(1));\n history.replaceState(null, '', location.pathname);\n const token = params.get('token');\n if (!/^zpt_[A-Za-z0-9_-]{43}$/.test(token || '')) return;\n const form = document.createElement('form');\n form.method = 'POST';\n form.action = location.pathname;\n for (const [name, value] of [['token', token], ['path', params.get('path') ?? '/']]) {\n const input = document.createElement('input');\n input.type = 'hidden';\n input.name = name;\n input.value = value;\n form.appendChild(input);\n }\n document.body.appendChild(form);\n form.submit();\n})();`\n\nfunction fragmentExchangeScript(): Response {\n return new Response(FRAGMENT_EXCHANGE_SCRIPT, {\n headers: { ...NO_STORE, 'Content-Type': 'application/javascript; charset=utf-8' },\n })\n}\n\nfunction fragmentExchangePage(): Response {\n const html = `<!doctype html><html><head><meta charset=\"utf-8\"><title>Zap preview</title></head><body>\n<script src=\"?script=1\"></script><noscript>JavaScript is required to enter Zap preview.</noscript></body></html>`\n return new Response(html, {\n headers: {\n ...NO_STORE,\n 'Content-Type': 'text/html; charset=utf-8',\n 'Content-Security-Policy':\n \"default-src 'none'; script-src 'self'; form-action 'self'; base-uri 'none'\",\n },\n })\n}\n\nexport function createDraftModeRoute(options: DraftModeRouteOptions) {\n requireKeys(options)\n const exchange = async (request: Request): Promise<Response> => {\n let params: URLSearchParams | FormData\n if (request.method === 'POST') {\n const sameOrigin =\n request.headers.get('sec-fetch-site') === 'same-origin' ||\n request.headers.get('origin') === new URL(request.url).origin\n if (!sameOrigin) return refuse(403, 'cross_origin')\n try {\n params = await request.formData()\n } catch {\n return refuse(401, 'invalid_token')\n }\n } else {\n params = new URL(request.url).searchParams\n }\n const token = params.get('token')\n if (!isPreviewTokenShape(token)) return refuse(401, 'invalid_token')\n const rawPath = params.get('path')\n if (rawPath !== null && typeof rawPath !== 'string') return refuse(400, 'invalid_path')\n const path = safeRedirectPath(rawPath, request.url)\n if (path === null) return refuse(400, 'invalid_path')\n\n let verdict: ZapVerdict\n try {\n verdict = await validateWithZap(token, options)\n } catch {\n return refuse(502, 'zap_unreachable')\n }\n if (!verdict.ok) {\n if (verdict.status >= 500) return refuse(502, 'zap_unreachable')\n return verdict.code === 'WRONG_SITE'\n ? refuse(403, 'wrong_site')\n : refuse(401, 'token_refused')\n }\n if (verdict.siteKey !== options.siteKey) return refuse(403, 'wrong_site')\n\n const draft = await options.draftMode()\n await draft.enable()\n\n const cookies = [cookieHeader(PREVIEW_TOKEN_COOKIE, token, PREVIEW_COOKIE_MAX_AGE)]\n // Next set its own draft cookie when `enable()` ran, without\n // `Partitioned`; the same value again, partitioned, is the copy Chrome\n // keeps inside a third-party frame.\n const bypass = options.cookies ? (await options.cookies()).get(NEXT_DRAFT_COOKIE)?.value : null\n if (bypass && /^[A-Za-z0-9._~-]+$/.test(bypass)) {\n // No longer than the token: past it the page would sit in draft mode\n // with nothing to read drafts with.\n cookies.push(cookieHeader(NEXT_DRAFT_COOKIE, bypass, PREVIEW_COOKIE_MAX_AGE))\n }\n return redirect(path, cookies, request.method === 'POST' ? 303 : 307)\n }\n return async function draftModeRoute(request: Request): Promise<Response> {\n if (request.method === 'GET') {\n const params = new URL(request.url).searchParams\n if (params.get('script') === '1') return fragmentExchangeScript()\n if (!params.has('token')) return fragmentExchangePage()\n // Old sites keep using query transport until their SDK opts in.\n return exchange(request)\n }\n if (request.method === 'POST') return exchange(request)\n return new Response(null, { status: 405, headers: { ...NO_STORE, Allow: 'GET, POST' } })\n }\n}\n\ninterface DraftModeExitLike {\n disable(): void | Promise<void>\n}\n\nexport interface DraftModeExitRouteOptions {\n /** `draftMode` from `next/headers`. */\n draftMode: () => DraftModeExitLike | Promise<DraftModeExitLike>\n}\n\n/**\n * The route that ends a preview: `createDraftModeRoute`'s twin.\n *\n * ```ts\n * // app/api/zap-preview/exit/route.ts\n * import { draftMode } from 'next/headers'\n * import { createDraftModeExitRoute } from '@8ux-co/eelzap/next'\n *\n * export const GET = createDraftModeExitRoute({ draftMode })\n * ```\n *\n * Disables Next's draft mode, then expires BOTH cookies the enable route set,\n * with the same `SameSite=None; Secure; Partitioned; HttpOnly; Path=/`: a\n * partitioned cookie is only replaced by a partitioned one, so `disable()`\n * alone (Next's unpartitioned expiry) would leave the framed copy alive.\n * Redirects (307) to `?path=` when `safeRedirectPath` accepts it, else to `/`.\n *\n * Mount it at `{draftRoute}/exit` (`/api/zap-preview/exit` by default): the\n * live-site toolbar's sign-out calls exactly that path to drop the draft\n * cookie a draft session left (ADR 041 amendment 2026-10-06). Without it,\n * signing out still revokes the session's tokens at Zap, and the cookie\n * expires within its ten minutes.\n */\nexport function createDraftModeExitRoute(options: DraftModeExitRouteOptions) {\n if (!options?.draftMode) throw new Error('eelzap/next: draftMode is required')\n return async function GET(request: Request): Promise<Response> {\n const url = new URL(request.url)\n const path = safeRedirectPath(url.searchParams.get('path'), request.url) ?? '/'\n const draft = await options.draftMode()\n await draft.disable()\n return redirect(path, [\n cookieHeader(NEXT_DRAFT_COOKIE, '', 0),\n cookieHeader(PREVIEW_TOKEN_COOKIE, '', 0),\n ])\n }\n}\n\ninterface HeaderSource {\n get(name: string): string | null | undefined\n}\n\n/**\n * The preview token for this request, from the client's header (the\n * cookieless fallback) or the cookie the draft-mode route set; null when\n * neither carries a well-formed one. Pass it to the server SDK as the\n * credential (`createClient({ apiKey: token })`), which reads drafts with it.\n *\n * ```ts\n * import { cookies, headers } from 'next/headers'\n * const token = getPreviewToken({ headers: await headers(), cookies: await cookies() })\n * ```\n */\nexport function getPreviewToken(\n source: Request | { headers?: HeaderSource; cookies?: CookieStoreLike },\n): string | null {\n const request = source instanceof Request\n const fromHeader = source.headers?.get(PREVIEW_TOKEN_HEADER)\n if (isPreviewTokenShape(fromHeader)) return fromHeader\n const fromCookie = request\n ? readCookie(source.headers.get('cookie'), PREVIEW_TOKEN_COOKIE)\n : source.cookies?.get(PREVIEW_TOKEN_COOKIE)?.value\n return isPreviewTokenShape(fromCookie) ? fromCookie : null\n}\n\nexport interface ValidPreviewTokenOptions {\n /** The Zap site's key: a token minted for any other site is refused. */\n siteKey: string\n /** One of the site's own API keys, as for `createDraftModeRoute`. Server-side only. */\n apiKey: string\n /** Where Zap answers. Default `https://zap.eel.software`. */\n zapOrigin?: string\n /** Injected in specs. */\n fetch?: typeof fetch\n}\n\n/** A confirmed token is trusted this long at most, less when it expires sooner. */\nconst VALID_TOKEN_CACHE_MS = 60_000\n/** A refused token is re-asked after this long. */\nconst REFUSED_TOKEN_CACHE_MS = 10_000\nconst TOKEN_CACHE_MAX = 500\nconst tokenCache = new Map<string, { valid: boolean; until: number }>()\n\nfunction rememberVerdict(key: string, valid: boolean, until: number): void {\n tokenCache.delete(key)\n // Full: drop the oldest (a Map iterates in insertion order).\n if (tokenCache.size >= TOKEN_CACHE_MAX) tokenCache.delete(tokenCache.keys().next().value!)\n tokenCache.set(key, { valid, until })\n}\n\n/** Test-only: forget every cached verdict. */\nexport function __resetPreviewTokenCacheForTests(): void {\n tokenCache.clear()\n}\n\n/**\n * `getPreviewToken`, checked with Zap: the token for this request only when\n * Zap confirms it is live AND was minted for this site, else null.\n *\n * `getPreviewToken` accepts any well-formed `x-zap-preview-token` header, and\n * anyone can send one: a token minted for their own Zap site, or one that\n * expired or was revoked. **Use this on every request that serves drafts**,\n * so a stranger's token never makes your server render drafts. It asks\n * `GET /api/public/v1/preview/token` exactly as the draft-mode route does\n * (the token as the bearer, your API key beside it) and compares the site\n * key Zap answers with `siteKey`.\n *\n * Verdicts are cached in memory per token, site and key, at most 500 of\n * them: a confirmed token for 60 seconds or until the expiry Zap answers,\n * whichever comes first; a refused one for 10 seconds. Zap unreachable is\n * null and not cached. The token is never logged.\n *\n * ```ts\n * import { cookies, headers } from 'next/headers'\n * const token = await getValidPreviewToken(\n * { headers: await headers(), cookies: await cookies() },\n * { siteKey: 'verdeorigen', apiKey: process.env.ZAP_SECRET_KEY! },\n * )\n * ```\n */\nexport async function getValidPreviewToken(\n source: Request | { headers?: HeaderSource; cookies?: CookieStoreLike },\n options: ValidPreviewTokenOptions,\n): Promise<string | null> {\n requireKeys(options)\n const token = getPreviewToken(source)\n if (token === null) return null\n\n // Keyed by everything the verdict depends on: one process may serve\n // several sites, and a token valid for one is not valid for another.\n const key = `${options.zapOrigin}\\n${options.siteKey}\\n${options.apiKey}\\n${token}`\n const now = Date.now()\n const cached = tokenCache.get(key)\n if (cached && cached.until > now) return cached.valid ? token : null\n\n let verdict: ZapVerdict\n try {\n verdict = await validateWithZap(token, options)\n } catch {\n return null\n }\n if (!verdict.ok) {\n if (verdict.status < 500) rememberVerdict(key, false, now + REFUSED_TOKEN_CACHE_MS)\n return null\n }\n if (verdict.siteKey !== options.siteKey) {\n rememberVerdict(key, false, now + REFUSED_TOKEN_CACHE_MS)\n return null\n }\n if (verdict.expiresAt !== null && verdict.expiresAt <= now) {\n rememberVerdict(key, false, now + REFUSED_TOKEN_CACHE_MS)\n return null\n }\n const until = Math.min(now + VALID_TOKEN_CACHE_MS, verdict.expiresAt ?? Number.POSITIVE_INFINITY)\n rememberVerdict(key, true, until)\n return token\n}\n\nfunction readCookie(header: string | null, name: string): string | null {\n if (!header) return null\n for (const part of header.split(';')) {\n const [key, ...rest] = part.trim().split('=')\n if (key === name) return rest.join('=')\n }\n return null\n}\n\n/**\n * The path `createDraftModeRoute` is documented at, and the one `ZapPreview`\n * announces to Zap's editor unless told otherwise (§2.5): mount the route at\n * `app/api/zap-preview/route.ts`, or pass `draftRoute` with its path.\n */\nexport const DEFAULT_DRAFT_ROUTE = '/api/zap-preview'\n\n/**\n * The preview boot for a Next.js site (§2.7): render it once, in the root\n * layout. It announces the draft-mode route (`/api/zap-preview` unless\n * `draftRoute` says otherwise; `null` for none) and loads the overlay only in\n * a preview session. Pass `preview={(await draftMode()).isEnabled}` so a\n * draft-mode page in its own tab boots too.\n *\n * ```tsx\n * import { draftMode } from 'next/headers'\n * import { ZapPreview } from '@8ux-co/eelzap/next'\n *\n * <ZapPreview siteKey=\"verdeorigen\" siteId=\"…uuid…\" preview={(await draftMode()).isEnabled} />\n * ```\n *\n * A server component that renders the client boot from `@8ux-co/eelzap/react`.\n */\nexport function ZapPreview(props: ZapPreviewProps) {\n return createElement(ClientZapPreview, {\n ...props,\n draftRoute: props.draftRoute === undefined ? DEFAULT_DRAFT_ROUTE : props.draftRoute,\n })\n}\n\n/**\n * True when this request carries a preview token in the shape Zap mints (the\n * client's header or the draft-mode cookie): a cheap hint for rendering\n * choices (skip a cache, render `ZapPreview` with `preview`). It does NOT\n * prove the token is live or yours; serve drafts only with\n * `getValidPreviewToken`.\n */\nexport function isZapPreview(\n source: Request | { headers?: HeaderSource; cookies?: CookieStoreLike },\n): boolean {\n return getPreviewToken(source) !== null\n}\n","/**\n * The record and field grammar of tags (`.docs/proposals/zap-cms-v2.md` §2.4),\n * in a module of its own so the light entries (`./fields`, `./react`, the\n * boot) can name records without pulling in the preview protocol.\n * No imports, no side effects.\n */\n\n/**\n * A record reference in its canonical string form: `collectionKey/slug` for an\n * entry, `doc:documentKey` for a document (the tag grammar, §2.4).\n */\nexport type RecordRef = string\n\n/** Zap's field key pattern. */\nexport const FIELD_KEY_RE = /^[a-z][a-z0-9_]{0,127}$/\n/** Entry and document references; keys and slugs are `[a-z0-9]+(?:-[a-z0-9]+)*`. */\nconst ENTRY_REF_RE = /^([a-z0-9]+(?:-[a-z0-9]+)*)\\/([a-z0-9]+(?:-[a-z0-9]+)*)$/\nconst DOC_REF_RE = /^doc:([a-z0-9]+(?:-[a-z0-9]+)*)$/\nconst MAX_REF_LENGTH = 300\n\nexport type ParsedRecordRef =\n | { kind: 'entry'; collection: string; slug: string }\n | { kind: 'document'; key: string }\n\nexport function parseRecordRef(value: unknown): ParsedRecordRef | null {\n if (typeof value !== 'string' || value.length > MAX_REF_LENGTH) return null\n const doc = DOC_REF_RE.exec(value)\n if (doc) return { kind: 'document', key: doc[1]! }\n const entry = ENTRY_REF_RE.exec(value)\n if (entry) return { kind: 'entry', collection: entry[1]!, slug: entry[2]! }\n return null\n}\n\nexport function isRecordRef(value: unknown): value is RecordRef {\n return parseRecordRef(value) !== null\n}\n\n/** `zpt_` + 32 bytes in base64url, as Zap mints it (zap-cms-v2 §3.5). */\nexport const PREVIEW_TOKEN_RE = /^zpt_[A-Za-z0-9_-]{43}$/\n\n/** A preview token in the shape Zap mints; says nothing about whether it is live. */\nexport function isPreviewTokenShape(value: unknown): value is string {\n return typeof value === 'string' && PREVIEW_TOKEN_RE.test(value)\n}\n\nexport function isFieldKey(value: unknown): value is string {\n return typeof value === 'string' && FIELD_KEY_RE.test(value)\n}\n\n/**\n * What `zapAttrs` and `onValues` accept: a record reference string\n * (`blog/hola`, `doc:home`) or a delivery API entry (an item carries\n * `collection.key` and `slug`, a document `key`).\n */\nexport type EntryLike =\n | RecordRef\n | {\n slug?: string | null\n key?: string | null\n collection?: { key?: string | null } | null\n localizedSlugs?: Record<string, string | null | undefined> | null\n }\n\n/** Every reference an entry answers to (default slug first, then localized ones). */\nexport function recordRefsOf(entry: EntryLike | null | undefined): RecordRef[] {\n if (!entry) return []\n if (typeof entry === 'string') return isRecordRef(entry) ? [entry] : []\n const refs: RecordRef[] = []\n const add = (ref: string) => {\n if (isRecordRef(ref) && !refs.includes(ref)) refs.push(ref)\n }\n const collection = entry.collection?.key\n if (collection) {\n if (entry.slug) add(`${collection}/${entry.slug}`)\n for (const slug of Object.values(entry.localizedSlugs ?? {})) {\n if (slug) add(`${collection}/${slug}`)\n }\n } else if (entry.key) {\n add(`doc:${entry.key}`)\n }\n return refs\n}\n\n/**\n * The tag for one field, as attributes to spread on the element that renders\n * it: `{ 'data-zap': 'blog/hola#title' }`. Empty when the entry or the field\n * key is not one Zap can name, so a bad call never breaks the page.\n */\nexport function zapAttrs(entry: EntryLike, fieldKey: string): { 'data-zap'?: string } {\n const [ref] = recordRefsOf(entry)\n if (!ref || !isFieldKey(fieldKey)) return {}\n return { 'data-zap': `${ref}#${fieldKey}` }\n}\n","// eslint-disable-next-line no-control-regex\nconst UNSAFE_PATH_CHARS = /[\\u0000-\\u001f\\u007f\\\\]/\n\n/**\n * The path to redirect to, or null when it could leave the site. Returned\n * as path + query + hash, never absolute, so the `Location` stays on the\n * host the browser asked.\n */\nexport function safeRedirectPath(raw: string | null, requestUrl: string): string | null {\n const value = raw ?? '/'\n if (value.length === 0 || value.length > 2048) return null\n if (!value.startsWith('/') || value.startsWith('//')) return null\n if (UNSAFE_PATH_CHARS.test(value)) return null\n let base: URL\n let target: URL\n try {\n base = new URL(requestUrl)\n target = new URL(value, base)\n } catch {\n return null\n }\n if (target.origin !== base.origin) return null\n // Dot segments can collapse into a leading `//` (`/.//evil.example`,\n // `/a/..//evil.example`), and a `Location` of `//evil.example` is\n // protocol-relative: another host. The resolved path must not start so.\n if (target.pathname.startsWith('//')) return null\n // Refuse a token anywhere in the redirect, including percent-encoded ones.\n let decoded = value\n for (let i = 0; i < value.length; i++) {\n if (/zpt_/i.test(decoded)) return null\n const next = decoded.replace(/%([0-9a-f]{2})/gi, (_, hex: string) =>\n String.fromCharCode(Number.parseInt(hex, 16)),\n )\n if (next === decoded) break\n decoded = next\n }\n return `${target.pathname}${target.search}${target.hash}`\n}\n"],"mappings":"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","names":["body"]}
package/dist/preview.d.ts CHANGED
@@ -101,6 +101,18 @@ declare function zapAttrs(entry: EntryLike, fieldKey: string): {
101
101
  * tagged elements, `runaway` when the count kept growing with nothing on
102
102
  * the page explaining it. The page stops writing values; the editor offers
103
103
  * a reload. Sent again after a hello, which a pause before it never reached.
104
+ * - Comparar (#953), two frames of one page side by side, the published one
105
+ * and the draft. Scroll sync, only to a page whose `zap:ready` advertised
106
+ * `scroll-sync`: `zap:scroll-sync` (editor → page) `{ enabled }` starts or
107
+ * stops `zap:scroll` (page → editor) `{ y, x?, anchor? }`, one per animation
108
+ * frame at most, positions as fractions (0..1) of the scrollable range and
109
+ * `anchor` the tagged field nearest the viewport's top; `zap:scroll-to`
110
+ * (editor → page, same shape) places the page there, by the anchor when
111
+ * the page has that field, else by the fraction, and is never echoed back.
112
+ * Diff marks, only to a page whose `zap:ready` advertised `diff-marks`:
113
+ * `zap:marks` (editor → page) `{ marks }`, every element of each field
114
+ * marked in its tone, removed (published) or added (draft); ≤ 200 marks,
115
+ * an empty list clears them.
104
116
  */
105
117
  declare const PROTOCOL_SOURCE = "eel-zap";
106
118
  /**
@@ -124,10 +136,14 @@ declare const MAX_TAG_REPORT = 2000;
124
136
  declare const MAX_LABELS = 500;
125
137
  /** Pins drawn at once (`zap:pins`). */
126
138
  declare const MAX_PINS = 50;
139
+ /** Diff marks drawn at once (`zap:marks`), and the longest mark label. */
140
+ declare const MAX_MARKS = 200;
141
+ declare const MAX_MARK_LABEL = 200;
142
+ /** Highest occurrence index a scroll anchor may name (`MAX_TAGGED` in `tags.ts`). */
143
+ declare const MAX_ANCHOR_NTH = 5000;
127
144
  type OverlayMode = 'inspect' | 'select' | 'spot' | 'off';
128
145
  declare const OVERLAY_MODES: readonly OverlayMode[];
129
- type Capability = 'overlay' | 'values' | 'stega' | 'pins' | 'links' | 'refresh';
130
- /** Every capability; a client built from this source advertises all of them. */
146
+ type Capability = 'overlay' | 'values' | 'stega' | 'pins' | 'links' | 'refresh' | 'scroll-sync' | 'diff-marks' | 'fragment-token';
131
147
  declare const CAPABILITIES: readonly Capability[];
132
148
 
133
149
  /**
@@ -303,6 +319,39 @@ interface PausedPayload {
303
319
  reason: 'runaway' | 'cap';
304
320
  count: number;
305
321
  }
322
+ /**
323
+ * A tagged field's element as a scroll anchor: the `nth` element of the field
324
+ * (in `TagIndex.byField` order, 0 first) and its top's distance below the
325
+ * viewport's top, CSS px (negative once it scrolled past).
326
+ */
327
+ interface ScrollAnchor {
328
+ recordRef: RecordRef;
329
+ fieldKey: string;
330
+ nth: number;
331
+ offset: number;
332
+ }
333
+ /**
334
+ * `zap:scroll` and `zap:scroll-to`: the scroll position as fractions (0..1) of
335
+ * the scrollable range (`x` only when the page scrolls sideways), and the
336
+ * field nearest the viewport's top, which aligns two pages of different
337
+ * lengths better than the fraction does.
338
+ */
339
+ interface ScrollPayload {
340
+ y: number;
341
+ x?: number;
342
+ anchor?: ScrollAnchor;
343
+ }
344
+ /**
345
+ * One Comparar mark (`zap:marks`): every element of the field gets a box in
346
+ * its tone, red for `removed` (the published page), green for `added` (the
347
+ * draft); `active` (the change picked in the list) is solid, tinted and
348
+ * named with `label`.
349
+ */
350
+ interface DiffMark extends FieldRefPayload {
351
+ tone: 'removed' | 'added';
352
+ label: string;
353
+ active?: boolean;
354
+ }
306
355
  interface PageMessages {
307
356
  'zap:ready': ReadyPayload;
308
357
  'zap:tags': TagSummary[];
@@ -322,6 +371,8 @@ interface PageMessages {
322
371
  id: string;
323
372
  };
324
373
  'zap:paused': PausedPayload;
374
+ /** `scroll-sync`: the page scrolled (while sync is on); never for a `zap:scroll-to`. */
375
+ 'zap:scroll': ScrollPayload;
325
376
  }
326
377
  interface EditorMessages {
327
378
  'zap:hello': HelloPayload;
@@ -343,6 +394,16 @@ interface EditorMessages {
343
394
  'zap:pins': {
344
395
  pins: Pin[];
345
396
  };
397
+ /** `scroll-sync`: start or stop reporting `zap:scroll`. */
398
+ 'zap:scroll-sync': {
399
+ enabled: boolean;
400
+ };
401
+ /** `scroll-sync`: place the page here (the anchor first), without a `zap:scroll` back. */
402
+ 'zap:scroll-to': ScrollPayload;
403
+ /** `diff-marks`: the marks to draw; an empty list clears them. */
404
+ 'zap:marks': {
405
+ marks: DiffMark[];
406
+ };
346
407
  }
347
408
  type PageMessageType = keyof PageMessages;
348
409
  type EditorMessageType = keyof EditorMessages;
@@ -379,6 +440,7 @@ declare function isDraftRoutePath(value: unknown): value is string;
379
440
  declare function isPinId(value: unknown): value is string;
380
441
  /** A pin: id grammar, integer `n` 1..999, and exactly one of `spot` / `dom`. */
381
442
  declare function isPin(value: unknown): value is Pin;
443
+ declare function isDiffMark(value: unknown): value is DiffMark;
382
444
  declare function isPreviewValue(value: unknown): value is PreviewValue;
383
445
  /** The selector cap is in BYTES (§3.3), so a multi-byte selector counts right. */
384
446
  declare function utf8Length(value: string): number;
@@ -506,6 +568,12 @@ declare function observeTags(target: Node, onChange: (page: boolean) => void, op
506
568
  * `off` too with `pinsWhenOff`, the live site's «Navegar»), and are the only
507
569
  * things here that take the pointer besides the capture layer.
508
570
  * A click on one is consumed and reported (`onPin`), never also a spot.
571
+ * - Comparar's diff marks (`setMarks`) are drawn in every mode, `off`
572
+ * included, under the other boxes (boards CompararEscritorio /
573
+ * CompararMovil): a dashed 1.5px outline in the tone, red #FB2C36 for
574
+ * `removed` and green #00C950 for `added`; the active change's is solid
575
+ * 2px, tinted at 8% and named by a chip (#C10007 / #008236) whose label
576
+ * starts with − or +. Divided by the zoom like the outlines.
509
577
  * - Boxes are `position: fixed` from `getBoundingClientRect()` and redrawn on
510
578
  * scroll and resize in one animation frame, so nothing in the site's layout
511
579
  * moves.
@@ -523,6 +591,10 @@ interface OverlayCallbacks {
523
591
  id: string;
524
592
  }): void;
525
593
  }
594
+ /** A diff mark on one element (`DiffMark`, resolved by the runtime). */
595
+ interface OverlayMark extends Pick<DiffMark, 'tone' | 'label' | 'active'> {
596
+ element: Element;
597
+ }
526
598
  /** A pin to draw: at a point of the document, or at an element's top-left corner. */
527
599
  interface OverlayPin {
528
600
  id: string;
@@ -548,7 +620,7 @@ interface OverlayOptions extends OverlayCallbacks {
548
620
  */
549
621
  spotOnLarge?: boolean;
550
622
  }
551
- type BoxKind = 'hover' | 'focus' | 'selected' | 'highlight';
623
+ type BoxKind = 'hover' | 'focus' | 'selected' | 'highlight' | 'mark';
552
624
  declare class Overlay {
553
625
  private readonly options;
554
626
  private host;
@@ -563,6 +635,7 @@ declare class Overlay {
563
635
  private highlighted;
564
636
  private selected;
565
637
  private pins;
638
+ private marks;
566
639
  private pending;
567
640
  private capture;
568
641
  private frame;
@@ -591,6 +664,8 @@ declare class Overlay {
591
664
  highlight(elements: Element[]): void;
592
665
  /** Draw these pins (≤ 50); drops the pending spot pin. */
593
666
  setPins(pins: OverlayPin[]): void;
667
+ /** Draw these diff marks (Comparar), in every mode; an empty list clears them. */
668
+ setMarks(marks: OverlayMark[]): void;
594
669
  /** Drop the pending spot pin. */
595
670
  clearPending(): void;
596
671
  clearSelection(): void;
@@ -600,6 +675,7 @@ declare class Overlay {
600
675
  boxes: Array<{
601
676
  kind: BoxKind;
602
677
  label: string | null;
678
+ tone?: string;
603
679
  }>;
604
680
  pins: HTMLElement[];
605
681
  capture: HTMLElement | null;
@@ -680,7 +756,7 @@ declare function createPageBridge(options: PageBridgeOptions): PageBridge;
680
756
  * Live client (`live.ts`) boots it inside the customer's own page, framed by
681
757
  * Zap's editor by its real URL (§2.7).
682
758
  */
683
- declare const CLIENT_VERSION = "0.10.0";
759
+ declare const CLIENT_VERSION = "0.11.0";
684
760
  interface RuntimeOptions {
685
761
  win?: Window;
686
762
  doc?: Document;
@@ -977,4 +1053,4 @@ declare function buildAnchor(element: Element, index: TagIndex, pageUrl: string,
977
1053
  */
978
1054
  declare function findAnchorElement(anchor: DomAnchor, index: TagIndex, doc: Document, win: Window): Element | null;
979
1055
 
980
- export { CAPABILITIES, CLIENT_VERSION, type Capability, type ClickPayload, type DomAnchor, type DropReason, type DropStats, EDITOR_MESSAGE_TYPES, type EditorMessage, type EditorMessageType, type EditorMessages, type EntryLike, type Envelope, FIELD_KEY_RE, type FieldRefPayload, type HelloPayload, type InitZapOptions, MAX_ANCHORS, MAX_DRAFT_ROUTE_LENGTH, MAX_EMAIL_LENGTH, MAX_LABELS, MAX_MESSAGE_BYTES, MAX_PINS, MAX_QUOTE_CONTEXT, MAX_QUOTE_EXACT, MAX_SELECTOR_BYTES, MAX_TAG_REPORT, MAX_URL_LENGTH, type MessageTargetLike, type NormalizedRect, OVERLAY_HOST_TAG, OVERLAY_MODES, type ObserveOptions, Overlay, type OverlayCallbacks, type OverlayMode, type OverlayOptions, type OverlayTheme, PAGE_MESSAGE_TYPES, PAGE_UNLOAD_ERROR, PREVIEW_TOKEN_RE, PREVIEW_TOKEN_STORAGE_KEY, PROTOCOL_SOURCE, PROTOCOL_VERSION, type PageAnchor, type PageBridge, type PageBridgeOptions, type PageMessage, type PageMessageType, type PageMessages, type PageRuntime, type ParseResult, type ParsedRecordRef, type PausedPayload, type Pin, type PreviewValue, RICH_TEXT_ALLOWED_ATTRS, RICH_TEXT_ALLOWED_TAGS, RICH_TEXT_DROPPED_TAGS, type ReadTagResult, type ReadyPayload, type RecordRef, type RuntimeConfig, type RuntimeOptions, type SpotAnchor, type SpotPayload, type SpotPoint, TAG_SELECTOR, TagIndex, type TagProblem, type TagSource, type TagSummary, type TaggedElement, type TextQuote, ValueApplier, type ValuesHandler, type ValuesPayload, ZAP_PREVIEW_TOKEN_HEADER, ZAP_PRODUCTION_ORIGIN, type ZapLive, buildAnchor, buildDomAnchor, buildSelector, cleanStega, createPageBridge, cssEscape, developmentZapOrigin, emptyDropStats, envelope, findAnchorElement, getPreviewToken, initZap, isDomAnchor, isDraftRoutePath, isFieldKey, isLocale, isPageAnchor, isPin, isPinId, isPreviewTokenShape, isPreviewValue, isRecordRef, isSafeImageUrl, isSafeSrcset, isSafeUrl, isSession, messageBytes, normalizeText, normalizedRect, observeTags, onValues, parseEditorMessage, parsePageMessage, parseRecordRef, parseTag, previewHeaders, readElementTag, recordRefsOf, richTextToText, safeQueryAll, sanitizeRichText, sanitizeRichTextToString, startRuntime, textQuoteFor, utf8Length, zapAttrs };
1056
+ export { CAPABILITIES, CLIENT_VERSION, type Capability, type ClickPayload, type DiffMark, type DomAnchor, type DropReason, type DropStats, EDITOR_MESSAGE_TYPES, type EditorMessage, type EditorMessageType, type EditorMessages, type EntryLike, type Envelope, FIELD_KEY_RE, type FieldRefPayload, type HelloPayload, type InitZapOptions, MAX_ANCHORS, MAX_ANCHOR_NTH, MAX_DRAFT_ROUTE_LENGTH, MAX_EMAIL_LENGTH, MAX_LABELS, MAX_MARKS, MAX_MARK_LABEL, MAX_MESSAGE_BYTES, MAX_PINS, MAX_QUOTE_CONTEXT, MAX_QUOTE_EXACT, MAX_SELECTOR_BYTES, MAX_TAG_REPORT, MAX_URL_LENGTH, type MessageTargetLike, type NormalizedRect, OVERLAY_HOST_TAG, OVERLAY_MODES, type ObserveOptions, Overlay, type OverlayCallbacks, type OverlayMode, type OverlayOptions, type OverlayTheme, PAGE_MESSAGE_TYPES, PAGE_UNLOAD_ERROR, PREVIEW_TOKEN_RE, PREVIEW_TOKEN_STORAGE_KEY, PROTOCOL_SOURCE, PROTOCOL_VERSION, type PageAnchor, type PageBridge, type PageBridgeOptions, type PageMessage, type PageMessageType, type PageMessages, type PageRuntime, type ParseResult, type ParsedRecordRef, type PausedPayload, type Pin, type PreviewValue, RICH_TEXT_ALLOWED_ATTRS, RICH_TEXT_ALLOWED_TAGS, RICH_TEXT_DROPPED_TAGS, type ReadTagResult, type ReadyPayload, type RecordRef, type RuntimeConfig, type RuntimeOptions, type ScrollAnchor, type ScrollPayload, type SpotAnchor, type SpotPayload, type SpotPoint, TAG_SELECTOR, TagIndex, type TagProblem, type TagSource, type TagSummary, type TaggedElement, type TextQuote, ValueApplier, type ValuesHandler, type ValuesPayload, ZAP_PREVIEW_TOKEN_HEADER, ZAP_PRODUCTION_ORIGIN, type ZapLive, buildAnchor, buildDomAnchor, buildSelector, cleanStega, createPageBridge, cssEscape, developmentZapOrigin, emptyDropStats, envelope, findAnchorElement, getPreviewToken, initZap, isDiffMark, isDomAnchor, isDraftRoutePath, isFieldKey, isLocale, isPageAnchor, isPin, isPinId, isPreviewTokenShape, isPreviewValue, isRecordRef, isSafeImageUrl, isSafeSrcset, isSafeUrl, isSession, messageBytes, normalizeText, normalizedRect, observeTags, onValues, parseEditorMessage, parsePageMessage, parseRecordRef, parseTag, previewHeaders, readElementTag, recordRefsOf, richTextToText, safeQueryAll, sanitizeRichText, sanitizeRichTextToString, startRuntime, textQuoteFor, utf8Length, zapAttrs };