@obelism/content 0.0.0-stage → 0.1.1

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