@obelism/content 0.28.2 → 0.28.4

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.mjs CHANGED
@@ -1,7 +1,169 @@
1
- import { U as UID_ATTRIBUTE, P as PREVIEW_PARAMS, E as ENTRY_ATTRIBUTE, C as COLLECTION_ATTRIBUTE, a as COLLECTION_HANDLE_ATTRIBUTE, b as PRODUCT_ATTRIBUTE, c as PRODUCT_HANDLE_ATTRIBUTE } from './preview-el7sfk3h.mjs';
2
- import { SITE_EDITING_COOKIE, SITE_REQUEST_HEADER } from './site.mjs';
1
+ import { U as UID_ATTRIBUTE, P as PREVIEW_PARAMS, C as COLLECTION_HANDLE_ATTRIBUTE, a as COLLECTION_ATTRIBUTE, b as PRODUCT_HANDLE_ATTRIBUTE, c as PRODUCT_ATTRIBUTE, E as ENTRY_ATTRIBUTE } from './preview-9qff497d.mjs';
2
+ import { p as parseCookies, S as SITE_EDITING_COOKIE, s as siteApi, a as SITE_REQUEST_HEADER } from './site-m85jdjid.mjs';
3
3
  import { n as normalizeUrl, D as DEFAULT_URL } from './config-c6qktlz6.mjs';
4
4
 
