@obelism/content 0.28.3 → 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}%',
@@ -298,119 +460,15 @@ const createViewportUnits = (doc)=>{
298
460
  // which one was clicked, hands the editor's live, unsaved entry to listeners
299
461
  // and sends the editor prompts. Outside the editor it does nothing.
300
462
  //
301
- // Messages are only exchanged with the CMS origin, and only with the parent
302
- // window:
303
- // - site → editor: { source: 'obelism-content-bridge', type: 'ready' |
304
- // 'size' | 'select' | 'prompt' | 'undo', uid?, id?, prompt?, height? }
305
- // - editor → site: { source: 'obelism-content-editor', type: 'connect' |
306
- // 'update' | 'select' | 'saved' | 'published' | 'features' |
307
- // 'promptResult' | 'promptState' | 'stats' | 'viewport', … }
308
- const BRIDGE_SOURCE = 'obelism-content-bridge';
309
- const EDITOR_SOURCE = 'obelism-content-editor';
310
- // How long a prompt may take before it counts as failed.
311
- const PROMPT_TIMEOUT_MS = 120_000;
312
- const OUTLINE = '#d58a1f';
313
- // Outlines on blocks; `scope` limits them to e.g. `html[data-cms-selecting] `.
314
- 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}`;
315
- const INITIAL_STATE = {
316
- features: [],
317
- selectedUid: null,
318
- prompting: false,
319
- canUndo: false,
320
- unsaved: false,
321
- saving: false,
322
- publishing: false,
323
- selecting: false,
324
- stats: {},
325
- statsVisible: false
326
- };
327
- const createEmitter = ()=>{
328
- const listeners = {
329
- update: new Set(),
330
- saved: new Set(),
331
- published: new Set(),
332
- change: new Set()
333
- };
334
- return {
335
- listeners,
336
- on (event, listener) {
337
- listeners[event].add(listener);
338
- return ()=>{
339
- listeners[event].delete(listener);
340
- };
341
- },
342
- emit (event, payload) {
343
- for (const listener of listeners[event]){
344
- try {
345
- listener(payload);
346
- } catch (error) {
347
- console.error('[@obelism/content bridge]', error);
348
- }
349
- }
350
- }
351
- };
352
- };
353
- const inertPrompt = ()=>Promise.reject(new Error('Prompts only work inside the visual editor'));
354
- const onlyOnSite = ()=>Promise.reject(new Error('Save and publish in the visual editor itself'));
355
- const onlyWhileEditingSite = ()=>Promise.reject(new Error('Only while signed in to edit on the site'));
356
- // What every bridge without a feature does for it.
357
- const INERT_METHODS = {
358
- prompt: inertPrompt,
359
- createPage: ()=>Promise.reject(new Error('New pages are made on the site itself')),
360
- createProductPage: ()=>Promise.reject(new Error('Product pages are made on the site itself')),
361
- createCollectionPage: ()=>Promise.reject(new Error('Collection pages are made on the site itself')),
362
- view: onlyWhileEditingSite,
363
- applyJson: onlyWhileEditingSite,
364
- openForm: (_container, options = {})=>{
365
- queueMicrotask(()=>options.onError?.('Only while signed in to edit on the site'));
366
- return {
367
- close: ()=>{}
368
- };
369
- },
370
- pages: onlyWhileEditingSite,
371
- openPage: ()=>{},
372
- undo: ()=>{},
373
- save: onlyOnSite,
374
- publish: onlyOnSite,
375
- signOut: ()=>{},
376
- select: ()=>{},
377
- setSelecting: ()=>{}
378
- };
379
- const isCount = (value)=>typeof value === 'number' && Number.isFinite(value) && value >= 0;
380
- // The editor's `stats`, keeping only well-formed entries.
381
- const readStats = (value)=>{
382
- if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
383
- const stats = {};
384
- for (const [uid, block] of Object.entries(value)){
385
- const { clicks, clickers, viewers, rate } = block ?? {};
386
- if (isCount(clicks) && isCount(clickers) && isCount(viewers) && isCount(rate)) stats[uid] = {
387
- clicks,
388
- clickers,
389
- viewers,
390
- rate
391
- };
392
- }
393
- return stats;
394
- };
395
- // The selector of the element marked with a block's uid.
396
- const blockSelector = (uid)=>`[${preview.UID_ATTRIBUTE}="${uid.replace(/["\\]/g, '\\$&')}"]`;
397
463
  // `cmsOrigin` is the only origin messages are accepted from and sent to.
