@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 CHANGED
@@ -5,7 +5,69 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [0.10.0] - Unreleased
8
+ ## [0.10.1] - Unreleased
9
+
10
+ ### Added
11
+
12
+ - The site client's on-page read returns the viewer's own `email` and an `editorUrl` for the record the page resolves to («Abrir en Zap»), plus `records` naming the other records the page shows.
13
+ - **`webhookChanges` covers SEO, collection, schema and site events**, so a
14
+ site that revalidates on webhooks also refreshes when they change:
15
+ - `zap.seo.updated` gives the same `item` or `document` change an edit
16
+ does (action `updated`), one per entry or document;
17
+ - `zap.collection.created`, `updated` and `deleted` give a `collection`
18
+ change carrying `collectionKey`;
19
+ - `zap.schema.field_changed` (action `field_changed`) gives one
20
+ `collection` change per collection whose fields changed, with
21
+ `resourceKey` and `collectionKey` set to the collection's key, and one
22
+ `document` change per document whose fields changed, named by the
23
+ document's key. Zap now sends `collection_key` and `document_key` on
24
+ every schema change. An older payload without them names a collection
25
+ by its id (no `collectionKey`) and widens a document's fields to one
26
+ `site` change;
27
+ - `zap.site.updated` gives a `site` change.
28
+ - `WebhookEventType` adds `'collection'` and `'site'`, and `WebhookAction`
29
+ adds `'field_changed'`. The item, document and media changes keep their
30
+ shape.
31
+ - Typed `data` for these events: `WebhookSeoEventData`,
32
+ `WebhookCollectionEventData`, `WebhookSchemaEventData` (with optional
33
+ `collection_key` and `document_key`) and `WebhookSiteEventData`.
34
+
35
+ ### Changed
36
+
37
+ - **`canonicalUrl` takes a path or a full URL** (`SeoInput`, docs only; the
38
+ type is still `string | null`). A path such as `/blog/original-post` is
39
+ stored as sent and resolved against the site URL on delivery, so it
40
+ follows a change of address; a full https URL (http only on localhost) is
41
+ kept as sent, for content first published on another domain. Leave it
42
+ unset and delivery uses the record's own URL. Zap refuses `//host`,
43
+ `javascript:` and other schemes, whitespace and credentials with a 400
44
+ whose `details[].message` says why.
45
+ - `Seo.canonicalUrl` on delivery is always an absolute URL or null, as
46
+ before; a stored path arrives resolved. `VersionSeo.canonicalUrl` and the
47
+ SEO routes return the value as stored.
48
+ - The canonical is an SEO field only: it no longer changes the page Zap's
49
+ preview opens. Where a record lives is its preview path.
50
+ - **A document without a preview path is site-wide** (a header and footer
51
+ document, say): `Seo.canonicalUrl` and `ogUrl` are null for it unless an
52
+ editor set a canonical, instead of `/{documentKey}`. A document that is a
53
+ page keeps its preview path's URL.
54
+ - **Zap keeps every version.** The per-site version limit is gone, so
55
+ `WebhookSiteEventData.changed` on `zap.site.updated` never lists
56
+ `maxVersionsPerEntry`, and `itemVersions.list()` and `documentVersions.list()`
57
+ return the whole history. No SDK type carried the setting, so no code changes.
58
+
59
+ ### Fixed
60
+
61
+ - `webhookChanges` returns an empty list for `zap.item.draft_updated` and
62
+ `zap.document.draft_updated`: saving a draft changes nothing the live site
63
+ serves.
64
+ - **The preview client pauses instead of running away.** When the tagged
65
+ count passes 5,000, or grows on three passes in a row that nothing on the
66
+ page explains, the client stops observing the page and writing values,
67
+ logs one warning and tells Zap's editor (`zap:paused`), which offers a
68
+ reload. Values carrying stega markers no longer add markers on each pass.
69
+
70
+ ## [0.10.0] - 2026-10-06
9
71
 
10
72
  The first release as **`@8ux-co/eelzap`**, which replaces
11
73
  `@8ux-co/eelzap-api-sdk-ts` (its last release, 0.9.1, points here). One
package/README.md CHANGED
@@ -761,20 +761,77 @@ never include comment text. Their types are `WebhookCommentCreatedData`,
761
761
 
762
762
  ### Cache invalidation
763
763
 
764
- `webhookChanges(payload)` flattens an item, document or media event into one
765
- `WebhookChange` per entry — `{ type, action, id, resourceKey, collectionKey?,
766
- siteKey }`, where `resourceKey` is the item's slug, the document's key or the
767
- file's id. Any other event gives an empty list.
764
+ `webhookChanges(payload)` flattens an event into the changes a site
765
+ revalidates by, `{ type, action, id, resourceKey, collectionKey?, siteKey }`:
766
+
767
+ | `type` | From | `resourceKey` | `collectionKey` |
768
+ | ------------ | ----------------------------------------------------------------------- | -------------------- | --------------- |
769
+ | `item` | `zap.item.*`, and `zap.seo.updated` on entries (action `updated`) | the entry's slug | its collection |
770
+ | `document` | `zap.document.*`, and `zap.seo.updated` on documents (action `updated`) | the document's key | |
771
+ | `document` | `zap.schema.field_changed` on a document's fields (`field_changed`) | the document's key | |
772
+ | `media` | `zap.media.*` | the file's id | |
773
+ | `collection` | `zap.collection.*` | the collection's key | the same key |
774
+ | `collection` | `zap.schema.field_changed` on a collection's fields (`field_changed`) | the collection's key | the same key |
775
+ | `site` | `zap.site.updated` | the site's key | |
776
+
777
+ One event can name several entries, documents or files: each is its own
778
+ change. A `collection` change means everything that shows that collection; a
779
+ `site` change means the whole site. Drafts (`draft_updated`), assignments,
780
+ comments, API keys, `ping`, and sites created or deleted give an empty list.
781
+
782
+ A `zap.schema.field_changed` sent before the event carried `collection_key`
783
+ and `document_key` names its owners by id only: a collection's fields give a
784
+ `collection` change whose `resourceKey` is the collection's id, with no
785
+ `collectionKey`, and a document's fields give one `site` change.
786
+
787
+ #### Subscribing a live site
788
+
789
+ Subscribe the site's webhook, narrowed to the site with `site_ids`, to:
790
+
791
+ - the `zap.item.*`, `zap.document.*` and `zap.media.*` events, except
792
+ `zap.item.draft_updated`, `zap.document.draft_updated`, `zap.item.assigned`
793
+ and `zap.document.assigned`;
794
+ - `zap.collection.created`, `zap.collection.updated` and
795
+ `zap.collection.deleted`;
796
+ - `zap.seo.updated`, `zap.schema.field_changed` and `zap.site.updated`.
797
+
798
+ Do not subscribe to `draft_updated` or `assigned`: a saved draft and a new
799
+ Responsable change nothing the site serves, and drafts save often.
800
+
801
+ #### Revalidating
802
+
803
+ A Next.js route handler for a site that serves documents at `/<key>` and
804
+ entries at `/<collection>/<slug>`:
768
805
 
