@tinacms/bridge 0.0.1 → 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.
@@ -0,0 +1,17 @@
1
+ export declare function setAdminOrigin(origin: string | string[]): void;
2
+ /**
3
+ * Returns the canonical admin origin used as the `targetOrigin` for outbound
4
+ * postMessage. When multiple origins are configured the first entry wins —
5
+ * that's the deployment's primary admin host; the others are accepted for
6
+ * inbound traffic but the bridge still posts to the canonical one.
7
+ */
8
+ export declare function getAdminOrigin(): string;
9
+ /**
10
+ * Returns true only for postMessage events that originated from the admin
11
+ * iframe parent — `event.origin` matches one of the configured admin
12
+ * origins AND `event.source` is the parent window. Both checks are
13
+ * required: origin alone leaves us open to sibling frames sharing the
14
+ * same origin, source alone leaves us open to cross-origin parents that
15
+ * happen to have a handle to our window.
16
+ */
17
+ export declare function isFromAdmin(event: MessageEvent): boolean;
@@ -1,8 +1,10 @@
1
1
  import type { DataStore } from './types';
2
2
  /**
3
3
  * Holds the latest resolved data per form id (keyed by `hashFromQuery`-style
4
- * id). Subscribers learn whether each update is the first one for that form,
5
- * so island-refresh can fire immediately on the first push (newly-created
6
- * docs reach a populated state ASAP) and debounce subsequent edits.
4
+ * id). `seed()` populates the initial server-rendered payload silently so
5
+ * the bridge doesn't trigger a refresh on page load. `set()` records edits
6
+ * from the admin and fires subscribers; `firstUpdate` is true only for the
7
+ * first admin update per id (used by island-refresh to skip the debounce
8
+ * on cold-start so newly-created docs reach a populated state ASAP).
7
9
  */
8
10
  export declare function initDataStore(): DataStore;
package/dist/forms.d.ts CHANGED
@@ -1,3 +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;
6
+ /** Diff active forms against the DOM and post the deltas. Safe to call
7
+ * before init() — no-op then. */
8
+ export declare function refreshForms(): void;
3
9
  export declare function reportQuickEdit(): void;
@@ -0,0 +1 @@
1
+ export {};
package/dist/index.d.ts CHANGED
@@ -1 +1,17 @@
1
- export * from "../src/index"
1
+ export interface BridgeOptions {
2
+ /** Debounce for refetches after the first edit. The first refetch
3
+ * fires immediately. Default 300ms. */
4
+ debounceMs?: number;
5
+ /** Allowed origin(s) of the admin parent for in-iframe postMessage.
6
+ * Defaults to `window.location.origin` (same-host `/admin`). */
7
+ adminOrigin?: string | string[];
8
+ }
9
+ export declare function init(options?: BridgeOptions): void;
10
+ /**
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.
14
+ */
15
+ export declare function refreshForms(): void;
16
+ export { tinaField } from './tina-field';
17
+ export type * from './types';
package/dist/index.js CHANGED
@@ -1,11 +1,23 @@
1
+ let configuredAdminOrigins = [];
2
+ function setAdminOrigin(origin) {
3
+ configuredAdminOrigins = Array.isArray(origin) ? [...origin] : [origin];
4
+ }
5
+ function getAdminOrigin() {
6
+ return configuredAdminOrigins[0] ?? "";
7
+ }
8
+ function isFromAdmin(event) {
9
+ if (typeof window === "undefined")
10
+ return false;
11
+ if (!configuredAdminOrigins.includes(event.origin))
12
+ return false;
13
+ return event.source === window.parent;
14
+ }
1
15
  const ENABLED = typeof window !== "undefined";
2
16
  function debug(...args) {
3
17
  if (!ENABLED)
4
18
  return;
5
19
  console.log("[@tinacms/bridge]", ...args);
6
20
  }
