@8ux-co/eelzap 0.10.1 → 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.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 };
package/dist/preview.js CHANGED
@@ -4,9 +4,12 @@ import {
4
4
  EDITOR_MESSAGE_TYPES,
5
5
  FIELD_KEY_RE,
6
6
  MAX_ANCHORS,
7
+ MAX_ANCHOR_NTH,
7
8
  MAX_DRAFT_ROUTE_LENGTH,
8
9
  MAX_EMAIL_LENGTH,
9
10
  MAX_LABELS,
11
+ MAX_MARKS,
12
+ MAX_MARK_LABEL,
10
13
  MAX_MESSAGE_BYTES,
11
14
  MAX_PINS,
12
15
  MAX_QUOTE_CONTEXT,
@@ -32,6 +35,7 @@ import {
32
35
  emptyDropStats,
33
36
  envelope,
34
37
  findAnchorElement,
38
+ isDiffMark,
35
39
  isDomAnchor,
36
40
  isDraftRoutePath,
37
41
  isFieldKey,
@@ -60,7 +64,7 @@ import {
60
64
  textQuoteFor,
61
65
  utf8Length,
62
66
  zapAttrs
63
- } from "./chunk-7CXQK2PR.js";
67
+ } from "./chunk-72YCJCBI.js";
64
68
  import {
65
69
  NEST_PRODUCTION_ORIGIN,
66
70
  PENDING_STORAGE_KEY,
@@ -77,7 +81,7 @@ var isLocalHost = (hostname) => LOCAL_ORIGIN.test(`http://${hostname}`);
77
81
 
78
82
  // src/preview/site/load.ts
79
83
  function loadChunk(name, _base) {
80
- return name === "signin" ? import("./signin-QT7YANBN.js") : import("./suggest-JZ63L3WP.js");
84
+ return name === "signin" ? import("./signin-XOGBCGBL.js") : import("./suggest-ERMR7PCE.js");
81
85
  }
82
86
 
83
87
  // src/preview/site/boot.ts
@@ -213,6 +217,94 @@ function createPageBridge(options) {
213
217
  };
214
218
  }
215
219
 
220
+ // src/preview/scroll.ts
221
+ var round = (value) => Math.round(value * 1e4) / 1e4;
222
+ function createScrollSync(win, doc, index, post) {
223
+ let enabled = false;
224
+ let frame = null;
225
+ let last = "";
226
+ let placed = null;
227
+ const at = () => `${Math.round(win.scrollX)},${Math.round(win.scrollY)}`;
228
+ const range = (size, view) => Math.max(0, size - view);
229
+ function anchor() {
230
+ let best = null;
231
+ let top = Infinity;
232
+ for (const tagged of index.elements) {
233
+ const rect = tagged.element.getBoundingClientRect();
234
+ if (rect.height <= 0 || rect.bottom <= 0 || rect.top >= win.innerHeight) continue;
235
+ if (Math.abs(rect.top) < Math.abs(top)) {
236
+ best = tagged;
237
+ top = rect.top;
238
+ }
239
+ }
240
+ if (!best) return void 0;
241
+ const { recordRef, fieldKey } = best;
242
+ const nth = index.byField(recordRef, fieldKey).indexOf(best);
243
+ return nth < 0 ? void 0 : { recordRef, fieldKey, nth, offset: Math.round(top) };
244
+ }
245
+ function measure() {
246
+ const root = doc.documentElement;
247
+ const fraction = (pos, size, view) => {
248
+ const max = range(size, view);
249
+ return max > 0 ? round(Math.min(1, Math.max(0, pos / max))) : 0;
250
+ };
251
+ const payload = { y: fraction(win.scrollY, root.scrollHeight, win.innerHeight) };
252
+ if (range(root.scrollWidth, win.innerWidth) > 0) {
253
+ payload.x = fraction(win.scrollX, root.scrollWidth, win.innerWidth);
254
+ }
255
+ const found = anchor();
256
+ if (found) payload.anchor = found;
257
+ return payload;
258
+ }
259
+ function flush() {
260
+ frame = null;
261
+ if (!enabled) return;
262
+ if (at() === placed) return;
263
+ placed = null;
264
+ const payload = measure();
265
+ const key = JSON.stringify(payload);
266
+ if (key === last) return;
267
+ last = key;
268
+ post(payload);
269
+ }
270
+ const onScroll = () => {
271
+ if (!enabled || frame !== null) return;
272
+ frame = typeof win.requestAnimationFrame === "function" ? win.requestAnimationFrame(flush) : win.setTimeout(flush, 16);
273
+ };
274
+ return {
275
+ setEnabled(next) {
276
+ if (next === enabled) return;
277
+ enabled = next;
278
+ if (next) {
279
+ win.addEventListener("scroll", onScroll, { passive: true });
280
+ last = "";
281
+ placed = null;
282
+ flush();
283
+ } else win.removeEventListener("scroll", onScroll);
284
+ },
285
+ scrollTo({ y, x, anchor: target }) {
286
+ const root = doc.documentElement;
287
+ let top = y * range(root.scrollHeight, win.innerHeight);
288
+ const element = target && index.byField(target.recordRef, target.fieldKey)[target.nth]?.element;
289
+ if (element?.isConnected) {
290
+ top = win.scrollY + element.getBoundingClientRect().top - target.offset;
291
+ }
292
+ const left = x === void 0 ? win.scrollX : x * range(root.scrollWidth, win.innerWidth);
293
+ try {
294
+ win.scrollTo({ top, left, behavior: "instant" });
295
+ } catch {
296
+ win.scrollTo(left, top);
297
+ }
298
+ placed = at();
299
+ last = JSON.stringify(measure());
300
+ },
301
+ destroy() {
302
+ enabled = false;
303
+ win.removeEventListener("scroll", onScroll);
304
+ }
305
+ };
306
+ }
307
+
216
308
  // src/preview/sanitize.ts
217
309
  var RICH_TEXT_ALLOWED_TAGS = /* @__PURE__ */ new Set([
218
310
  "p",
@@ -472,7 +564,7 @@ function writeImage(element, image, base) {
472
564
  }
473
565
 
474
566
  // src/preview/runtime.ts
475
- var CLIENT_VERSION = "0.10.0";
567
+ var CLIENT_VERSION = "0.11.0";
476
568
  var MAX_PROBLEM_REPORTS = 20;
477
569
  function startRuntime(config, options = {}) {
478
570
  const win = options.win ?? window;
@@ -525,6 +617,13 @@ function startRuntime(config, options = {}) {
525
617
  config.onMessage?.(message);
526
618
  }
527
619
  });
620
+ const scroll = createScrollSync(win, doc, index, (payload) => bridge.post("zap:scroll", payload));
621
+ let marks = [];
622
+ const drawMarks = () => overlay.setMarks(
623
+ marks.flatMap(
624
+ ({ recordRef, fieldKey, tone, label, active }) => index.byField(recordRef, fieldKey).map(({ element }) => ({ element, tone, label, active: !!active }))
625
+ )
626
+ );
528
627
  function handle(message) {
529
628
  switch (message.type) {
530
629
  case "zap:hello": {
@@ -572,6 +671,16 @@ function startRuntime(config, options = {}) {
572
671
  overlay.setPins(pins);
573
672
  return;
574
673
  }
674
+ case "zap:scroll-sync":
675
+ scroll.setEnabled(message.payload.enabled);
676
+ return;
677
+ case "zap:scroll-to":
678
+ scroll.scrollTo(message.payload);
679
+ return;
680
+ case "zap:marks":
681
+ marks = message.payload.marks;
682
+ drawMarks();
683
+ return;
575
684
  }
576
685
  }
577
686
  function report(why, force = false) {
@@ -608,6 +717,7 @@ function startRuntime(config, options = {}) {
608
717
  function refresh(page = true) {
609
718
  if (!scan(page)) return;
610
719
  values.reapply();
720
+ if (marks.length) drawMarks();
611
721
  report(page ? "mutation" : "reapply");
612
722
  const url = config.pageUrl();
613
723
  if (url !== lastUrl) {
@@ -625,6 +735,7 @@ function startRuntime(config, options = {}) {
625
735
  }
626
736
  };
627
737
  let greeted = false;
738
+ const retries = [];
628
739
  function announce() {
629
740
  log("ready", config.pageUrl());
630
741
  bridge.post("zap:ready", {
@@ -650,7 +761,8 @@ function startRuntime(config, options = {}) {
650
761
  }
651
762
  announce();
652
763
  report("ready", true);
653
- for (const ms of [500, 1500, 4e3]) win.setTimeout(() => greeted || announce(), ms);
764
+ for (const ms of [500, 1500, 4e3])
765
+ retries.push(win.setTimeout(() => greeted || announce(), ms));
654
766
  }
655
767
  if (doc.readyState === "loading") {
656
768
  doc.addEventListener("DOMContentLoaded", boot, { once: true });
@@ -663,7 +775,9 @@ function startRuntime(config, options = {}) {
663
775
  overlay,
664
776
  refresh,
665
777
  destroy() {
778
+ for (const id of retries) win.clearTimeout(id);
666
779
  stopObserving();
780
+ scroll.destroy();
667
781
  win.removeEventListener("pagehide", onPageHide);
668
782
  win.removeEventListener("pageshow", onPageShow);
669
783
  overlay.destroy();
@@ -793,8 +907,13 @@ function initZap(options, runtimeOptions = {}) {
793
907
  if (token) store?.setItem(PREVIEW_TOKEN_STORAGE_KEY, token);
794
908
  else forgetPreviewToken(win);
795
909
  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(() => {
910
+ const body = new URLSearchParams({ token, path: win.location.pathname });
911
+ void win.fetch(draftRoute, {
912
+ method: "POST",
913
+ body,
914
+ credentials: "same-origin",
915
+ redirect: "manual"
916
+ }).catch(() => {
798
917
  });
799
918
  }
800
919
  } catch {
@@ -864,9 +983,12 @@ export {
864
983
  EDITOR_MESSAGE_TYPES,
865
984
  FIELD_KEY_RE,
866
985
  MAX_ANCHORS,
986
+ MAX_ANCHOR_NTH,
867
987
  MAX_DRAFT_ROUTE_LENGTH,
868
988
  MAX_EMAIL_LENGTH,
869
989
  MAX_LABELS,
990
+ MAX_MARKS,
991
+ MAX_MARK_LABEL,
870
992
  MAX_MESSAGE_BYTES,
871
993
  MAX_PINS,
872
994
  MAX_QUOTE_CONTEXT,
@@ -903,6 +1025,7 @@ export {
903
1025
  findAnchorElement,
904
1026
  getPreviewToken,
905
1027
  initZap,
1028
+ isDiffMark,
906
1029
  isDomAnchor,
907
1030
  isDraftRoutePath,
908
1031
  isFieldKey,