@8ux-co/eelzap 0.10.0 → 0.10.1

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
@@ -182,6 +182,12 @@ interface DraftModeExitRouteOptions {
182
182
  * partitioned cookie is only replaced by a partitioned one, so `disable()`
183
183
  * alone (Next's unpartitioned expiry) would leave the framed copy alive.
184
184
  * Redirects (307) to `?path=` when `safeRedirectPath` accepts it, else to `/`.
185
+ *
186
+ * Mount it at `{draftRoute}/exit` (`/api/zap-preview/exit` by default): the
187
+ * live-site toolbar's sign-out calls exactly that path to drop the draft
188
+ * cookie a draft session left (ADR 041 amendment 2026-10-06). Without it,
189
+ * signing out still revokes the session's tokens at Zap, and the cookie
190
+ * expires within its ten minutes.
185
191
  */
186
192
  declare function createDraftModeExitRoute(options: DraftModeExitRouteOptions): (request: Request) => Promise<Response>;
187
193
  interface HeaderSource {
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 */\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":";AAAA,SAAS,qBAAqB;AAE9B,SAAS,cAAc,wBAA8C;;;ACoC9D,IAAM,mBAAmB;AAGzB,SAAS,oBAAoB,OAAiC;AACnE,SAAO,OAAO,UAAU,YAAY,iBAAiB,KAAK,KAAK;AACjE;;;ADoBO,IAAM,uBAAuB;AAE7B,IAAM,oBAAoB;AAE1B,IAAM,uBAAuB;AAC7B,IAAM,qBAAqB;AAC3B,IAAM,wBAAwB;AAE9B,IAAM,sBAAsB;AAEnC,IAAM,yBAAyB;AAC/B,IAAM,wBAAwB;AAoC9B,IAAM,WAAW,EAAE,iBAAiB,YAAY,mBAAmB,cAAc;AAEjF,SAAS,OAAO,QAAgB,OAAmC;AACjE,SAAO,SAAS,KAAK,EAAE,MAAM,GAAG,EAAE,QAAQ,SAAS,SAAS,CAAC;AAC/D;AAGA,SAAS,SAAS,MAAc,SAA6B;AAC3D,QAAM,UAAU,IAAI,QAAQ,EAAE,UAAU,MAAM,GAAG,SAAS,CAAC;AAC3D,aAAW,UAAU,QAAS,SAAQ,OAAO,cAAc,MAAM;AACjE,SAAO,IAAI,SAAS,MAAM,EAAE,QAAQ,KAAK,QAAQ,CAAC;AACpD;AAEA,SAAS,YAAY,SAAkE;AACrF,MAAI,CAAC,SAAS,WAAW,CAAC,QAAQ,QAAQ;AACxC,UAAM,IAAI,MAAM,iEAAiE;AAAA,EACnF;AACF;AAGA,IAAM,oBAAoB;AAOnB,SAAS,iBAAiB,KAAoB,YAAmC;AACtF,QAAM,QAAQ,OAAO;AACrB,MAAI,MAAM,WAAW,KAAK,MAAM,SAAS,KAAM,QAAO;AACtD,MAAI,CAAC,MAAM,WAAW,GAAG,KAAK,MAAM,WAAW,IAAI,EAAG,QAAO;AAC7D,MAAI,kBAAkB,KAAK,KAAK,EAAG,QAAO;AAC1C,MAAI;AACJ,MAAI;AACJ,MAAI;AACF,WAAO,IAAI,IAAI,UAAU;AACzB,aAAS,IAAI,IAAI,OAAO,IAAI;AAAA,EAC9B,QAAQ;AACN,WAAO;AAAA,EACT;AACA,MAAI,OAAO,WAAW,KAAK,OAAQ,QAAO;AAI1C,MAAI,OAAO,SAAS,WAAW,IAAI,EAAG,QAAO;AAC7C,MAAI,QAAQ,KAAK,OAAO,MAAM,KAAK,QAAQ,KAAK,OAAO,IAAI,EAAG,QAAO;AACrE,SAAO,GAAG,OAAO,QAAQ,GAAG,OAAO,MAAM,GAAG,OAAO,IAAI;AACzD;AAEA,SAAS,aAAa,MAAc,OAAe,QAAyB;AAC1E,QAAM,QAAQ,CAAC,GAAG,IAAI,IAAI,KAAK,IAAI,UAAU,YAAY,UAAU,iBAAiB,aAAa;AACjG,MAAI,WAAW,OAAW,OAAM,KAAK,WAAW,MAAM,EAAE;AACxD,SAAO,MAAM,KAAK,IAAI;AACxB;AASA,SAAS,WAAW,OAA+B;AACjD,QAAM,KAAK,OAAO,UAAU,WAAW,KAAK,MAAM,KAAK,IAAI;AAC3D,SAAO,OAAO,OAAO,YAAY,OAAO,SAAS,EAAE,IAAI,KAAK;AAC9D;AAGA,eAAe,gBAAgB,OAAe,SAAoD;AAChG,QAAM,UAAU,QAAQ,SAAS;AACjC,QAAM,SAAS,IAAI,IAAI,QAAQ,aAAa,kBAAkB,EAAE;AAChE,QAAM,WAAW,MAAM,QAAQ,GAAG,MAAM,GAAG,qBAAqB,IAAI;AAAA,IAClE,QAAQ;AAAA,IACR,SAAS;AAAA,MACP,eAAe,UAAU,KAAK;AAAA,MAC9B,CAAC,mBAAmB,GAAG,QAAQ;AAAA,MAC/B,QAAQ;AAAA,IACV;AAAA,IACA,OAAO;AAAA,IACP,UAAU;AAAA,IACV,QAAQ,YAAY,QAAQ,qBAAqB;AAAA,EACnD,CAAC;AACD,MAAI,CAAC,SAAS,IAAI;AAChB,UAAMA,QAAQ,MAAM,SAAS,KAAK,EAAE,MAAM,MAAM,IAAI;AACpD,UAAM,OAAO,OAAOA,OAAM,OAAO,SAAS,WAAWA,MAAK,MAAM,OAAO;AACvE,WAAO,EAAE,IAAI,OAAO,QAAQ,SAAS,QAAQ,KAAK;AAAA,EACpD;AACA,QAAM,OAAQ,MAAM,SAAS,KAAK,EAAE,MAAM,MAAM,IAAI;AAKpD,QAAM,MAAM,MAAM,MAAM;AACxB,SAAO,OAAO,QAAQ,WAClB,EAAE,IAAI,MAAM,SAAS,KAAK,WAAW,WAAW,MAAM,aAAa,MAAM,OAAO,SAAS,EAAE,IAC3F,EAAE,IAAI,OAAO,QAAQ,KAAK,MAAM,KAAK;AAC3C;AAEO,SAAS,qBAAqB,SAAgC;AACnE,cAAY,OAAO;AACnB,SAAO,eAAe,IAAI,SAAqC;AAC7D,UAAM,MAAM,IAAI,IAAI,QAAQ,GAAG;AAC/B,UAAM,QAAQ,IAAI,aAAa,IAAI,OAAO;AAC1C,QAAI,CAAC,oBAAoB,KAAK,EAAG,QAAO,OAAO,KAAK,eAAe;AACnE,UAAM,OAAO,iBAAiB,IAAI,aAAa,IAAI,MAAM,GAAG,QAAQ,GAAG;AACvE,QAAI,SAAS,KAAM,QAAO,OAAO,KAAK,cAAc;AAEpD,QAAI;AACJ,QAAI;AACF,gBAAU,MAAM,gBAAgB,OAAO,OAAO;AAAA,IAChD,QAAQ;AACN,aAAO,OAAO,KAAK,iBAAiB;AAAA,IACtC;AACA,QAAI,CAAC,QAAQ,IAAI;AACf,UAAI,QAAQ,UAAU,IAAK,QAAO,OAAO,KAAK,iBAAiB;AAC/D,aAAO,QAAQ,SAAS,eACpB,OAAO,KAAK,YAAY,IACxB,OAAO,KAAK,eAAe;AAAA,IACjC;AACA,QAAI,QAAQ,YAAY,QAAQ,QAAS,QAAO,OAAO,KAAK,YAAY;AAExE,UAAM,QAAQ,MAAM,QAAQ,UAAU;AACtC,UAAM,MAAM,OAAO;AAEnB,UAAM,UAAU,CAAC,aAAa,sBAAsB,OAAO,sBAAsB,CAAC;AAIlF,UAAM,SAAS,QAAQ,WAAW,MAAM,QAAQ,QAAQ,GAAG,IAAI,iBAAiB,GAAG,QAAQ;AAC3F,QAAI,UAAU,qBAAqB,KAAK,MAAM,GAAG;AAG/C,cAAQ,KAAK,aAAa,mBAAmB,QAAQ,sBAAsB,CAAC;AAAA,IAC9E;AACA,WAAO,SAAS,MAAM,OAAO;AAAA,EAC/B;AACF;AA4BO,SAAS,yBAAyB,SAAoC;AAC3E,MAAI,CAAC,SAAS,UAAW,OAAM,IAAI,MAAM,oCAAoC;AAC7E,SAAO,eAAe,IAAI,SAAqC;AAC7D,UAAM,MAAM,IAAI,IAAI,QAAQ,GAAG;AAC/B,UAAM,OAAO,iBAAiB,IAAI,aAAa,IAAI,MAAM,GAAG,QAAQ,GAAG,KAAK;AAC5E,UAAM,QAAQ,MAAM,QAAQ,UAAU;AACtC,UAAM,MAAM,QAAQ;AACpB,WAAO,SAAS,MAAM;AAAA,MACpB,aAAa,mBAAmB,IAAI,CAAC;AAAA,MACrC,aAAa,sBAAsB,IAAI,CAAC;AAAA,IAC1C,CAAC;AAAA,EACH;AACF;AAiBO,SAAS,gBACd,QACe;AACf,QAAM,UAAU,kBAAkB;AAClC,QAAM,aAAa,OAAO,SAAS,IAAI,oBAAoB;AAC3D,MAAI,oBAAoB,UAAU,EAAG,QAAO;AAC5C,QAAM,aAAa,UACf,WAAW,OAAO,QAAQ,IAAI,QAAQ,GAAG,oBAAoB,IAC7D,OAAO,SAAS,IAAI,oBAAoB,GAAG;AAC/C,SAAO,oBAAoB,UAAU,IAAI,aAAa;AACxD;AAcA,IAAM,uBAAuB;AAE7B,IAAM,yBAAyB;AAC/B,IAAM,kBAAkB;AACxB,IAAM,aAAa,oBAAI,IAA+C;AAEtE,SAAS,gBAAgB,KAAa,OAAgB,OAAqB;AACzE,aAAW,OAAO,GAAG;AAErB,MAAI,WAAW,QAAQ,gBAAiB,YAAW,OAAO,WAAW,KAAK,EAAE,KAAK,EAAE,KAAM;AACzF,aAAW,IAAI,KAAK,EAAE,OAAO,MAAM,CAAC;AACtC;AAGO,SAAS,mCAAyC;AACvD,aAAW,MAAM;AACnB;AA2BA,eAAsB,qBACpB,QACA,SACwB;AACxB,cAAY,OAAO;AACnB,QAAM,QAAQ,gBAAgB,MAAM;AACpC,MAAI,UAAU,KAAM,QAAO;AAI3B,QAAM,MAAM,GAAG,QAAQ,SAAS;AAAA,EAAK,QAAQ,OAAO;AAAA,EAAK,QAAQ,MAAM;AAAA,EAAK,KAAK;AACjF,QAAM,MAAM,KAAK,IAAI;AACrB,QAAM,SAAS,WAAW,IAAI,GAAG;AACjC,MAAI,UAAU,OAAO,QAAQ,IAAK,QAAO,OAAO,QAAQ,QAAQ;AAEhE,MAAI;AACJ,MAAI;AACF,cAAU,MAAM,gBAAgB,OAAO,OAAO;AAAA,EAChD,QAAQ;AACN,WAAO;AAAA,EACT;AACA,MAAI,CAAC,QAAQ,IAAI;AACf,QAAI,QAAQ,SAAS,IAAK,iBAAgB,KAAK,OAAO,MAAM,sBAAsB;AAClF,WAAO;AAAA,EACT;AACA,MAAI,QAAQ,YAAY,QAAQ,SAAS;AACvC,oBAAgB,KAAK,OAAO,MAAM,sBAAsB;AACxD,WAAO;AAAA,EACT;AACA,MAAI,QAAQ,cAAc,QAAQ,QAAQ,aAAa,KAAK;AAC1D,oBAAgB,KAAK,OAAO,MAAM,sBAAsB;AACxD,WAAO;AAAA,EACT;AACA,QAAM,QAAQ,KAAK,IAAI,MAAM,sBAAsB,QAAQ,aAAa,OAAO,iBAAiB;AAChG,kBAAgB,KAAK,MAAM,KAAK;AAChC,SAAO;AACT;AAEA,SAAS,WAAW,QAAuB,MAA6B;AACtE,MAAI,CAAC,OAAQ,QAAO;AACpB,aAAW,QAAQ,OAAO,MAAM,GAAG,GAAG;AACpC,UAAM,CAAC,KAAK,GAAG,IAAI,IAAI,KAAK,KAAK,EAAE,MAAM,GAAG;AAC5C,QAAI,QAAQ,KAAM,QAAO,KAAK,KAAK,GAAG;AAAA,EACxC;AACA,SAAO;AACT;AAOO,IAAM,sBAAsB;AAkB5B,SAAS,WAAW,OAAwB;AACjD,SAAO,cAAc,kBAAkB;AAAA,IACrC,GAAG;AAAA,IACH,YAAY,MAAM,eAAe,SAAY,sBAAsB,MAAM;AAAA,EAC3E,CAAC;AACH;AASO,SAAS,aACd,QACS;AACT,SAAO,gBAAgB,MAAM,MAAM;AACrC;","names":["body"]}
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"]}
package/dist/preview.d.ts CHANGED
@@ -96,6 +96,11 @@ declare function zapAttrs(entry: EntryLike, fieldKey: string): {
96
96
  * a URL field's value is `{ url }` and an EMAIL field's `{ email }`, so the
97
97
  * page writes a link's `href` (`mailto:` for an email) and leaves its label
98
98
  * alone (`values.ts`). Older clients get the bare string, as before.
99
+ * - `zap:paused` (page → editor) `{ reason, count }`: the runaway guard
100
+ * (`tagGuard` in `tags.ts`) stopped following the page: `cap` past 5,000
101
+ * tagged elements, `runaway` when the count kept growing with nothing on
102
+ * the page explaining it. The page stops writing values; the editor offers
103
+ * a reload. Sent again after a hello, which a pause before it never reached.
99
104
  */
100
105
  declare const PROTOCOL_SOURCE = "eel-zap";
101
106
  /**
@@ -121,7 +126,7 @@ declare const MAX_LABELS = 500;
121
126
  declare const MAX_PINS = 50;
122
127
  type OverlayMode = 'inspect' | 'select' | 'spot' | 'off';
123
128
  declare const OVERLAY_MODES: readonly OverlayMode[];
124
- type Capability = 'overlay' | 'values' | 'stega' | 'pins' | 'links';
129
+ type Capability = 'overlay' | 'values' | 'stega' | 'pins' | 'links' | 'refresh';
125
130
  /** Every capability; a client built from this source advertises all of them. */
126
131
  declare const CAPABILITIES: readonly Capability[];
127
132
 
@@ -225,6 +230,14 @@ interface HelloPayload {
225
230
  zoom?: number;
226
231
  /** Live only: the draft-mode token for the cookieless fallback. */
227
232
  previewToken?: string;
233
+ /**
234
+ * Fields on the page that belong to ANOTHER record than `recordRef` (a
235
+ * site-wide document in the header, a related entry): the full tag
236
+ * (`TagSummary.tag`, `recordRef#fieldKey`) to `[label, suffix]`, the suffix
237
+ * already localized («en Configuración»). The chip draws the label, then
238
+ * the suffix muted. At most `MAX_LABELS`; optional, older clients ignore it.
239
+ */
240
+ foreign?: Record<string, [label: string, suffix: string]>;
228
241
  }
229
242
  /** Colours the editor may override; `#rgb` / `#rrggbb` only. */
230
243
  interface OverlayTheme {
@@ -241,6 +254,18 @@ interface FieldRefPayload {
241
254
  recordRef: RecordRef;
242
255
  fieldKey: string;
243
256
  }
257
+ /**
258
+ * `zap:click`: the field, and optionally the clicked element's box in the
259
+ * frame's viewport, CSS px before the editor's zoom (older clients omit it).
260
+ */
261
+ interface ClickPayload extends FieldRefPayload {
262
+ rect?: {
263
+ x: number;
264
+ y: number;
265
+ w: number;
266
+ h: number;
267
+ };
268
+ }
244
269
  /** A point as fractions (0..1) of the whole document, not the viewport (§3.3). */
245
270
  interface SpotPoint {
246
271
  x: number;
@@ -273,10 +298,15 @@ type Pin = {
273
298
  dom: DomAnchor;
274
299
  spot?: undefined;
275
300
  });
301
+ /** `zap:paused`: why the page stopped following itself, and the tagged count then. */
302
+ interface PausedPayload {
303
+ reason: 'runaway' | 'cap';
304
+ count: number;
305
+ }
276
306
  interface PageMessages {
277
307
  'zap:ready': ReadyPayload;
278
308
  'zap:tags': TagSummary[];
279
- 'zap:click': FieldRefPayload;
309
+ 'zap:click': ClickPayload;
280
310
  'zap:select': {
281
311
  anchors: PageAnchor[];
282
312
  };
@@ -291,6 +321,7 @@ interface PageMessages {
291
321
  'zap:pin': {
292
322
  id: string;
293
323
  };
324
+ 'zap:paused': PausedPayload;
294
325
  }
295
326
  interface EditorMessages {
296
327
  'zap:hello': HelloPayload;
@@ -300,6 +331,12 @@ interface EditorMessages {
300
331
  'zap:zoom': {
301
332
  zoom: number;
302
333
  };
334
+ /**
335
+ * Reload the page in place (`refresh` capability): for values the overlay
336
+ * cannot place, once the draft is saved. The browser keeps the scroll and
337
+ * the draft-mode cookie keeps the draft; the new document says ready.
338
+ */
339
+ 'zap:refresh': Record<string, never>;
303
340
  'zap:highlight': {
304
341
  anchors: DomAnchor[];
305
342
  };
@@ -435,8 +472,13 @@ interface ObserveOptions {
435
472
  * thousands of records a second, and the index needs one rescan per burst.
436
473
  * Mutations made by the overlay's own host are ignored, so drawing outlines
437
474
  * never triggers a rescan.
475
+ *
476
+ * `page` says whether the page changed since the last call. Mutations made
477
+ * DURING `onChange` (our own writes: `reapply()`) still lead to one more
478
+ * call, so they settle as before, but with `page` false: the runaway guard
479
+ * (`tagGuard`) tells a site that keeps rendering from a pass feeding itself.
438
480
  */
439
- declare function observeTags(target: Node, onChange: () => void, options?: ObserveOptions): () => void;
481
+ declare function observeTags(target: Node, onChange: (page: boolean) => void, options?: ObserveOptions): () => void;
440
482
 
441
483
  /**
442
484
  * The in-page overlay (§2.4 "Finding the element for a field", §3.3
@@ -515,6 +557,7 @@ declare class Overlay {
515
557
  private zoom;
516
558
  private theme;
517
559
  private labels;
560
+ private foreign;
518
561
  private hover;
519
562
  private focused;
520
563
  private highlighted;
@@ -540,7 +583,8 @@ declare class Overlay {
540
583
  setMode(mode: OverlayMode): void;
541
584
  setZoom(zoom: number): void;
542
585
  setTheme(theme: OverlayTheme | undefined): void;
543
- setLabels(labels: Record<string, string>): void;
586
+ /** Labels by field key; `foreign`, by full tag, the fields of other records (`HelloPayload.foreign`). */
587
+ setLabels(labels: Record<string, string>, foreign?: Record<string, [string, string]>): void;
544
588
  /** Outline every element of a field and scroll the first into view. */
545
589
  focusField(elements: Element[]): number;
546
590
  /** Outline change-request anchors and scroll to the first. */
@@ -648,7 +692,7 @@ interface PageRuntime {
648
692
  readonly bridge: PageBridge;
649
693
  readonly index: TagIndex;
650
694
  readonly overlay: Overlay;
651
- /** Force a rescan + report now (specs, and after a navigation). */
695
+ /** Force a rescan + report now (specs, and after a navigation); nothing once paused. */
652
696
  refresh(): void;
653
697
  destroy(): void;
654
698
  }
@@ -900,7 +944,11 @@ declare function safeQueryAll(root: ParentNode, selector: string): Element[];
900
944
  * out on purpose: utility and hashed class names change on every deploy.
901
945
  */
902
946
  declare function buildSelector(element: Element, doc: Document): string | null;
903
- /** Collapse whitespace the way a reader sees text. */
947
+ /**
948
+ * Collapse whitespace the way a reader sees text, without stega markers: an
949
+ * element's text in preview carries them, and they are never content (a
950
+ * quote, a «before» or a typed value read from the page must not store one).
951
+ */
904
952
  declare function normalizeText(value: string): string;
905
953
  /** W3C TextQuoteSelector-shaped quote: the element's text plus 32 chars around it. */
906
954
  declare function textQuoteFor(element: Element, doc: Document): TextQuote | null;
@@ -929,4 +977,4 @@ declare function buildAnchor(element: Element, index: TagIndex, pageUrl: string,
929
977
  */
930
978
  declare function findAnchorElement(anchor: DomAnchor, index: TagIndex, doc: Document, win: Window): Element | null;
931
979
 
932
- export { CAPABILITIES, CLIENT_VERSION, type Capability, 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 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 };
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 };
package/dist/preview.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  CAPABILITIES,
3
+ DEFAULT_SHORTCUT,
3
4
  EDITOR_MESSAGE_TYPES,
4
5
  FIELD_KEY_RE,
5
6
  MAX_ANCHORS,
@@ -42,6 +43,7 @@ import {
42
43
  isPreviewValue,
43
44
  isRecordRef,
44
45
  isSession,
46
+ isShortcut,
45
47
  messageBytes,
46
48
  normalizeText,
47
49
  normalizedRect,
@@ -54,19 +56,20 @@ import {
54
56
  recordRefsOf,
55
57
  safeQueryAll,
56
58
  splitStega,
59
+ tagGuard,
57
60
  textQuoteFor,
58
61
  utf8Length,
59
62
  zapAttrs
60
- } from "./chunk-VW4EH7NY.js";
63
+ } from "./chunk-7CXQK2PR.js";
61
64
  import {
62
65
  NEST_PRODUCTION_ORIGIN,
63
66
  PENDING_STORAGE_KEY,
64
67
  SIGNIN_CHANNEL,
65
68
  STATE_PREFIX,
66
69
  __publicField,
67
- readToken,
70
+ readSession,
68
71
  siteClientId
69
- } from "./chunk-QN53G5P7.js";
72
+ } from "./chunk-6XHCA4U4.js";
70
73
 
71
74
  // src/boot/local.ts
72
75
  var LOCAL_ORIGIN = /^https?:\/\/(?:(?:[a-z\d-]+\.)*localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/i;
@@ -74,16 +77,7 @@ var isLocalHost = (hostname) => LOCAL_ORIGIN.test(`http://${hostname}`);
74
77
 
75
78
  // src/preview/site/load.ts
76
79
  function loadChunk(name, _base) {
77
- return name === "signin" ? import("./signin-2QOSWZFK.js") : import("./suggest-5RHURHED.js");
78
- }
79
-
80
- // src/preview/site/shortcut.ts
81
- var DEFAULT_SHORTCUT = "Z";
82
- var TYPING = "input,textarea,select,[contenteditable]";
83
- function isShortcut(event, key) {
84
- const target = event.composedPath?.()[0] ?? event.target;
85
- return event.shiftKey && !(event.ctrlKey || event.metaKey || event.altKey || event.repeat || event.isComposing) && // Some browsers report a key inside an IME composition only as keyCode 229.
86
- event.keyCode !== 229 && event.key?.toLowerCase() === key.toLowerCase() && !target?.closest?.(TYPING);
80
+ return name === "signin" ? import("./signin-QT7YANBN.js") : import("./suggest-JZ63L3WP.js");
87
81
  }
88
82
 
89
83
  // src/preview/site/boot.ts
@@ -123,6 +117,7 @@ function startSiteMode(options) {
123
117
  redirectUri: `${win.location.origin}/`,
124
118
  locale: options.locale ?? pageLocale(doc),
125
119
  shortcut: key,
120
+ draftRoute: options.draftRoute ?? null,
126
121
  async open(name, startOptions) {
127
122
  opening = true;
128
123
  try {
@@ -158,9 +153,9 @@ function startSiteMode(options) {
158
153
  }
159
154
  const launch = () => {
160
155
  if (current2 || opening) return;
161
- void ctx.open(readToken(win, siteId) ? "suggest" : "signin", { reason: "trigger" });
156
+ void ctx.open(readSession(win, siteId) ? "suggest" : "signin", { reason: "trigger" });
162
157
  };
163
- if (readToken(win, siteId)) void ctx.open("suggest");
158
+ if (readSession(win, siteId)) void ctx.open("suggest");
164
159
  else if (options.open || new URLSearchParams(win.location.search).has("zap")) launch();
165
160
  const onKey = (event) => {
166
161
  if (key && isShortcut(event, key)) launch();
@@ -368,7 +363,8 @@ var ValueApplier = class {
368
363
  /** Remember and apply a patch. Returns how many elements were written. */
369
364
  apply(payload) {
370
365
  let written = 0;
371
- for (const [fieldKey, value] of Object.entries(payload.patch)) {
366
+ for (const [fieldKey, raw] of Object.entries(payload.patch)) {
367
+ const value = cleanStega(raw);
372
368
  this.desired.set(`${payload.recordRef}#${fieldKey}`, { locale: payload.locale, value });
373
369
  for (const tagged of this.index.byField(payload.recordRef, fieldKey)) {
374
370
  if (this.write(tagged, payload.locale, value)) written++;
@@ -488,11 +484,26 @@ function startRuntime(config, options = {}) {
488
484
  const reportedProblems = /* @__PURE__ */ new Set();
489
485
  let stopObserving = () => {
490
486
  };
487
+ const guard = tagGuard();
488
+ let paused = null;
489
+ let debug = false;
490
+ try {
491
+ debug = !!win.localStorage.getItem("eelzap:debug");
492
+ } catch {
493
+ }
494
+ const log = (...args) => debug && console.debug("[zap:fields]", ...args);
491
495
  const overlay = new Overlay({
492
496
  doc,
493
497
  win,
494
498
  index,
495
- onInspect: (tagged) => bridge.post("zap:click", { recordRef: tagged.recordRef, fieldKey: tagged.fieldKey }),
499
+ onInspect: (tagged) => {
500
+ const { x, y, width: w, height: h } = tagged.element.getBoundingClientRect();
501
+ bridge.post("zap:click", {
502
+ recordRef: tagged.recordRef,
503
+ fieldKey: tagged.fieldKey,
504
+ rect: { x, y, w, h }
505
+ });
506
+ },
496
507
  onSelect: (elements) => bridge.post("zap:select", {
497
508
  anchors: elements.map((element) => buildAnchor(element, index, config.pageUrl(), doc, win))
498
509
  }),
@@ -506,6 +517,8 @@ function startRuntime(config, options = {}) {
506
517
  parent,
507
518
  editorOrigins: config.editorOrigins,
508
519
  preHelloTargets: config.preHelloTargets,
520
+ // Any window may post to the page: only drops from the editor are news.
521
+ onDrop: (reason) => reason !== "wrong-source" && log("drop", reason),
509
522
  onMessage: (message) => {
510
523
  if (config.accept && !config.accept(message)) return;
511
524
  handle(message);
@@ -516,16 +529,18 @@ function startRuntime(config, options = {}) {
516
529
  switch (message.type) {
517
530
  case "zap:hello": {
518
531
  const hello = message.payload;
532
+ greeted = true;
519
533
  overlay.mount();
520
- overlay.setLabels(hello.labels);
534
+ overlay.setLabels(hello.labels, hello.foreign);
521
535
  overlay.setTheme(hello.theme);
522
536
  if (hello.zoom !== void 0) overlay.setZoom(hello.zoom);
523
537
  overlay.setMode(hello.mode);
524
- report(true);
538
+ report("hello", true);
539
+ if (paused) bridge.post("zap:paused", paused);
525
540
  return;
526
541
  }
527
542
  case "zap:values":
528
- values.apply(message.payload);
543
+ if (!paused) values.apply(message.payload);
529
544
  return;
530
545
  case "zap:focus-field":
531
546
  overlay.focusField(
@@ -538,6 +553,10 @@ function startRuntime(config, options = {}) {
538
553
  case "zap:zoom":
539
554
  overlay.setZoom(message.payload.zoom);
540
555
  return;
556
+ case "zap:refresh":
557
+ log("refresh");
558
+ win.location.reload();
559
+ return;
541
560
  case "zap:highlight": {
542
561
  const found = message.payload.anchors.map((anchor) => findAnchorElement(anchor, index, doc, win)).filter((element) => element !== null);
543
562
  overlay.highlight(found);
@@ -555,12 +574,13 @@ function startRuntime(config, options = {}) {
555
574
  }
556
575
  }
557
576
  }
558
- function report(force = false) {
577
+ function report(why, force = false) {
559
578
  const summary = index.summary();
560
579
  const key = JSON.stringify(summary);
561
580
  if (force || key !== lastReport) {
562
581
  lastReport = key;
563
582
  bridge.post("zap:tags", summary);
583
+ log("tags", why, summary.length, config.pageUrl(), bridge.session);
564
584
  }
565
585
  reportProblems(index.problems);
566
586
  }
@@ -573,10 +593,22 @@ function startRuntime(config, options = {}) {
573
593
  }
574
594
  }
575
595
  let lastUrl = config.pageUrl();
576
- function refresh() {
596
+ function scan(page) {
597
+ if (paused) return false;
577
598
  index.scan();
599
+ const count = index.elements.length;
600
+ const reason = guard(count, page);
601
+ if (!reason) return true;
602
+ paused = { reason, count };
603
+ stopObserving();
604
+ console.warn("eelzap: preview paused", paused);
605
+ bridge.post("zap:paused", paused);
606
+ return false;
607
+ }
608
+ function refresh(page = true) {
609
+ if (!scan(page)) return;
578
610
  values.reapply();
579
- report();
611
+ report(page ? "mutation" : "reapply");
580
612
  const url = config.pageUrl();
581
613
  if (url !== lastUrl) {
582
614
  lastUrl = url;
@@ -586,25 +618,39 @@ function startRuntime(config, options = {}) {
586
618
  const onPageHide = () => {
587
619
  bridge.post("zap:error", { code: PAGE_UNLOAD_ERROR });
588
620
  };
589
- if (!config.lazyOverlay) overlay.install();
590
- win.addEventListener("pagehide", onPageHide);
591
- function boot() {
592
- if (!config.lazyOverlay) overlay.mount();
593
- index.scan();
594
- stopObserving = observeTags(doc.documentElement, refresh, {
595
- throttleMs: options.throttleMs,
596
- ignore: (node) => {
597
- const host = overlay.hostElement;
598
- return !!host && (node === host || host.contains(node));
599
- }
600
- });
621
+ const onPageShow = (event) => {
622
+ if (event.persisted) {
623
+ log("pageshow");
624
+ announce();
625
+ }
626
+ };
627
+ let greeted = false;
628
+ function announce() {
629
+ log("ready", config.pageUrl());
601
630
  bridge.post("zap:ready", {
602
631
  version: CLIENT_VERSION,
603
632
  capabilities: [...CAPABILITIES],
604
633
  pageUrl: config.pageUrl(),
605
634
  draftRoute: config.draftRoute ?? null
606
635
  });
607
- report(true);
636
+ }
637
+ if (!config.lazyOverlay) overlay.install();
638
+ win.addEventListener("pagehide", onPageHide);
639
+ win.addEventListener("pageshow", onPageShow);
640
+ function boot() {
641
+ if (!config.lazyOverlay) overlay.mount();
642
+ if (scan(true)) {
643
+ stopObserving = observeTags(doc.documentElement, refresh, {
644
+ throttleMs: options.throttleMs,
645
+ ignore: (node) => {
646
+ const host = overlay.hostElement;
647
+ return !!host && (node === host || host.contains(node));
648
+ }
649
+ });
650
+ }
651
+ announce();
652
+ report("ready", true);
653
+ for (const ms of [500, 1500, 4e3]) win.setTimeout(() => greeted || announce(), ms);
608
654
  }
609
655
  if (doc.readyState === "loading") {
610
656
  doc.addEventListener("DOMContentLoaded", boot, { once: true });
@@ -619,6 +665,7 @@ function startRuntime(config, options = {}) {
619
665
  destroy() {
620
666
  stopObserving();
621
667
  win.removeEventListener("pagehide", onPageHide);
668
+ win.removeEventListener("pageshow", onPageShow);
622
669
  overlay.destroy();
623
670
  bridge.destroy();
624
671
  }
@@ -719,6 +766,7 @@ function initZap(options, runtimeOptions = {}) {
719
766
  if (!parent || parent === win) return startSuggestions(options, win, dev);
720
767
  const exact = dev ? [ZAP_PRODUCTION_ORIGIN, dev] : [ZAP_PRODUCTION_ORIGIN];
721
768
  let helloOk = false;
769
+ const draftRoute = isDraftRoutePath(options.draftRoute) ? options.draftRoute : null;
722
770
  const runtime = startRuntime(
723
771
  {
724
772
  editorOrigins: exact,
@@ -726,7 +774,7 @@ function initZap(options, runtimeOptions = {}) {
726
774
  const candidate = parentOriginHint(win);
727
775
  return candidate && exact.includes(candidate) ? [candidate] : exact;
728
776
  },
729
- draftRoute: isDraftRoutePath(options.draftRoute) ? options.draftRoute : null,
777
+ draftRoute,
730
778
  pageUrl: () => win.location.href,
731
779
  lazyOverlay: true,
732
780
  accept: (message) => {
@@ -740,8 +788,15 @@ function initZap(options, runtimeOptions = {}) {
740
788
  if (message.type === "zap:hello") {
741
789
  try {
742
790
  const token = message.payload.previewToken;
743
- if (token) sessionStore(win)?.setItem(PREVIEW_TOKEN_STORAGE_KEY, token);
791
+ const store = sessionStore(win);
792
+ const last = store?.getItem(PREVIEW_TOKEN_STORAGE_KEY);
793
+ if (token) store?.setItem(PREVIEW_TOKEN_STORAGE_KEY, token);
744
794
  else forgetPreviewToken(win);
795
+ if (token && last && last !== token && draftRoute) {
796
+ const query = new URLSearchParams({ token, path: win.location.pathname });
797
+ void win.fetch(`${draftRoute}?${query}`, { credentials: "same-origin", redirect: "manual" }).catch(() => {
798
+ });
799
+ }
745
800
  } catch {
746
801
  }
747
802
  } else if (message.type === "zap:values") {
@@ -781,7 +836,8 @@ function startSuggestions(options, win, devZap) {
781
836
  shortcut: options.shortcut,
782
837
  locale: options.locale,
783
838
  chunkBase: options.chunkBase,
784
- open: options.open
839
+ open: options.open,
840
+ draftRoute: isDraftRoutePath(options.draftRoute) ? options.draftRoute : null
785
841
  });
786
842
  stopSuggestions = stop;
787
843
  return {