7
- const STYLE_ID = "__tina-bridge-quick-edit-style";
8
- const BODY_CLASS = "__tina-quick-editing-enabled";
9
21
  const QUICK_EDIT_CSS = `
10
22
  [data-tina-field] {
11
23
  outline: 2px dashed rgba(34,150,254,0.5);
@@ -37,8 +49,10 @@ const QUICK_EDIT_CSS = `
37
49
  opacity: 1;
38
50
  }
39
51
  `;
52
+ const QUICK_EDIT_BODY_CLASS = "__tina-quick-editing-enabled";
53
+ const QUICK_EDIT_STYLE_ID = "__tina-bridge-quick-edit-style";
40
54
  function initClickToFocus() {
41
- let enabled = true;
55
+ let enabled = false;
42
56
  document.addEventListener(
43
57
  "click",
44
58
  (event) => {
@@ -58,12 +72,14 @@ function initClickToFocus() {
58
72
  event.stopPropagation();
59
73
  window.parent.postMessage(
60
74
  { type: "field:selected", fieldName },
61
- window.location.origin
75
+ getAdminOrigin()
62
76
  );
63
77
  },
64
78
  true
65
79
  );
66
80
  window.addEventListener("message", (event) => {
81
+ if (!isFromAdmin(event))
82
+ return;
67
83
  const message = event.data;
68
84
  if (!message || message.type !== "quickEditEnabled")
69
85
  return;
@@ -97,18 +113,18 @@ function readTinaField(el) {
97
113
  return null;
98
114
  }
99
115
  function installStyle() {
100
- if (document.getElementById(STYLE_ID))
116
+ if (document.getElementById(QUICK_EDIT_STYLE_ID))
101
117
  return;
102
118
  const style = document.createElement("style");
103
- style.id = STYLE_ID;
119
+ style.id = QUICK_EDIT_STYLE_ID;
104
120
  style.textContent = QUICK_EDIT_CSS;
105
121
  document.head.appendChild(style);
106
- document.body.classList.add(BODY_CLASS);
122
+ document.body.classList.add(QUICK_EDIT_BODY_CLASS);
107
123
  }
108
124
  function removeStyle() {
109
125
  var _a;
110
- (_a = document.getElementById(STYLE_ID)) == null ? void 0 : _a.remove();
111
- document.body.classList.remove(BODY_CLASS);
126
+ (_a = document.getElementById(QUICK_EDIT_STYLE_ID)) == null ? void 0 : _a.remove();
127
+ document.body.classList.remove(QUICK_EDIT_BODY_CLASS);
112
128
  }
113
129
  function initDataStore() {
114
130
  const data = /* @__PURE__ */ new Map();
@@ -116,6 +132,7 @@ function initDataStore() {
116
132
  const listeners = /* @__PURE__ */ new Set();
117
133
  return {
118
134
  get: (id) => data.get(id),
135
+ has: (id) => data.has(id),
119
136
  seed(id, next) {
120
137
  data.set(id, next);
121
138
  },
@@ -133,16 +150,58 @@ function initDataStore() {
133
150
  }
134
151
  };
135
152
  }
136
- const SCRIPT_TYPE = "application/tina+json";
153
+ const FORM_SELECTOR = "[data-tina-form]";
154
+ const FORM_ATTR = "data-tina-form";
155
+ const PRIMARY_FORM_ATTR = "data-tina-primary";
137
156
  const RETRY_INTERVAL_MS = 250;
138
157
  const MAX_ATTEMPTS = 40;
158
+ let controller = null;
139
159
  function initForms(store) {
140
- const scripts = document.querySelectorAll(
141
- `script[type="${SCRIPT_TYPE}"]`
142
- );
160
+ if (controller) {
161
+ debug("initForms called twice; ignoring");
162
+ return;
163
+ }
164
+ controller = {
165
+ store,
166
+ active: /* @__PURE__ */ new Map(),
167
+ acknowledged: /* @__PURE__ */ new Set(),
168
+ primaryId: null,
169
+ retryTimer: null,
170
+ attempts: 0
171
+ };
172
+ window.addEventListener("message", onAck);
173
+ window.addEventListener("beforeunload", onBeforeUnload);
174
+ }
175
+ function refreshForms$1() {
176
+ if (!controller) {
177
+ debug("refreshForms called before initForms; ignoring");
178
+ return;
179
+ }
180
+ const { payloads: next, primaryId } = readPayloads();
181
+ const nextIds = new Set(next.map((p) => p.id));
182
+ for (const [id] of controller.active) {
183
+ if (nextIds.has(id))
184
+ continue;
185
+ debug("posting close for", id);
186
+ window.parent.postMessage({ type: "close", id }, getAdminOrigin());
187
+ controller.acknowledged.delete(id);
188
+ }
189
+ for (const payload of next) {
190
+ controller.store.seed(payload.id, payload.data ?? {});
191
+ }
192
+ controller.active = new Map(next.map((p) => [p.id, p]));
193
+ controller.primaryId = primaryId && nextIds.has(primaryId) ? primaryId : null;
194
+ if (next.some((p) => !controller.acknowledged.has(p.id))) {
195
+ startAnnounceLoop();
196
+ }
197
+ reportQuickEdit();
198
+ }
199
+ function readPayloads() {
200
+ const elements = document.querySelectorAll(FORM_SELECTOR);
143
201
  const payloads = [];
144
- for (const script of scripts) {
145
- const raw = script.textContent;
202
+ let primaryId = null;
203
+ for (const el of elements) {
204
+ const raw = el.getAttribute(FORM_ATTR);
146
205
  if (!raw)
147
206
  continue;
148
207
  try {
@@ -150,80 +209,108 @@ function initForms(store) {
150
209
  if (!payload.id || !payload.query)
151
210
  continue;
152
211
  payloads.push(payload);
153
- store.seed(payload.id, payload.data ?? {});
212
+ if (primaryId === null && el.hasAttribute(PRIMARY_FORM_ATTR)) {
213
+ primaryId = payload.id;
214
+ }
154
215
  } catch (error) {
155
216
  debug("failed to parse form payload", error);
156
217
  }
157
218
  }
158
219
  debug("discovered", payloads.length, "form(s)");
159
- const acknowledged = /* @__PURE__ */ new Set();
160
- const onAck = (event) => {
161
- const msg = event.data;
162
- if (!msg || typeof msg !== "object")
163
- return;
164
- if (msg.type !== "updateData" || typeof msg.id !== "string")
165
- return;
166
- if (!acknowledged.has(msg.id)) {
167
- debug("admin acked form", msg.id);
168
- acknowledged.add(msg.id);
169
- }
170
- };
171
- window.addEventListener("message", onAck);
172
- let attempts = 0;
173
- const announce = () => {
174
- attempts++;
175
- const pending = payloads.filter((p) => !acknowledged.has(p.id));
176
- if (pending.length === 0) {
177
- debug("all forms acked after", attempts, "attempt(s)");
178
- return;
179
- }
180
- if (attempts > MAX_ATTEMPTS) {
181
- debug(
182
- "giving up after",
183
- MAX_ATTEMPTS,
184
- "attempts; pending ids:",
185
- pending.map((p) => p.id)
186
- );
187
- return;
188
- }
189
- for (const payload of pending) {
190
- debug("posting open for", payload.id, "attempt", attempts);
191
- window.parent.postMessage(
192
- {
193
- type: "open",
194
- id: payload.id,
195
- query: payload.query,
196
- variables: payload.variables,
197
- data: payload.data
198
- },
199
- window.location.origin
200
- );
201
- }
202
- setTimeout(announce, RETRY_INTERVAL_MS);
203
- };
220
+ return { payloads, primaryId };
221
+ }
222
+ function onAck(event) {
223
+ if (!isFromAdmin(event))
224
+ return;
225
+ const msg = event.data;
226
+ if (!msg || typeof msg !== "object")
227
+ return;
228
+ if (msg.type !== "updateData" || typeof msg.id !== "string")
229
+ return;
230
+ if (!controller)
231
+ return;
232
+ if (!controller.acknowledged.has(msg.id)) {
233
+ debug("admin acked form", msg.id);
234
+ controller.acknowledged.add(msg.id);
235
+ }
236
+ }
237
+ function onBeforeUnload() {
238
+ if (!controller)
239
+ return;
240
+ for (const [id] of controller.active) {
241
+ window.parent.postMessage({ type: "close", id }, getAdminOrigin());
242
+ }
243
+ }
244
+ function startAnnounceLoop() {
245
+ if (!controller)
246
+ return;
247
+ if (controller.retryTimer) {
248
+ clearTimeout(controller.retryTimer);
249
+ controller.retryTimer = null;
250
+ }
251
+ controller.attempts = 0;
204
252
  announce();
205
- reportQuickEdit();
206
- window.addEventListener("beforeunload", () => {
207
- for (const payload of payloads) {
208
- window.parent.postMessage(
209
- { type: "close", id: payload.id },
210
- window.location.origin
211
- );
212
- }
213
- });
253
+ }
254
+ function announce() {
255
+ if (!controller)
256
+ return;
257
+ controller.attempts++;
258
+ const pending = [];
259
+ for (const [id, payload] of controller.active) {
260
+ if (!controller.acknowledged.has(id))
261
+ pending.push(payload);
262
+ }
263
+ if (pending.length === 0) {
264
+ debug("all forms acked after", controller.attempts, "attempt(s)");
265
+ controller.retryTimer = null;
266
+ return;
267
+ }
268
+ if (controller.attempts > MAX_ATTEMPTS) {
269
+ debug(
270
+ "giving up after",
271
+ MAX_ATTEMPTS,
272
+ "attempts; pending ids:",
273
+ pending.map((p) => p.id)
274
+ );
275
+ controller.retryTimer = null;
276
+ return;
277
+ }
278
+ for (const payload of pending) {
279
+ debug("posting open for", payload.id, "attempt", controller.attempts);
280
+ window.parent.postMessage(
281
+ {
282
+ type: "open",
283
+ id: payload.id,
284
+ query: payload.query,
285
+ variables: payload.variables,
286
+ data: payload.data
287
+ },
288
+ getAdminOrigin()
289
+ );
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
+ }
297
+ controller.retryTimer = setTimeout(announce, RETRY_INTERVAL_MS);
214
298
  }
215
299
  function reportQuickEdit() {
216
300
  const hasMarkers = !!document.querySelector("[data-tina-field]");
217
301
  window.parent.postMessage(
218
302
  { type: "quick-edit", value: hasMarkers },
219
- window.location.origin
303
+ getAdminOrigin()
220
304
  );
221
305
  }
222
306
  const PREVIEW_CONTENT_TYPE = "application/x-tina-preview+json";
307
+ const PRIME_HEADER = "X-Tina-Prime";
223
308
  const ISLAND_SELECTOR = "[data-tina-island]";
224
309
  const ENDPOINT_ATTR = "data-tina-island";
310
+ const PRIMARY_ISLAND_ATTR = "data-tina-island-primary";
311
+ const PRIMED_FORM_ATTR = "data-tina-primed";
225
312
  function initIslandRefresh(store, options) {
226
- const debounceTimers = /* @__PURE__ */ new Map();
313
+ let pendingRefresh = null;
227
314
  const refreshAll = () => {
228
315
  const islands = document.querySelectorAll(ISLAND_SELECTOR);
229
316
  for (const island of islands) {
@@ -231,27 +318,30 @@ function initIslandRefresh(store, options) {
231
318
  }
232
319
  };
233
320
  store.subscribe(({ firstUpdate }) => {
234
- const key = "__all__";
235
- const existing = debounceTimers.get(key);
236
- if (existing)
237
- clearTimeout(existing);
321
+ if (pendingRefresh) {
322
+ clearTimeout(pendingRefresh);
323
+ pendingRefresh = null;
324
+ }
238
325
  if (firstUpdate) {
239
326
  refreshAll();
240
327
  return;
241
328
  }
242
- debounceTimers.set(
243
- key,
244
- setTimeout(() => {
245
- debounceTimers.delete(key);
246
- refreshAll();
247
- }, options.debounceMs)
248
- );
329
+ pendingRefresh = setTimeout(() => {
330
+ pendingRefresh = null;
331
+ refreshAll();
332
+ }, options.debounceMs);
249
333
  });
250
334
  window.addEventListener("message", (event) => {
335
+ if (!isFromAdmin(event))
336
+ return;
251
337
  const message = event.data;
252
338
  if (!message || typeof message !== "object")
253
339
  return;
254
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
+ }
255
345
  debug("updateData received for", message.id);
256
346
  store.set(message.id, message.data ?? {});
257
347
  }
@@ -280,6 +370,10 @@ async function refreshIsland(island, store) {
280
370
  debug("island refetch failed", endpoint, response.status);
281
371
  return;
282
372
  }
373
+ if (!isHtmlResponse(response)) {
374
+ debug("island refetch wrong content-type", endpoint);
375
+ return;
376
+ }
283
377
  const html = await response.text();
284
378
  swapIslandHtml(island, html);
285
379
  reportQuickEdit();
@@ -287,11 +381,66 @@ async function refreshIsland(island, store) {
287
381
  debug("island refetch error", endpoint, error);
288
382
  }
289
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
+ }
290
440
  function swapIslandHtml(island, html) {
291
441
  const template = document.createElement("template");
292
442
  template.innerHTML = html.trim();
293
- const fragment = template.content;
294
- const replacement = fragment.firstElementChild;
443
+ const replacement = template.content.firstElementChild;
295
444
  if (replacement) {
296
445
  for (const attr of Array.from(island.attributes)) {
297
446
  if (attr.name === ENDPOINT_ATTR)
@@ -299,6 +448,8 @@ function swapIslandHtml(island, html) {
299
448
  island.removeAttribute(attr.name);
300
449
  }
301
450
  for (const attr of Array.from(replacement.attributes)) {
451
+ if (!isAllowedSwapAttribute(attr.name))
452
+ continue;
302
453
  island.setAttribute(attr.name, attr.value);
303
454
  }
304
455
  island.innerHTML = replacement.innerHTML;
@@ -318,23 +469,61 @@ const tinaField = (object, property, index) => {
318
469
  const fullPath = typeof index === "number" ? [...path, property, index] : [...path, property];
319
470
  return `${queryId}---${fullPath.join(".")}`;
320
471
  };
321
- let initialized = false;
472
+ let running = false;
322
473
  function init(options = {}) {
323
- if (initialized)
474
+ if (running)
324
475
  return;
325
- initialized = true;
326
476
  if (typeof window === "undefined" || window.parent === window) {
327
477
  debug("not in an iframe; bridge is a no-op");
328
478
  return;
329
479
  }
330
480
  debug("initialising in iframe");
331
- const { debounceMs = 300 } = options;
481
+ const { debounceMs = 300, adminOrigin = window.location.origin } = options;
482
+ setAdminOrigin(adminOrigin);
332
483
  const store = initDataStore();
333
484
  initIslandRefresh(store, { debounceMs });
334
485
  initClickToFocus();
335
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
+ }
336
524
  }
337
525
  export {
338
526
  init,
527
+ refreshForms,
339
528
  tinaField
340
529
  };
@@ -1,15 +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
- * each `[data-tina-island]` with the unsaved data attached as the
5
- * `X-Tina-Preview` header. The island endpoint reads the header (via
6
- * `tina-preview` helper in the example) and renders with overlay data
7
- * instead of hitting the canonical content store.
8
- *
9
- * The very first updateData per form fires immediately so newly-created
10
- * docs leave the empty-template state ASAP. Subsequent updates are debounced.
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).
11
7
  */
12
8
  export interface IslandRefreshOptions {
13
9
  debounceMs: number;
14
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";
15
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 {};
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Canonical content-source metadata helpers.
3
+ *
4
+ * `addMetadata` walks a query result and stamps every non-system object with
5
+ * `_content_source: { queryId, path }`. The pair is what `tinaField()` reads
6
+ * to build the `data-tina-field` markers the admin uses for click-to-focus.
7
+ *
8
+ * `hashFromQuery` derives the queryId from `JSON.stringify({ query, variables })`.
9
+ * Both ends of the wire (page render, admin sidebar) hash the same string so
10
+ * overlay updates address the same form.
11
+ *
12
+ * One source of truth for React (useTina), Astro (server-side overlay), and
13
+ * any future framework integration. Kept dependency-free so it runs in any
14
+ * runtime — browser, Node, edge.
15
+ */
16
+ export declare const addMetadata: <T>(id: string, obj: T, path?: (string | number)[]) => T;
17
+ /**
18
+ * Rudimentary string hash. Both ends of the wire derive the queryId from
19
+ * the same JSON.stringify({ query, variables }) — collisions are theoretically
20
+ * possible but vanishingly rare in practice.
21
+ */
22
+ export declare const hashFromQuery: (input: string) => string;
@@ -0,0 +1,66 @@
1
+ const SYSTEM_KEYS = /* @__PURE__ */ new Set([
2
+ "__typename",
3
+ "_sys",
4
+ "_internalSys",
5
+ "_values",
6
+ "_internalValues",
7
+ "_content_source",
8
+ "_tina_metadata"
9
+ ]);
10
+ const addMetadata = (id, obj, path = []) => {
11
+ if (obj === null)
12
+ return obj;
13
+ if (isScalarOrUndefined(obj))
14
+ return obj;
15
+ if (obj instanceof String)
16
+ return obj.valueOf();
17
+ if (Array.isArray(obj)) {
18
+ return obj.map(
19
+ (item, index) => addMetadata(id, item, [...path, index])
20
+ );
21
+ }
22
+ const next = {};
23
+ for (const [key, value] of Object.entries(obj)) {
24
+ if (SYSTEM_KEYS.has(key)) {
25
+ next[key] = value;
26
+ } else {
27
+ next[key] = addMetadata(id, value, [...path, key]);
28
+ }
29
+ }
30
+ if (next && typeof next === "object" && "type" in next && next.type === "root") {
31
+ return next;
32
+ }
33
+ return { ...next, _content_source: { queryId: id, path } };
34
+ };
35
+ function isScalarOrUndefined(value) {
36
+ const type = typeof value;
37
+ if (type === "string")
38
+ return true;
39
+ if (type === "number")
40
+ return true;
41
+ if (type === "boolean")
42
+ return true;
43
+ if (type === "undefined")
44
+ return true;
45
+ if (value == null)
46
+ return true;
47
+ if (value instanceof String)
48
+ return true;
49
+ if (value instanceof Number)
50
+ return true;
51
+ if (value instanceof Boolean)
52
+ return true;
53
+ return false;
54
+ }
55
+ const hashFromQuery = (input) => {
56
+ let hash = 0;
57
+ for (let i = 0; i < input.length; i++) {
58
+ const char = input.charCodeAt(i);
59
+ hash = (hash << 5) - hash + char & 4294967295;
60
+ }
61
+ return Math.abs(hash).toString(36);
62
+ };
63
+ export {
64
+ addMetadata,
65
+ hashFromQuery
66
+ };
package/dist/preview.d.ts CHANGED
@@ -1 +1,16 @@
1
- export * from "../src/preview"
1
+ /**
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)`.
5
+ */
6
+ export declare const PREVIEW_HEADER = "X-Tina-Preview";
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";
11
+ export interface PreviewEnvelope {
12
+ [queryId: string]: unknown;
13
+ }
14
+ export declare function readOverlay<T>(request: Request, queryId: string): Promise<T | undefined>;
15
+ /** Read and parse the full overlay envelope (every form on the page). */
16
+ export declare function readEnvelope(request: Request): Promise<PreviewEnvelope | undefined>;
package/dist/preview.js CHANGED
@@ -1,5 +1,7 @@
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";
4
+ const MAX_ENVELOPE_BYTES = 1e6;
3
5
  async function readOverlay(request, queryId) {
4
6
  const envelope = await readEnvelope(request);
5
7
  if (!envelope)
@@ -7,14 +9,28 @@ async function readOverlay(request, queryId) {
7
9
  const value = envelope[queryId];
8
10
  return value === void 0 ? void 0 : value;
9
11
  }
10
- 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) {
11
22
  const contentType = request.headers.get("content-type") ?? "";
12
23
  if (!contentType.includes(PREVIEW_CONTENT_TYPE))
13
24
  return void 0;
25
+ const declaredLength = Number(request.headers.get("content-length") ?? "0");
26
+ if (declaredLength > MAX_ENVELOPE_BYTES)
27
+ return void 0;
14
28
  try {
15
29
  const text = await request.text();
16
30
  if (!text)
17
31
  return void 0;
32
+ if (text.length > MAX_ENVELOPE_BYTES)
33
+ return void 0;
18
34
  return JSON.parse(text);
19
35
  } catch {
20
36
  return void 0;
@@ -23,6 +39,7 @@ async function readEnvelope(request) {
23
39
  export {
24
40
  PREVIEW_CONTENT_TYPE,
25
41
  PREVIEW_HEADER,
42
+ PRIME_HEADER,
26
43
  readEnvelope,
27
44
  readOverlay
28
45
  };
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Shared CSS for the quick-edit outline. Used by both the vanilla bridge
3
+ * (click-to-focus.ts) and the React `useTina` hook (packages/tinacms/src/react.tsx),
4
+ * so the visible affordance is identical regardless of which integration
5
+ * the consumer is running.
6
+ */
7
+ export declare const QUICK_EDIT_CSS = "\n [data-tina-field] {\n outline: 2px dashed rgba(34,150,254,0.5);\n transition: box-shadow ease-out 150ms;\n }\n [data-tina-field]:hover {\n box-shadow: inset 100vi 100vh rgba(34,150,254,0.3);\n outline: 2px solid rgba(34,150,254,1);\n cursor: pointer;\n }\n [data-tina-field-overlay] {\n outline: 2px dashed rgba(34,150,254,0.5);\n position: relative;\n }\n [data-tina-field-overlay]:hover {\n cursor: pointer;\n outline: 2px solid rgba(34,150,254,1);\n }\n [data-tina-field-overlay]::after {\n content: '';\n position: absolute;\n inset: 0;\n z-index: 20;\n transition: opacity ease-out 150ms;\n background-color: rgba(34,150,254,0.3);\n opacity: 0;\n }\n [data-tina-field-overlay]:hover::after {\n opacity: 1;\n }\n";
8
+ export declare const QUICK_EDIT_BODY_CLASS = "__tina-quick-editing-enabled";
9
+ export declare const QUICK_EDIT_STYLE_ID = "__tina-bridge-quick-edit-style";
@@ -0,0 +1,38 @@
1
+ const QUICK_EDIT_CSS = `
2
+ [data-tina-field] {
3
+ outline: 2px dashed rgba(34,150,254,0.5);
4
+ transition: box-shadow ease-out 150ms;
5
+ }
6
+ [data-tina-field]:hover {
7
+ box-shadow: inset 100vi 100vh rgba(34,150,254,0.3);
8
+ outline: 2px solid rgba(34,150,254,1);
9
+ cursor: pointer;
10
+ }
11
+ [data-tina-field-overlay] {
12
+ outline: 2px dashed rgba(34,150,254,0.5);
13
+ position: relative;
14
+ }
15
+ [data-tina-field-overlay]:hover {
16
+ cursor: pointer;
17
+ outline: 2px solid rgba(34,150,254,1);
18
+ }
19
+ [data-tina-field-overlay]::after {
20
+ content: '';
21
+ position: absolute;
22
+ inset: 0;
23
+ z-index: 20;
24
+ transition: opacity ease-out 150ms;
25
+ background-color: rgba(34,150,254,0.3);
26
+ opacity: 0;
27
+ }
28
+ [data-tina-field-overlay]:hover::after {
29
+ opacity: 1;
30
+ }
31
+ `;
32
+ const QUICK_EDIT_BODY_CLASS = "__tina-quick-editing-enabled";
33
+ const QUICK_EDIT_STYLE_ID = "__tina-bridge-quick-edit-style";
34
+ export {
35
+ QUICK_EDIT_BODY_CLASS,
36
+ QUICK_EDIT_CSS,
37
+ QUICK_EDIT_STYLE_ID
38
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -1 +1,14 @@
1
- export * from "../src/tina-field"
1
+ /**
2
+ * Generate a field identifier for Tina to associate DOM elements with form fields.
3
+ * Format: "queryId---path.to.field" or "queryId---path.to.array.index"
4
+ *
5
+ * Canonical implementation. The React-side `tinacms/tina-field` and Astro-side
6
+ * `@tinacms/astro/tina-field` both re-export from here so non-React frontends
7
+ * can consume it without pulling tinacms (and its React deps) into their bundle.
8
+ */
9
+ export declare const tinaField: <T extends {
10
+ _content_source?: {
11
+ queryId: string;
12
+ path: (number | string)[];
13
+ };
14
+ } | Record<string, unknown> | null | undefined>(object: T, property?: keyof Omit<NonNullable<T>, "__typename" | "_sys">, index?: number) => string;
package/dist/types.d.ts CHANGED
@@ -45,7 +45,11 @@ export interface FormPayload {
45
45
  export interface DataStore {
46
46
  /** Latest resolved data per form id. */
47
47
  get(id: string): object | undefined;
48
- /** Replace cached data for a form. */
48
+ /** Whether a form id has been seeded or set. */
49
+ has(id: string): boolean;
50
+ /** Populate without notifying subscribers (used for the initial seed). */
51
+ seed(id: string, data: object): void;
52
+ /** Replace cached data for a form and notify subscribers. */
49
53
  set(id: string, data: object): void;
50
54
  /** All known form ids. */
51
55
  ids(): string[];
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tinacms/bridge",
3
- "version": "0.0.1",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -20,6 +20,14 @@
20
20
  "./preview": {
21
21
  "types": "./dist/preview.d.ts",
22
22
  "default": "./dist/preview.js"
23
+ },
24
+ "./metadata": {
25
+ "types": "./dist/metadata.d.ts",
26
+ "default": "./dist/metadata.js"
27
+ },
28
+ "./quick-edit-css": {
29
+ "types": "./dist/quick-edit-css.d.ts",
30
+ "default": "./dist/quick-edit-css.js"
23
31
  }
24
32
  },
25
33
  "license": "Apache-2.0",
@@ -27,7 +35,9 @@
27
35
  "entryPoints": [
28
36
  "src/index.ts",
29
37
  "src/tina-field.ts",
30
- "src/preview.ts"
38
+ "src/preview.ts",
39
+ "src/metadata.ts",
40
+ "src/quick-edit-css.ts"
31
41
  ]
32
42
  },
33
43
  "devDependencies": {
@@ -36,7 +46,7 @@
36
46
  "typescript": "^5.7.3",
37
47
  "vite": "^5.4.14",
38
48
  "vitest": "^2.1.9",
39
- "@tinacms/scripts": "1.6.0"
49
+ "@tinacms/scripts": "1.6.1"
40
50
  },
41
51
  "publishConfig": {
42
52
  "registry": "https://registry.npmjs.org"
@@ -1 +0,0 @@
1
- export declare function initEditMode(timeoutMs?: number): Promise<boolean>;