769
806
  ```ts
807
+ // app/api/zap-webhook/route.ts
808
+ import { revalidatePath } from 'next/cache'
770
809
  import {
771
- MemoryCacheAdapter,
772
810
  verifyWebhookSignature,
773
811
  webhookChanges,
812
+ type WebhookChange,
774
813
  type WebhookPayload,
775
814
  } from '@8ux-co/eelzap'
776
815
 
777
- const cache = new MemoryCacheAdapter()
816
+ function revalidate(change: WebhookChange) {
817
+ switch (change.type) {
818
+ case 'item':
819
+ revalidatePath(`/${change.collectionKey}/${change.resourceKey}`)
820
+ revalidatePath(`/${change.collectionKey}`) // the listing
821
+ return
822
+ case 'document':
823
+ revalidatePath(`/${change.resourceKey}`)
824
+ return
825
+ case 'collection':
826
+ // An older field change without the key names the collection by id: refresh the whole site.
827
+ revalidatePath(change.collectionKey ? `/${change.collectionKey}` : '/', 'layout')
828
+ return
829
+ case 'media':
830
+ case 'site':
831
+ revalidatePath('/', 'layout')
832
+ return
833
+ }
834
+ }
778
835
 
779
836
  export async function POST(request: Request) {
780
837
  const payload = await request.text()
@@ -785,13 +842,16 @@ export async function POST(request: Request) {
785
842
  }
786
843
 
787
844
  for (const change of webhookChanges(JSON.parse(payload) as WebhookPayload)) {
788
- if (change.type === 'document') cache.delete(change.resourceKey)
789
- if (change.type === 'item') cache.delete(`${change.collectionKey}:${change.resourceKey}`)
845
+ revalidate(change)
790
846
  }
791
847
  return new Response('ok')
792
848
  }
793
849
  ```
794
850
 
851
+ With `cachedFetch` and a `MemoryCacheAdapter`, do the same with
852
+ `cache.delete(key)` for an item or a document, and `cache.clear()` for a
853
+ `collection`, `media` or `site` change.
854
+
795
855
  `WEBHOOK_SIGNATURE_HEADER`, `WEBHOOK_TIMESTAMP_HEADER` and
796
856
  `WEBHOOK_TOLERANCE_SECONDS` export the header names and the default window.
797
857
 
