@tinacms/bridge 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/forms.d.ts CHANGED
@@ -1,11 +1,9 @@
1
1
  import type { DataStore } from './types';
2
+ export declare const FORM_SELECTOR = "[data-tina-form]";
3
+ export declare const FORM_ATTR = "data-tina-form";
4
+ export declare const PRIMARY_FORM_ATTR = "data-tina-primary";
2
5
  export declare function initForms(store: DataStore): void;
3
- /**
4
- * Re-scan the DOM for form payloads after a soft navigation. Diff
5
- * against the previous mount and post `close` / `open` for the delta.
6
- *
7
- * Safe to call even if the bridge isn't initialised — used for setups
8
- * that wire `astro:page-load` unconditionally. No-op outside an iframe.
9
- */
6
+ /** Diff active forms against the DOM and post the deltas. Safe to call
7
+ * before init() no-op then. */
10
8
  export declare function refreshForms(): void;
11
9
  export declare function reportQuickEdit(): void;
@@ -0,0 +1 @@
1
+ export {};
package/dist/index.d.ts CHANGED
@@ -1,44 +1,17 @@
1
- import { refreshForms } from './forms';
2
1
  export interface BridgeOptions {
3
- /**
4
- * Per-island debounce for refetches triggered by subsequent edits.
5
- * The first refetch after page load fires immediately so newly-created
6
- * docs reach a populated state ASAP. Default 300ms.
7
- */
2
+ /** Debounce for refetches after the first edit. The first refetch
3
+ * fires immediately. Default 300ms. */
8
4
  debounceMs?: number;
9
- /**
10
- * Origin(s) of the TinaCMS admin parent. Inbound postMessage events are
11
- * accepted only when `event.origin` matches one of these AND
12
- * `event.source` is `window.parent`. Outbound posts use the first entry
13
- * (or the single string) as `targetOrigin`.
14
- *
15
- * Defaults to `window.location.origin` — correct when the admin is
16
- * mounted at `/admin` on the same host (the common case). Override when
17
- * the admin runs on a different origin (cross-domain self-hosted
18
- * deployments, Codespaces, Docker setups). Mirrors the role of
19
- * `server.allowedOrigins` in `tina.config` for the dev server's CORS,
20
- * but applied to the in-iframe postMessage channel instead of HTTP.
21
- */
5
+ /** Allowed origin(s) of the admin parent for in-iframe postMessage.
6
+ * Defaults to `window.location.origin` (same-host `/admin`). */
22
7
  adminOrigin?: string | string[];
23
8
  }
24
9
  export declare function init(options?: BridgeOptions): void;
25
10
  /**
26
- * Re-scan the page for `[data-tina-form]` payloads after a soft
27
- * navigation (Astro view transitions, Turbo, htmx, etc.). Posts `close`
28
- * for forms that left and `open` for forms that appeared. Safe to call
29
- * before `init()` — no-op when the bridge isn't running.
30
- *
31
- * Astro projects using `@tinacms/astro/integration` get this wired
32
- * automatically (the middleware splices the bootstrap script that
33
- * listens for `astro:page-load`). Sites consuming `@tinacms/bridge`
34
- * directly need:
35
- *
36
- * ```ts
37
- * import { init, refreshForms } from '@tinacms/bridge';
38
- * init();
39
- * document.addEventListener('astro:page-load', refreshForms);
40
- * ```
11
+ * Re-scan for `[data-tina-form]` payloads after a soft navigation
12
+ * (Astro view transitions, Turbo, htmx). On a prerendered page with no
13
+ * server-injected payloads, prime from the island endpoints first.
41
14
  */
42
- export { refreshForms };
15
+ export declare function refreshForms(): void;
43
16
  export { tinaField } from './tina-field';
44
17
  export type * from './types';
