@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.
- package/dist/config.d.ts +17 -0
- package/dist/data-store.d.ts +5 -3
- package/dist/forms.d.ts +6 -0
- package/dist/forms.test.d.ts +1 -0
- package/dist/index.d.ts +17 -1
- package/dist/index.js +278 -89
- package/dist/island-refresh.d.ts +15 -8
- package/dist/island-refresh.test.d.ts +1 -0
- package/dist/metadata.d.ts +22 -0
- package/dist/metadata.js +66 -0
- package/dist/preview.d.ts +16 -1
- package/dist/preview.js +18 -1
- package/dist/quick-edit-css.d.ts +9 -0
- package/dist/quick-edit-css.js +38 -0
- package/dist/refresh-forms.test.d.ts +1 -0
- package/dist/tina-field.d.ts +14 -1
- package/dist/types.d.ts +5 -1
- package/dist/types.test-d.d.ts +1 -0
- package/package.json +13 -3
- package/dist/edit-mode.d.ts +0 -1
package/dist/config.d.ts
ADDED
|
@@ -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;
|
package/dist/data-store.d.ts
CHANGED
|
@@ -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).
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
116
|
+
if (document.getElementById(QUICK_EDIT_STYLE_ID))
|
|
101
117
|
return;
|
|
102
118
|
const style = document.createElement("style");
|
|
103
|
-
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(
|
|
122
|
+
document.body.classList.add(QUICK_EDIT_BODY_CLASS);
|
|
107
123
|
}
|
|
108
124
|
function removeStyle() {
|
|
109
125
|
var _a;
|
|
110
|
-
(_a = document.getElementById(
|
|
111
|
-
document.body.classList.remove(
|
|
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
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
321
|
+
if (pendingRefresh) {
|
|
322
|
+
clearTimeout(pendingRefresh);
|
|
323
|
+
pendingRefresh = null;
|
|
324
|
+
}
|
|
238
325
|
if (firstUpdate) {
|
|
239
326
|
refreshAll();
|
|
240
327
|
return;
|
|
241
328
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
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
|
|
472
|
+
let running = false;
|
|
322
473
|
function init(options = {}) {
|
|
323
|
-
if (
|
|
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
|
};
|
package/dist/island-refresh.d.ts
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
import type { DataStore } from './types';
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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;
|
package/dist/metadata.js
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {};
|
package/dist/tina-field.d.ts
CHANGED
|
@@ -1 +1,14 @@
|
|
|
1
|
-
|
|
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
|
-
/**
|
|
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
|
|
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.
|
|
49
|
+
"@tinacms/scripts": "1.6.1"
|
|
40
50
|
},
|
|
41
51
|
"publishConfig": {
|
|
42
52
|
"registry": "https://registry.npmjs.org"
|
package/dist/edit-mode.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export declare function initEditMode(timeoutMs?: number): Promise<boolean>;
|