@obelism/content 0.0.0-stage → 0.1.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,915 @@
1
+ import { P as PREVIEW_PARAMS, U as UID_ATTRIBUTE, E as ENTRY_ATTRIBUTE } from './preview-kvotryzw.mjs';
2
+ import { SITE_EDITING_COOKIE, SITE_REQUEST_HEADER } from './site.mjs';
3
+ import { n as normalizeUrl, D as DEFAULT_URL } from './config-c6qktlz6.mjs';
4
+
5
+ // The site's half of the visual editor (the CMS's public/bridge.js, as a
6
+ // module). Inside the editor's iframe it outlines blocks, tells the editor
7
+ // which one was clicked, hands the editor's live, unsaved entry to listeners
8
+ // and sends the editor prompts. Outside the editor it does nothing.
9
+ //
10
+ // Messages are only exchanged with the CMS origin, and only with the parent
11
+ // window:
12
+ // - site → editor: { source: 'obelism-content-bridge', type: 'ready' |
13
+ // 'select' | 'prompt' | 'undo', uid?, id?, prompt? }
14
+ // - editor → site: { source: 'obelism-content-editor', type: 'connect' |
15
+ // 'update' | 'select' | 'saved' | 'published' | 'features' |
16
+ // 'promptResult' | 'promptState', … }
17
+ const BRIDGE_SOURCE = 'obelism-content-bridge';
18
+ const EDITOR_SOURCE = 'obelism-content-editor';
19
+ // How long a prompt may take before it counts as failed.
20
+ const PROMPT_TIMEOUT_MS = 120_000;
21
+ const OUTLINE = '#d58a1f';
22
+ // Outlines on blocks; `scope` limits them to e.g. `html[data-cms-selecting] `.
23
+ const selectionStyle = (scope = '')=>`${scope}[${UID_ATTRIBUTE}]{cursor:pointer;transition:outline-color .15s}` + `${scope}[${UID_ATTRIBUTE}]:hover{outline:2px dashed ${OUTLINE};outline-offset:2px}` + `${scope}[${UID_ATTRIBUTE}][data-cms-selected]{outline:2px solid ${OUTLINE};outline-offset:2px}`;
24
+ const INITIAL_STATE = {
25
+ features: [],
26
+ selectedUid: null,
27
+ prompting: false,
28
+ canUndo: false,
29
+ unsaved: false,
30
+ saving: false,
31
+ publishing: false,
32
+ selecting: false
33
+ };
34
+ const createEmitter = ()=>{
35
+ const listeners = {
36
+ update: new Set(),
37
+ saved: new Set(),
38
+ published: new Set(),
39
+ change: new Set()
40
+ };
41
+ return {
42
+ listeners,
43
+ on (event, listener) {
44
+ listeners[event].add(listener);
45
+ return ()=>{
46
+ listeners[event].delete(listener);
47
+ };
48
+ },
49
+ emit (event, payload) {
50
+ for (const listener of listeners[event]){
51
+ try {
52
+ listener(payload);
53
+ } catch (error) {
54
+ console.error('[@obelism/content bridge]', error);
55
+ }
56
+ }
57
+ }
58
+ };
59
+ };
60
+ const inertPrompt = ()=>Promise.reject(new Error('Prompts only work inside the visual editor'));
61
+ const onlyOnSite = ()=>Promise.reject(new Error('Save and publish in the visual editor itself'));
62
+ // What every bridge without a feature does for it.
63
+ const INERT_METHODS = {
64
+ prompt: inertPrompt,
65
+ undo: ()=>{},
66
+ save: onlyOnSite,
67
+ publish: onlyOnSite,
68
+ signOut: ()=>{},
69
+ select: ()=>{},
70
+ setSelecting: ()=>{}
71
+ };
72
+ // The selector of the element marked with a block's uid.
73
+ const blockSelector = (uid)=>`[${UID_ATTRIBUTE}="${uid.replace(/["\\]/g, '\\$&')}"]`;
74
+ // `cmsOrigin` is the only origin messages are accepted from and sent to.
75
+ const createBridge = (win, cmsOrigin)=>{
76
+ const params = new URLSearchParams(win.location.search);
77
+ const isEditing = win.parent !== win && params.has(PREVIEW_PARAMS.preview);
78
+ const emitter = createEmitter();
79
+ const cleanup = [];
80
+ let state = {
81
+ ...INITIAL_STATE,
82
+ selecting: true
83
+ };
84
+ const setState = (next)=>{
85
+ state = {
86
+ ...state,
87
+ ...next
88
+ };
89
+ emitter.emit('change', state);
90
+ };
91
+ const bridge = {
92
+ isEditing,
93
+ mode: isEditing ? 'editor' : null,
94
+ entryId: params.get(PREVIEW_PARAMS.entry),
95
+ environment: params.get(PREVIEW_PARAMS.environment),
96
+ on: emitter.on,
97
+ getState: ()=>state,
98
+ ...INERT_METHODS,
99
+ destroy: ()=>{
100
+ cleanup.splice(0).forEach((undo)=>undo());
101
+ }
102
+ };
103
+ if (!isEditing) return bridge;
104
+ const doc = win.document;
105
+ const send = (type, data = {})=>{
106
+ win.parent.postMessage({
107
+ source: BRIDGE_SOURCE,
108
+ type,
109
+ ...data
110
+ }, cmsOrigin);
111
+ };
112
+ const style = doc.createElement('style');
113
+ style.textContent = selectionStyle();
114
+ doc.head.appendChild(style);
115
+ cleanup.push(()=>style.remove());
116
+ const highlight = (uid, scroll)=>{
117
+ doc.querySelectorAll('[data-cms-selected]').forEach((element)=>{
118
+ element.removeAttribute('data-cms-selected');
119
+ });
120
+ if (state.selectedUid !== (uid ?? null)) {
121
+ setState({
122
+ selectedUid: uid ?? null
123
+ });
124
+ }
125
+ if (!uid) return;
126
+ const element = doc.querySelector(blockSelector(uid));
127
+ if (!element) return;
128
+ element.setAttribute('data-cms-selected', '');
129
+ if (scroll) element.scrollIntoView({
130
+ behavior: 'smooth',
131
+ block: 'center'
132
+ });
133
+ };
134
+ // Capture phase, so a block that is also a link selects instead of
135
+ // navigating away.
136
+ const onClick = (event)=>{
137
+ const target = event.target;
138
+ const element = target?.closest?.(`[${UID_ATTRIBUTE}]`);
139
+ if (!element) return;
140
+ event.preventDefault();
141
+ event.stopPropagation();
142
+ const uid = element.getAttribute(UID_ATTRIBUTE);
143
+ highlight(uid, false);
144
+ send('select', {
145
+ uid
146
+ });
147
+ };
148
+ doc.addEventListener('click', onClick, true);
149
+ cleanup.push(()=>doc.removeEventListener('click', onClick, true));
150
+ const ready = ()=>send('ready', {
151
+ url: win.location.href
152
+ });
153
+ // Prompts waiting for the editor's answer, by id.
154
+ const pending = new Map();
155
+ let nextId = 0;
156
+ const settle = (id)=>{
157
+ const waiting = pending.get(id);
158
+ if (!waiting) return null;
159
+ clearTimeout(waiting.timer);
160
+ pending.delete(id);
161
+ if (pending.size === 0) setState({
162
+ prompting: false
163
+ });
164
+ return waiting;
165
+ };
166
+ bridge.prompt = (prompt, options = {})=>{
167
+ const text = prompt.trim();
168
+ if (!text) return Promise.reject(new Error('Say what to change'));
169
+ if (!state.features.includes('prompt')) {
170
+ return Promise.reject(new Error('The editor does not offer prompts here'));
171
+ }
172
+ const id = `prompt_${++nextId}`;
173
+ const uid = options.uid === undefined ? state.selectedUid : options.uid;
174
+ return new Promise((resolve, reject)=>{
175
+ const timer = setTimeout(()=>{
176
+ settle(id)?.reject(new Error('The editor did not answer in time'));
177
+ }, PROMPT_TIMEOUT_MS);
178
+ pending.set(id, {
179
+ resolve,
180
+ reject,
181
+ timer
182
+ });
183
+ setState({
184
+ prompting: true
185
+ });
186
+ send('prompt', {
187
+ id,
188
+ prompt: text,
189
+ uid
190
+ });
191
+ });
192
+ };
193
+ bridge.undo = ()=>send('undo');
194
+ bridge.select = (uid)=>{
195
+ highlight(uid, false);
196
+ send('select', {
197
+ uid
198
+ });
199
+ };
200
+ cleanup.push(()=>{
201
+ for (const id of pending.keys()){
202
+ settle(id)?.reject(new Error('The bridge was destroyed'));
203
+ }
204
+ });
205
+ const onMessage = (event)=>{
206
+ if (event.origin !== cmsOrigin || event.source !== win.parent) return;
207
+ const data = event.data;
208
+ if (!data || data.source !== EDITOR_SOURCE) return;
209
+ if (data.type === 'connect') {
210
+ // The editor asks once its listener is up: the first `ready` may
211
+ // have been sent before it was listening.
212
+ ready();
213
+ } else if (data.type === 'update' && data.entry) {
214
+ emitter.emit('update', {
215
+ entry: data.entry,
216
+ selectedUid: data.selectedUid ?? null
217
+ });
218
+ // Re-apply the selection to whatever the site rendered.
219
+ win.requestAnimationFrame(()=>highlight(data.selectedUid, false));
220
+ } else if (data.type === 'select') {
221
+ highlight(data.uid, true);
222
+ } else if (data.type === 'saved' || data.type === 'published') {
223
+ emitter.emit(data.type, {});
224
+ if (emitter.listeners.update.size === 0) win.location.reload();
225
+ } else if (data.type === 'features' && Array.isArray(data.features)) {
226
+ setState({
227
+ features: data.features.filter((feature)=>typeof feature === 'string')
228
+ });
229
+ } else if (data.type === 'promptState') {
230
+ setState({
231
+ canUndo: data.canUndo === true
232
+ });
233
+ } else if (data.type === 'promptResult' && typeof data.id === 'string') {
234
+ const waiting = settle(data.id);
235
+ if (!waiting) return;
236
+ if (data.ok === true) {
237
+ waiting.resolve({
238
+ summary: typeof data.summary === 'string' ? data.summary : ''
239
+ });
240
+ } else {
241
+ waiting.reject(new Error(typeof data.error === 'string' ? data.error : 'The prompt failed'));
242
+ }
243
+ }
244
+ };
245
+ win.addEventListener('message', onMessage);
246
+ cleanup.push(()=>win.removeEventListener('message', onMessage));
247
+ if (doc.readyState === 'loading') {
248
+ doc.addEventListener('DOMContentLoaded', ready, {
249
+ once: true
250
+ });
251
+ cleanup.push(()=>doc.removeEventListener('DOMContentLoaded', ready));
252
+ } else {
253
+ ready();
254
+ }
255
+ return bridge;
256
+ };
257
+ const wrapScriptBridge = (script, params)=>{
258
+ const emitter = createEmitter();
259
+ script.on('saved', ()=>emitter.emit('saved', {}));
260
+ script.on('published', ()=>emitter.emit('published', {}));
261
+ // Only once something listens: bridge.js reloads after a save while it has
262
+ // no `update` listener, and has no way to remove one.
263
+ let forwardingUpdates = false;
264
+ return {
265
+ isEditing: script.isEditing,
266
+ mode: script.isEditing ? 'editor' : null,
267
+ entryId: script.entryId,
268
+ environment: params.get(PREVIEW_PARAMS.environment),
269
+ on: (event, listener)=>{
270
+ if (event === 'update' && !forwardingUpdates) {
271
+ forwardingUpdates = true;
272
+ script.on('update', (payload)=>{
273
+ const entry = payload?.entry;
274
+ if (entry) emitter.emit('update', {
275
+ entry,
276
+ selectedUid: null
277
+ });
278
+ });
279
+ }
280
+ return emitter.on(event, listener);
281
+ },
282
+ getState: ()=>INITIAL_STATE,
283
+ ...INERT_METHODS,
284
+ destroy: ()=>{}
285
+ };
286
+ };
287
+
288
+ const DEFAULT_PROMPT_LABELS = {
289
+ open: 'Edit with a prompt',
290
+ placeholder: 'Describe a change, e.g. “make the heading shorter”',
291
+ send: 'Apply',
292
+ undo: 'Undo',
293
+ close: 'Close',
294
+ scopeEntry: 'Whole page',
295
+ scopeBlock: 'Selected block',
296
+ clearBlock: 'Edit the whole page instead',
297
+ working: 'Working on it…',
298
+ save: 'Save',
299
+ publish: 'Publish',
300
+ saving: 'Saving…',
301
+ publishing: 'Publishing…',
302
+ saved: 'Saved as a draft',
303
+ published: 'Published',
304
+ unsaved: 'Unsaved changes',
305
+ signOut: 'Exit editing',
306
+ viewingDrafts: 'Viewing drafts',
307
+ selectHint: 'Click a block to change only that block'
308
+ };
309
+ const ACCENT = '#d58a1f';
310
+ const CSS = `
311
+ :host { all: initial; }
312
+ * { box-sizing: border-box; font: 14px/1.4 system-ui, -apple-system, 'Segoe UI', sans-serif; }
313
+ .root {
314
+ --bg: #fff; --fg: #1d1b19; --muted: #6b655e; --line: #e4ded6; --error: #b3261e;
315
+ position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%);
316
+ z-index: 2147483647; width: min(640px, calc(100vw - 32px)); color: var(--fg);
317
+ }
318
+ @media (prefers-color-scheme: dark) {
319
+ .root { --bg: #23201d; --fg: #f3efe9; --muted: #a9a198; --line: #3b3631; --error: #ff8a80; }
320
+ }
321
+ .pill, .panel { background: var(--bg); border: 1px solid var(--line); box-shadow: 0 8px 30px rgba(0,0,0,.18); }
322
+ .pill {
323
+ display: block; margin: 0 auto; border-radius: 999px; padding: 8px 16px; cursor: pointer;
324
+ color: var(--fg);
325
+ }
326
+ .pill:hover { border-color: ${ACCENT}; }
327
+ .panel { border-radius: 14px; padding: 10px; display: grid; gap: 8px; }
328
+ .row { display: flex; align-items: center; gap: 8px; }
329
+ .scope { color: var(--muted); font-size: 12px; flex: 1; min-width: 0; }
330
+ .scope button { color: inherit; font-size: 12px; text-decoration: underline; background: none; border: 0; padding: 0 0 0 6px; cursor: pointer; }
331
+ textarea {
332
+ width: 100%; resize: none; min-height: 44px; max-height: 160px; padding: 8px 10px;
333
+ border-radius: 10px; border: 1px solid var(--line); background: transparent; color: var(--fg);
334
+ }
335
+ textarea:focus { outline: 2px solid ${ACCENT}; outline-offset: -1px; }
336
+ button.action {
337
+ border-radius: 8px; border: 1px solid var(--line); background: transparent; color: var(--fg);
338
+ padding: 6px 12px; cursor: pointer;
339
+ }
340
+ button.primary { background: ${ACCENT}; border-color: ${ACCENT}; color: #fff; }
341
+ button:disabled { opacity: .5; cursor: default; }
342
+ .close { border: 0; background: none; color: var(--muted); cursor: pointer; font-size: 18px; line-height: 1; padding: 2px 6px; }
343
+ .status { font-size: 13px; color: var(--muted); flex: 1; min-width: 0; }
344
+ .status.error { color: var(--error); }
345
+ .bar { display: flex; justify-content: center; gap: 8px; }
346
+ .row > .link { margin-right: auto; }
347
+ .link { border: 0; background: none; color: var(--muted); cursor: pointer; font-size: 12px; text-decoration: underline; padding: 0; }
348
+ .dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: ${ACCENT}; margin-right: 6px; vertical-align: 1px; }
349
+ [hidden] { display: none !important; }
350
+ `;
351
+ const element = (doc, tag, props = {}, ...children)=>{
352
+ const node = doc.createElement(tag);
353
+ Object.assign(node, props);
354
+ // appendChild, not append: Workers types redefine Element.append, and a
355
+ // site's server code may well have them loaded.
356
+ for (const child of children){
357
+ node.appendChild(typeof child === 'string' ? doc.createTextNode(child) : child);
358
+ }
359
+ return node;
360
+ };
361
+ // Mounts the box on the page; returns a function that removes it.
362
+ const mountPromptBox = (doc, bridge, options = {})=>{
363
+ const labels = {
364
+ ...DEFAULT_PROMPT_LABELS,
365
+ ...options.labels
366
+ };
367
+ const host = element(doc, 'div');
368
+ host.setAttribute('data-obelism-content-prompt', '');
369
+ const shadow = host.attachShadow({
370
+ mode: 'open'
371
+ });
372
+ const site = bridge.mode === 'site';
373
+ const pill = element(doc, 'button', {
374
+ className: 'pill',
375
+ type: 'button',
376
+ textContent: `✨ ${labels.open}`
377
+ });
378
+ // Someone signed in who may only read: a note that drafts show, and a
379
+ // way out.
380
+ const viewing = element(doc, 'span', {
381
+ className: 'pill'
382
+ }, `${labels.viewingDrafts} · `);
383
+ const pillSignOut = element(doc, 'button', {
384
+ className: 'link',
385
+ type: 'button',
386
+ textContent: labels.signOut
387
+ });
388
+ viewing.appendChild(pillSignOut);
389
+ const scopeText = element(doc, 'span');
390
+ const clearBlock = element(doc, 'button', {
391
+ type: 'button',
392
+ textContent: labels.clearBlock
393
+ });
394
+ const scope = element(doc, 'div', {
395
+ className: 'scope'
396
+ }, '✨ ', scopeText, clearBlock);
397
+ const close = element(doc, 'button', {
398
+ className: 'close',
399
+ type: 'button',
400
+ textContent: '×',
401
+ title: labels.close
402
+ });
403
+ close.setAttribute('aria-label', labels.close);
404
+ const input = element(doc, 'textarea', {
405
+ placeholder: labels.placeholder,
406
+ rows: 1
407
+ });
408
+ input.setAttribute('aria-label', labels.open);
409
+ const status = element(doc, 'div', {
410
+ className: 'status'
411
+ });
412
+ status.setAttribute('role', 'status');
413
+ const undo = element(doc, 'button', {
414
+ className: 'action',
415
+ type: 'button',
416
+ textContent: labels.undo
417
+ });
418
+ const send = element(doc, 'button', {
419
+ className: 'action primary',
420
+ type: 'submit',
421
+ textContent: labels.send
422
+ });
423
+ const save = element(doc, 'button', {
424
+ className: 'action',
425
+ type: 'button',
426
+ textContent: labels.save
427
+ });
428
+ const publish = element(doc, 'button', {
429
+ className: 'action',
430
+ type: 'button',
431
+ textContent: labels.publish
432
+ });
433
+ const signOut = element(doc, 'button', {
434
+ className: 'link',
435
+ type: 'button',
436
+ textContent: labels.signOut
437
+ });
438
+ const unsaved = element(doc, 'span', {
439
+ className: 'scope'
440
+ });
441
+ unsaved.appendChild(element(doc, 'span', {
442
+ className: 'dot'
443
+ }));
444
+ unsaved.appendChild(doc.createTextNode(labels.unsaved));
445
+ const panel = element(doc, 'form', {
446
+ className: 'panel'
447
+ }, element(doc, 'div', {
448
+ className: 'row'
449
+ }, scope, close), input, element(doc, 'div', {
450
+ className: 'row'
451
+ }, status, undo, send), element(doc, 'div', {
452
+ className: 'row'
453
+ }, unsaved, signOut, save, publish));
454
+ const root = element(doc, 'div', {
455
+ className: 'root'
456
+ }, element(doc, 'div', {
457
+ className: 'bar'
458
+ }, pill, viewing), panel);
459
+ shadow.append(element(doc, 'style', {
460
+ textContent: CSS
461
+ }), root);
462
+ let open = false;
463
+ let message = null;
464
+ const render = (state)=>{
465
+ const available = state.features.includes('prompt');
466
+ const storing = state.saving || state.publishing;
467
+ const busy = state.prompting || storing;
468
+ root.hidden = !available && !site;
469
+ pill.hidden = open || !available;
470
+ viewing.hidden = available || !site;
471
+ panel.hidden = !open || !available;
472
+ scopeText.textContent = state.selectedUid ? labels.scopeBlock : labels.scopeEntry;
473
+ clearBlock.hidden = !state.selectedUid;
474
+ undo.hidden = !state.canUndo;
475
+ undo.disabled = busy;
476
+ send.disabled = busy || input.value.trim() === '';
477
+ input.disabled = busy;
478
+ save.hidden = !state.features.includes('save');
479
+ save.disabled = busy || !state.unsaved;
480
+ save.textContent = state.saving ? labels.saving : labels.save;
481
+ publish.hidden = !state.features.includes('publish');
482
+ publish.disabled = busy;
483
+ publish.textContent = state.publishing ? labels.publishing : labels.publish;
484
+ signOut.hidden = !site;
485
+ unsaved.hidden = !state.unsaved;
486
+ const text = state.prompting ? labels.working : message?.text ?? (site && !state.selectedUid ? labels.selectHint : '');
487
+ status.textContent = text;
488
+ status.className = `status${!busy && message?.error ? ' error' : ''}`;
489
+ };
490
+ const refresh = ()=>render(bridge.getState());
491
+ const grow = ()=>{
492
+ input.style.height = 'auto';
493
+ input.style.height = `${input.scrollHeight}px`;
494
+ };
495
+ const submit = async ()=>{
496
+ const prompt = input.value.trim();
497
+ if (!prompt || bridge.getState().prompting) return;
498
+ message = null;
499
+ try {
500
+ const { summary } = await bridge.prompt(prompt);
501
+ message = {
502
+ text: summary,
503
+ error: false
504
+ };
505
+ input.value = '';
506
+ grow();
507
+ } catch (error) {
508
+ message = {
509
+ text: error instanceof Error ? error.message : String(error),
510
+ error: true
511
+ };
512
+ }
513
+ refresh();
514
+ input.focus();
515
+ };
516
+ const setOpen = (next)=>{
517
+ open = next;
518
+ if (site) bridge.setSelecting(open);
519
+ refresh();
520
+ };
521
+ const store = async (action)=>{
522
+ message = null;
523
+ try {
524
+ await bridge[action]();
525
+ message = {
526
+ text: action === 'save' ? labels.saved : labels.published,
527
+ error: false
528
+ };
529
+ } catch (error) {
530
+ message = {
531
+ text: error instanceof Error ? error.message : String(error),
532
+ error: true
533
+ };
534
+ }
535
+ refresh();
536
+ };
537
+ pill.addEventListener('click', ()=>{
538
+ setOpen(true);
539
+ input.focus();
540
+ });
541
+ close.addEventListener('click', ()=>setOpen(false));
542
+ save.addEventListener('click', ()=>void store('save'));
543
+ publish.addEventListener('click', ()=>void store('publish'));
544
+ signOut.addEventListener('click', ()=>bridge.signOut());
545
+ pillSignOut.addEventListener('click', ()=>bridge.signOut());
546
+ clearBlock.addEventListener('click', ()=>bridge.select(null));
547
+ undo.addEventListener('click', ()=>{
548
+ message = null;
549
+ bridge.undo();
550
+ });
551
+ panel.addEventListener('submit', (event)=>{
552
+ event.preventDefault();
553
+ void submit();
554
+ });
555
+ input.addEventListener('input', ()=>{
556
+ grow();
557
+ refresh();
558
+ });
559
+ input.addEventListener('keydown', (event)=>{
560
+ if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
561
+ event.preventDefault();
562
+ void submit();
563
+ } else if (event.key === 'Escape') {
564
+ setOpen(false);
565
+ }
566
+ });
567
+ const unsubscribe = bridge.on('change', render);
568
+ refresh();
569
+ doc.body.appendChild(host);
570
+ return ()=>{
571
+ unsubscribe();
572
+ host.remove();
573
+ };
574
+ };
575
+
576
+ // The bridge on a site signed in to edit (`/cms/login`, from
577
+ // @obelism/content/site), outside the visual editor. It plays the editor's
578
+ // part itself: it keeps the changed content in memory, asks the site's
579
+ // `/cms/api/…` (which forwards to the CMS with the session's token) to apply
580
+ // prompts and to shape the content for rendering, hands that to `update`
581
+ // listeners like the editor would, and saves or publishes on request.
582
+ //
583
+ // What it edits is the page's entry — the outermost element marked with
584
+ // `editable(entry)` — or, for a selected block, the entry around that block.
585
+ // The base path of the site's handler while signed in to edit, else null.
586
+ const readSiteEditing = (doc)=>{
587
+ for (const part of doc.cookie.split(';')){
588
+ const [name, ...value] = part.trim().split('=');
589
+ if (name !== SITE_EDITING_COOKIE) continue;
590
+ try {
591
+ const basePath = decodeURIComponent(value.join('='));
592
+ return basePath.startsWith('/') && !basePath.startsWith('//') ? basePath : null;
593
+ } catch {
594
+ return null;
595
+ }
596
+ }
597
+ return null;
598
+ };
599
+ const same = (a, b)=>JSON.stringify(a) === JSON.stringify(b);
600
+ const createSiteBridge = (win, basePath)=>{
601
+ const doc = win.document;
602
+ const emitter = createEmitter();
603
+ const cleanup = [];
604
+ const working = new Map();
605
+ // Content before each prompt, newest last, for undo.
606
+ const history = [];
607
+ // Entries changed here since the last publish.
608
+ const touched = new Set();
609
+ let session = null;
610
+ let state = INITIAL_STATE;
611
+ const setState = (next)=>{
612
+ state = {
613
+ ...state,
614
+ ...next
615
+ };
616
+ emitter.emit('change', state);
617
+ };
618
+ const refreshUnsaved = ()=>setState({
619
+ unsaved: [
620
+ ...working.values()
621
+ ].some(({ content, saved })=>!same(content, saved)),
622
+ canUndo: history.length > 0
623
+ });
624
+ const api = async (path, body)=>{
625
+ const response = await win.fetch(`${basePath}/api/${path}`, body === undefined ? {
626
+ credentials: 'same-origin'
627
+ } : {
628
+ method: 'POST',
629
+ credentials: 'same-origin',
630
+ headers: {
631
+ 'Content-Type': 'application/json',
632
+ [SITE_REQUEST_HEADER]: '1'
633
+ },
634
+ body: JSON.stringify(body)
635
+ });
636
+ const data = await response.json().catch(()=>({}));
637
+ if (response.status === 401) {
638
+ setState({
639
+ features: []
640
+ });
641
+ throw new Error('You were signed out. Sign in again to edit.');
642
+ }
643
+ if (!response.ok) {
644
+ throw new Error(typeof data.error === 'string' ? data.error : `Obelism Content answered ${response.status}`);
645
+ }
646
+ return data;
647
+ };
648
+ const pageEntryId = ()=>doc.querySelector(`[${ENTRY_ATTRIBUTE}]`)?.getAttribute(ENTRY_ATTRIBUTE) ?? null;
649
+ const entryFor = (uid)=>{
650
+ const block = uid ? doc.querySelector(blockSelector(uid)) : null;
651
+ return block?.closest(`[${ENTRY_ATTRIBUTE}]`)?.getAttribute(ENTRY_ATTRIBUTE) ?? pageEntryId();
652
+ };
653
+ // The page's language when the space has it (or its base language), else
654
+ // the space's default.
655
+ const locale = ()=>{
656
+ if (!session) return undefined;
657
+ const codes = session.space.locales.map(({ code })=>code);
658
+ const lang = doc.documentElement.lang;
659
+ if (codes.includes(lang)) return lang;
660
+ const base = lang.split('-')[0] ?? '';
661
+ return codes.includes(base) ? base : session.space.defaultLocale;
662
+ };
663
+ const load = async (entryId)=>{
664
+ const loaded = working.get(entryId);
665
+ if (loaded) return loaded;
666
+ const entry = await api(`entries/${encodeURIComponent(entryId)}`);
667
+ const fresh = {
668
+ content: entry.content,
669
+ saved: entry.content,
670
+ revision: entry.draftRevision
671
+ };
672
+ working.set(entryId, fresh);
673
+ return fresh;
674
+ };
675
+ const highlight = (uid)=>{
676
+ doc.querySelectorAll('[data-cms-selected]').forEach((element)=>{
677
+ element.removeAttribute('data-cms-selected');
678
+ });
679
+ if (state.selectedUid !== uid) setState({
680
+ selectedUid: uid
681
+ });
682
+ if (uid) doc.querySelector(blockSelector(uid))?.setAttribute('data-cms-selected', '');
683
+ };
684
+ // Shows the in-memory content through `update`, shaped like the delivery
685
+ // API. A page without `update` listeners shows it once saved (it reloads).
686
+ const render = async (entryId)=>{
687
+ if (emitter.listeners.update.size === 0) return;
688
+ const content = working.get(entryId)?.content;
689
+ const { entry } = await api(`entries/${encodeURIComponent(entryId)}/preview`, {
690
+ content,
691
+ locale: locale()
692
+ });
693
+ emitter.emit('update', {
694
+ entry,
695
+ selectedUid: state.selectedUid
696
+ });
697
+ win.requestAnimationFrame(()=>highlight(state.selectedUid));
698
+ };
699
+ const afterStore = (event)=>{
700
+ emitter.emit(event, {});
701
+ if (emitter.listeners.update.size === 0) win.location.reload();
702
+ };
703
+ const saveAll = async ()=>{
704
+ for (const [entryId, entry] of working){
705
+ if (same(entry.content, entry.saved)) continue;
706
+ const { draftRevision } = await api(`entries/${encodeURIComponent(entryId)}/save`, {
707
+ content: entry.content,
708
+ baseRevision: entry.revision
709
+ });
710
+ entry.saved = entry.content;
711
+ entry.revision = draftRevision;
712
+ touched.add(entryId);
713
+ }
714
+ history.length = 0;
715
+ refreshUnsaved();
716
+ };
717
+ const has = (feature)=>state.features.includes(feature);
718
+ const bridge = {
719
+ isEditing: true,
720
+ mode: 'site',
721
+ get entryId () {
722
+ return pageEntryId();
723
+ },
724
+ environment: null,
725
+ on: emitter.on,
726
+ getState: ()=>state,
727
+ async prompt (prompt, options = {}) {
728
+ const text = prompt.trim();
729
+ if (!text) throw new Error('Say what to change');
730
+ if (!has('prompt')) throw new Error('You may not edit content here');
731
+ const uid = options.uid === undefined ? state.selectedUid : options.uid;
732
+ const entryId = entryFor(uid);
733
+ if (!entryId) {
734
+ throw new Error('Nothing on this page is marked as an entry: add editable(entry)');
735
+ }
736
+ setState({
737
+ prompting: true
738
+ });
739
+ try {
740
+ const entry = await load(entryId);
741
+ const result = await api(`entries/${encodeURIComponent(entryId)}/prompt`, {
742
+ prompt: text,
743
+ content: entry.content,
744
+ locale: locale(),
745
+ uid
746
+ });
747
+ history.push({
748
+ entryId,
749
+ content: entry.content
750
+ });
751
+ entry.content = result.content;
752
+ refreshUnsaved();
753
+ await render(entryId);
754
+ return {
755
+ summary: result.summary
756
+ };
757
+ } finally{
758
+ setState({
759
+ prompting: false
760
+ });
761
+ }
762
+ },
763
+ undo () {
764
+ const last = history.pop();
765
+ const entry = last && working.get(last.entryId);
766
+ if (!last || !entry) return;
767
+ entry.content = last.content;
768
+ refreshUnsaved();
769
+ void render(last.entryId).catch((error)=>console.error('[@obelism/content bridge]', error));
770
+ },
771
+ async save () {
772
+ if (!has('save')) throw new Error('You may not edit content here');
773
+ setState({
774
+ saving: true
775
+ });
776
+ try {
777
+ await saveAll();
778
+ } finally{
779
+ setState({
780
+ saving: false
781
+ });
782
+ }
783
+ afterStore('saved');
784
+ },
785
+ async publish () {
786
+ if (!has('publish')) throw new Error('You may not publish here');
787
+ setState({
788
+ publishing: true
789
+ });
790
+ try {
791
+ await saveAll();
792
+ const page = pageEntryId();
793
+ const entries = touched.size > 0 ? [
794
+ ...touched
795
+ ] : page ? [
796
+ page
797
+ ] : [];
798
+ if (entries.length === 0) {
799
+ throw new Error('Nothing on this page is marked as an entry: add editable(entry)');
800
+ }
801
+ for (const entryId of entries){
802
+ await api(`entries/${encodeURIComponent(entryId)}/publish`, {});
803
+ touched.delete(entryId);
804
+ }
805
+ } finally{
806
+ setState({
807
+ publishing: false
808
+ });
809
+ }
810
+ afterStore('published');
811
+ },
812
+ signOut () {
813
+ if (state.unsaved && !win.confirm('Sign out and lose the changes you have not saved?')) {
814
+ return;
815
+ }
816
+ working.clear();
817
+ refreshUnsaved();
818
+ const returnTo = win.location.pathname + win.location.search;
819
+ win.location.href = `${basePath}/logout?return_to=${encodeURIComponent(returnTo)}`;
820
+ },
821
+ select (uid) {
822
+ highlight(uid);
823
+ },
824
+ setSelecting (selecting) {
825
+ if (selecting) doc.documentElement.setAttribute('data-cms-selecting', '');
826
+ else doc.documentElement.removeAttribute('data-cms-selecting');
827
+ setState({
828
+ selecting
829
+ });
830
+ },
831
+ destroy () {
832
+ cleanup.splice(0).forEach((undo)=>undo());
833
+ }
834
+ };
835
+ const style = doc.createElement('style');
836
+ style.textContent = selectionStyle('html[data-cms-selecting] ');
837
+ doc.head.appendChild(style);
838
+ cleanup.push(()=>style.remove());
839
+ cleanup.push(()=>doc.documentElement.removeAttribute('data-cms-selecting'));
840
+ // Capture phase, so a block that is also a link selects instead of
841
+ // navigating — only while selecting, so the site stays usable.
842
+ const onClick = (event)=>{
843
+ if (!state.selecting) return;
844
+ const target = event.target;
845
+ const element = target?.closest?.(`[${UID_ATTRIBUTE}]`);
846
+ if (!element) return;
847
+ event.preventDefault();
848
+ event.stopPropagation();
849
+ highlight(element.getAttribute(UID_ATTRIBUTE));
850
+ };
851
+ doc.addEventListener('click', onClick, true);
852
+ cleanup.push(()=>doc.removeEventListener('click', onClick, true));
853
+ const onBeforeUnload = (event)=>{
854
+ if (!state.unsaved) return;
855
+ event.preventDefault();
856
+ event.returnValue = '';
857
+ };
858
+ win.addEventListener('beforeunload', onBeforeUnload);
859
+ cleanup.push(()=>win.removeEventListener('beforeunload', onBeforeUnload));
860
+ api('session').then((info)=>{
861
+ session = info;
862
+ setState({
863
+ features: info.features
864
+ });
865
+ }).catch((error)=>console.error('[@obelism/content bridge]', error));
866
+ return bridge;
867
+ };
868
+
869
+ // Kept on the window, so two copies of this package on one page still share
870
+ // one bridge.
871
+ const KEY = Symbol.for('@obelism/content/bridge');
872
+ const SERVER_BRIDGE = wrapScriptBridge({
873
+ isEditing: false,
874
+ entryId: null,
875
+ on: ()=>{}
876
+ }, new URLSearchParams());
877
+ // The page's bridge if something already started it, else null. Never
878
+ // starts one: for code that has to stay side-effect free, like a React
879
+ // store's snapshot.
880
+ const peekContentBridge = ()=>typeof window === 'undefined' ? null : window[KEY] ?? null;
881
+ // The page's bridge to the visual editor — or, on a site signed in to edit,
882
+ // its stand-in — started on first use. Call it in the browser (on the server
883
+ // it returns an inert bridge):
884
+ //
885
+ // const bridge = getContentBridge()
886
+ // if (bridge.isEditing) bridge.on('update', ({ entry }) => render(entry))
887
+ //
888
+ // The first call's options win: there is one bridge per page.
889
+ const getContentBridge = ({ url, prompt = true } = {})=>{
890
+ if (typeof window === 'undefined') return SERVER_BRIDGE;
891
+ const win = window;
892
+ if (win[KEY]) return win[KEY];
893
+ let bridge;
894
+ if (win.ObelismBridge) {
895
+ bridge = wrapScriptBridge(win.ObelismBridge, new URLSearchParams(win.location.search));
896
+ } else {
897
+ bridge = createBridge(win, normalizeUrl(url || DEFAULT_URL));
898
+ // Not in the visual editor, but signed in to edit through the site's
899
+ // own `/cms/login` (@obelism/content/site).
900
+ const sitePath = bridge.isEditing || win.parent !== win ? null : readSiteEditing(win.document);
901
+ if (sitePath) bridge = createSiteBridge(win, sitePath);
902
+ if (bridge.isEditing && prompt) {
903
+ const unmount = mountPromptBox(win.document, bridge, prompt === true ? {} : prompt);
904
+ const destroy = bridge.destroy;
905
+ bridge.destroy = ()=>{
906
+ unmount();
907
+ destroy();
908
+ };
909
+ }
910
+ }
911
+ win[KEY] = bridge;
912
+ return bridge;
913
+ };
914
+
915
+ export { DEFAULT_PROMPT_LABELS, INITIAL_STATE, getContentBridge, peekContentBridge };