@@ -15,6 +15,7 @@ function siteClientId(zapOrigin, siteId) {
15
15
 
16
16
  // src/preview/site/token.ts
17
17
  var EXPIRY_MARGIN_MS = 3e4;
18
+ var REFRESH_LIFETIME_MS = 8 * 60 * 60 * 1e3;
18
19
  function store(win) {
19
20
  try {
20
21
  return win.sessionStorage;
@@ -22,15 +23,26 @@ function store(win) {
22
23
  return null;
23
24
  }
24
25
  }
25
- function readToken(win, siteId, now = Date.now()) {
26
+ function parse(raw, siteId) {
27
+ if (!raw) return null;
28
+ const value = JSON.parse(raw);
29
+ if (typeof value.token !== "string" || !value.token.startsWith("eel_at_") || typeof value.exp !== "number" || value.site !== siteId) {
30
+ return null;
31
+ }
32
+ const refresh = typeof value.refresh === "string" && value.refresh.startsWith("eel_rt_") && typeof value.rexp === "number" ? { refresh: value.refresh, rexp: value.rexp } : {};
33
+ return { token: value.token, exp: value.exp, site: value.site, ...refresh };
34
+ }
35
+ function accessLive(value, now = Date.now()) {
36
+ return value.exp - EXPIRY_MARGIN_MS > now;
37
+ }
38
+ function refreshLive(value, now = Date.now()) {
39
+ return !!value.refresh && typeof value.rexp === "number" && value.rexp > now;
40
+ }
41
+ function readSession(win, siteId, now = Date.now()) {
26
42
  const storage = store(win);
27
43
  try {
28
- const raw = storage?.getItem(TOKEN_STORAGE_KEY);
29
- if (!raw) return null;
30
- const value = JSON.parse(raw);
31
- if (typeof value.token === "string" && value.token.startsWith("eel_at_") && typeof value.exp === "number" && value.exp - EXPIRY_MARGIN_MS > now && value.site === siteId) {
32
- return { token: value.token, exp: value.exp, site: value.site };
33
- }
44
+ const value = parse(storage?.getItem(TOKEN_STORAGE_KEY), siteId);
45
+ if (value && (accessLive(value, now) || refreshLive(value, now))) return value;
34
46
  storage?.removeItem(TOKEN_STORAGE_KEY);
35
47
  } catch {
36
48
  }
@@ -57,8 +69,12 @@ export {
57
69
  STATE_PREFIX,
58
70
  PENDING_STORAGE_KEY,
59
71
  siteClientId,
60
- readToken,
72
+ EXPIRY_MARGIN_MS,
73
+ REFRESH_LIFETIME_MS,
74
+ accessLive,
75
+ refreshLive,
76
+ readSession,
61
77
  writeToken,
62
78
  clearToken
63
79
  };
64
- //# sourceMappingURL=chunk-QN53G5P7.js.map
80
+ //# sourceMappingURL=chunk-6XHCA4U4.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/preview/site/config.ts","../src/preview/site/token.ts"],"sourcesContent":["/**\n * «Editar» and «Comentar» on the customer's site (zap-cms-v2 §3.4, ADR 041):\n * the constants and the context the core hands its two lazy chunks.\n *\n * Three pieces, so an ordinary visitor downloads nothing beyond the core:\n *\n * - the core (`boot.ts`, in the `./preview` budget): the opt-in trigger\n * (`?zap` in the URL, or Shift Z), the OAuth callback relay on `{origin}/`,\n * and the loader;\n * - `signin` (loaded only on the trigger): the launcher, «Entrar con Eel», PKCE,\n * the popup or full-page redirect and the code exchange at Nest;\n * - `suggest` (loaded only after sign-in; the name is the file's, kept): the\n * toolbar, Navegar, Editar and Comentar, the composer, the numbered pins\n * and the calls to Zap.\n *\n * No directive, no imports: pure values both chunks and the core share.\n */\n\n/** Nest, the suite's OAuth issuer (ADR 024). Compiled in; `authOrigin` is the dev opt-in. */\nexport const NEST_PRODUCTION_ORIGIN = 'https://auth.eel.software'\n\n/**\n * The scope a site client asks for, by name (ADR 041 §2). `zap:preview:read`\n * is not asked for until a «Ver borradores» feature needs it (Manager,\n * 2026-10-05); Nest would drop it anyway.\n */\nexport const SITE_CLIENT_SCOPE = 'zap:suggest:write'\n\n/** The popup hands the code to the opener tab over this channel (same origin). */\nexport const SIGNIN_CHANNEL = 'eel-zap'\n\n/** Every `state` this client mints starts with it, so a site's own `?code=` is never ours. */\nexport const STATE_PREFIX = 'zap1.'\n\n/** sessionStorage only (ADR 041 §7): the token, and a pending full-page sign-in. */\nexport const TOKEN_STORAGE_KEY = 'eelzap:site-token'\nexport const PENDING_STORAGE_KEY = 'eelzap:signin'\n\nexport type ChunkName = 'signin' | 'suggest'\n\n/** What a callback page carries back from Nest (`{origin}/?code&state&iss`). */\nexport interface CallbackParams {\n code: string\n state: string\n iss: string\n}\n\nexport interface StartOptions {\n /**\n * signin: open the panel at once, and why (`expired` after a 401 or the\n * hour). suggest: `renewed` after a sign-in that followed an expiry, which\n * says «Sesión renovada» (board SitioRenovacion).\n */\n reason?: 'expired' | 'trigger' | 'renewed'\n /** signin: finish a full-page sign-in that landed on this page. */\n callback?: CallbackParams\n /**\n * signin: start signing in at once, as if «Entrar con Eel» was pressed.\n * Passed by «Volver a entrar» (the bar's expired state), inside its click, so\n * the popup is still allowed.\n */\n signIn?: boolean\n}\n\nexport interface ChunkHandle {\n destroy(): void\n}\n\nexport interface ChunkModule {\n start(ctx: SiteContext, options?: StartOptions): ChunkHandle\n}\n\n/** Everything a chunk needs, decided once by the core. */\nexport interface SiteContext {\n win: Window\n doc: Document\n /** The Zap site's id (`data-site-id`); names its OAuth client. */\n siteId: string\n /** Where Zap's API and the client's CIMD document live. */\n zapOrigin: string\n /** Nest, the issuer: `/oauth/authorize`, `/api/oauth/token`, and the `iss` to expect. */\n authOrigin: string\n /** `{zapOrigin}/oauth/clients/{siteId}.json` (ADR 041 §1). */\n clientId: string\n /** `{page origin}/`, exactly as the CIMD document lists it. */\n redirectUri: string\n locale: 'es' | 'en'\n /** The letter pressed with Shift to open the tools, for the launcher's hint; false when off. */\n shortcut: string | false\n /**\n * The site's draft-mode route (`/api/zap-preview` by default), as the page\n * announced it: «Editar» enters draft mode through it on an untagged page\n * (`draft-session.ts`). Null or absent when the site has none.\n */\n draftRoute?: string | null\n /** Replace whatever chunk is showing with `name`. */\n open(name: ChunkName, options?: StartOptions): Promise<void>\n /** Close the showing chunk; the trigger works again. */\n close(): void\n}\n\n/** The client id of one site, the same string `@eel/api-keys` `zapSiteClientId` builds. */\nexport function siteClientId(zapOrigin: string, siteId: string): string {\n return `${zapOrigin}/oauth/clients/${siteId.toLowerCase()}.json`\n}\n","import { TOKEN_STORAGE_KEY } from './config'\n\n/**\n * The site client's tokens (ADR 041 §7): in memory and in `sessionStorage`,\n * never in `localStorage` or a cookie. The access token lives one hour. Since\n * ADR 041's amendment of 2026-10-06 it comes with a short refresh token (eight\n * hours, rotating, redeemable only inside the person's remembered consent and\n * from this origin), which `session.ts` spends to renew the hour without a\n * sign-in; when that fails the person signs in again.\n *\n * Stored with the expiries and the site it was issued for: a token for another\n * site, a malformed entry, or one whose access AND refresh are both spent reads\n * as nothing and is removed.\n */\n\nexport interface SiteToken {\n /** The `eel_at_` access token. */\n token: string\n /** Epoch ms when it stops working. */\n exp: number\n /** The site it was issued for. */\n site: string\n /** The `eel_rt_` refresh token, when Nest issued one. */\n refresh?: string\n /** Epoch ms after which the refresh token is not worth trying. */\n rexp?: number\n}\n\n/** How long before the real expiry we stop using a token, so a call never races it. */\nexport const EXPIRY_MARGIN_MS = 30_000\n\n/** A refresh token lives eight hours at Nest (ADR 041 amendment 2026-10-06). */\nexport const REFRESH_LIFETIME_MS = 8 * 60 * 60 * 1000\n\nfunction store(win: Window): Storage | null {\n try {\n return win.sessionStorage\n } catch {\n return null\n }\n}\n\nfunction parse(raw: string | null | undefined, siteId: string): SiteToken | null {\n if (!raw) return null\n const value = JSON.parse(raw) as Partial<SiteToken>\n if (\n typeof value.token !== 'string' ||\n !value.token.startsWith('eel_at_') ||\n typeof value.exp !== 'number' ||\n value.site !== siteId\n ) {\n return null\n }\n const refresh =\n typeof value.refresh === 'string' &&\n value.refresh.startsWith('eel_rt_') &&\n typeof value.rexp === 'number'\n ? { refresh: value.refresh, rexp: value.rexp }\n : {}\n return { token: value.token, exp: value.exp, site: value.site, ...refresh }\n}\n\n/** True while the access token is still worth sending. */\nexport function accessLive(value: SiteToken, now = Date.now()): boolean {\n return value.exp - EXPIRY_MARGIN_MS > now\n}\n\n/** True while the refresh token is still worth trying. */\nexport function refreshLive(value: SiteToken, now = Date.now()): boolean {\n return !!value.refresh && typeof value.rexp === 'number' && value.rexp > now\n}\n\n/**\n * The stored session while it can still act: a live access token, or a\n * refresh token that may renew it (`session.ts` does). Anything else is\n * removed and reads as null.\n */\nexport function readSession(win: Window, siteId: string, now = Date.now()): SiteToken | null {\n const storage = store(win)\n try {\n const value = parse(storage?.getItem(TOKEN_STORAGE_KEY), siteId)\n if (value && (accessLive(value, now) || refreshLive(value, now))) return value\n storage?.removeItem(TOKEN_STORAGE_KEY)\n } catch {\n // Unreadable entry or blocked storage: no token.\n }\n return null\n}\n\n/** The stored session only while its ACCESS token is live (no renewal needed). */\nexport function readToken(win: Window, siteId: string, now = Date.now()): SiteToken | null {\n const value = readSession(win, siteId, now)\n return value && accessLive(value, now) ? value : null\n}\n\nexport function writeToken(win: Window, value: SiteToken): void {\n try {\n store(win)?.setItem(TOKEN_STORAGE_KEY, JSON.stringify(value))\n } catch {\n // Blocked storage: the token lives in memory for this page only.\n }\n}\n\nexport function clearToken(win: Window): void {\n try {\n store(win)?.removeItem(TOKEN_STORAGE_KEY)\n } catch {\n // Nothing to clear.\n }\n}\n"],"mappings":";;;;;AAmBO,IAAM,yBAAyB;AAO/B,IAAM,oBAAoB;AAG1B,IAAM,iBAAiB;AAGvB,IAAM,eAAe;AAGrB,IAAM,oBAAoB;AAC1B,IAAM,sBAAsB;AAkE5B,SAAS,aAAa,WAAmB,QAAwB;AACtE,SAAO,GAAG,SAAS,kBAAkB,OAAO,YAAY,CAAC;AAC3D;;;AC3EO,IAAM,mBAAmB;AAGzB,IAAM,sBAAsB,IAAI,KAAK,KAAK;AAEjD,SAAS,MAAM,KAA6B;AAC1C,MAAI;AACF,WAAO,IAAI;AAAA,EACb,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,MAAM,KAAgC,QAAkC;AAC/E,MAAI,CAAC,IAAK,QAAO;AACjB,QAAM,QAAQ,KAAK,MAAM,GAAG;AAC5B,MACE,OAAO,MAAM,UAAU,YACvB,CAAC,MAAM,MAAM,WAAW,SAAS,KACjC,OAAO,MAAM,QAAQ,YACrB,MAAM,SAAS,QACf;AACA,WAAO;AAAA,EACT;AACA,QAAM,UACJ,OAAO,MAAM,YAAY,YACzB,MAAM,QAAQ,WAAW,SAAS,KAClC,OAAO,MAAM,SAAS,WAClB,EAAE,SAAS,MAAM,SAAS,MAAM,MAAM,KAAK,IAC3C,CAAC;AACP,SAAO,EAAE,OAAO,MAAM,OAAO,KAAK,MAAM,KAAK,MAAM,MAAM,MAAM,GAAG,QAAQ;AAC5E;AAGO,SAAS,WAAW,OAAkB,MAAM,KAAK,IAAI,GAAY;AACtE,SAAO,MAAM,MAAM,mBAAmB;AACxC;AAGO,SAAS,YAAY,OAAkB,MAAM,KAAK,IAAI,GAAY;AACvE,SAAO,CAAC,CAAC,MAAM,WAAW,OAAO,MAAM,SAAS,YAAY,MAAM,OAAO;AAC3E;AAOO,SAAS,YAAY,KAAa,QAAgB,MAAM,KAAK,IAAI,GAAqB;AAC3F,QAAM,UAAU,MAAM,GAAG;AACzB,MAAI;AACF,UAAM,QAAQ,MAAM,SAAS,QAAQ,iBAAiB,GAAG,MAAM;AAC/D,QAAI,UAAU,WAAW,OAAO,GAAG,KAAK,YAAY,OAAO,GAAG,GAAI,QAAO;AACzE,aAAS,WAAW,iBAAiB;AAAA,EACvC,QAAQ;AAAA,EAER;AACA,SAAO;AACT;AAQO,SAAS,WAAW,KAAa,OAAwB;AAC9D,MAAI;AACF,UAAM,GAAG,GAAG,QAAQ,mBAAmB,KAAK,UAAU,KAAK,CAAC;AAAA,EAC9D,QAAQ;AAAA,EAER;AACF;AAEO,SAAS,WAAW,KAAmB;AAC5C,MAAI;AACF,UAAM,GAAG,GAAG,WAAW,iBAAiB;AAAA,EAC1C,QAAQ;AAAA,EAER;AACF;","names":[]}
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  __publicField
3
- } from "./chunk-QN53G5P7.js";
3
+ } from "./chunk-6XHCA4U4.js";
4
4
 
5
5
  // src/refs.ts
6
6
  var FIELD_KEY_RE = /^[a-z][a-z0-9_]{0,127}$/;
@@ -63,7 +63,14 @@ var MAX_TAG_REPORT = 2e3;
63
63
  var MAX_LABELS = 500;
64
64
  var MAX_PINS = 50;
65
65
  var OVERLAY_MODES = ["inspect", "select", "spot", "off"];
66
- var CAPABILITIES = ["overlay", "values", "stega", "pins", "links"];
66
+ var CAPABILITIES = [
67
+ "overlay",
68
+ "values",
69
+ "stega",
70
+ "pins",
71
+ "links",
72
+ "refresh"
73
+ ];
67
74
  var MAX_EMAIL_LENGTH = 320;
68
75
  function emptyDropStats() {
69
76
  return {
@@ -200,12 +207,13 @@ var PAGE_VALIDATORS = {
200
207
  "zap:tags": (p) => Array.isArray(p) && p.length <= MAX_TAG_REPORT && p.every(
201
208
  (t) => isObject(t) && typeof t.tag === "string" && isRecordRef(t.recordRef) && isFieldKey(t.fieldKey) && t.tag === `${t.recordRef}#${t.fieldKey}` && Number.isInteger(t.count) && t.count > 0 && (t.source === void 0 || t.source === "stega" || t.source === "attr")
202
209
  ),
203
- "zap:click": isFieldRef,
210
+ "zap:click": (p) => isFieldRef(p) && (p.rect === void 0 || isObject(p.rect) && isFiniteNumber(p.rect.x, -1e7, 1e7) && isFiniteNumber(p.rect.y, -1e7, 1e7) && isFiniteNumber(p.rect.w, 0, 1e7) && isFiniteNumber(p.rect.h, 0, 1e7)),
204
211
  "zap:select": (p) => isObject(p) && isAnchorList(p.anchors, isPageAnchor),
205
212
  "zap:navigate": (p) => isObject(p) && isHttpUrl(p.url),
206
213
  "zap:error": (p) => isObject(p) && typeof p.code === "string" && ERROR_CODE_RE.test(p.code) && isOptional(p.detail, (d) => isString(d, 500)),
207
214
  "zap:spot": (p) => isObject(p) && isSpotPoint(p.spot) && isObject(p.viewport) && isFiniteNumber(p.viewport.w, 1, 1e5) && isFiniteNumber(p.viewport.h, 1, 1e7) && isHttpUrl(p.pageUrl),
208
- "zap:pin": (p) => isObject(p) && isPinId(p.id)
215
+ "zap:pin": (p) => isObject(p) && isPinId(p.id),
216
+ "zap:paused": (p) => isObject(p) && (p.reason === "runaway" || p.reason === "cap") && Number.isInteger(p.count) && p.count >= 0
209
217
  };
210
218
  var EDITOR_VALIDATORS = {
211
219
  "zap:hello": (p) => {
@@ -214,10 +222,11 @@ var EDITOR_VALIDATORS = {
214
222
  if (p.theme !== void 0 && !isTheme(p.theme)) return false;
215
223
  if (p.zoom !== void 0 && !isZoom(p.zoom)) return false;
216
224
  if (p.previewToken !== void 0 && !isPreviewTokenShape(p.previewToken)) return false;
217
- const labels = p.labels;
218
- if (!isObject(labels)) return false;
219
- const entries = Object.entries(labels);
220
- return entries.length <= MAX_LABELS && entries.every(([key, label]) => isFieldKey(key) && isString(label, 200));
225
+ const within = (map, ok) => isObject(map) && Object.keys(map).length <= MAX_LABELS && Object.entries(map).every(([key, value]) => ok(key, value));
226
+ return within(p.labels, (key, label) => isFieldKey(key) && isString(label, 200)) && within(
227
+ p.foreign ?? {},
228
+ (tag, pair) => !!parseTag(tag) && Array.isArray(pair) && pair.length === 2 && pair.every((part) => isString(part, 200))
229
+ );
221
230
  },
222
231
  "zap:values": (p) => {
223
232
  if (!isObject(p) || !isRecordRef(p.recordRef) || !isLocale(p.locale)) return false;
@@ -228,6 +237,7 @@ var EDITOR_VALIDATORS = {
228
237
  "zap:focus-field": isFieldRef,
229
238
  "zap:mode": isMode,
230
239
  "zap:zoom": (p) => isObject(p) && isZoom(p.zoom),
240
+ "zap:refresh": isObject,
231
241
  "zap:highlight": (p) => isObject(p) && isAnchorList(p.anchors, isDomAnchor),
232
242
  "zap:pins": (p) => isObject(p) && Array.isArray(p.pins) && p.pins.length <= MAX_PINS && p.pins.every(isPin)
233
243
  };
@@ -272,6 +282,53 @@ function envelope(type, session, payload) {
272
282
  return { source: PROTOCOL_SOURCE, v: PROTOCOL_VERSION, session, type, payload };
273
283
  }
274
284
 
285
+ // src/stega.ts
286
+ var DIGITS = ["\u200B", "\u200C", "\u200D", "\u2060"];
287
+ var PREFIX = "\u2060\u200D\u200C\u200C\u200D\u200D\u200C\u2060";
288
+ var PAYLOAD_RE = /^(doc:[a-z0-9]+(?:-[a-z0-9]+)*|[a-z0-9]+(?:-[a-z0-9]+)*\/[a-z0-9]+(?:-[a-z0-9]+)*)#([a-z][a-z0-9_]{0,127})(?:@([A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8}){0,3}))?$/;
289
+ var MARKER_RE = /⁠‍‌‌‍‍‌⁠(?:[​‌][​‌‍⁠]{3})*/g;
290
+ function hasStega(value) {
291
+ return typeof value === "string" && value.includes(PREFIX);
292
+ }
293
+ function stegaDecode(text) {
294
+ if (!text.includes(PREFIX)) return [];
295
+ const found = [];
296
+ for (const match of text.match(MARKER_RE) ?? []) {
297
+ let payload = "";
298
+ for (let i = PREFIX.length; i + 4 <= match.length; i += 4) {
299
+ let byte = 0;
300
+ for (let j = 0; j < 4; j++) byte = byte * 4 + DIGITS.indexOf(match[i + j]);
301
+ payload += String.fromCharCode(byte);
302
+ }
303
+ const parsed = PAYLOAD_RE.exec(payload);
304
+ if (!parsed) continue;
305
+ found.push({
306
+ recordRef: parsed[1],
307
+ fieldKey: parsed[2],
308
+ locale: parsed[3] ?? null,
309
+ tag: `${parsed[1]}#${parsed[2]}`
310
+ });
311
+ }
312
+ return found;
313
+ }
314
+ function splitStega(text) {
315
+ if (!text.includes(PREFIX)) return { text, markers: "" };
316
+ return { text: text.replace(MARKER_RE, ""), markers: (text.match(MARKER_RE) ?? []).join("") };
317
+ }
318
+ function cleanString(text) {
319
+ return text.includes(PREFIX) ? text.replace(MARKER_RE, "") : text;
320
+ }
321
+ function cleanStega(value) {
322
+ if (typeof value === "string") return cleanString(value);
323
+ if (Array.isArray(value)) return value.map((item) => cleanStega(item));
324
+ if (value && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {
325
+ const out = {};
326
+ for (const [key, item] of Object.entries(value)) out[key] = cleanStega(item);
327
+ return out;
328
+ }
329
+ return value;
330
+ }
331
+
275
332
  // src/preview/anchor.ts
276
333
  function cssEscape(value) {
277
334
  const native = globalThis.CSS?.escape;
@@ -352,7 +409,7 @@ function fit(selector) {
352
409
  return utf8Length(selector) <= MAX_SELECTOR_BYTES ? selector : null;
353
410
  }
354
411
  function normalizeText(value) {
355
- return value.replace(/\s+/g, " ").trim();
412
+ return cleanStega(value).replace(/\s+/g, " ").trim();
356
413
  }
357
414
  function textQuoteFor(element, doc) {
358
415
  const exact = normalizeText(element.textContent ?? "").slice(0, MAX_QUOTE_EXACT);
@@ -480,6 +537,7 @@ var Overlay = class {
480
537
  __publicField(this, "zoom", 1);
481
538
  __publicField(this, "theme", { ...DEFAULT_THEME });
482
539
  __publicField(this, "labels", {});
540
+ __publicField(this, "foreign", {});
483
541
  __publicField(this, "hover", null);
484
542
  __publicField(this, "focused", []);
485
543
  __publicField(this, "highlighted", []);
@@ -538,6 +596,7 @@ var Overlay = class {
538
596
  if (!event.relatedTarget) this.setHover(null);
539
597
  });
540
598
  this.listen(win, "click", (event) => this.onClick(event));
599
+ this.listen(win, "submit", (event) => this.mode !== "off" && this.consume(event));
541
600
  this.listen(win, "keydown", (event) => this.onKey(event));
542
601
  this.listen(win, "scroll", () => this.schedule());
543
602
  this.listen(win, "resize", () => this.schedule());
@@ -584,17 +643,17 @@ var Overlay = class {
584
643
  this.theme = { ...DEFAULT_THEME, ...theme ?? {} };
585
644
  this.schedule();
586
645
  }
587
- setLabels(labels) {
646
+ /** Labels by field key; `foreign`, by full tag, the fields of other records (`HelloPayload.foreign`). */
647
+ setLabels(labels, foreign = {}) {
588
648
  this.labels = { ...labels };
649
+ this.foreign = { ...foreign };
589
650
  this.schedule();
590
651
  }
591
652
  /** Outline every element of a field and scroll the first into view. */
592
653
  focusField(elements) {
593
654
  this.focused = elements.slice();
594
655
  const first = elements[0];
595
- if (first && typeof first.scrollIntoView === "function") {
596
- first.scrollIntoView({ block: "center", behavior: "smooth" });
597
- }
656
+ if (first) reveal(this.options.win, first);
598
657
  this.schedule();
599
658
  return elements.length;
600
659
  }
@@ -602,9 +661,7 @@ var Overlay = class {
602
661
  highlight(elements) {
603
662
  this.highlighted = elements.slice(0, MAX_ANCHORS);
604
663
  const first = this.highlighted[0];
605
- if (first && typeof first.scrollIntoView === "function") {
606
- first.scrollIntoView({ block: "center", behavior: "smooth" });
607
- }
664
+ if (first) reveal(this.options.win, first);
608
665
  this.schedule();
609
666
  }
610
667
  /** Draw these pins (≤ 50); drops the pending spot pin. */
@@ -672,6 +729,7 @@ var Overlay = class {
672
729
  }
673
730
  onClick(event) {
674
731
  if (this.mode === "off" || this.isOwn(event.target)) return;
732
+ if (event.target.closest?.("a[href],area[href]")) this.consume(event);
675
733
  if (this.mode === "spot") {
676
734
  this.consume(event);
677
735
  return this.spotAt(event);
@@ -758,7 +816,8 @@ var Overlay = class {
758
816
  if (seen.has(element) || !element.isConnected) return;
759
817
  seen.add(element);
760
818
  const tagged = this.options.index.get(element);
761
- boxes.push({ element, kind, label: tagged ? this.labelFor(tagged) : null });
819
+ const [label, suffix] = tagged ? this.labelFor(tagged) : [null];
820
+ boxes.push({ element, kind, label, suffix });
762
821
  };
763
822
  if (this.mode !== "off") {
764
823
  for (const element of this.selected) add(element, "selected");
@@ -788,6 +847,12 @@ var Overlay = class {
788
847
  }
789
848
  }
790
849
  layer.replaceChildren(...nodes);
850
+ const inset = EDGE_PX / this.zoom;
851
+ for (const chip of layer.querySelectorAll(".chip")) {
852
+ const { left, right } = chip.getBoundingClientRect();
853
+ const dx = Math.min(Math.max(0, inset - left), this.options.win.innerWidth - inset - right);
854
+ chip.style.transform = `translateX(${dx}px)`;
855
+ }
791
856
  }
792
857
  /** A gold teardrop whose point (bottom-left) sits on `[x, y]`; without a pin, the pending one. */
793
858
  drawPin([x, y], pin) {
@@ -795,9 +860,12 @@ var Overlay = class {
795
860
  node.className = "pin";
796
861
  const z = this.zoom;
797
862
  const size = PIN_PX / z;
863
+ const vw = this.options.win.innerWidth;
864
+ const below = y >= 0 && y - size < EDGE_PX / z;
865
+ if (below) node.style.borderRadius = "0 999px 999px";
798
866
  Object.assign(node.style, {
799
- left: `${x}px`,
800
- top: `${y - size}px`,
867
+ left: `${x <= vw ? Math.min(x, vw - size - EDGE_PX / z) : x}px`,
868
+ top: `${below ? y : y - size}px`,
801
869
  width: `${size}px`,
802
870
  height: `${size}px`,
803
871
  fontSize: `${11 / z}px`,
@@ -814,8 +882,8 @@ var Overlay = class {
814
882
  return node;
815
883
  }
816
884
  labelFor(tagged) {
817
- const label = this.labels[tagged.fieldKey];
818
- return typeof label === "string" && label.trim() ? label : tagged.fieldKey;
885
+ const [label, suffix] = this.foreign[tagged.tag] ?? [this.labels[tagged.fieldKey]];
886
+ return [typeof label === "string" && label.trim() ? label : tagged.fieldKey, suffix];
819
887
  }
820
888
  drawBox(box) {
821
889
  const doc = this.options.doc;
@@ -839,9 +907,11 @@ var Overlay = class {
839
907
  if (box.label && box.kind !== "highlight" && !tooSmall) {
840
908
  const chip = doc.createElement("span");
841
909
  chip.className = "chip";
910
+ const lift = 27 / z;
911
+ const above = rect.top - offset - lift >= EDGE_PX / z || rect.bottom + lift > this.options.win.innerHeight;
842
912
  Object.assign(chip.style, {
843
913
  right: px(-5),
844
- top: px(-27),
914
+ [above ? "top" : "bottom"]: px(-27),
845
915
  height: px(CHIP_HEIGHT_PX),
846
916
  padding: `0 ${px(7)}`,
847
917
  gap: px(4),
@@ -859,13 +929,26 @@ var Overlay = class {
859
929
  chip.appendChild(icon);
860
930
  }
861
931
  chip.appendChild(doc.createTextNode(box.label));
932
+ if (box.suffix) {
933
+ const muted = doc.createElement("span");
934
+ muted.className = "of";
935
+ muted.textContent = box.suffix;
936
+ chip.appendChild(muted);
937
+ }
862
938
  node.appendChild(chip);
863
939
  }
864
940
  return node;
865
941
  }
866
942
  };
867
943
  var LARGE_SHARE = 0.5;
944
+ function reveal(win, element) {
945
+ const { top, bottom, height } = element.getBoundingClientRect();
946
+ const h = win.innerHeight;
947
+ if (top < 0 || bottom > h)
948
+ win.scrollTo?.({ top: win.scrollY + top - Math.max(0, (h - height) / 2), behavior: "smooth" });
949
+ }
868
950
  var CHIP_HEIGHT_PX = 20;
951
+ var EDGE_PX = 4;
869
952
  var PIN_PX = 22;
870
953
  var STYLES = `
871
954
  .layer { position: fixed; inset: 0; pointer-events: none; }
@@ -875,6 +958,7 @@ var STYLES = `
875
958
  font-family: Poppins, ui-sans-serif, system-ui, sans-serif; font-weight: 600; line-height: 1;
876
959
  letter-spacing: 0; box-sizing: border-box;
877
960
  }
961
+ .of { font-weight: 400; opacity: .72; }
878
962
  .capture { position: fixed; inset: 0; pointer-events: auto; cursor: crosshair; display: none; }
879
963
  .pin {
880
964
  position: fixed; box-sizing: border-box; pointer-events: auto; cursor: pointer; text-align: center;
@@ -885,53 +969,6 @@ var STYLES = `
885
969
  .icon svg { width: 100%; height: 100%; display: block; }
886
970
  `;
887
971
 
888
- // src/stega.ts
889
- var DIGITS = ["\u200B", "\u200C", "\u200D", "\u2060"];
890
- var PREFIX = "\u2060\u200D\u200C\u200C\u200D\u200D\u200C\u2060";
891
- var PAYLOAD_RE = /^(doc:[a-z0-9]+(?:-[a-z0-9]+)*|[a-z0-9]+(?:-[a-z0-9]+)*\/[a-z0-9]+(?:-[a-z0-9]+)*)#([a-z][a-z0-9_]{0,127})(?:@([A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8}){0,3}))?$/;
892
- var MARKER_RE = /⁠‍‌‌‍‍‌⁠(?:[​‌][​‌‍⁠]{3})*/g;
893
- function hasStega(value) {
894
- return typeof value === "string" && value.includes(PREFIX);
895
- }
896
- function stegaDecode(text) {
897
- if (!text.includes(PREFIX)) return [];
898
- const found = [];
899
- for (const match of text.match(MARKER_RE) ?? []) {
900
- let payload = "";
901
- for (let i = PREFIX.length; i + 4 <= match.length; i += 4) {
902
- let byte = 0;
903
- for (let j = 0; j < 4; j++) byte = byte * 4 + DIGITS.indexOf(match[i + j]);
904
- payload += String.fromCharCode(byte);
905
- }
906
- const parsed = PAYLOAD_RE.exec(payload);
907
- if (!parsed) continue;
908
- found.push({
909
- recordRef: parsed[1],
910
- fieldKey: parsed[2],
911
- locale: parsed[3] ?? null,
912
- tag: `${parsed[1]}#${parsed[2]}`
913
- });
914
- }
915
- return found;
916
- }
917
- function splitStega(text) {
918
- if (!text.includes(PREFIX)) return { text, markers: "" };
919
- return { text: text.replace(MARKER_RE, ""), markers: (text.match(MARKER_RE) ?? []).join("") };
920
- }
921
- function cleanString(text) {
922
- return text.includes(PREFIX) ? text.replace(MARKER_RE, "") : text;
923
- }
924
- function cleanStega(value) {
925
- if (typeof value === "string") return cleanString(value);
926
- if (Array.isArray(value)) return value.map((item) => cleanStega(item));
927
- if (value && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {
928
- const out = {};
929
- for (const [key, item] of Object.entries(value)) out[key] = cleanStega(item);
930
- return out;
931
- }
932
- return value;
933
- }
934
-
935
972
  // src/preview/tags.ts
936
973
  var TAG_SELECTOR = "[data-zap],[data-zap-field]";
937
974
  function readElementTag(element) {
@@ -1065,6 +1102,17 @@ var TagIndex = class {
1065
1102
  })).sort((a, b) => a.tag < b.tag ? -1 : a.tag > b.tag ? 1 : 0);
1066
1103
  }
1067
1104
  };
1105
+ var MAX_TAGGED = 5e3;
1106
+ var RUNAWAY_PASSES = 3;
1107
+ function tagGuard() {
1108
+ let last = 0;
1109
+ let grew = 0;
1110
+ return (count, page) => {
1111
+ grew = count > last && !page ? grew + 1 : 0;
1112
+ last = count;
1113
+ return count > MAX_TAGGED ? "cap" : grew >= RUNAWAY_PASSES ? "runaway" : null;
1114
+ };
1115
+ }
1068
1116
  function observeTags(target, onChange, options = {}) {
1069
1117
  const throttleMs = options.throttleMs ?? 250;
1070
1118
  const set = options.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
@@ -1075,17 +1123,24 @@ function observeTags(target, onChange, options = {}) {
1075
1123
  };
1076
1124
  let timer = null;
1077
1125
  let last = -Infinity;
1078
- const fire = () => {
1079
- timer = null;
1080
- last = Date.now();
1081
- onChange();
1082
- };
1083
- const observer = new Observer((records) => {
1126
+ let page = false;
1127
+ const queue = (records, fromPage) => {
1084
1128
  if (options.ignore && records.every((record) => options.ignore(record.target))) return;
1129
+ page || (page = fromPage);
1085
1130
  if (timer !== null) return;
1086
1131
  const wait = Math.max(0, last + throttleMs - Date.now());
1087
1132
  timer = set(fire, Math.max(wait, 16));
1088
- });
1133
+ };
1134
+ const fire = () => {
1135
+ timer = null;
1136
+ last = Date.now();
1137
+ const changed = page;
1138
+ page = false;
1139
+ onChange(changed);
1140
+ const own = observer.takeRecords();
1141
+ if (own.length) queue(own, false);
1142
+ };
1143
+ const observer = new Observer((records) => queue(records, true));
1089
1144
  observer.observe(target, {
1090
1145
  subtree: true,
1091
1146
  childList: true,
@@ -1099,6 +1154,15 @@ function observeTags(target, onChange, options = {}) {
1099
1154
  };
1100
1155
  }
1101
1156
 
1157
+ // src/preview/site/shortcut.ts
1158
+ var DEFAULT_SHORTCUT = "Z";
1159
+ var TYPING = 'input,textarea,select,[contenteditable]:not([contenteditable="false"])';
1160
+ function isShortcut(event, key) {
1161
+ const target = event.composedPath?.()[0] ?? event.target;
1162
+ 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.
1163
+ event.keyCode !== 229 && event.key?.toLowerCase() === key.toLowerCase() && !target?.closest?.(TYPING);
1164
+ }
1165
+
1102
1166
  export {
1103
1167
  FIELD_KEY_RE,
1104
1168
  parseRecordRef,
@@ -1141,6 +1205,8 @@ export {
1141
1205
  parseEditorMessage,
1142
1206
  parsePageMessage,
1143
1207
  envelope,
1208
+ splitStega,
1209
+ cleanStega,
1144
1210
  cssEscape,
1145
1211
  safeQueryAll,
1146
1212
  buildSelector,
@@ -1152,11 +1218,13 @@ export {
1152
1218
  findAnchorElement,
1153
1219
  OVERLAY_HOST_TAG,
1154
1220
  Overlay,
1155
- splitStega,
1156
- cleanStega,
1221
+ reveal,
1157
1222
  TAG_SELECTOR,
1158
1223
  readElementTag,
1159
1224
  TagIndex,
1160
- observeTags
1225
+ tagGuard,
1226
+ observeTags,
1227
+ DEFAULT_SHORTCUT,
1228
+ isShortcut
1161
1229
  };
1162
- //# sourceMappingURL=chunk-VW4EH7NY.js.map
1230
+ //# sourceMappingURL=chunk-7CXQK2PR.js.map