@obelism/content 0.28.3 → 0.29.0

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