398
464
  const createBridge = (win, cmsOrigin)=>{
399
465
  const params = new URLSearchParams(win.location.search);
400
466
  const isEditing = win.parent !== win && params.has(preview.PREVIEW_PARAMS.preview);
401
- const emitter = createEmitter();
402
- const cleanup = [];
403
- let state = {
467
+ const { on, getState, setState, emit, listeners } = createBridgeState({
404
468
  ...INITIAL_STATE,
405
469
  selecting: true
406
- };
407
- const setState = (next)=>{
408
- state = {
409
- ...state,
410
- ...next
411
- };
412
- emitter.emit('change', state);
413
- };
470
+ });
471
+ const cleanup = [];
414
472
  const bridge = {
415
473
  isEditing,
416
474
  mode: isEditing ? 'editor' : null,
@@ -418,8 +476,8 @@ const createBridge = (win, cmsOrigin)=>{
418
476
  environment: params.get(preview.PREVIEW_PARAMS.environment),
419
477
  product: null,
420
478
  collection: null,
421
- on: emitter.on,
422
- getState: ()=>state,
479
+ on,
480
+ getState,
423
481
  ...INERT_METHODS,
424
482
  destroy: ()=>{
425
483
  cleanup.splice(0).forEach((undo)=>undo());
@@ -439,22 +497,12 @@ const createBridge = (win, cmsOrigin)=>{
439
497
  doc.head.appendChild(style);
440
498
  cleanup.push(()=>style.remove());
441
499
  const highlight = (uid, scroll)=>{
442
- doc.querySelectorAll('[data-cms-selected]').forEach((element)=>{
443
- element.removeAttribute('data-cms-selected');
444
- });
445
- if (state.selectedUid !== (uid ?? null)) {
500
+ if (getState().selectedUid !== (uid ?? null)) {
446
501
  setState({
447
502
  selectedUid: uid ?? null
448
503
  });
449
504
  }
450
- if (!uid) return;
451
- const element = doc.querySelector(blockSelector(uid));
452
- if (!element) return;
453
- element.setAttribute('data-cms-selected', '');
454
- if (scroll) element.scrollIntoView({
455
- behavior: 'smooth',
456
- block: 'center'
457
- });
505
+ showSelection(doc, uid, scroll);
458
506
  };
459
507
  // Capture phase, so a block that is also a link selects instead of
460
508
  // navigating away.
@@ -514,11 +562,11 @@ const createBridge = (win, cmsOrigin)=>{
514
562
  bridge.prompt = (prompt, options = {})=>{
515
563
  const text = prompt.trim();
516
564
  if (!text) return Promise.reject(new Error('Say what to change'));
517
- if (!state.features.includes('prompt')) {
565
+ if (!getState().features.includes('prompt')) {
518
566
  return Promise.reject(new Error('The editor does not offer prompts here'));
519
567
  }
520
568
  const id = `prompt_${++nextId}`;
521
- const uid = options.uid === undefined ? state.selectedUid : options.uid;
569
+ const uid = options.uid === undefined ? getState().selectedUid : options.uid;
522
570
  return new Promise((resolve, reject)=>{
523
571
  const timer = setTimeout(()=>{
524
572
  settle(id)?.reject(new Error('The editor did not answer in time'));
@@ -545,17 +593,11 @@ const createBridge = (win, cmsOrigin)=>{
545
593
  uid
546
594
  });
547
595
  };
548
- // Mounted with the first rates, so pages without numbers never get it.
549
- let overlay = null;
550
- const showStats = ()=>{
551
- if (!overlay && !Object.keys(state.stats).length) return;
552
- overlay ??= mountStatsOverlay(win, {
596
+ const stats = createStatsView(()=>mountStatsOverlay(win, {
553
597
  selector: blockSelector,
554
598
  onSelect: (uid)=>bridge.select(uid)
555
- });
556
- overlay.update(state.stats, state.statsVisible);
557
- };
558
- cleanup.push(()=>overlay?.destroy());
599
+ }));
600
+ cleanup.push(()=>stats.destroy());
559
601
  cleanup.push(()=>{
560
602
  for (const id of pending.keys()){
561
603
  settle(id)?.reject(new Error('The bridge was destroyed'));
@@ -572,29 +614,31 @@ const createBridge = (win, cmsOrigin)=>{
572
614
  // have been sent before it was listening.
573
615
  ready();
574
616
  } else if (data.type === 'update' && data.entry) {
575
- emitter.emit('update', {
617
+ emit('update', {
576
618
  entry: data.entry,
577
619
  selectedUid: data.selectedUid ?? null
578
620
  });
579
621
  // Re-apply the selection to whatever the site rendered.
580
622
  win.requestAnimationFrame(()=>highlight(data.selectedUid, false));
581
- overlay?.refresh();
623
+ stats.refresh();
582
624
  } else if (data.type === 'select') {
583
625
  highlight(data.uid, true);
584
626
  } else if (data.type === 'saved' || data.type === 'published') {
585
- emitter.emit(data.type, {});
586
- if (emitter.listeners.update.size === 0) win.location.reload();
627
+ emit(data.type, {});
628
+ if (listeners.update.size === 0) win.location.reload();
587
629
  } else if (data.type === 'features' && Array.isArray(data.features)) {
588
630
  setState({
589
631
  features: data.features.filter((feature)=>typeof feature === 'string')
590
632
  });
591
633
  } else if (data.type === 'stats') {
592
634
  // An editor from before the switch sends no `visible`: show them.
593
- setState({
635
+ const next = {
594
636
  stats: readStats(data.stats),
595
637
  statsVisible: data.visible !== false
596
- });
597
- showStats();
638
+ };
639
+ setState(next);
640
+ const { stats: rates, statsVisible } = getState();
641
+ stats.update(rates, statsVisible);
598
642
  } else if (data.type === 'viewport' && typeof data.height === 'number') {
599
643
  // The canvas: the frame is as tall as the page (`size`), so `vh`
600
644
  // sizes are fixed at the screen height it stands for.
@@ -627,6 +671,7 @@ const createBridge = (win, cmsOrigin)=>{
627
671
  }
628
672
  return bridge;
629
673
  };
674
+
630
675
  const wrapScriptBridge = (script, params)=>{
631
676
  const emitter = createEmitter();
632
677
  script.on('saved', ()=>emitter.emit('saved', {}));
@@ -635,12 +680,11 @@ const wrapScriptBridge = (script, params)=>{
635
680
  // no `update` listener, and has no way to remove one.
636
681
  let forwardingUpdates = false;
637
682
  return {
683
+ ...createInertBridge(),
638
684
  isEditing: script.isEditing,
639
685
  mode: script.isEditing ? 'editor' : null,
640
686
  entryId: script.entryId,
641
687
  environment: params.get(preview.PREVIEW_PARAMS.environment),
642
- product: null,
643
- collection: null,
644
688
  on: (event, listener)=>{
645
689
  if (event === 'update' && !forwardingUpdates) {
646
690
  forwardingUpdates = true;
@@ -653,10 +697,7 @@ const wrapScriptBridge = (script, params)=>{
653
697
  });
654
698
  }
655
699
  return emitter.on(event, listener);
656
- },
657
- getState: ()=>INITIAL_STATE,
658
- ...INERT_METHODS,
659
- destroy: ()=>{}
700
+ }
660
701
  };
661
702
  };
662
703
 
@@ -691,6 +732,11 @@ const svgSpan = (doc, className, paths)=>{
691
732
  };
692
733
  // `{name}` style placeholders.
693
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
+ };
694
740
  // Line icons (Lucide), drawn in the text colour.
695
741
  const ICONS = {
696
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"/>',
@@ -1001,6 +1047,55 @@ const createPagesPicker = (doc, bridge, labels, { onLoad, onClose })=>{
1001
1047
  };
1002
1048
  };
1003
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
+
1004
1099
  // What the prompt box and the on-site editor decide alike on the site.
1005
1100
  const failure = (error)=>error instanceof Error ? error.message : String(error);
1006
1101
  const pageContext = (bridge, state, promptWithoutPage)=>{
@@ -1011,39 +1106,26 @@ const pageContext = (bridge, state, promptWithoutPage)=>{
1011
1106
  const pageless = site && bridge.entryId === null;
1012
1107
  // …unless it shows a product (collection): then Add a product
1013
1108
  // (collection) page first.
1014
- const shopHere = !pageless || !canCreate ? null : bridge.product ? 'product' : bridge.collection ? 'collection' : null;
1015
- const onlyNew = pageless && canCreate && !shopHere && !promptWithoutPage;
1109
+ const shop = pageless && canCreate ? shopKindOf(bridge) : null;
1110
+ const onlyNew = pageless && canCreate && !shop && !promptWithoutPage;
1016
1111
  return {
1017
1112
  site,
1018
1113
  canCreate,
1019
1114
  pageless,
1020
- shopHere,
1115
+ shop,
1021
1116
  onlyNew
1022
1117
  };
1023
1118
  };
1024
- const shopCopy = (labels, shopHere)=>shopHere === 'collection' ? {
1025
- add: labels.addCollectionPage,
1026
- adding: labels.addingCollectionPage,
1027
- hint: labels.collectionPageHint
1028
- } : {
1029
- add: labels.addProductPage,
1030
- adding: labels.addingProductPage,
1031
- hint: labels.productPageHint
1032
- };
1033
1119
  // Makes the page's product (collection) page; the bridge then reloads, and
1034
1120
  // the answer shows until it does.
1035
1121
  const addShopPage = async (bridge, labels)=>{
1122
+ const kind = shopKindOf(bridge);
1123
+ if (!kind) return null;
1036
1124
  try {
1037
- if (bridge.product) {
1038
- await bridge.createProductPage();
1039
- return {
1040
- text: labels.addedProductPage,
1041
- error: false
1042
- };
1043
- }
1044
- await bridge.createCollectionPage();
1125
+ if (kind === 'product') await bridge.createProductPage();
1126
+ else await bridge.createCollectionPage();
1045
1127
  return {
1046
- text: labels.addedCollectionPage,
1128
+ text: labels[SHOP[kind].added],
1047
1129
  error: false
1048
1130
  };
1049
1131
  } catch (error) {
@@ -1063,6 +1145,36 @@ const storeChanges = async (bridge, action)=>{
1063
1145
  }
1064
1146
  };
1065
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
+
1066
1178
  const SIDE_KEY = 'obelism-content-prompt-side';
1067
1179
  // How far the pointer moves before it drags the box, not clicks it.
1068
1180
  const DRAG_THRESHOLD_PX = 6;
@@ -1644,26 +1756,29 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1644
1756
  error: true
1645
1757
  });
1646
1758
  const render = (state)=>{
1647
- const available = state.features.includes('prompt');
1648
- const storing = state.saving || state.publishing;
1649
- 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;
1650
1770
  root.hidden = !available && !site;
1651
1771
  pill.hidden = open || !available;
1652
1772
  viewing.hidden = available || !site;
1653
1773
  panel.hidden = !open || !available;
1654
- const { canCreate, pageless, shopHere, onlyNew } = pageContext(bridge, state, options.promptWithoutPage);
1655
- const productHere = shopHere !== null;
1656
- const shopCopy$1 = shopCopy(labels, shopHere);
1657
- addProduct.lastChild.textContent = shopCopy$1.add;
1658
- if (!canCreate) creating = false;
1659
- if (onlyNew) creating = true;
1660
- if (creating || pageless || !site) form = false;
1774
+ const words = page.shop ? shopCopy(labels, page.shop) : null;
1775
+ addProduct.lastChild.textContent = words?.add ?? labels.addProductPage;
1661
1776
  // The form is there only while shown in the open panel.
1662
1777
  const showForm = open && available && form;
1663
1778
  if (showForm && !formSlot.mounted) formSlot.mount();
1664
1779
  else if (!showForm && formSlot.mounted) formSlot.unmount();
1665
- const offerProduct = productHere && !creating;
1666
- 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;
1667
1782
  cancelNew.hidden = onlyNew;
1668
1783
  scope.hidden = creating || offerProduct;
1669
1784
  newScope.hidden = !creating;
@@ -1680,7 +1795,7 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1680
1795
  formLoader.hidden = !formSlot.loading;
1681
1796
  toolbar.hidden = picking;
1682
1797
  pagesSection.hidden = !picking;
1683
- input.placeholder = creating ? labels.newPagePlaceholder : labels.placeholder;
1798
+ input.placeholder = page.placeholder;
1684
1799
  scopeText.textContent = state.selectedUid ? labels.scopeBlock : pageless ? labels.scopeNoPage : labels.scopeEntry;
1685
1800
  clearBlock.hidden = !state.selectedUid;
1686
1801
  pageIcon.hidden = !!state.selectedUid;
@@ -1703,16 +1818,16 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1703
1818
  minimize.setAttribute('aria-expanded', String(!minimized));
1704
1819
  minimizeIcon.hidden = minimized;
1705
1820
  expandIcon.hidden = !minimized;
1706
- send.disabled = busy || input.value.trim() === '' || creating && pathInput.value.trim() === '';
1821
+ send.disabled = !page.canSend(input.value, pathInput.value);
1707
1822
  input.disabled = busy;
1708
- save.hidden = !state.features.includes('save') || pageless;
1823
+ save.hidden = !page.canSave;
1709
1824
  save.disabled = busy || !state.unsaved;
1710
1825
  save.textContent = state.saving ? labels.saving : labels.save;
1711
- publish.hidden = !state.features.includes('publish') || pageless;
1826
+ publish.hidden = !page.canPublish;
1712
1827
  publish.disabled = busy;
1713
1828
  publish.textContent = state.publishing ? labels.publishing : labels.publish;
1714
- signOut.hidden = !site || options.signOut === false;
1715
- pillSignOut.hidden = options.signOut === false;
1829
+ signOut.hidden = !page.showSignOut;
1830
+ pillSignOut.hidden = !page.showSignOut;
1716
1831
  if (options.actions) {
1717
1832
  options.actions.slot = open && available ? 'actions-footer' : 'actions-bar';
1718
1833
  }
@@ -1724,16 +1839,12 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1724
1839
  const loadingForm = form && formSlot.loading && !picking;
1725
1840
  const working = state.prompting;
1726
1841
  // A form opening says so in its own place.
1727
- 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 : '');
1728
1843
  status.textContent = note;
1729
1844
  status.hidden = !note || minimized;
1730
1845
  status.className = `status${working ? ' working' : ''}${!busy && !loadingForm && message?.error ? ' error' : ''}`;
1731
1846
  };
1732
1847
  const refresh = ()=>render(bridge.getState());
1733
- const grow = ()=>{
1734
- input.style.height = 'auto';
1735
- input.style.height = `${input.scrollHeight}px`;
1736
- };
1737
1848
  const setForm = (next)=>{
1738
1849
  if (form === next) return;
1739
1850
  form = next;
@@ -1765,7 +1876,7 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1765
1876
  };
1766
1877
  }
1767
1878
  input.value = '';
1768
- grow();
1879
+ fitToText(input);
1769
1880
  } catch (error) {
1770
1881
  message = failure$1(error);
1771
1882
  }
@@ -1861,7 +1972,7 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1861
1972
  void submit();
1862
1973
  });
1863
1974
  input.addEventListener('input', ()=>{
1864
- grow();
1975
+ fitToText(input);
1865
1976
  refresh();
1866
1977
  });
1867
1978
  input.addEventListener('keydown', (event)=>{
@@ -2013,17 +2124,8 @@ const webUrl = (url)=>{
2013
2124
  };
2014
2125
  // The base path of the site's handler while signed in to edit, else null.
2015
2126
  const readSiteEditing = (doc)=>{
2016
- for (const part of doc.cookie.split(';')){
2017
- const [name, ...value] = part.trim().split('=');
2018
- if (name !== site.SITE_EDITING_COOKIE) continue;
2019
- try {
2020
- const basePath = decodeURIComponent(value.join('='));
2021
- return BASE_PATH.test(basePath) ? basePath : null;
2022
- } catch {
2023
- return null;
2024
- }
2025
- }
2026
- return null;
2127
+ const basePath = site.parseCookies(doc.cookie).get(site.SITE_EDITING_COOKIE);
2128
+ return basePath && BASE_PATH.test(basePath) ? basePath : null;
2027
2129
  };
2028
2130
  const same = (a, b)=>JSON.stringify(a) === JSON.stringify(b);
2029
2131
  // Edits in the form further apart than this are separate undo steps.
@@ -2033,7 +2135,7 @@ const FORM_BURST_MS = 1000;
2033
2135
  const FORM_RENDER_MS = 300;
2034
2136
  const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/^\/+/, '')}`, productPath: ownProductPath, collectionPath: ownCollectionPath } = {})=>{
2035
2137
  const doc = win.document;
2036
- const emitter = createEmitter();
2138
+ const { on, getState, setState, emit, listeners } = createBridgeState();
2037
2139
  const cleanup = [];
2038
2140
  const working = new Map();
2039
2141
  // Content before each prompt, newest last, for undo.
@@ -2041,21 +2143,20 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2041
2143
  // Entries changed here since the last publish.
2042
2144
  const touched = new Set();
2043
2145
  let session = null;
2044
- const productPath = (handle, locale)=>ownProductPath ? ownProductPath(handle, locale) : (session?.productPath ?? '/products/{handle}').replaceAll('{handle}', encodeURIComponent(handle));
2045
- 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
+ };
2046
2155
  // The open form's, if any; and when the form last changed content, for
2047
2156
  // undo steps (-Infinity: the next edit starts a new step).
2048
2157
  let closeForm = null;
2049
2158
  let lastFormEdit = -Infinity;
2050
2159
  const renderTimers = new Map();
2051
- let state = INITIAL_STATE;
2052
- const setState = (next)=>{
2053
- state = {
2054
- ...state,
2055
- ...next
2056
- };
2057
- emitter.emit('change', state);
2058
- };
2059
2160
  const refreshUnsaved = ()=>setState({
2060
2161
  unsaved: [
2061
2162
  ...working.values()
@@ -2088,26 +2189,6 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2088
2189
  return data;
2089
2190
  };
2090
2191
  const pageEntryId = ()=>doc.querySelector(`[${preview.ENTRY_ATTRIBUTE}]`)?.getAttribute(preview.ENTRY_ATTRIBUTE) ?? null;
2091
- // The product the page shows (editableProduct), if it marks one.
2092
- const pageProduct = ()=>{
2093
- const element = doc.querySelector(`[${preview.PRODUCT_ATTRIBUTE}]`);
2094
- const id = element?.getAttribute(preview.PRODUCT_ATTRIBUTE);
2095
- const handle = element?.getAttribute(preview.PRODUCT_HANDLE_ATTRIBUTE);
2096
- return id && handle ? {
2097
- id,
2098
- handle
2099
- } : null;
2100
- };
2101
- // The collection the page shows (editableCollection), if it marks one.
2102
- const pageCollection = ()=>{
2103
- const element = doc.querySelector(`[${preview.COLLECTION_ATTRIBUTE}]`);
2104
- const id = element?.getAttribute(preview.COLLECTION_ATTRIBUTE);
2105
- const handle = element?.getAttribute(preview.COLLECTION_HANDLE_ATTRIBUTE);
2106
- return id && handle ? {
2107
- id,
2108
- handle
2109
- } : null;
2110
- };
2111
2192
  const entryFor = (uid)=>{
2112
2193
  const block = uid ? doc.querySelector(blockSelector(uid)) : null;
2113
2194
  return block?.closest(`[${preview.ENTRY_ATTRIBUTE}]`)?.getAttribute(preview.ENTRY_ATTRIBUTE) ?? pageEntryId();
@@ -2130,7 +2211,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2130
2211
  if (loaded) return loaded;
2131
2212
  let pending = loading.get(entryId);
2132
2213
  if (!pending) {
2133
- pending = api(`entries/${encodeURIComponent(entryId)}`).then((entry)=>{
2214
+ pending = api(site.siteApi.entry(entryId)).then((entry)=>{
2134
2215
  const fresh = working.get(entryId) ?? {
2135
2216
  content: entry.content,
2136
2217
  saved: entry.content,
@@ -2146,7 +2227,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2146
2227
  // The entry a prompt or JSON acts on, for the block `uid` (the selected
2147
2228
  // one when left out).
2148
2229
  const target = (option)=>{
2149
- const uid = option === undefined ? state.selectedUid : option;
2230
+ const uid = option === undefined ? getState().selectedUid : option;
2150
2231
  const entryId = entryFor(uid);
2151
2232
  if (!entryId) {
2152
2233
  throw new Error('Nothing on this page is marked as an entry: add editable(entry)');
@@ -2160,38 +2241,38 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2160
2241
  // blocks: only their rates show (statsOverlay.ts), so a few can be
2161
2242
  // compared without every tag at once.
2162
2243
  const revealed = new Set();
2163
- let overlay = null;
2164
2244
  const asked = new Set();
2165
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
+ }));
2166
2252
  const highlight = (uid)=>{
2167
- doc.querySelectorAll('[data-cms-selected]').forEach((element)=>{
2168
- element.removeAttribute('data-cms-selected');
2169
- });
2170
- if (state.selectedUid !== uid) setState({
2253
+ if (getState().selectedUid !== uid) setState({
2171
2254
  selectedUid: uid
2172
2255
  });
2173
- if (uid) {
2174
- doc.querySelector(blockSelector(uid))?.setAttribute('data-cms-selected', '');
2175
- if (!revealed.has(uid)) {
2176
- revealed.add(uid);
2177
- overlay?.refresh();
2178
- }
2256
+ showSelection(doc, uid);
2257
+ if (uid && !revealed.has(uid)) {
2258
+ revealed.add(uid);
2259
+ stats.refresh();
2179
2260
  }
2180
2261
  };
2181
2262
  // Shows the in-memory content through `update`, shaped like the delivery
2182
2263
  // API. A page without `update` listeners shows it once saved (it reloads).
2183
2264
  const render = async (entryId)=>{
2184
- if (emitter.listeners.update.size === 0) return;
2265
+ if (listeners.update.size === 0) return;
2185
2266
  const content = working.get(entryId)?.content;
2186
- const { entry } = await api(`entries/${encodeURIComponent(entryId)}/preview`, {
2267
+ const { entry } = await api(site.siteApi.preview(entryId), {
2187
2268
  content,
2188
2269
  locale: locale()
2189
2270
  });
2190
- emitter.emit('update', {
2271
+ emit('update', {
2191
2272
  entry,
2192
- selectedUid: state.selectedUid
2273
+ selectedUid: getState().selectedUid
2193
2274
  });
2194
- win.requestAnimationFrame(()=>highlight(state.selectedUid));
2275
+ win.requestAnimationFrame(()=>highlight(getState().selectedUid));
2195
2276
  };
2196
2277
  const logError = (error)=>console.error('[@obelism/content bridge]', error);
2197
2278
  // Renders once the form's edits settle.
@@ -2203,13 +2284,13 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2203
2284
  }, FORM_RENDER_MS));
2204
2285
  };
2205
2286
  const afterStore = (event)=>{
2206
- emitter.emit(event, {});
2207
- if (emitter.listeners.update.size === 0) win.location.reload();
2287
+ emit(event, {});
2288
+ if (listeners.update.size === 0) win.location.reload();
2208
2289
  };
2209
2290
  const saveAll = async ()=>{
2210
2291
  for (const [entryId, entry] of working){
2211
2292
  if (same(entry.content, entry.saved)) continue;
2212
- const { draftRevision } = await api(`entries/${encodeURIComponent(entryId)}/save`, {
2293
+ const { draftRevision } = await api(site.siteApi.save(entryId), {
2213
2294
  content: entry.content,
2214
2295
  baseRevision: entry.revision
2215
2296
  });
@@ -2221,7 +2302,36 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2221
2302
  lastFormEdit = -Infinity;
2222
2303
  refreshUnsaved();
2223
2304
  };
2224
- 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
+ };
2225
2335
  const bridge = {
2226
2336
  isEditing: true,
2227
2337
  mode: 'site',
@@ -2229,16 +2339,16 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2229
2339
  return pageEntryId();
2230
2340
  },
2231
2341
  get product () {
2232
- return pageProduct();
2342
+ return pageShopItem(doc, 'product');
2233
2343
  },
2234
2344
  get collection () {
2235
- return pageCollection();
2345
+ return pageShopItem(doc, 'collection');
2236
2346
  },
2237
2347
  get environment () {
2238
2348
  return session?.environment ?? null;
2239
2349
  },
2240
- on: emitter.on,
2241
- getState: ()=>state,
2350
+ on,
2351
+ getState,
2242
2352
  async prompt (prompt, options = {}) {
2243
2353
  const text = prompt.trim();
2244
2354
  if (!text) throw new Error('Say what to change');
@@ -2249,7 +2359,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2249
2359
  });
2250
2360
  try {
2251
2361
  const entry = await load(entryId);
2252
- const result = await api(`entries/${encodeURIComponent(entryId)}/prompt`, {
2362
+ const result = await api(site.siteApi.prompt(entryId), {
2253
2363
  prompt: text,
2254
2364
  content: entry.content,
2255
2365
  locale: locale(),
@@ -2278,14 +2388,14 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2278
2388
  if (!path.trim()) throw new Error('Say where the page goes, like /pricing');
2279
2389
  if (!has('create')) throw new Error('You may not add pages here');
2280
2390
  // Opening the new page would lose them.
2281
- if (state.unsaved) {
2391
+ if (getState().unsaved) {
2282
2392
  throw new Error('Save or undo your changes before adding a page');
2283
2393
  }
2284
2394
  setState({
2285
2395
  prompting: true
2286
2396
  });
2287
2397
  try {
2288
- const created = await api('entries', {
2398
+ const created = await api(site.siteApi.createEntry(), {
2289
2399
  path,
2290
2400
  prompt: text,
2291
2401
  locale: locale(),
@@ -2305,66 +2415,12 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2305
2415
  });
2306
2416
  }
2307
2417
  },
2308
- async createProductPage (product = pageProduct() ?? undefined) {
2309
- if (!product) {
2310
- throw new Error('Nothing on this page is marked as a product: add editableProduct(product)');
2311
- }
2312
- if (!has('create')) throw new Error('You may not add pages here');
2313
- if (state.unsaved) {
2314
- throw new Error('Save or undo your changes before adding a page');
2315
- }
2316
- setState({
2317
- prompting: true
2318
- });
2319
- try {
2320
- const made = await api('product-pages', {
2321
- product: {
2322
- id: product.id,
2323
- handle: product.handle
2324
- }
2325
- });
2326
- // The site's product route finds its page now (drafts, while
2327
- // editing): show it.
2328
- win.location.reload();
2329
- return made;
2330
- } finally{
2331
- setState({
2332
- prompting: false
2333
- });
2334
- }
2335
- },
2336
- async createCollectionPage (collection = pageCollection() ?? undefined) {
2337
- if (!collection) {
2338
- throw new Error('Nothing on this page is marked as a collection: add editableCollection(collection)');
2339
- }
2340
- if (!has('create')) throw new Error('You may not add pages here');
2341
- if (state.unsaved) {
2342
- throw new Error('Save or undo your changes before adding a page');
2343
- }
2344
- setState({
2345
- prompting: true
2346
- });
2347
- try {
2348
- const made = await api('collection-pages', {
2349
- collection: {
2350
- id: collection.id,
2351
- handle: collection.handle
2352
- }
2353
- });
2354
- // The site's collection route finds its page now (drafts, while
2355
- // editing): show it.
2356
- win.location.reload();
2357
- return made;
2358
- } finally{
2359
- setState({
2360
- prompting: false
2361
- });
2362
- }
2363
- },
2418
+ createProductPage: (product = pageShopItem(doc, 'product') ?? undefined)=>createShopPage('product', product),
2419
+ createCollectionPage: (collection = pageShopItem(doc, 'collection') ?? undefined)=>createShopPage('collection', collection),
2364
2420
  async view (options = {}) {
2365
2421
  const { uid, entryId } = target(options.uid);
2366
2422
  const entry = await load(entryId);
2367
- const { view } = await api(`entries/${encodeURIComponent(entryId)}/view`, {
2423
+ const { view } = await api(site.siteApi.view(entryId), {
2368
2424
  content: entry.content,
2369
2425
  locale: locale(),
2370
2426
  uid
@@ -2382,7 +2438,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2382
2438
  });
2383
2439
  try {
2384
2440
  const entry = await load(entryId);
2385
- const result = await api(`entries/${encodeURIComponent(entryId)}/apply`, {
2441
+ const result = await api(site.siteApi.apply(entryId), {
2386
2442
  changes,
2387
2443
  content: entry.content,
2388
2444
  locale: locale(),
@@ -2434,7 +2490,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2434
2490
  // changed: prompts, undo, a reload after saving, the selection.
2435
2491
  const show = ()=>{
2436
2492
  if (closed || !frame) return;
2437
- const entryId = entryFor(state.selectedUid);
2493
+ const entryId = entryFor(getState().selectedUid);
2438
2494
  if (!entryId) return;
2439
2495
  const entry = working.get(entryId);
2440
2496
  if (!entry) {
@@ -2445,7 +2501,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2445
2501
  entryId,
2446
2502
  content: entry.content,
2447
2503
  locale: locale(),
2448
- selectedUid: state.selectedUid
2504
+ selectedUid: getState().selectedUid
2449
2505
  };
2450
2506
  if (shown && shown.entryId === next.entryId && shown.content === next.content && shown.locale === next.locale && shown.selectedUid === next.selectedUid) {
2451
2507
  return;
@@ -2480,7 +2536,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2480
2536
  if (!has('form')) throw new Error('You may not edit content here');
2481
2537
  await load(target(undefined).entryId);
2482
2538
  // A fresh one every time: the address works once, for a minute.
2483
- const { url } = await api('form', {});
2539
+ const { url } = await api(site.siteApi.form(), {});
2484
2540
  if (closed) return;
2485
2541
  const address = typeof url === 'string' ? url : '';
2486
2542
  frame = mountFormFrame(win, container, address, title, {
@@ -2489,7 +2545,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2489
2545
  onError: (message)=>onError?.(message),
2490
2546
  onEscape: ()=>onEscape?.()
2491
2547
  });
2492
- unsubscribe = emitter.on('change', show);
2548
+ unsubscribe = on('change', show);
2493
2549
  show();
2494
2550
  };
2495
2551
  start().catch(fail);
@@ -2498,7 +2554,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2498
2554
  };
2499
2555
  },
2500
2556
  async pages () {
2501
- const { pages } = await api('pages');
2557
+ const { pages } = await api(site.siteApi.pages());
2502
2558
  const preferred = [
2503
2559
  locale(),
2504
2560
  session?.space.defaultLocale
@@ -2508,6 +2564,8 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2508
2564
  const code = preferred.find((code)=>code !== undefined && page.paths[code] !== undefined) ?? Object.keys(page.paths)[0];
2509
2565
  const path = code === undefined ? undefined : page.paths[code];
2510
2566
  if (code === undefined || path === undefined) return [];
2567
+ const shop = shopKindOf(page);
2568
+ const item = shopItemOf(page);
2511
2569
  return [
2512
2570
  {
2513
2571
  entryId: page.entryId,
@@ -2515,7 +2573,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2515
2573
  contentType: page.contentType,
2516
2574
  status: page.status,
2517
2575
  path,
2518
- 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),
2519
2577
  product: page.product ?? null,
2520
2578
  collection: page.collection ?? null
2521
2579
  }
@@ -2523,7 +2581,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2523
2581
  });
2524
2582
  },
2525
2583
  openPage (page) {
2526
- 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?')) {
2527
2585
  return;
2528
2586
  }
2529
2587
  // Asked already: no second question from `beforeunload`.
@@ -2572,7 +2630,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2572
2630
  throw new Error('Nothing on this page is marked as an entry: add editable(entry)');
2573
2631
  }
2574
2632
  for (const entryId of entries){
2575
- await api(`entries/${encodeURIComponent(entryId)}/publish`, {});
2633
+ await api(site.siteApi.publish(entryId), {});
2576
2634
  touched.delete(entryId);
2577
2635
  }
2578
2636
  } finally{
@@ -2583,7 +2641,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2583
2641
  afterStore('published');
2584
2642
  },
2585
2643
  signOut () {
2586
- 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?')) {
2587
2645
  return;
2588
2646
  }
2589
2647
  working.clear();
@@ -2618,7 +2676,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2618
2676
  // Capture phase, so a block that is also a link selects instead of
2619
2677
  // navigating — only while selecting, so the site stays usable.
2620
2678
  const onClick = (event)=>{
2621
- if (!state.selecting) return;
2679
+ if (!getState().selecting) return;
2622
2680
  const target = event.target;
2623
2681
  const element = target?.closest?.(`[${preview.UID_ATTRIBUTE}]`);
2624
2682
  // A tagged element outside the blocks (a menu button) can't be
@@ -2630,7 +2688,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2630
2688
  event.stopPropagation();
2631
2689
  if (!revealed.has(tagId)) {
2632
2690
  revealed.add(tagId);
2633
- overlay?.refresh();
2691
+ stats.refresh();
2634
2692
  }
2635
2693
  return;
2636
2694
  }
@@ -2642,7 +2700,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2642
2700
  doc.addEventListener('click', onClick, true);
2643
2701
  cleanup.push(()=>doc.removeEventListener('click', onClick, true));
2644
2702
  const onBeforeUnload = (event)=>{
2645
- if (!state.unsaved) return;
2703
+ if (!getState().unsaved) return;
2646
2704
  event.preventDefault();
2647
2705
  event.returnValue = '';
2648
2706
  };
@@ -2653,7 +2711,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2653
2711
  // as they appear.
2654
2712
  let statsOff = false;
2655
2713
  let statsTimer = null;
2656
- const showStats = ()=>overlay?.update(state.stats, state.statsVisible);
2714
+ const showStats = ()=>stats.update(getState().stats, getState().statsVisible);
2657
2715
  const loadStats = async ()=>{
2658
2716
  if (statsOff) return;
2659
2717
  const ids = [
@@ -2671,7 +2729,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2671
2729
  ].map((element)=>element.getAttribute(preview.ENTRY_ATTRIBUTE) ?? ''))
2672
2730
  ].filter(Boolean).slice(0, MAX_STATS_ENTRIES);
2673
2731
  try {
2674
- const { stats } = await api('stats', {
2732
+ const { stats } = await api(site.siteApi.stats(), {
2675
2733
  uids: batch,
2676
2734
  ...entries.length > 0 && {
2677
2735
  entries
@@ -2680,7 +2738,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2680
2738
  batch.forEach((id)=>answered.add(id));
2681
2739
  setState({
2682
2740
  stats: {
2683
- ...state.stats,
2741
+ ...getState().stats,
2684
2742
  ...readStats(stats)
2685
2743
  }
2686
2744
  });
@@ -2689,12 +2747,6 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2689
2747
  statsOff = true;
2690
2748
  return;
2691
2749
  }
2692
- overlay ??= mountStatsOverlay(win, {
2693
- selector: blockSelector,
2694
- onSelect: (uid)=>highlight(uid),
2695
- checked: (id)=>answered.has(id),
2696
- shows: (id, element)=>revealed.has(element.closest(`[${preview.UID_ATTRIBUTE}]`)?.getAttribute(preview.UID_ATTRIBUTE) ?? id)
2697
- });
2698
2750
  showStats();
2699
2751
  if (ids.length > batch.length) scheduleStats();
2700
2752
  };
@@ -2710,9 +2762,9 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2710
2762
  cleanup.push(()=>{
2711
2763
  statsObserver?.disconnect();
2712
2764
  if (statsTimer !== null) clearTimeout(statsTimer);
2713
- overlay?.destroy();
2765
+ stats.destroy();
2714
2766
  });
2715
- api('session').then((info)=>{
2767
+ api(site.siteApi.session()).then((info)=>{
2716
2768
  session = info;
2717
2769
  setState({
2718
2770
  features: info.features
@@ -3228,7 +3280,7 @@ const mountSiteEditor = (doc, bridge, options = {})=>{
3228
3280
  });
3229
3281
  const shopButtonText = element(doc, 'span');
3230
3282
  const shopButton = button('button primary', icon('plus'), shopButtonText);
3231
- const shop = element(doc, 'div', {
3283
+ const shopBox = element(doc, 'div', {
3232
3284
  className: 'shop'
3233
3285
  }, shopText, shopButton);
3234
3286
  const log = element(doc, 'div', {
@@ -3237,7 +3289,7 @@ const mountSiteEditor = (doc, bridge, options = {})=>{
3237
3289
  log.setAttribute('role', 'log');
3238
3290
  const scroll = element(doc, 'div', {
3239
3291
  className: 'scroll'
3240
- }, note, intro, shop, log);
3292
+ }, note, intro, shopBox, log);
3241
3293
  const pathInput = element(doc, 'input', {
3242
3294
  type: 'text',
3243
3295
  placeholder: labels.pathPlaceholder,
@@ -3644,14 +3696,18 @@ const mountSiteEditor = (doc, bridge, options = {})=>{
3644
3696
  };
3645
3697
  const render = (state)=>{
3646
3698
  if (destroyed) return;
3647
- const available = state.features.includes('prompt');
3648
- const storing = state.saving || state.publishing;
3649
- 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');
3650
3709
  const shown = available && open;
3651
- const { site, canCreate, pageless, shopHere, onlyNew } = pageContext(bridge, state, options.promptWithoutPage);
3652
- if (!canCreate) creating = false;
3653
- if (onlyNew) creating = true;
3654
- const hasForm = site && !creating && !pageless && state.features.includes('form');
3710
+ creating = page.creating;
3655
3711
  view = hasForm ? mode : 'ask';
3656
3712
  const waiting = pending().length > 0;
3657
3713
  if (!waiting || view === 'fields') gated = false;
@@ -3667,7 +3723,7 @@ const mountSiteEditor = (doc, bridge, options = {})=>{
3667
3723
  eye.setAttribute('aria-pressed', String(!outlines));
3668
3724
  eyeOn.hidden = !outlines;
3669
3725
  eyeOff.hidden = outlines;
3670
- exit.hidden = options.signOut === false;
3726
+ exit.hidden = !page.showSignOut;
3671
3727
  // The site beside the panel, wide enough; a sheet over it otherwise.
3672
3728
  const docked = shown && wide;
3673
3729
  html.toggleAttribute('data-obelism-docked', docked);
@@ -3697,7 +3753,7 @@ const mountSiteEditor = (doc, bridge, options = {})=>{
3697
3753
  gateKeep.disabled = gateUndo.disabled = busy;
3698
3754
  ask.hidden = view !== 'ask';
3699
3755
  fields.hidden = view !== 'fields';
3700
- const offerShop = shopHere !== null && !creating;
3756
+ const offerShop = page.offerShop;
3701
3757
  if (creating) {
3702
3758
  noteText.textContent = labels.newPage;
3703
3759
  } else if (state.selectedUid) {
@@ -3724,26 +3780,26 @@ const mountSiteEditor = (doc, bridge, options = {})=>{
3724
3780
  up.hidden = creating || !state.selectedUid;
3725
3781
  cancelNew.hidden = !creating || onlyNew;
3726
3782
  intro.hidden = answers.length > 0 || offerShop || creating;
3727
- shop.hidden = !offerShop;
3728
- const shopWords = shopCopy(labels, shopHere);
3729
- 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 : '';
3730
3786
  shopButton.disabled = busy;
3731
- shopText.textContent = shopMessage?.text ?? shopWords.hint;
3787
+ shopText.textContent = shopMessage?.text ?? shopWords?.hint ?? '';
3732
3788
  shopText.className = shopMessage?.error ? 'failure' : 'summary';
3733
3789
  composer.hidden = offerShop;
3734
3790
  pathRow.hidden = !creating;
3735
3791
  pathInput.disabled = busy;
3736
- input.placeholder = creating ? labels.newPagePlaceholder : labels.placeholder;
3792
+ input.placeholder = page.placeholder;
3737
3793
  input.disabled = busy;
3738
- send.disabled = busy || input.value.trim() === '' || creating && pathInput.value.trim() === '';
3794
+ send.disabled = !page.canSend(input.value, pathInput.value);
3739
3795
  send.setAttribute('aria-busy', String(state.prompting));
3740
3796
  sendIcon.hidden = state.prompting;
3741
3797
  sendWorking.hidden = !state.prompting;
3742
3798
  undo.hidden = waiting || !state.canUndo || creating;
3743
3799
  undo.disabled = busy;
3744
- save.hidden = !state.features.includes('save') || pageless;
3800
+ save.hidden = !page.canSave;
3745
3801
  save.disabled = busy || waiting || !state.unsaved;
3746
- publish.hidden = !state.features.includes('publish') || pageless;
3802
+ publish.hidden = !page.canPublish;
3747
3803
  publish.disabled = busy || waiting;
3748
3804
  if (options.actions) options.actions.slot = shown ? 'actions-footer' : 'actions-bar';
3749
3805
  foot.hidden = !site;
@@ -3781,10 +3837,6 @@ const mountSiteEditor = (doc, bridge, options = {})=>{
3781
3837
  scheduleMarks();
3782
3838
  };
3783
3839
  const refresh = ()=>render(bridge.getState());
3784
- const grow = ()=>{
3785
- input.style.height = 'auto';
3786
- input.style.height = `${input.scrollHeight}px`;
3787
- };
3788
3840
  const scrollDown = ()=>{
3789
3841
  scroll.scrollTop = scroll.scrollHeight;
3790
3842
  };
@@ -3913,7 +3965,7 @@ const mountSiteEditor = (doc, bridge, options = {})=>{
3913
3965
  textContent: `${labels.you} (${scopeName}): `
3914
3966
  }), prompt));
3915
3967
  input.value = '';
3916
- grow();
3968
+ fitToText(input);
3917
3969
  const answer = addAnswer(prompt, uid);
3918
3970
  refresh();
3919
3971
  scrollDown();
@@ -3997,7 +4049,7 @@ const mountSiteEditor = (doc, bridge, options = {})=>{
3997
4049
  void submit();
3998
4050
  });
3999
4051
  input.addEventListener('input', ()=>{
4000
- grow();
4052
+ fitToText(input);
4001
4053
  refresh();
4002
4054
  });
4003
4055
  input.addEventListener('keydown', (event)=>{
@@ -4086,11 +4138,8 @@ const mountSiteEditor = (doc, bridge, options = {})=>{
4086
4138
  // Kept on the window, so two copies of this package on one page still share
4087
4139
  // one bridge.
4088
4140
  const KEY = Symbol.for('@obelism/content/bridge');
4089
- const SERVER_BRIDGE = wrapScriptBridge({
4090
- isEditing: false,
4091
- entryId: null,
4092
- on: ()=>{}
4093
- }, new URLSearchParams());
4141
+ // The bridge on the server, where there is nothing to edit.
4142
+ const SERVER_BRIDGE = createInertBridge();
4094
4143
  // The page's bridge if something already started it, else null. Never
4095
4144
  // starts one: for code that has to stay side-effect free, like a React
4096
4145
  // store's snapshot.