package/dist/index.js CHANGED
@@ -132,6 +132,7 @@ function initDataStore() {
132
132
  const listeners = /* @__PURE__ */ new Set();
133
133
  return {
134
134
  get: (id) => data.get(id),
135
+ has: (id) => data.has(id),
135
136
  seed(id, next) {
136
137
  data.set(id, next);
137
138
  },
@@ -151,6 +152,7 @@ function initDataStore() {
151
152
  }
152
153
  const FORM_SELECTOR = "[data-tina-form]";
153
154
  const FORM_ATTR = "data-tina-form";
155
+ const PRIMARY_FORM_ATTR = "data-tina-primary";
154
156
  const RETRY_INTERVAL_MS = 250;
155
157
  const MAX_ATTEMPTS = 40;
156
158
  let controller = null;
@@ -163,19 +165,19 @@ function initForms(store) {
163
165
  store,
164
166
  active: /* @__PURE__ */ new Map(),
165
167
  acknowledged: /* @__PURE__ */ new Set(),
168
+ primaryId: null,
166
169
  retryTimer: null,
167
170
  attempts: 0
168
171
  };
169
172
  window.addEventListener("message", onAck);
170
173
  window.addEventListener("beforeunload", onBeforeUnload);
171
- refreshForms();
172
174
  }
173
- function refreshForms() {
175
+ function refreshForms$1() {
174
176
  if (!controller) {
175
177
  debug("refreshForms called before initForms; ignoring");
176
178
  return;
177
179
  }
178
- const next = readPayloads();
180
+ const { payloads: next, primaryId } = readPayloads();
179
181
  const nextIds = new Set(next.map((p) => p.id));
180
182
  for (const [id] of controller.active) {
181
183
  if (nextIds.has(id))
@@ -188,6 +190,7 @@ function refreshForms() {
188
190
  controller.store.seed(payload.id, payload.data ?? {});
189
191
  }
190
192
  controller.active = new Map(next.map((p) => [p.id, p]));
193
+ controller.primaryId = primaryId && nextIds.has(primaryId) ? primaryId : null;
191
194
  if (next.some((p) => !controller.acknowledged.has(p.id))) {
192
195
  startAnnounceLoop();
193
196
  }
@@ -196,6 +199,7 @@ function refreshForms() {
196
199
  function readPayloads() {
197
200
  const elements = document.querySelectorAll(FORM_SELECTOR);
198
201
  const payloads = [];
202
+ let primaryId = null;
199
203
  for (const el of elements) {
200
204
  const raw = el.getAttribute(FORM_ATTR);
201
205
  if (!raw)
@@ -205,12 +209,15 @@ function readPayloads() {
205
209
  if (!payload.id || !payload.query)
206
210
  continue;
207
211
  payloads.push(payload);
212
+ if (primaryId === null && el.hasAttribute(PRIMARY_FORM_ATTR)) {
213
+ primaryId = payload.id;
214
+ }
208
215
  } catch (error) {
209
216
  debug("failed to parse form payload", error);
210
217
  }
211
218
  }
212
219
  debug("discovered", payloads.length, "form(s)");
213
- return payloads;
220
+ return { payloads, primaryId };
214
221
  }
215
222
  function onAck(event) {
216
223
  if (!isFromAdmin(event))
@@ -281,6 +288,12 @@ function announce() {
281
288
  getAdminOrigin()
282
289
  );
283
290
  }
291
+ if (controller.primaryId && pending.some((p) => p.id === controller.primaryId)) {
292
+ window.parent.postMessage(
293
+ { type: "user-select-form", formId: controller.primaryId },
294
+ getAdminOrigin()
295
+ );
296
+ }
284
297
  controller.retryTimer = setTimeout(announce, RETRY_INTERVAL_MS);
285
298
  }
286
299
  function reportQuickEdit() {
@@ -291,8 +304,11 @@ function reportQuickEdit() {
291
304
  );
292
305
  }
293
306
  const PREVIEW_CONTENT_TYPE = "application/x-tina-preview+json";
307
+ const PRIME_HEADER = "X-Tina-Prime";
294
308
  const ISLAND_SELECTOR = "[data-tina-island]";
295
309
  const ENDPOINT_ATTR = "data-tina-island";
310
+ const PRIMARY_ISLAND_ATTR = "data-tina-island-primary";
311
+ const PRIMED_FORM_ATTR = "data-tina-primed";
296
312
  function initIslandRefresh(store, options) {
297
313
  let pendingRefresh = null;
298
314
  const refreshAll = () => {
@@ -322,6 +338,10 @@ function initIslandRefresh(store, options) {
322
338
  if (!message || typeof message !== "object")
323
339
  return;
324
340
  if (message.type === "updateData" && typeof message.id === "string") {
341
+ if (!store.has(message.id)) {
342
+ debug("updateData for unknown id", message.id, "— ignoring");
343
+ return;
344
+ }
325
345
  debug("updateData received for", message.id);
326
346
  store.set(message.id, message.data ?? {});
327
347
  }
@@ -350,6 +370,10 @@ async function refreshIsland(island, store) {
350
370
  debug("island refetch failed", endpoint, response.status);
351
371
  return;
352
372
  }
373
+ if (!isHtmlResponse(response)) {
374
+ debug("island refetch wrong content-type", endpoint);
375
+ return;
376
+ }
353
377
  const html = await response.text();
354
378
  swapIslandHtml(island, html);
355
379
  reportQuickEdit();
@@ -357,11 +381,66 @@ async function refreshIsland(island, store) {
357
381
  debug("island refetch error", endpoint, error);
358
382
  }
359
383
  }
384
+ async function primeIslands() {
385
+ const islands = document.querySelectorAll(ISLAND_SELECTOR);
386
+ const results = await Promise.all(Array.from(islands, primeIsland));
387
+ for (const formEl of results.flat()) {
388
+ formEl.setAttribute(PRIMED_FORM_ATTR, "");
389
+ document.body.appendChild(formEl);
390
+ }
391
+ }
392
+ async function primeIsland(island) {
393
+ const endpoint = island.getAttribute(ENDPOINT_ATTR);
394
+ if (!endpoint)
395
+ return [];
396
+ const isPrimary = island.hasAttribute(PRIMARY_ISLAND_ATTR);
397
+ try {
398
+ debug("priming island", endpoint);
399
+ const response = await fetch(endpoint, {
400
+ method: "POST",
401
+ headers: {
402
+ "Content-Type": PREVIEW_CONTENT_TYPE,
403
+ [PRIME_HEADER]: "1"
404
+ },
405
+ body: "{}",
406
+ cache: "no-store",
407
+ credentials: "same-origin"
408
+ });
409
+ if (!response.ok) {
410
+ debug("island prime failed", endpoint, response.status);
411
+ return [];
412
+ }
413
+ if (!isHtmlResponse(response)) {
414
+ debug("island prime wrong content-type", endpoint);
415
+ return [];
416
+ }
417
+ const template = document.createElement("template");
418
+ template.innerHTML = (await response.text()).trim();
419
+ const formEls = Array.from(
420
+ template.content.querySelectorAll(FORM_SELECTOR)
421
+ );
422
+ if (isPrimary && formEls[0]) {
423
+ formEls[0].setAttribute(PRIMARY_FORM_ATTR, "");
424
+ }
425
+ return formEls;
426
+ } catch (error) {
427
+ debug("island prime error", endpoint, error);
428
+ return [];
429
+ }
430
+ }
431
+ function isHtmlResponse(response) {
432
+ const contentType = response.headers.get("content-type") ?? "";
433
+ return contentType.includes("text/html");
434
+ }
435
+ function isAllowedSwapAttribute(name) {
436
+ if (name === "class" || name === "id")
437
+ return true;
438
+ return name.startsWith("data-tina-");
439
+ }
360
440
  function swapIslandHtml(island, html) {
361
441
  const template = document.createElement("template");
362
442
  template.innerHTML = html.trim();
363
- const fragment = template.content;
364
- const replacement = fragment.firstElementChild;
443
+ const replacement = template.content.firstElementChild;
365
444
  if (replacement) {
366
445
  for (const attr of Array.from(island.attributes)) {
367
446
  if (attr.name === ENDPOINT_ATTR)
@@ -369,6 +448,8 @@ function swapIslandHtml(island, html) {
369
448
  island.removeAttribute(attr.name);
370
449
  }
371
450
  for (const attr of Array.from(replacement.attributes)) {
451
+ if (!isAllowedSwapAttribute(attr.name))
452
+ continue;
372
453
  island.setAttribute(attr.name, attr.value);
373
454
  }
374
455
  island.innerHTML = replacement.innerHTML;
@@ -388,11 +469,10 @@ const tinaField = (object, property, index) => {
388
469
  const fullPath = typeof index === "number" ? [...path, property, index] : [...path, property];
389
470
  return `${queryId}---${fullPath.join(".")}`;
390
471
  };
391
- let initialized = false;
472
+ let running = false;
392
473
  function init(options = {}) {
393
- if (initialized)
474
+ if (running)
394
475
  return;
395
- initialized = true;
396
476
  if (typeof window === "undefined" || window.parent === window) {
397
477
  debug("not in an iframe; bridge is a no-op");
398
478
  return;
@@ -404,6 +484,43 @@ function init(options = {}) {
404
484
  initIslandRefresh(store, { debounceMs });
405
485
  initClickToFocus();
406
486
  initForms(store);
487
+ running = true;
488
+ refreshForms();
489
+ }
490
+ let primingInFlight = null;
491
+ let reprimePending = false;
492
+ function refreshForms() {
493
+ if (!running) {
494
+ debug("refreshForms called before init() finished; ignoring");
495
+ return;
496
+ }
497
+ removePrimedForms();
498
+ const hasServerForms = document.querySelector("[data-tina-form]");
499
+ if (!hasServerForms && document.querySelector("[data-tina-island]")) {
500
+ if (primingInFlight) {
501
+ reprimePending = true;
502
+ return;
503
+ }
504
+ debug("no server-injected forms; priming from island endpoints");
505
+ primingInFlight = primeIslands().finally(() => {
506
+ primingInFlight = null;
507
+ });
508
+ void primingInFlight.then(() => {
509
+ if (reprimePending) {
510
+ reprimePending = false;
511
+ refreshForms();
512
+ return;
513
+ }
514
+ refreshForms$1();
515
+ });
516
+ return;
517
+ }
518
+ refreshForms$1();
519
+ }
520
+ function removePrimedForms() {
521
+ for (const el of document.querySelectorAll(`[${PRIMED_FORM_ATTR}]`)) {
522
+ el.remove();
523
+ }
407
524
  }
408
525
  export {
409
526
  init,
@@ -1,22 +1,22 @@
1
1
  import type { DataStore } from './types';
2
2
  /**
3
- * Listens for `{type:'updateData', id, data}` from the admin and re-fetches
4
- * every `[data-tina-island]` on the page with the unsaved data attached as
5
- * a JSON POST body. The island endpoint reads the body via `readOverlay()`
6
- * from `@tinacms/bridge/preview` and renders with overlay data instead of
7
- * hitting the canonical content store.
8
- *
9
- * Why POST: HTTP headers are capped at ~8 KB (server-dependent) — large
10
- * posts overflow easily — and are restricted to Latin-1 so UTF-8 content
11
- * needs base64 padding. A POST body has neither limit and round-trips
12
- * UTF-8 directly.
13
- *
14
- * The very first updateData fires immediately so newly-created docs leave
15
- * the empty-template state ASAP. Subsequent updates collapse into a single
16
- * debounced refetch — each refresh re-renders every island anyway, so a
17
- * per-id timer would just fire N redundant times for the same DOM scan.
3
+ * Listen for admin `updateData` events and re-fetch every island on the
4
+ * page with the unsaved data POSTed as a JSON body. POST (vs header)
5
+ * avoids the ~8KB Latin-1 cap. Refetches collapse into one debounced
6
+ * pass (every refresh re-renders every island).
18
7
  */
19
8
  export interface IslandRefreshOptions {
20
9
  debounceMs: number;
21
10
  }
11
+ /** Marks a `[data-tina-form]` div that a prime pass appended (vs. one the
12
+ * server injected), so a later re-scan can drop it before re-priming. */
13
+ export declare const PRIMED_FORM_ATTR = "data-tina-primed";
22
14
  export declare function initIslandRefresh(store: DataStore, options: IslandRefreshOptions): void;
15
+ /**
16
+ * Hydrate `[data-tina-form]` payloads for prerendered pages by hitting
17
+ * each island endpoint with the prime header set; the endpoint prepends
18
+ * payloads to the region HTML. The caller follows up with refreshForms()
19
+ * to announce them. Region HTML isn't swapped — canonical data is
20
+ * already on the page; the first real `updateData` handles updates.
21
+ */
22
+ export declare function primeIslands(): Promise<void>;
@@ -0,0 +1 @@
1
+ export {};
package/dist/preview.d.ts CHANGED
@@ -1,29 +1,16 @@
1
1
  /**
2
- * Shared preview-protocol helpers for both ends of the X-Tina-Preview
3
- * channel. The bridge POSTs island refetches with a JSON body of shape
4
- *
5
- * { [queryId]: data, ... }
6
- *
7
- * Server-side island handlers call `readOverlay(request, queryId)` to
8
- * fetch the overlay data for their own query without having to know the
9
- * transport details (POST body vs header, JSON vs base64-encoded JSON).
10
- *
11
- * Runs in Node (Astro server endpoints, Hugo plugins, etc.) — no DOM
12
- * dependencies. The browser-side encoder lives in island-refresh.ts.
2
+ * Server-side helpers for the X-Tina-Preview channel. The bridge POSTs
3
+ * island refetches with body `{ [queryId]: data, ... }`; handlers read
4
+ * their slice via `readOverlay(request, queryId)`.
13
5
  */
14
6
  export declare const PREVIEW_HEADER = "X-Tina-Preview";
15
7
  export declare const PREVIEW_CONTENT_TYPE = "application/x-tina-preview+json";
8
+ /** Set on the one-time prime refetch for prerendered pages; the endpoint
9
+ * prepends `<div data-tina-form>` payloads to the region HTML. */
10
+ export declare const PRIME_HEADER = "X-Tina-Prime";
16
11
  export interface PreviewEnvelope {
17
12
  [queryId: string]: unknown;
18
13
  }
19
- /**
20
- * Read the overlay envelope for a given query id from an incoming
21
- * request. Returns `undefined` if no overlay is present (production
22
- * traffic, or an island refetch with no matching id).
23
- */
24
14
  export declare function readOverlay<T>(request: Request, queryId: string): Promise<T | undefined>;
25
- /**
26
- * Read and parse the full overlay envelope (every form on the page),
27
- * for callers that want to inspect the raw payload.
28
- */
15
+ /** Read and parse the full overlay envelope (every form on the page). */
29
16
  export declare function readEnvelope(request: Request): Promise<PreviewEnvelope | undefined>;
package/dist/preview.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const PREVIEW_HEADER = "X-Tina-Preview";
2
2
  const PREVIEW_CONTENT_TYPE = "application/x-tina-preview+json";
3
+ const PRIME_HEADER = "X-Tina-Prime";
3
4
  const MAX_ENVELOPE_BYTES = 1e6;
4
5
  async function readOverlay(request, queryId) {
5
6
  const envelope = await readEnvelope(request);
@@ -8,7 +9,16 @@ async function readOverlay(request, queryId) {
8
9
  const value = envelope[queryId];
9
10
  return value === void 0 ? void 0 : value;
10
11
  }
11
- async function readEnvelope(request) {
12
+ const envelopeCache = /* @__PURE__ */ new WeakMap();
13
+ function readEnvelope(request) {
14
+ let cached = envelopeCache.get(request);
15
+ if (!cached) {
16
+ cached = parseEnvelope(request);
17
+ envelopeCache.set(request, cached);
18
+ }
19
+ return cached;
20
+ }
21
+ async function parseEnvelope(request) {
12
22
  const contentType = request.headers.get("content-type") ?? "";
13
23
  if (!contentType.includes(PREVIEW_CONTENT_TYPE))
14
24
  return void 0;
@@ -29,6 +39,7 @@ async function readEnvelope(request) {
29
39
  export {
30
40
  PREVIEW_CONTENT_TYPE,
31
41
  PREVIEW_HEADER,
42
+ PRIME_HEADER,
32
43
  readEnvelope,
33
44
  readOverlay
34
45
  };
@@ -0,0 +1 @@
1
+ export {};
package/dist/types.d.ts CHANGED
@@ -45,6 +45,8 @@ export interface FormPayload {
45
45
  export interface DataStore {
46
46
  /** Latest resolved data per form id. */
47
47
  get(id: string): object | undefined;
48
+ /** Whether a form id has been seeded or set. */
49
+ has(id: string): boolean;
48
50
  /** Populate without notifying subscribers (used for the initial seed). */
49
51
  seed(id: string, data: object): void;
50
52
  /** Replace cached data for a form and notify subscribers. */
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tinacms/bridge",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",