@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/CHANGELOG.md +63 -1
- package/README.md +68 -8
- package/dist/{chunk-QN53G5P7.js → chunk-6XHCA4U4.js} +25 -9
- package/dist/chunk-6XHCA4U4.js.map +1 -0
- package/dist/{chunk-VW4EH7NY.js → chunk-7CXQK2PR.js} +148 -80
- package/dist/chunk-7CXQK2PR.js.map +1 -0
- package/dist/{chunk-2IUU53QA.js → chunk-7ZPXZZUT.js} +171 -2
- package/dist/chunk-7ZPXZZUT.js.map +1 -0
- package/dist/index.cjs +94 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +119 -12
- package/dist/index.d.ts +119 -12
- package/dist/index.js +94 -1
- package/dist/index.js.map +1 -1
- package/dist/next.d.ts +6 -0
- package/dist/next.js.map +1 -1
- package/dist/preview.d.ts +55 -7
- package/dist/preview.js +95 -39
- package/dist/preview.js.map +1 -1
- package/dist/react.js +3 -3
- package/dist/react.js.map +1 -1
- package/dist/{signin-2QOSWZFK.js → signin-QT7YANBN.js} +14 -111
- package/dist/signin-QT7YANBN.js.map +1 -0
- package/dist/{suggest-5RHURHED.js → suggest-JZ63L3WP.js} +1001 -66
- package/dist/suggest-JZ63L3WP.js.map +1 -0
- package/package.json +1 -1
- package/dist/chunk-2IUU53QA.js.map +0 -1
- package/dist/chunk-QN53G5P7.js.map +0 -1
- package/dist/chunk-VW4EH7NY.js.map +0 -1
- package/dist/signin-2QOSWZFK.js.map +0 -1
- package/dist/suggest-5RHURHED.js.map +0 -1
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":"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","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':
|
|
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
|
-
|
|
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
|
-
/**
|
|
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-
|
|
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
|
-
|
|
70
|
+
readSession,
|
|
68
71
|
siteClientId
|
|
69
|
-
} from "./chunk-
|
|
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-
|
|
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(
|
|
156
|
+
void ctx.open(readSession(win, siteId) ? "suggest" : "signin", { reason: "trigger" });
|
|
162
157
|
};
|
|
163
|
-
if (
|
|
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,
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|