5
+ // What every bridge to the visual editor offers, and the pieces both of them
6
+ // are built from: the state and its emitter, block selection, the click-rate
7
+ // labels. The editor's own bridge is editorBridge.ts, editing on the site is
8
+ // siteBridge.ts, and the CMS's old script tag is scriptBridge.ts.
9
+ // Messages are only exchanged with the CMS origin, and only with the parent
10
+ // window:
11
+ // - site → editor: { source: 'obelism-content-bridge', type: 'ready' |
12
+ // 'size' | 'select' | 'prompt' | 'undo', uid?, id?, prompt?, height? }
13
+ // - editor → site: { source: 'obelism-content-editor', type: 'connect' |
14
+ // 'update' | 'select' | 'saved' | 'published' | 'features' |
15
+ // 'promptResult' | 'promptState' | 'stats' | 'viewport', … }
16
+ const BRIDGE_SOURCE = 'obelism-content-bridge';
17
+ const EDITOR_SOURCE = 'obelism-content-editor';
18
+ // How long a prompt may take before it counts as failed.
19
+ const PROMPT_TIMEOUT_MS = 120_000;
20
+ const INITIAL_STATE = {
21
+ features: [],
22
+ selectedUid: null,
23
+ prompting: false,
24
+ canUndo: false,
25
+ unsaved: false,
26
+ saving: false,
27
+ publishing: false,
28
+ selecting: false,
29
+ stats: {},
30
+ statsVisible: false
31
+ };
32
+ const OUTLINE = '#d58a1f';
33
+ // Outlines on blocks; `scope` limits them to e.g. `html[data-cms-selecting] `.
34
+ 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}`;
35
+ // The selector of the element marked with a block's uid.
36
+ const blockSelector = (uid)=>`[${UID_ATTRIBUTE}="${uid.replace(/["\\]/g, '\\$&')}"]`;
37
+ const createEmitter = ()=>{
38
+ const listeners = {
39
+ update: new Set(),
40
+ saved: new Set(),
41
+ published: new Set(),
42
+ change: new Set()
43
+ };
44
+ return {
45
+ listeners,
46
+ on (event, listener) {
47
+ listeners[event].add(listener);
48
+ return ()=>{
49
+ listeners[event].delete(listener);
50
+ };
51
+ },
52
+ emit (event, payload) {
53
+ for (const listener of listeners[event]){
54
+ try {
55
+ listener(payload);
56
+ } catch (error) {
57
+ console.error('[@obelism/content bridge]', error);
58
+ }
59
+ }
60
+ }
61
+ };
62
+ };
63
+ // The state both bridges keep, and the emitter that says it changed.
64
+ const createBridgeState = (initial = INITIAL_STATE)=>{
65
+ const emitter = createEmitter();
66
+ let state = initial;
67
+ return {
68
+ ...emitter,
69
+ getState: ()=>state,
70
+ setState (next) {
71
+ state = {
72
+ ...state,
73
+ ...next
74
+ };
75
+ emitter.emit('change', state);
76
+ }
77
+ };
78
+ };
79
+ // Outlines the block's element on the page, and nothing else, in view when
80
+ // `scroll`.
81
+ const showSelection = (doc, uid, scroll = false)=>{
82
+ for (const element of doc.querySelectorAll('[data-cms-selected]')){
83
+ element.removeAttribute('data-cms-selected');
84
+ }
85
+ const element = uid ? doc.querySelector(blockSelector(uid)) : null;
86
+ if (!element) return;
87
+ element.setAttribute('data-cms-selected', '');
88
+ if (scroll) element.scrollIntoView({
89
+ behavior: 'smooth',
90
+ block: 'center'
91
+ });
92
+ };
93
+ // The click rates on the page: mounted with the first rates, so a page
94
+ // without Numbers never gets it, then kept.
95
+ const createStatsView = (mount)=>{
96
+ let overlay = null;
97
+ return {
98
+ update (stats, visible) {
99
+ if (!overlay && Object.keys(stats).length === 0) return;
100
+ overlay ??= mount();
101
+ overlay.update(stats, visible);
102
+ },
103
+ refresh: ()=>overlay?.refresh(),
104
+ destroy: ()=>overlay?.destroy()
105
+ };
106
+ };
107
+ // The rates the page can show, from the editor's `stats` message: only
108
+ // well-formed entries.
109
+ const isCount = (value)=>typeof value === 'number' && Number.isFinite(value) && value >= 0;
110
+ const readStats = (value)=>{
111
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
112
+ const stats = {};
113
+ for (const [uid, block] of Object.entries(value)){
114
+ const { clicks, clickers, viewers, rate } = block ?? {};
115
+ if (isCount(clicks) && isCount(clickers) && isCount(viewers) && isCount(rate)) stats[uid] = {
116
+ clicks,
117
+ clickers,
118
+ viewers,
119
+ rate
120
+ };
121
+ }
122
+ return stats;
123
+ };
124
+ const inertPrompt = ()=>Promise.reject(new Error('Prompts only work inside the visual editor'));
125
+ const onlyOnSite = ()=>Promise.reject(new Error('Save and publish in the visual editor itself'));
126
+ const onlyWhileEditingSite = ()=>Promise.reject(new Error('Only while signed in to edit on the site'));
127
+ // What every bridge without a feature does for it.
128
+ const INERT_METHODS = {
129
+ prompt: inertPrompt,
130
+ createPage: ()=>Promise.reject(new Error('New pages are made on the site itself')),
131
+ createProductPage: ()=>Promise.reject(new Error('Product pages are made on the site itself')),
132
+ createCollectionPage: ()=>Promise.reject(new Error('Collection pages are made on the site itself')),
133
+ view: onlyWhileEditingSite,
134
+ applyJson: onlyWhileEditingSite,
135
+ openForm: (_container, options = {})=>{
136
+ queueMicrotask(()=>options.onError?.('Only while signed in to edit on the site'));
137
+ return {
138
+ close: ()=>{}
139
+ };
140
+ },
141
+ pages: onlyWhileEditingSite,
142
+ openPage: ()=>{},
143
+ undo: ()=>{},
144
+ save: onlyOnSite,
145
+ publish: onlyOnSite,
146
+ signOut: ()=>{},
147
+ select: ()=>{},
148
+ setSelecting: ()=>{}
149
+ };
150
+ // A bridge for a page where nothing can be edited.
151
+ const createInertBridge = ()=>{
152
+ const emitter = createEmitter();
153
+ return {
154
+ isEditing: false,
155
+ mode: null,
156
+ entryId: null,
157
+ environment: null,
158
+ product: null,
159
+ collection: null,
160
+ on: emitter.on,
161
+ getState: ()=>INITIAL_STATE,
162
+ ...INERT_METHODS,
163
+ destroy: ()=>{}
164
+ };
165
+ };
166
+
5
167
  const DEFAULT_STATS_LABELS = {
6
168
  clicked: '{rate}% clicked',
7
169
  clickedShort: '{rate}%',
@@ -77,7 +239,7 @@ const placeTag = (rect, viewport)=>({
77
239
  top: Math.min(Math.max(rect.top, 10), viewport.height - 10),
78
240
  left: Math.min(rect.right, viewport.width - 10)
79
241
  });
80
- const mountStatsOverlay = (win, { selector, onSelect, checked, labels: labelOverrides })=>{
242
+ const mountStatsOverlay = (win, { selector, onSelect, checked, shows, labels: labelOverrides })=>{
81
243
  const doc = win.document;
82
244
  const labels = {
83
245
  ...DEFAULT_STATS_LABELS,
@@ -155,13 +317,14 @@ const mountStatsOverlay = (win, { selector, onSelect, checked, labels: labelOver
155
317
  const id = element.getAttribute(TAG_ATTRIBUTE);
156
318
  if (!id || !stats[id] && !isChecked(id)) continue;
157
319
  tagged.add(id);
320
+ if (shows && !shows(id, element)) continue;
158
321
  draw(id, element, blocks);
159
322
  }
160
323
  // Rates of blocks the site doesn't tag an element of.
161
324
  for (const id of Object.keys(stats)){
162
325
  if (tagged.has(id) || !blocks.has(id)) continue;
163
326
  const element = doc.querySelector(selector(id));
164
- if (element) draw(id, element, blocks);
327
+ if (element && (!shows || shows(id, element))) draw(id, element, blocks);
165
328
  }
166
329
  };
167
330
  // At most once a frame, however many scroll and mutation events come in.
@@ -295,119 +458,15 @@ const createViewportUnits = (doc)=>{
295
458
  // which one was clicked, hands the editor's live, unsaved entry to listeners
296
459
  // and sends the editor prompts. Outside the editor it does nothing.
297
460
  //
298
- // Messages are only exchanged with the CMS origin, and only with the parent
299
- // window:
300
- // - site → editor: { source: 'obelism-content-bridge', type: 'ready' |
301
- // 'size' | 'select' | 'prompt' | 'undo', uid?, id?, prompt?, height? }
302
- // - editor → site: { source: 'obelism-content-editor', type: 'connect' |
303
- // 'update' | 'select' | 'saved' | 'published' | 'features' |
304
- // 'promptResult' | 'promptState' | 'stats' | 'viewport', … }
305
- const BRIDGE_SOURCE = 'obelism-content-bridge';
306
- const EDITOR_SOURCE = 'obelism-content-editor';
307
- // How long a prompt may take before it counts as failed.
308
- const PROMPT_TIMEOUT_MS = 120_000;
309
- const OUTLINE = '#d58a1f';
310
- // Outlines on blocks; `scope` limits them to e.g. `html[data-cms-selecting] `.
311
- 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}`;
312
- const INITIAL_STATE = {
313
- features: [],
314
- selectedUid: null,
315
- prompting: false,
316
- canUndo: false,
317
- unsaved: false,
318
- saving: false,
319
- publishing: false,
320
- selecting: false,
321
- stats: {},
322
- statsVisible: false
323
- };
324
- const createEmitter = ()=>{
325
- const listeners = {
326
- update: new Set(),
327
- saved: new Set(),
328
- published: new Set(),
329
- change: new Set()
330
- };
331
- return {
332
- listeners,
333
- on (event, listener) {
334
- listeners[event].add(listener);
335
- return ()=>{
336
- listeners[event].delete(listener);
337
- };
338
- },
339
- emit (event, payload) {
340
- for (const listener of listeners[event]){
341
- try {
342
- listener(payload);
343
- } catch (error) {
344
- console.error('[@obelism/content bridge]', error);
345
- }
346
- }
347
- }
348
- };
349
- };
350
- const inertPrompt = ()=>Promise.reject(new Error('Prompts only work inside the visual editor'));
351
- const onlyOnSite = ()=>Promise.reject(new Error('Save and publish in the visual editor itself'));
352
- const onlyWhileEditingSite = ()=>Promise.reject(new Error('Only while signed in to edit on the site'));
353
- // What every bridge without a feature does for it.
354
- const INERT_METHODS = {
355
- prompt: inertPrompt,
356
- createPage: ()=>Promise.reject(new Error('New pages are made on the site itself')),
357
- createProductPage: ()=>Promise.reject(new Error('Product pages are made on the site itself')),
358
- createCollectionPage: ()=>Promise.reject(new Error('Collection pages are made on the site itself')),
359
- view: onlyWhileEditingSite,
360
- applyJson: onlyWhileEditingSite,
361
- openForm: (_container, options = {})=>{
362
- queueMicrotask(()=>options.onError?.('Only while signed in to edit on the site'));
363
- return {
364
- close: ()=>{}
365
- };
366
- },
367
- pages: onlyWhileEditingSite,
368
- openPage: ()=>{},
369
- undo: ()=>{},
370
- save: onlyOnSite,
371
- publish: onlyOnSite,
372
- signOut: ()=>{},
373
- select: ()=>{},
374
- setSelecting: ()=>{}
375
- };
376
- const isCount = (value)=>typeof value === 'number' && Number.isFinite(value) && value >= 0;
377
- // The editor's `stats`, keeping only well-formed entries.
378
- const readStats = (value)=>{
379
- if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
380
- const stats = {};
381
- for (const [uid, block] of Object.entries(value)){
382
- const { clicks, clickers, viewers, rate } = block ?? {};
383
- if (isCount(clicks) && isCount(clickers) && isCount(viewers) && isCount(rate)) stats[uid] = {
384
- clicks,
385
- clickers,
386
- viewers,
387
- rate
388
- };
389
- }
390
- return stats;
391
- };
392
- // The selector of the element marked with a block's uid.
393
- const blockSelector = (uid)=>`[${UID_ATTRIBUTE}="${uid.replace(/["\\]/g, '\\$&')}"]`;
394
461
  // `cmsOrigin` is the only origin messages are accepted from and sent to.
395
462
  const createBridge = (win, cmsOrigin)=>{
396
463
  const params = new URLSearchParams(win.location.search);
397
464
  const isEditing = win.parent !== win && params.has(PREVIEW_PARAMS.preview);
398
- const emitter = createEmitter();
399
- const cleanup = [];
400
- let state = {
465
+ const { on, getState, setState, emit, listeners } = createBridgeState({
401
466
  ...INITIAL_STATE,
402
467
  selecting: true
403
- };
404
- const setState = (next)=>{
405
- state = {
406
- ...state,
407
- ...next
408
- };
409
- emitter.emit('change', state);
410
- };
468
+ });
469
+ const cleanup = [];
411
470
  const bridge = {
412
471
  isEditing,
413
472
  mode: isEditing ? 'editor' : null,
@@ -415,8 +474,8 @@ const createBridge = (win, cmsOrigin)=>{
415
474
  environment: params.get(PREVIEW_PARAMS.environment),
416
475
  product: null,
417
476
  collection: null,
418
- on: emitter.on,
419
- getState: ()=>state,
477
+ on,
478
+ getState,
420
479
  ...INERT_METHODS,
421
480
  destroy: ()=>{
422
481
  cleanup.splice(0).forEach((undo)=>undo());
@@ -436,22 +495,12 @@ const createBridge = (win, cmsOrigin)=>{
436
495
  doc.head.appendChild(style);
437
496
  cleanup.push(()=>style.remove());
438
497
  const highlight = (uid, scroll)=>{
439
- doc.querySelectorAll('[data-cms-selected]').forEach((element)=>{
440
- element.removeAttribute('data-cms-selected');
441
- });
442
- if (state.selectedUid !== (uid ?? null)) {
498
+ if (getState().selectedUid !== (uid ?? null)) {
443
499
  setState({
444
500
  selectedUid: uid ?? null
445
501
  });
446
502
  }
447
- if (!uid) return;
448
- const element = doc.querySelector(blockSelector(uid));
449
- if (!element) return;
450
- element.setAttribute('data-cms-selected', '');
451
- if (scroll) element.scrollIntoView({
452
- behavior: 'smooth',
453
- block: 'center'
454
- });
503
+ showSelection(doc, uid, scroll);
455
504
  };
456
505
  // Capture phase, so a block that is also a link selects instead of
457
506
  // navigating away.
@@ -511,11 +560,11 @@ const createBridge = (win, cmsOrigin)=>{
511
560
  bridge.prompt = (prompt, options = {})=>{
512
561
  const text = prompt.trim();
513
562
  if (!text) return Promise.reject(new Error('Say what to change'));
514
- if (!state.features.includes('prompt')) {
563
+ if (!getState().features.includes('prompt')) {
515
564
  return Promise.reject(new Error('The editor does not offer prompts here'));
516
565
  }
517
566
  const id = `prompt_${++nextId}`;
518
- const uid = options.uid === undefined ? state.selectedUid : options.uid;
567
+ const uid = options.uid === undefined ? getState().selectedUid : options.uid;
519
568
  return new Promise((resolve, reject)=>{
520
569
  const timer = setTimeout(()=>{
521
570
  settle(id)?.reject(new Error('The editor did not answer in time'));
@@ -542,17 +591,11 @@ const createBridge = (win, cmsOrigin)=>{
542
591
  uid
543
592
  });
544
593
  };
545
- // Mounted with the first rates, so pages without numbers never get it.
546
- let overlay = null;
547
- const showStats = ()=>{
548
- if (!overlay && !Object.keys(state.stats).length) return;
549
- overlay ??= mountStatsOverlay(win, {
594
+ const stats = createStatsView(()=>mountStatsOverlay(win, {
550
595
  selector: blockSelector,
551
596
  onSelect: (uid)=>bridge.select(uid)
552
- });
553
- overlay.update(state.stats, state.statsVisible);
554
- };
555
- cleanup.push(()=>overlay?.destroy());
597
+ }));
598
+ cleanup.push(()=>stats.destroy());
556
599
  cleanup.push(()=>{
557
600
  for (const id of pending.keys()){
558
601
  settle(id)?.reject(new Error('The bridge was destroyed'));
@@ -569,29 +612,31 @@ const createBridge = (win, cmsOrigin)=>{
569
612
  // have been sent before it was listening.
570
613
  ready();
571
614
  } else if (data.type === 'update' && data.entry) {
572
- emitter.emit('update', {
615
+ emit('update', {
573
616
  entry: data.entry,
574
617
  selectedUid: data.selectedUid ?? null
575
618
  });
576
619
  // Re-apply the selection to whatever the site rendered.
577
620
  win.requestAnimationFrame(()=>highlight(data.selectedUid, false));
578
- overlay?.refresh();
621
+ stats.refresh();
579
622
  } else if (data.type === 'select') {
580
623
  highlight(data.uid, true);
581
624
  } else if (data.type === 'saved' || data.type === 'published') {
582
- emitter.emit(data.type, {});
583
- if (emitter.listeners.update.size === 0) win.location.reload();
625
+ emit(data.type, {});
626
+ if (listeners.update.size === 0) win.location.reload();
584
627
  } else if (data.type === 'features' && Array.isArray(data.features)) {
585
628
  setState({
586
629
  features: data.features.filter((feature)=>typeof feature === 'string')
587
630
  });
588
631
  } else if (data.type === 'stats') {
589
632
  // An editor from before the switch sends no `visible`: show them.
590
- setState({
633
+ const next = {
591
634
  stats: readStats(data.stats),
592
635
  statsVisible: data.visible !== false
593
- });
594
- showStats();
636
+ };
637
+ setState(next);
638
+ const { stats: rates, statsVisible } = getState();
639
+ stats.update(rates, statsVisible);
595
640
  } else if (data.type === 'viewport' && typeof data.height === 'number') {
596
641
  // The canvas: the frame is as tall as the page (`size`), so `vh`
597
642
  // sizes are fixed at the screen height it stands for.
@@ -624,6 +669,7 @@ const createBridge = (win, cmsOrigin)=>{
624
669
  }
625
670
  return bridge;
626
671
  };
672
+
627
673
  const wrapScriptBridge = (script, params)=>{
628
674
  const emitter = createEmitter();
629
675
  script.on('saved', ()=>emitter.emit('saved', {}));
@@ -632,12 +678,11 @@ const wrapScriptBridge = (script, params)=>{
632
678
  // no `update` listener, and has no way to remove one.
633
679
  let forwardingUpdates = false;
634
680
  return {
681
+ ...createInertBridge(),
635
682
  isEditing: script.isEditing,
636
683
  mode: script.isEditing ? 'editor' : null,
637
684
  entryId: script.entryId,
638
685
  environment: params.get(PREVIEW_PARAMS.environment),
639
- product: null,
640
- collection: null,
641
686
  on: (event, listener)=>{
642
687
  if (event === 'update' && !forwardingUpdates) {
643
688
  forwardingUpdates = true;
@@ -650,10 +695,7 @@ const wrapScriptBridge = (script, params)=>{
650
695
  });
651
696
  }
652
697
  return emitter.on(event, listener);
653
- },
654
- getState: ()=>INITIAL_STATE,
655
- ...INERT_METHODS,
656
- destroy: ()=>{}
698
+ }
657
699
  };
658
700
  };
659
701
 
@@ -688,6 +730,11 @@ const svgSpan = (doc, className, paths)=>{
688
730
  };
689
731
  // `{name}` style placeholders.
690
732
  const fill = (template, values)=>template.replace(/\{(\w+)\}/g, (match, key)=>key in values ? String(values[key]) : match);
733
+ // A textarea as tall as its text.
734
+ const fitToText = (textarea)=>{
735
+ textarea.style.height = 'auto';
736
+ textarea.style.height = `${textarea.scrollHeight}px`;
737
+ };
691
738
  // Line icons (Lucide), drawn in the text colour.
692
739
  const ICONS = {
693
740
  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"/>',
@@ -998,6 +1045,55 @@ const createPagesPicker = (doc, bridge, labels, { onLoad, onClose })=>{
998
1045
  };
999
1046
  };
1000
1047
 
1048
+ const SHOP = {
1049
+ product: {
1050
+ item: PRODUCT_ATTRIBUTE,
1051
+ handle: PRODUCT_HANDLE_ATTRIBUTE,
1052
+ setting: 'productPath',
1053
+ route: '/products/{handle}',
1054
+ marker: 'editableProduct(product)',
1055
+ add: 'addProductPage',
1056
+ adding: 'addingProductPage',
1057
+ hint: 'productPageHint',
1058
+ added: 'addedProductPage'
1059
+ },
1060
+ collection: {
1061
+ item: COLLECTION_ATTRIBUTE,
1062
+ handle: COLLECTION_HANDLE_ATTRIBUTE,
1063
+ setting: 'collectionPath',
1064
+ route: '/collections/{handle}',
1065
+ marker: 'editableCollection(collection)',
1066
+ add: 'addCollectionPage',
1067
+ adding: 'addingCollectionPage',
1068
+ hint: 'collectionPageHint',
1069
+ added: 'addedCollectionPage'
1070
+ }
1071
+ };
1072
+ // The shop item the page marks with `editableProduct` or
1073
+ // `editableCollection`, if it marks one.
1074
+ const pageShopItem = (doc, kind)=>{
1075
+ const { item, handle } = SHOP[kind];
1076
+ const element = doc.querySelector(`[${item}]`);
1077
+ const id = element?.getAttribute(item);
1078
+ const itemHandle = element?.getAttribute(handle);
1079
+ return id && itemHandle ? {
1080
+ id,
1081
+ handle: itemHandle
1082
+ } : null;
1083
+ };
1084
+ // Which kind of shop page a page entry is, else null.
1085
+ const shopKindOf = (page)=>page.product ? 'product' : page.collection ? 'collection' : null;
1086
+ const shopItemOf = (page)=>page.product ?? page.collection ?? null;
1087
+ const shopCopy = (labels, kind)=>{
1088
+ const shop = SHOP[kind];
1089
+ return {
1090
+ add: labels[shop.add],
1091
+ adding: labels[shop.adding],
1092
+ hint: labels[shop.hint],
1093
+ added: labels[shop.added]
1094
+ };
1095
+ };
1096
+
1001
1097
  // What the prompt box and the on-site editor decide alike on the site.
1002
1098
  const failure = (error)=>error instanceof Error ? error.message : String(error);
1003
1099
  const pageContext = (bridge, state, promptWithoutPage)=>{
@@ -1008,39 +1104,26 @@ const pageContext = (bridge, state, promptWithoutPage)=>{
1008
1104
  const pageless = site && bridge.entryId === null;
1009
1105
  // …unless it shows a product (collection): then Add a product
1010
1106
  // (collection) page first.
1011
- const shopHere = !pageless || !canCreate ? null : bridge.product ? 'product' : bridge.collection ? 'collection' : null;
1012
- const onlyNew = pageless && canCreate && !shopHere && !promptWithoutPage;
1107
+ const shop = pageless && canCreate ? shopKindOf(bridge) : null;
1108
+ const onlyNew = pageless && canCreate && !shop && !promptWithoutPage;
1013
1109
  return {
1014
1110
  site,
1015
1111
  canCreate,
1016
1112
  pageless,
1017
- shopHere,
1113
+ shop,
1018
1114
  onlyNew
1019
1115
  };
1020
1116
  };
1021
- const shopCopy = (labels, shopHere)=>shopHere === 'collection' ? {
1022
- add: labels.addCollectionPage,
1023
- adding: labels.addingCollectionPage,
1024
- hint: labels.collectionPageHint
1025
- } : {
1026
- add: labels.addProductPage,
1027
- adding: labels.addingProductPage,
1028
- hint: labels.productPageHint
1029
- };
1030
1117
  // Makes the page's product (collection) page; the bridge then reloads, and
1031
1118
  // the answer shows until it does.
1032
1119
  const addShopPage = async (bridge, labels)=>{
1120
+ const kind = shopKindOf(bridge);
1121
+ if (!kind) return null;
1033
1122
  try {
1034
- if (bridge.product) {
1035
- await bridge.createProductPage();
1036
- return {
1037
- text: labels.addedProductPage,
1038
- error: false
1039
- };
1040
- }
1041
- await bridge.createCollectionPage();
1123
+ if (kind === 'product') await bridge.createProductPage();
1124
+ else await bridge.createCollectionPage();
1042
1125
  return {
1043
- text: labels.addedCollectionPage,
1126
+ text: labels[SHOP[kind].added],
1044
1127
  error: false
1045
1128
  };
1046
1129
  } catch (error) {
@@ -1060,6 +1143,36 @@ const storeChanges = async (bridge, action)=>{
1060
1143
  }
1061
1144
  };
1062
1145
 
1146
+ const bridgeView = (bridge, state, // What the editor asked for; `creating` is that, kept in step with what
1147
+ // the page actually offers.
1148
+ intent, { promptWithoutPage, signOut, labels })=>{
1149
+ const page = pageContext(bridge, state, promptWithoutPage);
1150
+ // A page with nothing to edit has one thing to offer; a page that can't
1151
+ // take a new one has nothing to make.
1152
+ const creating = page.canCreate && (intent.creating || page.onlyNew);
1153
+ const available = state.features.includes('prompt');
1154
+ const storing = state.saving || state.publishing;
1155
+ const busy = state.prompting || storing;
1156
+ return {
1157
+ ...page,
1158
+ creating,
1159
+ available,
1160
+ storing,
1161
+ busy,
1162
+ // The page's product (collection) page, when that is all it can offer.
1163
+ offerShop: page.shop !== null && !creating,
1164
+ // The CMS's form is for a page that is an entry, in its own mode,
1165
+ // when the CMS offers one (`state.features`).
1166
+ canForm: page.site && !creating && !page.pageless,
1167
+ placeholder: creating ? labels.newPagePlaceholder : labels.placeholder,
1168
+ canSave: state.features.includes('save') && !page.pageless,
1169
+ canPublish: state.features.includes('publish') && !page.pageless,
1170
+ showSignOut: page.site && signOut !== false,
1171
+ // Sending needs something to say, and a path when making a page.
1172
+ canSend: (prompt, path)=>!busy && prompt.trim() !== '' && (!creating || path.trim() !== '')
1173
+ };
1174
+ };
1175
+
1063
1176
  const SIDE_KEY = 'obelism-content-prompt-side';
1064
1177
  // How far the pointer moves before it drags the box, not clicks it.
1065
1178
  const DRAG_THRESHOLD_PX = 6;
@@ -1641,26 +1754,29 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1641
1754
  error: true
1642
1755
  });
1643
1756
  const render = (state)=>{
1644
- const available = state.features.includes('prompt');
1645
- const storing = state.saving || state.publishing;
1646
- const busy = state.prompting || storing;
1757
+ const page = bridgeView(bridge, state, {
1758
+ creating
1759
+ }, {
1760
+ promptWithoutPage: options.promptWithoutPage,
1761
+ signOut: options.signOut,
1762
+ labels
1763
+ });
1764
+ const { available, busy, canCreate, site, pageless, onlyNew } = page;
1765
+ const { offerShop, canForm } = page;
1766
+ creating = page.creating;
1767
+ if (!canForm) form = false;
1647
1768
  root.hidden = !available && !site;
1648
1769
  pill.hidden = open || !available;
1649
1770
  viewing.hidden = available || !site;
1650
1771
  panel.hidden = !open || !available;
1651
- const { canCreate, pageless, shopHere, onlyNew } = pageContext(bridge, state, options.promptWithoutPage);
1652
- const productHere = shopHere !== null;
1653
- const shopCopy$1 = shopCopy(labels, shopHere);
1654
- addProduct.lastChild.textContent = shopCopy$1.add;
1655
- if (!canCreate) creating = false;
1656
- if (onlyNew) creating = true;
1657
- if (creating || pageless || !site) form = false;
1772
+ const words = page.shop ? shopCopy(labels, page.shop) : null;
1773
+ addProduct.lastChild.textContent = words?.add ?? labels.addProductPage;
1658
1774
  // The form is there only while shown in the open panel.
1659
1775
  const showForm = open && available && form;
1660
1776
  if (showForm && !formSlot.mounted) formSlot.mount();
1661
1777
  else if (!showForm && formSlot.mounted) formSlot.unmount();
1662
- const offerProduct = productHere && !creating;
1663
- pillText.textContent = offerProduct ? shopCopy$1.add : onlyNew ? labels.newPage : labels.open;
1778
+ const offerProduct = offerShop;
1779
+ pillText.textContent = offerProduct ? words.add : onlyNew ? labels.newPage : labels.open;
1664
1780
  cancelNew.hidden = onlyNew;
1665
1781
  scope.hidden = creating || offerProduct;
1666
1782
  newScope.hidden = !creating;
@@ -1677,7 +1793,7 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1677
1793
  formLoader.hidden = !formSlot.loading;
1678
1794
  toolbar.hidden = picking;
1679
1795
  pagesSection.hidden = !picking;
1680
- input.placeholder = creating ? labels.newPagePlaceholder : labels.placeholder;
1796
+ input.placeholder = page.placeholder;
1681
1797
  scopeText.textContent = state.selectedUid ? labels.scopeBlock : pageless ? labels.scopeNoPage : labels.scopeEntry;
1682
1798
  clearBlock.hidden = !state.selectedUid;
1683
1799
  pageIcon.hidden = !!state.selectedUid;
@@ -1700,16 +1816,16 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1700
1816
  minimize.setAttribute('aria-expanded', String(!minimized));
1701
1817
  minimizeIcon.hidden = minimized;
1702
1818
  expandIcon.hidden = !minimized;
1703
- send.disabled = busy || input.value.trim() === '' || creating && pathInput.value.trim() === '';
1819
+ send.disabled = !page.canSend(input.value, pathInput.value);
1704
1820
  input.disabled = busy;
1705
- save.hidden = !state.features.includes('save') || pageless;
1821
+ save.hidden = !page.canSave;
1706
1822
  save.disabled = busy || !state.unsaved;
1707
1823
  save.textContent = state.saving ? labels.saving : labels.save;
1708
- publish.hidden = !state.features.includes('publish') || pageless;
1824
+ publish.hidden = !page.canPublish;
1709
1825
  publish.disabled = busy;
1710
1826
  publish.textContent = state.publishing ? labels.publishing : labels.publish;
1711
- signOut.hidden = !site || options.signOut === false;
1712
- pillSignOut.hidden = options.signOut === false;
1827
+ signOut.hidden = !page.showSignOut;
1828
+ pillSignOut.hidden = !page.showSignOut;
1713
1829
  if (options.actions) {
1714
1830
  options.actions.slot = open && available ? 'actions-footer' : 'actions-bar';
1715
1831
  }
@@ -1721,16 +1837,12 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1721
1837
  const loadingForm = form && formSlot.loading && !picking;
1722
1838
  const working = state.prompting;
1723
1839
  // A form opening says so in its own place.
1724
- const note = state.prompting ? creating ? labels.creating : offerProduct ? shopCopy$1.adding : labels.working : loadingForm ? '' : message?.text ?? (offerProduct ? shopCopy$1.hint : site && !creating && !picking && !state.selectedUid ? labels.selectHint : '');
1840
+ const note = state.prompting ? creating ? labels.creating : offerProduct ? words.adding : labels.working : loadingForm ? '' : message?.text ?? (offerProduct ? words.hint : site && !creating && !picking && !state.selectedUid ? labels.selectHint : '');
1725
1841
  status.textContent = note;
1726
1842
  status.hidden = !note || minimized;
1727
1843
  status.className = `status${working ? ' working' : ''}${!busy && !loadingForm && message?.error ? ' error' : ''}`;
1728
1844
  };
1729
1845
  const refresh = ()=>render(bridge.getState());
1730
- const grow = ()=>{
1731
- input.style.height = 'auto';
1732
- input.style.height = `${input.scrollHeight}px`;
1733
- };
1734
1846
  const setForm = (next)=>{
1735
1847
  if (form === next) return;
1736
1848
  form = next;
@@ -1762,7 +1874,7 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1762
1874
  };
1763
1875
  }
1764
1876
  input.value = '';
1765
- grow();
1877
+ fitToText(input);
1766
1878
  } catch (error) {
1767
1879
  message = failure$1(error);
1768
1880
  }
@@ -1858,7 +1970,7 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1858
1970
  void submit();
1859
1971
  });
1860
1972
  input.addEventListener('input', ()=>{
1861
- grow();
1973
+ fitToText(input);
1862
1974
  refresh();
1863
1975
  });
1864
1976
  input.addEventListener('keydown', (event)=>{
@@ -2010,17 +2122,8 @@ const webUrl = (url)=>{
2010
2122
  };
2011
2123
  // The base path of the site's handler while signed in to edit, else null.
2012
2124
  const readSiteEditing = (doc)=>{
2013
- for (const part of doc.cookie.split(';')){
2014
- const [name, ...value] = part.trim().split('=');
2015
- if (name !== SITE_EDITING_COOKIE) continue;
2016
- try {
2017
- const basePath = decodeURIComponent(value.join('='));
2018
- return BASE_PATH.test(basePath) ? basePath : null;
2019
- } catch {
2020
- return null;
2021
- }
2022
- }
2023
- return null;
2125
+ const basePath = parseCookies(doc.cookie).get(SITE_EDITING_COOKIE);
2126
+ return basePath && BASE_PATH.test(basePath) ? basePath : null;
2024
2127
  };
2025
2128
  const same = (a, b)=>JSON.stringify(a) === JSON.stringify(b);
2026
2129
  // Edits in the form further apart than this are separate undo steps.
@@ -2030,7 +2133,7 @@ const FORM_BURST_MS = 1000;
2030
2133
  const FORM_RENDER_MS = 300;
2031
2134
  const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/^\/+/, '')}`, productPath: ownProductPath, collectionPath: ownCollectionPath } = {})=>{
2032
2135
  const doc = win.document;
2033
- const emitter = createEmitter();
2136
+ const { on, getState, setState, emit, listeners } = createBridgeState();
2034
2137
  const cleanup = [];
2035
2138
  const working = new Map();
2036
2139
  // Content before each prompt, newest last, for undo.
@@ -2038,21 +2141,20 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2038
2141
  // Entries changed here since the last publish.
2039
2142
  const touched = new Set();
2040
2143
  let session = null;
2041
- const productPath = (handle, locale)=>ownProductPath ? ownProductPath(handle, locale) : (session?.productPath ?? '/products/{handle}').replaceAll('{handle}', encodeURIComponent(handle));
2042
- const collectionPath = (handle, locale)=>ownCollectionPath ? ownCollectionPath(handle, locale) : (session?.collectionPath ?? '/collections/{handle}').replaceAll('{handle}', encodeURIComponent(handle));
2144
+ // Where a product or collection page is on the site: the site's own
2145
+ // routing when it said, else the environment's setting in the CMS, else
2146
+ // the CMS's default path.
2147
+ const shopPath = (kind, handle, locale)=>{
2148
+ const own = kind === 'product' ? ownProductPath : ownCollectionPath;
2149
+ if (own) return own(handle, locale);
2150
+ const { setting, route } = SHOP[kind];
2151
+ return (session?.[setting] ?? route).replaceAll('{handle}', encodeURIComponent(handle));
2152
+ };
2043
2153
  // The open form's, if any; and when the form last changed content, for
2044
2154
  // undo steps (-Infinity: the next edit starts a new step).
2045
2155
  let closeForm = null;
2046
2156
  let lastFormEdit = -Infinity;
2047
2157
  const renderTimers = new Map();
2048
- let state = INITIAL_STATE;
2049
- const setState = (next)=>{
2050
- state = {
2051
- ...state,
2052
- ...next
2053
- };
2054
- emitter.emit('change', state);
2055
- };
2056
2158
  const refreshUnsaved = ()=>setState({
2057
2159
  unsaved: [
2058
2160
  ...working.values()
@@ -2085,26 +2187,6 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2085
2187
  return data;
2086
2188
  };
2087
2189
  const pageEntryId = ()=>doc.querySelector(`[${ENTRY_ATTRIBUTE}]`)?.getAttribute(ENTRY_ATTRIBUTE) ?? null;
2088
- // The product the page shows (editableProduct), if it marks one.
2089
- const pageProduct = ()=>{
2090
- const element = doc.querySelector(`[${PRODUCT_ATTRIBUTE}]`);
2091
- const id = element?.getAttribute(PRODUCT_ATTRIBUTE);
2092
- const handle = element?.getAttribute(PRODUCT_HANDLE_ATTRIBUTE);
2093
- return id && handle ? {
2094
- id,
2095
- handle
2096
- } : null;
2097
- };
2098
- // The collection the page shows (editableCollection), if it marks one.
2099
- const pageCollection = ()=>{
2100
- const element = doc.querySelector(`[${COLLECTION_ATTRIBUTE}]`);
2101
- const id = element?.getAttribute(COLLECTION_ATTRIBUTE);
2102
- const handle = element?.getAttribute(COLLECTION_HANDLE_ATTRIBUTE);
2103
- return id && handle ? {
2104
- id,
2105
- handle
2106
- } : null;
2107
- };
2108
2190
  const entryFor = (uid)=>{
2109
2191
  const block = uid ? doc.querySelector(blockSelector(uid)) : null;
2110
2192
  return block?.closest(`[${ENTRY_ATTRIBUTE}]`)?.getAttribute(ENTRY_ATTRIBUTE) ?? pageEntryId();
@@ -2127,7 +2209,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2127
2209
  if (loaded) return loaded;
2128
2210
  let pending = loading.get(entryId);
2129
2211
  if (!pending) {
2130
- pending = api(`entries/${encodeURIComponent(entryId)}`).then((entry)=>{
2212
+ pending = api(siteApi.entry(entryId)).then((entry)=>{
2131
2213
  const fresh = working.get(entryId) ?? {
2132
2214
  content: entry.content,
2133
2215
  saved: entry.content,
@@ -2143,7 +2225,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2143
2225
  // The entry a prompt or JSON acts on, for the block `uid` (the selected
2144
2226
  // one when left out).
2145
2227
  const target = (option)=>{
2146
- const uid = option === undefined ? state.selectedUid : option;
2228
+ const uid = option === undefined ? getState().selectedUid : option;
2147
2229
  const entryId = entryFor(uid);
2148
2230
  if (!entryId) {
2149
2231
  throw new Error('Nothing on this page is marked as an entry: add editable(entry)');
@@ -2153,29 +2235,42 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2153
2235
  entryId
2154
2236
  };
2155
2237
  };
2238
+ // The blocks selected on this page, and the tags clicked outside the
2239
+ // blocks: only their rates show (statsOverlay.ts), so a few can be
2240
+ // compared without every tag at once.
2241
+ const revealed = new Set();
2242
+ const asked = new Set();
2243
+ const answered = new Set();
2244
+ const stats = createStatsView(()=>mountStatsOverlay(win, {
2245
+ selector: blockSelector,
2246
+ onSelect: (uid)=>highlight(uid),
2247
+ checked: (id)=>answered.has(id),
2248
+ shows: (id, element)=>revealed.has(element.closest(`[${UID_ATTRIBUTE}]`)?.getAttribute(UID_ATTRIBUTE) ?? id)
2249
+ }));
2156
2250
  const highlight = (uid)=>{
2157
- doc.querySelectorAll('[data-cms-selected]').forEach((element)=>{
2158
- element.removeAttribute('data-cms-selected');
2159
- });
2160
- if (state.selectedUid !== uid) setState({
2251
+ if (getState().selectedUid !== uid) setState({
2161
2252
  selectedUid: uid
2162
2253
  });
2163
- if (uid) doc.querySelector(blockSelector(uid))?.setAttribute('data-cms-selected', '');
2254
+ showSelection(doc, uid);
2255
+ if (uid && !revealed.has(uid)) {
2256
+ revealed.add(uid);
2257
+ stats.refresh();
2258
+ }
2164
2259
  };
2165
2260
  // Shows the in-memory content through `update`, shaped like the delivery
2166
2261
  // API. A page without `update` listeners shows it once saved (it reloads).
2167
2262
  const render = async (entryId)=>{
2168
- if (emitter.listeners.update.size === 0) return;
2263
+ if (listeners.update.size === 0) return;
2169
2264
  const content = working.get(entryId)?.content;
2170
- const { entry } = await api(`entries/${encodeURIComponent(entryId)}/preview`, {
2265
+ const { entry } = await api(siteApi.preview(entryId), {
2171
2266
  content,
2172
2267
  locale: locale()
2173
2268
  });
2174
- emitter.emit('update', {
2269
+ emit('update', {
2175
2270
  entry,
2176
- selectedUid: state.selectedUid
2271
+ selectedUid: getState().selectedUid
2177
2272
  });
2178
- win.requestAnimationFrame(()=>highlight(state.selectedUid));
2273
+ win.requestAnimationFrame(()=>highlight(getState().selectedUid));
2179
2274
  };
2180
2275
  const logError = (error)=>console.error('[@obelism/content bridge]', error);
2181
2276
  // Renders once the form's edits settle.
@@ -2187,13 +2282,13 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2187
2282
  }, FORM_RENDER_MS));
2188
2283
  };
2189
2284
  const afterStore = (event)=>{
2190
- emitter.emit(event, {});
2191
- if (emitter.listeners.update.size === 0) win.location.reload();
2285
+ emit(event, {});
2286
+ if (listeners.update.size === 0) win.location.reload();
2192
2287
  };
2193
2288
  const saveAll = async ()=>{
2194
2289
  for (const [entryId, entry] of working){
2195
2290
  if (same(entry.content, entry.saved)) continue;
2196
- const { draftRevision } = await api(`entries/${encodeURIComponent(entryId)}/save`, {
2291
+ const { draftRevision } = await api(siteApi.save(entryId), {
2197
2292
  content: entry.content,
2198
2293
  baseRevision: entry.revision
2199
2294
  });
@@ -2205,7 +2300,36 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2205
2300
  lastFormEdit = -Infinity;
2206
2301
  refreshUnsaved();
2207
2302
  };
2208
- const has = (feature)=>state.features.includes(feature);
2303
+ const has = (feature)=>getState().features.includes(feature);
2304
+ // Makes the page kind's page for `item`, or the one it has already, and
2305
+ // reloads the page: the site's route finds it now (drafts, while editing).
2306
+ const createShopPage = async (kind, item)=>{
2307
+ const shop = SHOP[kind];
2308
+ if (!item) {
2309
+ throw new Error(`Nothing on this page is marked as a ${kind}: add ${shop.marker}`);
2310
+ }
2311
+ if (!has('create')) throw new Error('You may not add pages here');
2312
+ if (getState().unsaved) {
2313
+ throw new Error('Save or undo your changes before adding a page');
2314
+ }
2315
+ setState({
2316
+ prompting: true
2317
+ });
2318
+ try {
2319
+ const made = await api(siteApi.shopPages(kind), {
2320
+ [kind]: {
2321
+ id: item.id,
2322
+ handle: item.handle
2323
+ }
2324
+ });
2325
+ win.location.reload();
2326
+ return made;
2327
+ } finally{
2328
+ setState({
2329
+ prompting: false
2330
+ });
2331
+ }
2332
+ };
2209
2333
  const bridge = {
2210
2334
  isEditing: true,
2211
2335
  mode: 'site',
@@ -2213,16 +2337,16 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2213
2337
  return pageEntryId();
2214
2338
  },
2215
2339
  get product () {
2216
- return pageProduct();
2340
+ return pageShopItem(doc, 'product');
2217
2341
  },
2218
2342
  get collection () {
2219
- return pageCollection();
2343
+ return pageShopItem(doc, 'collection');
2220
2344
  },
2221
2345
  get environment () {
2222
2346
  return session?.environment ?? null;
2223
2347
  },
2224
- on: emitter.on,
2225
- getState: ()=>state,
2348
+ on,
2349
+ getState,
2226
2350
  async prompt (prompt, options = {}) {
2227
2351
  const text = prompt.trim();
2228
2352
  if (!text) throw new Error('Say what to change');
@@ -2233,7 +2357,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2233
2357
  });
2234
2358
  try {
2235
2359
  const entry = await load(entryId);
2236
- const result = await api(`entries/${encodeURIComponent(entryId)}/prompt`, {
2360
+ const result = await api(siteApi.prompt(entryId), {
2237
2361
  prompt: text,
2238
2362
  content: entry.content,
2239
2363
  locale: locale(),
@@ -2262,14 +2386,14 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2262
2386
  if (!path.trim()) throw new Error('Say where the page goes, like /pricing');
2263
2387
  if (!has('create')) throw new Error('You may not add pages here');
2264
2388
  // Opening the new page would lose them.
2265
- if (state.unsaved) {
2389
+ if (getState().unsaved) {
2266
2390
  throw new Error('Save or undo your changes before adding a page');
2267
2391
  }
2268
2392
  setState({
2269
2393
  prompting: true
2270
2394
  });
2271
2395
  try {
2272
- const created = await api('entries', {
2396
+ const created = await api(siteApi.createEntry(), {
2273
2397
  path,
2274
2398
  prompt: text,
2275
2399
  locale: locale(),
@@ -2289,66 +2413,12 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2289
2413
  });
2290
2414
  }
2291
2415
  },
2292
- async createProductPage (product = pageProduct() ?? undefined) {
2293
- if (!product) {
2294
- throw new Error('Nothing on this page is marked as a product: add editableProduct(product)');
2295
- }
2296
- if (!has('create')) throw new Error('You may not add pages here');
2297
- if (state.unsaved) {
2298
- throw new Error('Save or undo your changes before adding a page');
2299
- }
2300
- setState({
2301
- prompting: true
2302
- });
2303
- try {
2304
- const made = await api('product-pages', {
2305
- product: {
2306
- id: product.id,
2307
- handle: product.handle
2308
- }
2309
- });
2310
- // The site's product route finds its page now (drafts, while
2311
- // editing): show it.
2312
- win.location.reload();
2313
- return made;
2314
- } finally{
2315
- setState({
2316
- prompting: false
2317
- });
2318
- }
2319
- },
2320
- async createCollectionPage (collection = pageCollection() ?? undefined) {
2321
- if (!collection) {
2322
- throw new Error('Nothing on this page is marked as a collection: add editableCollection(collection)');
2323
- }
2324
- if (!has('create')) throw new Error('You may not add pages here');
2325
- if (state.unsaved) {
2326
- throw new Error('Save or undo your changes before adding a page');
2327
- }
2328
- setState({
2329
- prompting: true
2330
- });
2331
- try {
2332
- const made = await api('collection-pages', {
2333
- collection: {
2334
- id: collection.id,
2335
- handle: collection.handle
2336
- }
2337
- });
2338
- // The site's collection route finds its page now (drafts, while
2339
- // editing): show it.
2340
- win.location.reload();
2341
- return made;
2342
- } finally{
2343
- setState({
2344
- prompting: false
2345
- });
2346
- }
2347
- },
2416
+ createProductPage: (product = pageShopItem(doc, 'product') ?? undefined)=>createShopPage('product', product),
2417
+ createCollectionPage: (collection = pageShopItem(doc, 'collection') ?? undefined)=>createShopPage('collection', collection),
2348
2418
  async view (options = {}) {
2349
2419
  const { uid, entryId } = target(options.uid);
2350
2420
  const entry = await load(entryId);
2351
- const { view } = await api(`entries/${encodeURIComponent(entryId)}/view`, {
2421
+ const { view } = await api(siteApi.view(entryId), {
2352
2422
  content: entry.content,
2353
2423
  locale: locale(),
2354
2424
  uid
@@ -2366,7 +2436,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2366
2436
  });
2367
2437
  try {
2368
2438
  const entry = await load(entryId);
2369
- const result = await api(`entries/${encodeURIComponent(entryId)}/apply`, {
2439
+ const result = await api(siteApi.apply(entryId), {
2370
2440
  changes,
2371
2441
  content: entry.content,
2372
2442
  locale: locale(),
@@ -2418,7 +2488,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2418
2488
  // changed: prompts, undo, a reload after saving, the selection.
2419
2489
  const show = ()=>{
2420
2490
  if (closed || !frame) return;
2421
- const entryId = entryFor(state.selectedUid);
2491
+ const entryId = entryFor(getState().selectedUid);
2422
2492
  if (!entryId) return;
2423
2493
  const entry = working.get(entryId);
2424
2494
  if (!entry) {
@@ -2429,7 +2499,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2429
2499
  entryId,
2430
2500
  content: entry.content,
2431
2501
  locale: locale(),
2432
- selectedUid: state.selectedUid
2502
+ selectedUid: getState().selectedUid
2433
2503
  };
2434
2504
  if (shown && shown.entryId === next.entryId && shown.content === next.content && shown.locale === next.locale && shown.selectedUid === next.selectedUid) {
2435
2505
  return;
@@ -2464,7 +2534,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2464
2534
  if (!has('form')) throw new Error('You may not edit content here');
2465
2535
  await load(target(undefined).entryId);
2466
2536
  // A fresh one every time: the address works once, for a minute.
2467
- const { url } = await api('form', {});
2537
+ const { url } = await api(siteApi.form(), {});
2468
2538
  if (closed) return;
2469
2539
  const address = typeof url === 'string' ? url : '';
2470
2540
  frame = mountFormFrame(win, container, address, title, {
@@ -2473,7 +2543,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2473
2543
  onError: (message)=>onError?.(message),
2474
2544
  onEscape: ()=>onEscape?.()
2475
2545
  });
2476
- unsubscribe = emitter.on('change', show);
2546
+ unsubscribe = on('change', show);
2477
2547
  show();
2478
2548
  };
2479
2549
  start().catch(fail);
@@ -2482,7 +2552,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2482
2552
  };
2483
2553
  },
2484
2554
  async pages () {
2485
- const { pages } = await api('pages');
2555
+ const { pages } = await api(siteApi.pages());
2486
2556
  const preferred = [
2487
2557
  locale(),
2488
2558
  session?.space.defaultLocale
@@ -2492,6 +2562,8 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2492
2562
  const code = preferred.find((code)=>code !== undefined && page.paths[code] !== undefined) ?? Object.keys(page.paths)[0];
2493
2563
  const path = code === undefined ? undefined : page.paths[code];
2494
2564
  if (code === undefined || path === undefined) return [];
2565
+ const shop = shopKindOf(page);
2566
+ const item = shopItemOf(page);
2495
2567
  return [
2496
2568
  {
2497
2569
  entryId: page.entryId,
@@ -2499,7 +2571,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2499
2571
  contentType: page.contentType,
2500
2572
  status: page.status,
2501
2573
  path,
2502
- url: page.product ? productPath(page.product.handle, code) : page.collection ? collectionPath(page.collection.handle, code) : pagePath(path, code),
2574
+ url: shop && item ? shopPath(shop, item.handle, code) : pagePath(path, code),
2503
2575
  product: page.product ?? null,
2504
2576
  collection: page.collection ?? null
2505
2577
  }
@@ -2507,7 +2579,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2507
2579
  });
2508
2580
  },
2509
2581
  openPage (page) {
2510
- if (state.unsaved && !win.confirm('Leave this page and lose the changes you have not saved?')) {
2582
+ if (getState().unsaved && !win.confirm('Leave this page and lose the changes you have not saved?')) {
2511
2583
  return;
2512
2584
  }
2513
2585
  // Asked already: no second question from `beforeunload`.
@@ -2556,7 +2628,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2556
2628
  throw new Error('Nothing on this page is marked as an entry: add editable(entry)');
2557
2629
  }
2558
2630
  for (const entryId of entries){
2559
- await api(`entries/${encodeURIComponent(entryId)}/publish`, {});
2631
+ await api(siteApi.publish(entryId), {});
2560
2632
  touched.delete(entryId);
2561
2633
  }
2562
2634
  } finally{
@@ -2567,7 +2639,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2567
2639
  afterStore('published');
2568
2640
  },
2569
2641
  signOut () {
2570
- if (state.unsaved && !win.confirm('Sign out and lose the changes you have not saved?')) {
2642
+ if (getState().unsaved && !win.confirm('Sign out and lose the changes you have not saved?')) {
2571
2643
  return;
2572
2644
  }
2573
2645
  working.clear();
@@ -2602,9 +2674,22 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2602
2674
  // Capture phase, so a block that is also a link selects instead of
2603
2675
  // navigating — only while selecting, so the site stays usable.
2604
2676
  const onClick = (event)=>{
2605
- if (!state.selecting) return;
2677
+ if (!getState().selecting) return;
2606
2678
  const target = event.target;
2607
2679
  const element = target?.closest?.(`[${UID_ATTRIBUTE}]`);
2680
+ // A tagged element outside the blocks (a menu button) can't be
2681
+ // selected: a click shows its rate, when the CMS answered for it.
2682
+ const tag = element ? null : target?.closest?.(`[${TAG_ATTRIBUTE}]`);
2683
+ const tagId = tag?.getAttribute(TAG_ATTRIBUTE);
2684
+ if (tagId && answered.has(tagId)) {
2685
+ event.preventDefault();
2686
+ event.stopPropagation();
2687
+ if (!revealed.has(tagId)) {
2688
+ revealed.add(tagId);
2689
+ stats.refresh();
2690
+ }
2691
+ return;
2692
+ }
2608
2693
  if (!element) return;
2609
2694
  event.preventDefault();
2610
2695
  event.stopPropagation();
@@ -2613,20 +2698,18 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2613
2698
  doc.addEventListener('click', onClick, true);
2614
2699
  cleanup.push(()=>doc.removeEventListener('click', onClick, true));
2615
2700
  const onBeforeUnload = (event)=>{
2616
- if (!state.unsaved) return;
2701
+ if (!getState().unsaved) return;
2617
2702
  event.preventDefault();
2618
2703
  event.returnValue = '';
2619
2704
  };
2620
2705
  win.addEventListener('beforeunload', onBeforeUnload);
2621
2706
  cleanup.push(()=>win.removeEventListener('beforeunload', onBeforeUnload));
2622
- // The tags on the page's links (statsOverlay.ts) while blocks are
2623
- // outlined: the rates of the ids the page tags, asked for as they appear.
2624
- const asked = new Set();
2625
- const answered = new Set();
2707
+ // The tags on the page's links (statsOverlay.ts) of what was selected,
2708
+ // while blocks are outlined: the rates of the ids the page tags, asked for
2709
+ // as they appear.
2626
2710
  let statsOff = false;
2627
- let overlay = null;
2628
2711
  let statsTimer = null;
2629
- const showStats = ()=>overlay?.update(state.stats, state.statsVisible);
2712
+ const showStats = ()=>stats.update(getState().stats, getState().statsVisible);
2630
2713
  const loadStats = async ()=>{
2631
2714
  if (statsOff) return;
2632
2715
  const ids = [
@@ -2644,7 +2727,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2644
2727
  ].map((element)=>element.getAttribute(ENTRY_ATTRIBUTE) ?? ''))
2645
2728
  ].filter(Boolean).slice(0, MAX_STATS_ENTRIES);
2646
2729
  try {
2647
- const { stats } = await api('stats', {
2730
+ const { stats } = await api(siteApi.stats(), {
2648
2731
  uids: batch,
2649
2732
  ...entries.length > 0 && {
2650
2733
  entries
@@ -2653,7 +2736,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2653
2736
  batch.forEach((id)=>answered.add(id));
2654
2737
  setState({
2655
2738
  stats: {
2656
- ...state.stats,
2739
+ ...getState().stats,
2657
2740
  ...readStats(stats)
2658
2741
  }
2659
2742
  });
@@ -2662,11 +2745,6 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2662
2745
  statsOff = true;
2663
2746
  return;
2664
2747
  }
2665
- overlay ??= mountStatsOverlay(win, {
2666
- selector: blockSelector,
2667
- onSelect: (uid)=>highlight(uid),
2668
- checked: (id)=>answered.has(id)
2669
- });
2670
2748
  showStats();
2671
2749
  if (ids.length > batch.length) scheduleStats();
2672
2750
  };
@@ -2682,9 +2760,9 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2682
2760
  cleanup.push(()=>{
2683
2761
  statsObserver?.disconnect();
2684
2762
  if (statsTimer !== null) clearTimeout(statsTimer);
2685
- overlay?.destroy();
2763
+ stats.destroy();
2686
2764
  });
2687
- api('session').then((info)=>{
2765
+ api(siteApi.session()).then((info)=>{
2688
2766
  session = info;
2689
2767
  setState({
2690
2768
  features: info.features
@@ -3200,7 +3278,7 @@ const mountSiteEditor = (doc, bridge, options = {})=>{
3200
3278
  });
3201
3279
  const shopButtonText = element(doc, 'span');
3202
3280
  const shopButton = button('button primary', icon('plus'), shopButtonText);
3203
- const shop = element(doc, 'div', {
3281
+ const shopBox = element(doc, 'div', {
3204
3282
  className: 'shop'
3205
3283
  }, shopText, shopButton);
3206
3284
  const log = element(doc, 'div', {
@@ -3209,7 +3287,7 @@ const mountSiteEditor = (doc, bridge, options = {})=>{
3209
3287
  log.setAttribute('role', 'log');
3210
3288
  const scroll = element(doc, 'div', {
3211
3289
  className: 'scroll'
3212
- }, note, intro, shop, log);
3290
+ }, note, intro, shopBox, log);
3213
3291
  const pathInput = element(doc, 'input', {
3214
3292
  type: 'text',
3215
3293
  placeholder: labels.pathPlaceholder,
@@ -3616,14 +3694,18 @@ const mountSiteEditor = (doc, bridge, options = {})=>{
3616
3694
  };
3617
3695
  const render = (state)=>{
3618
3696
  if (destroyed) return;
3619
- const available = state.features.includes('prompt');
3620
- const storing = state.saving || state.publishing;
3621
- const busy = state.prompting || storing;
3697
+ const page = bridgeView(bridge, state, {
3698
+ creating
3699
+ }, {
3700
+ promptWithoutPage: options.promptWithoutPage,
3701
+ signOut: options.signOut,
3702
+ labels
3703
+ });
3704
+ const { available, busy, canCreate, pageless, site, shop, onlyNew } = page;
3705
+ const { storing } = page;
3706
+ const hasForm = page.canForm && state.features.includes('form');
3622
3707
  const shown = available && open;
3623
- const { site, canCreate, pageless, shopHere, onlyNew } = pageContext(bridge, state, options.promptWithoutPage);
3624
- if (!canCreate) creating = false;
3625
- if (onlyNew) creating = true;
3626
- const hasForm = site && !creating && !pageless && state.features.includes('form');
3708
+ creating = page.creating;
3627
3709
  view = hasForm ? mode : 'ask';
3628
3710
  const waiting = pending().length > 0;
3629
3711
  if (!waiting || view === 'fields') gated = false;
@@ -3639,7 +3721,7 @@ const mountSiteEditor = (doc, bridge, options = {})=>{
3639
3721
  eye.setAttribute('aria-pressed', String(!outlines));
3640
3722
  eyeOn.hidden = !outlines;
3641
3723
  eyeOff.hidden = outlines;
3642
- exit.hidden = options.signOut === false;
3724
+ exit.hidden = !page.showSignOut;
3643
3725
  // The site beside the panel, wide enough; a sheet over it otherwise.
3644
3726
  const docked = shown && wide;
3645
3727
  html.toggleAttribute('data-obelism-docked', docked);
@@ -3669,7 +3751,7 @@ const mountSiteEditor = (doc, bridge, options = {})=>{
3669
3751
  gateKeep.disabled = gateUndo.disabled = busy;
3670
3752
  ask.hidden = view !== 'ask';
3671
3753
  fields.hidden = view !== 'fields';
3672
- const offerShop = shopHere !== null && !creating;
3754
+ const offerShop = page.offerShop;
3673
3755
  if (creating) {
3674
3756
  noteText.textContent = labels.newPage;
3675
3757
  } else if (state.selectedUid) {
@@ -3696,26 +3778,26 @@ const mountSiteEditor = (doc, bridge, options = {})=>{
3696
3778
  up.hidden = creating || !state.selectedUid;
3697
3779
  cancelNew.hidden = !creating || onlyNew;
3698
3780
  intro.hidden = answers.length > 0 || offerShop || creating;
3699
- shop.hidden = !offerShop;
3700
- const shopWords = shopCopy(labels, shopHere);
3701
- shopButtonText.textContent = state.prompting && offerShop ? shopWords.adding : shopWords.add;
3781
+ shopBox.hidden = !offerShop;
3782
+ const shopWords = shop ? shopCopy(labels, shop) : null;
3783
+ shopButtonText.textContent = shopWords ? state.prompting && offerShop ? shopWords.adding : shopWords.add : '';
3702
3784
  shopButton.disabled = busy;
3703
- shopText.textContent = shopMessage?.text ?? shopWords.hint;
3785
+ shopText.textContent = shopMessage?.text ?? shopWords?.hint ?? '';
3704
3786
  shopText.className = shopMessage?.error ? 'failure' : 'summary';
3705
3787
  composer.hidden = offerShop;
3706
3788
  pathRow.hidden = !creating;
3707
3789
  pathInput.disabled = busy;
3708
- input.placeholder = creating ? labels.newPagePlaceholder : labels.placeholder;
3790
+ input.placeholder = page.placeholder;
3709
3791
  input.disabled = busy;
3710
- send.disabled = busy || input.value.trim() === '' || creating && pathInput.value.trim() === '';
3792
+ send.disabled = !page.canSend(input.value, pathInput.value);
3711
3793
  send.setAttribute('aria-busy', String(state.prompting));
3712
3794
  sendIcon.hidden = state.prompting;
3713
3795
  sendWorking.hidden = !state.prompting;
3714
3796
  undo.hidden = waiting || !state.canUndo || creating;
3715
3797
  undo.disabled = busy;
3716
- save.hidden = !state.features.includes('save') || pageless;
3798
+ save.hidden = !page.canSave;
3717
3799
  save.disabled = busy || waiting || !state.unsaved;
3718
- publish.hidden = !state.features.includes('publish') || pageless;
3800
+ publish.hidden = !page.canPublish;
3719
3801
  publish.disabled = busy || waiting;
3720
3802
  if (options.actions) options.actions.slot = shown ? 'actions-footer' : 'actions-bar';
3721
3803
  foot.hidden = !site;
@@ -3753,10 +3835,6 @@ const mountSiteEditor = (doc, bridge, options = {})=>{
3753
3835
  scheduleMarks();
3754
3836
  };
3755
3837
  const refresh = ()=>render(bridge.getState());
3756
- const grow = ()=>{
3757
- input.style.height = 'auto';
3758
- input.style.height = `${input.scrollHeight}px`;
3759
- };
3760
3838
  const scrollDown = ()=>{
3761
3839
  scroll.scrollTop = scroll.scrollHeight;
3762
3840
  };
@@ -3885,7 +3963,7 @@ const mountSiteEditor = (doc, bridge, options = {})=>{
3885
3963
  textContent: `${labels.you} (${scopeName}): `
3886
3964
  }), prompt));
3887
3965
  input.value = '';
3888
- grow();
3966
+ fitToText(input);
3889
3967
  const answer = addAnswer(prompt, uid);
3890
3968
  refresh();
3891
3969
  scrollDown();
@@ -3969,7 +4047,7 @@ const mountSiteEditor = (doc, bridge, options = {})=>{
3969
4047
  void submit();
3970
4048
  });
3971
4049
  input.addEventListener('input', ()=>{
3972
- grow();
4050
+ fitToText(input);
3973
4051
  refresh();
3974
4052
  });
3975
4053
  input.addEventListener('keydown', (event)=>{
@@ -4058,11 +4136,8 @@ const mountSiteEditor = (doc, bridge, options = {})=>{
4058
4136
  // Kept on the window, so two copies of this package on one page still share
4059
4137
  // one bridge.
4060
4138
  const KEY = Symbol.for('@obelism/content/bridge');
4061
- const SERVER_BRIDGE = wrapScriptBridge({
4062
- isEditing: false,
4063
- entryId: null,
4064
- on: ()=>{}
4065
- }, new URLSearchParams());
4139
+ // The bridge on the server, where there is nothing to edit.
4140
+ const SERVER_BRIDGE = createInertBridge();
4066
4141
  // The page's bridge if something already started it, else null. Never
4067
4142
  // starts one: for code that has to stay side-effect free, like a React
4068
4143
  // store's snapshot.