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