@obelism/content 0.28.0 → 0.28.2

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
@@ -4,18 +4,21 @@ var preview = require('./preview-c4giufnr.js');
4
4
  var site = require('./site.js');
5
5
  var config = require('./config-ci2hxmas.js');
6
6
 
7
- // Click rates on the page: in the visual editor, a small label on each block
8
- // that numbers has clicks for ("24% clicked"), from the editor's `stats`
9
- // message. The editor's "Show click rates" switch turns it on and off. Plain
10
- // DOM in a shadow root, like the prompt box, in a fixed layer that follows
11
- // the blocks as the page scrolls, resizes and re-renders.
12
- //
13
- // Sites tag a block's clickable element with `data-numbers-id={block._uid}`
14
- // and load Obelism numbers' script; the CMS reads the rates from numbers
15
- // (the CMS's content/docs/guides/canvas.mdx, "Click rates").
16
7
  const DEFAULT_STATS_LABELS = {
17
8
  clicked: '{rate}% clicked',
18
- title: '{clickers} of {viewers} visitors of its page clicked it in the last 30 days ({clicks} clicks)'
9
+ clickedShort: '{rate}%',
10
+ title: '{clickers} of {viewers} visitors of its page clicked it in the last 30 days ({clicks} clicks)',
11
+ noClicks: 'No clicks yet in the last 30 days',
12
+ counting: '{clicks} clicks so far: a click rate once {needed} visitors saw its page ({viewers} yet)'
13
+ };
14
+ // Fewer visitors than this make a rate noise.
15
+ const MIN_VIEWERS = 20;
16
+ const TAG_ATTRIBUTE = 'data-numbers-id';
17
+ const statsStatus = (block)=>!block || block.clicks === 0 ? 'none' : block.viewers < MIN_VIEWERS ? 'counting' : 'rate';
18
+ // Lucide's x and hourglass.
19
+ const ICONS$1 = {
20
+ none: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
21
+ counting: '<path d="M5 22h14"/><path d="M5 2h14"/><path d="M17 22v-4.172a2 2 0 0 0-.586-1.414L12 12l-4.414 4.414A2 2 0 0 0 7 17.828V22"/><path d="M7 2v4.172a2 2 0 0 0 .586 1.414L12 12l4.414-4.414A2 2 0 0 0 17 6.172V2"/>'
19
22
  };
20
23
  const fill$1 = (template, values)=>template.replace(/\{(\w+)\}/g, (match, key)=>key in values ? String(values[key]) : match);
21
24
  // The same tokens as the prompt box: an ink pill, paper text, the orange
@@ -41,37 +44,42 @@ const CSS$2 = `
41
44
  content: ''; width: 8px; height: 8px; border-radius: 50%;
42
45
  background: rgb(212 130 30);
43
46
  }
44
- .badge[data-place='beside'] { transform: translateY(-50%); }
45
- .badge[data-place='above'] { transform: translate(-100%, -100%); }
46
- .badge:focus-visible { outline: 2px solid rgb(212 130 30); outline-offset: 2px; }
47
+ .badge:focus-visible, .tag:focus-visible { outline: 2px solid rgb(212 130 30); outline-offset: 2px; }
48
+ .tag {
49
+ position: absolute; transform: translate(-50%, -50%);
50
+ display: grid; place-items: center; width: 20px; height: 20px; padding: 0;
51
+ cursor: pointer; pointer-events: auto; border: 0; border-radius: 50%;
52
+ color: rgb(242 240 236); background: rgb(23 22 20 / .88);
53
+ box-shadow: 0 4px 10px -6px rgb(23 22 20 / .5);
54
+ }
55
+ .tag::after { content: ''; position: absolute; inset: -6px; }
56
+ .tag[data-status='counting'] { color: rgb(212 130 30); }
57
+ .tag[data-status='rate'] {
58
+ width: auto; height: 20px; padding: 0 7px; border-radius: 5em;
59
+ font-size: 11px; transform: translate(-70%, -50%);
60
+ }
61
+ .tag svg {
62
+ width: 12px; height: 12px; fill: none; stroke: currentColor;
63
+ stroke-width: 2.5; stroke-linecap: round; stroke-linejoin: round;
64
+ }
47
65
  `;
48
- // Inset from a block's top-right corner, or gap beside a small element.
49
66
  const INSET = 8;
50
- // Below this an element is a button or a link, not a section: its label goes
51
- // beside it (or above, without room on the right) so it doesn't cover it.
67
+ // Below this an element is a button or a link, not a section: a tag on its
68
+ // corner rather than a label over it.
52
69
  const SMALL_HEIGHT = 64;
53
- // Room a label needs beside an element.
54
- const LABEL_WIDTH = 140;
55
- // Where a label goes for an element's box: inside the top-right corner of a
56
- // block, beside a small element, or above one at the right edge.
57
- const placeLabel = (rect, viewportWidth)=>{
58
- if (rect.height >= SMALL_HEIGHT) return {
59
- place: 'inside',
70
+ // A block's label: inside its top-right corner, in view when it starts above
71
+ // the screen.
72
+ const placeLabel = (rect)=>({
60
73
  top: Math.max(rect.top, 0) + INSET,
61
74
  left: rect.right - INSET
62
- };
63
- if (rect.right + INSET + LABEL_WIDTH <= viewportWidth) return {
64
- place: 'beside',
65
- top: rect.top + rect.height / 2,
66
- left: rect.right + INSET
67
- };
68
- return {
69
- place: 'above',
70
- top: rect.top - INSET / 2,
71
- left: rect.right
72
- };
73
- };
74
- const mountStatsOverlay = (win, { selector, onSelect, labels: labelOverrides })=>{
75
+ });
76
+ // A tag's: on the element's top-right corner, like a notification badge,
77
+ // kept on screen.
78
+ const placeTag = (rect, viewport)=>({
79
+ top: Math.min(Math.max(rect.top, 10), viewport.height - 10),
80
+ left: Math.min(rect.right, viewport.width - 10)
81
+ });
82
+ const mountStatsOverlay = (win, { selector, onSelect, checked, labels: labelOverrides })=>{
75
83
  const doc = win.document;
76
84
  const labels = {
77
85
  ...DEFAULT_STATS_LABELS,
@@ -91,37 +99,71 @@ const mountStatsOverlay = (win, { selector, onSelect, labels: labelOverrides })=
91
99
  let stats = {};
92
100
  let visible = false;
93
101
  let frame = null;
102
+ const draw = (id, element, blocks)=>{
103
+ const rect = element.getBoundingClientRect();
104
+ // Not rendered, or off screen.
105
+ if (!rect.width && !rect.height) return;
106
+ if (rect.bottom < 0 || rect.top > win.innerHeight) return;
107
+ const block = stats[id];
108
+ const status = statsStatus(block);
109
+ const label = doc.createElement('button');
110
+ label.type = 'button';
111
+ label.dataset.uid = id;
112
+ label.dataset.status = status;
113
+ const rate = block ? Math.round(block.rate * 100) : 0;
114
+ label.title = status === 'rate' ? fill$1(labels.title, {
115
+ clickers: block.clickers,
116
+ viewers: block.viewers,
117
+ clicks: block.clicks
118
+ }) : status === 'none' ? labels.noClicks : fill$1(labels.counting, {
119
+ clicks: block.clicks,
120
+ viewers: block.viewers,
121
+ needed: MIN_VIEWERS
122
+ });
123
+ const { top, left } = status === 'rate' && rect.height >= SMALL_HEIGHT ? placeLabel(rect) : placeTag(rect, {
124
+ width: win.innerWidth,
125
+ height: win.innerHeight
126
+ });
127
+ if (status === 'rate' && rect.height >= SMALL_HEIGHT) {
128
+ label.className = 'badge';
129
+ label.textContent = fill$1(labels.clicked, {
130
+ rate
131
+ });
132
+ } else {
133
+ label.className = 'tag';
134
+ if (status === 'rate') label.textContent = fill$1(labels.clickedShort, {
135
+ rate
136
+ });
137
+ else label.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true">${ICONS$1[status]}</svg>`;
138
+ label.setAttribute('aria-label', label.title);
139
+ }
140
+ label.style.top = `${top}px`;
141
+ label.style.left = `${left}px`;
142
+ // Its id names a block by convention; else the block around it.
143
+ const uid = blocks.has(id) ? id : element.closest(`[${preview.UID_ATTRIBUTE}]`)?.getAttribute(preview.UID_ATTRIBUTE);
144
+ if (uid) label.addEventListener('click', ()=>onSelect(uid));
145
+ layer.appendChild(label);
146
+ };
94
147
  const render = ()=>{
95
148
  frame = null;
96
149
  layer.replaceChildren();
97
150
  layer.hidden = !visible;
98
151
  if (!visible) return;
99
- const height = win.innerHeight;
100
- for (const [uid, block] of Object.entries(stats)){
101
- const element = doc.querySelector(selector(uid));
102
- if (!element) continue;
103
- const rect = element.getBoundingClientRect();
104
- // Not rendered, or off screen.
105
- if (!rect.width && !rect.height) continue;
106
- if (rect.bottom < 0 || rect.top > height) continue;
107
- const badge = doc.createElement('button');
108
- badge.type = 'button';
109
- badge.className = 'badge';
110
- badge.dataset.uid = uid;
111
- badge.textContent = fill$1(labels.clicked, {
112
- rate: Math.round(block.rate * 100)
113
- });
114
- badge.title = fill$1(labels.title, {
115
- clickers: block.clickers,
116
- viewers: block.viewers,
117
- clicks: block.clicks
118
- });
119
- const { place, top, left } = placeLabel(rect, win.innerWidth);
120
- badge.dataset.place = place;
121
- badge.style.top = `${top}px`;
122
- badge.style.left = `${left}px`;
123
- badge.addEventListener('click', ()=>onSelect(uid));
124
- layer.appendChild(badge);
152
+ const blocks = new Set();
153
+ for (const element of doc.querySelectorAll(`[${preview.UID_ATTRIBUTE}]`))blocks.add(element.getAttribute(preview.UID_ATTRIBUTE) ?? '');
154
+ const isChecked = checked ?? ((id)=>blocks.has(id));
155
+ const tagged = new Set();
156
+ for (const element of doc.querySelectorAll(`[${TAG_ATTRIBUTE}]`)){
157
+ const id = element.getAttribute(TAG_ATTRIBUTE);
158
+ if (!id || !stats[id] && !isChecked(id)) continue;
159
+ tagged.add(id);
160
+ draw(id, element, blocks);
161
+ }
162
+ // Rates of blocks the site doesn't tag an element of.
163
+ for (const id of Object.keys(stats)){
164
+ if (tagged.has(id) || !blocks.has(id)) continue;
165
+ const element = doc.querySelector(selector(id));
166
+ if (element) draw(id, element, blocks);
125
167
  }
126
168
  };
127
169
  // At most once a frame, however many scroll and mutation events come in.
@@ -786,6 +828,7 @@ const DEFAULT_SITE_EDITOR_LABELS = {
786
828
  envProduction: 'live',
787
829
  scopeNoteBlock: 'Working on {name}. Click another block to switch.',
788
830
  scopeNotePage: 'Working on the whole page. Click a block to change only that block.',
831
+ scopeUp: 'Up to {name}',
789
832
  block: 'Block',
790
833
  previewChip: '{name} · preview',
791
834
  askIntro: 'Describe a change and Obelism shows it on the page. Nothing is stored until you keep it and save.',
@@ -1950,6 +1993,11 @@ const mountFormFrame = (win, container, url, title, handlers)=>{
1950
1993
  // kept in step with the content here, see formFrame.ts), edits JSON (`view`,
1951
1994
  // then `applyJson`: the raw alternative), and lists the space's pages to go
1952
1995
  // to one.
1996
+ // The CMS's limit on ids in one stats request.
1997
+ const MAX_STATS_IDS = 500;
1998
+ const MAX_STATS_ENTRIES = 20;
1999
+ // After the page changes, before asking for the ids it newly tags.
2000
+ const STATS_DELAY_MS = 1000;
1953
2001
  // Plain path segments only: the URL parser drops tabs and newlines and reads
1954
2002
  // \ as /, so anything looser can resolve to another origin.
1955
2003
  const BASE_PATH = /^(?:\/[\w.~-]+)+$/;
@@ -2536,8 +2584,10 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2536
2584
  if (selecting) doc.documentElement.setAttribute('data-cms-selecting', '');
2537
2585
  else doc.documentElement.removeAttribute('data-cms-selecting');
2538
2586
  setState({
2539
- selecting
2587
+ selecting,
2588
+ statsVisible: selecting
2540
2589
  });
2590
+ showStats();
2541
2591
  },
2542
2592
  destroy () {
2543
2593
  closeForm?.();
@@ -2571,11 +2621,81 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2571
2621
  };
2572
2622
  win.addEventListener('beforeunload', onBeforeUnload);
2573
2623
  cleanup.push(()=>win.removeEventListener('beforeunload', onBeforeUnload));
2624
+ // The tags on the page's links (statsOverlay.ts) while blocks are
2625
+ // outlined: the rates of the ids the page tags, asked for as they appear.
2626
+ const asked = new Set();
2627
+ const answered = new Set();
2628
+ let statsOff = false;
2629
+ let overlay = null;
2630
+ let statsTimer = null;
2631
+ const showStats = ()=>overlay?.update(state.stats, state.statsVisible);
2632
+ const loadStats = async ()=>{
2633
+ if (statsOff) return;
2634
+ const ids = [
2635
+ ...new Set([
2636
+ ...doc.querySelectorAll(`[${TAG_ATTRIBUTE}]`)
2637
+ ].map((element)=>element.getAttribute(TAG_ATTRIBUTE) ?? ''))
2638
+ ].filter((id)=>id && !asked.has(id));
2639
+ const batch = ids.slice(0, MAX_STATS_IDS);
2640
+ if (!batch.length) return;
2641
+ batch.forEach((id)=>asked.add(id));
2642
+ // So a draft's copy of a live block gets the live one's clicks.
2643
+ const entries = [
2644
+ ...new Set([
2645
+ ...doc.querySelectorAll(`[${preview.ENTRY_ATTRIBUTE}]`)
2646
+ ].map((element)=>element.getAttribute(preview.ENTRY_ATTRIBUTE) ?? ''))
2647
+ ].filter(Boolean).slice(0, MAX_STATS_ENTRIES);
2648
+ try {
2649
+ const { stats } = await api('stats', {
2650
+ uids: batch,
2651
+ ...entries.length > 0 && {
2652
+ entries
2653
+ }
2654
+ });
2655
+ batch.forEach((id)=>answered.add(id));
2656
+ setState({
2657
+ stats: {
2658
+ ...state.stats,
2659
+ ...readStats(stats)
2660
+ }
2661
+ });
2662
+ } catch {
2663
+ // No tags rather than wrong ones, and no asking again.
2664
+ statsOff = true;
2665
+ return;
2666
+ }
2667
+ overlay ??= mountStatsOverlay(win, {
2668
+ selector: blockSelector,
2669
+ onSelect: (uid)=>highlight(uid),
2670
+ checked: (id)=>answered.has(id)
2671
+ });
2672
+ showStats();
2673
+ if (ids.length > batch.length) scheduleStats();
2674
+ };
2675
+ const scheduleStats = ()=>{
2676
+ if (statsTimer !== null || statsOff) return;
2677
+ statsTimer = setTimeout(()=>{
2678
+ statsTimer = null;
2679
+ void loadStats();
2680
+ }, STATS_DELAY_MS);
2681
+ };
2682
+ const Observer = win.MutationObserver;
2683
+ const statsObserver = typeof Observer === 'function' ? new Observer(scheduleStats) : null;
2684
+ cleanup.push(()=>{
2685
+ statsObserver?.disconnect();
2686
+ if (statsTimer !== null) clearTimeout(statsTimer);
2687
+ overlay?.destroy();
2688
+ });
2574
2689
  api('session').then((info)=>{
2575
2690
  session = info;
2576
2691
  setState({
2577
2692
  features: info.features
2578
2693
  });
2694
+ void loadStats();
2695
+ statsObserver?.observe(doc.body, {
2696
+ childList: true,
2697
+ subtree: true
2698
+ });
2579
2699
  }).catch((error)=>console.error('[@obelism/content bridge]', error));
2580
2700
  return bridge;
2581
2701
  };
@@ -2759,6 +2879,7 @@ button:disabled { cursor: not-allowed; }
2759
2879
  flex: none; font-size: 12px; font-weight: 500; text-decoration: underline; text-underline-offset: 2px;
2760
2880
  }
2761
2881
  .note button::after { content: ''; position: absolute; inset: -13px -6px; }
2882
+ .scroll > .note { position: sticky; top: 0; z-index: 1; }
2762
2883
  .intro { color: rgb(var(--muted)); font-size: 13px; }
2763
2884
  .gate {
2764
2885
  display: grid; gap: 8px; margin: 10px 14px 0; padding: 10px 12px; border-radius: 12px;
@@ -3067,11 +3188,11 @@ const mountSiteEditor = (doc, bridge, options = {})=>{
3067
3188
  }, gateKeep, gateUndo));
3068
3189
  gate.setAttribute('role', 'alert');
3069
3190
  const noteText = element(doc, 'span');
3070
- const wholePage = button('', labels.scopeEntry);
3191
+ const up = button('', labels.scopeEntry);
3071
3192
  const cancelNew = button('', labels.newPageCancel);
3072
3193
  const note = element(doc, 'div', {
3073
3194
  className: 'note'
3074
- }, icon('scope'), noteText, wholePage, cancelNew);
3195
+ }, icon('scope'), noteText, up, cancelNew);
3075
3196
  const intro = element(doc, 'p', {
3076
3197
  className: 'intro',
3077
3198
  textContent: labels.askIntro
@@ -3137,10 +3258,10 @@ const mountSiteEditor = (doc, bridge, options = {})=>{
3137
3258
  }, formNote);
3138
3259
  // The same way back as the ask view's note, above the form it changes.
3139
3260
  const fieldsNoteText = element(doc, 'span');
3140
- const wholePageFields = button('', labels.scopeEntry);
3261
+ const upFields = button('', labels.scopeEntry);
3141
3262
  const fieldsNote = element(doc, 'div', {
3142
3263
  className: 'note'
3143
- }, icon('scope'), fieldsNoteText, wholePageFields);
3264
+ }, icon('scope'), fieldsNoteText, upFields);
3144
3265
  const fields = element(doc, 'div', {
3145
3266
  className: 'fields',
3146
3267
  id: 'obelism-fields'
@@ -3220,6 +3341,7 @@ const mountSiteEditor = (doc, bridge, options = {})=>{
3220
3341
  let hoverUid = null;
3221
3342
  // Block names by uid, from the views; null when it couldn't be read.
3222
3343
  const names = new Map();
3344
+ const parents = new Map();
3223
3345
  let pageNames = 'idle';
3224
3346
  let destroyed = false;
3225
3347
  const formSlot = createFormSlot(bridge, formBox, labels.formTitle, {
@@ -3255,13 +3377,21 @@ const mountSiteEditor = (doc, bridge, options = {})=>{
3255
3377
  }
3256
3378
  return names.get(uid) ?? labels.block;
3257
3379
  };
3258
- const readNames = (value)=>{
3259
- if (Array.isArray(value)) value.forEach(readNames);
3380
+ const readNames = (value, parent = null)=>{
3381
+ if (Array.isArray(value)) value.forEach((item)=>readNames(item, parent));
3260
3382
  else if (isRecord(value)) {
3261
- if (typeof value._uid === 'string' && typeof value._type === 'string') names.set(value._uid, humanize(value._type));
3262
- Object.values(value).forEach(readNames);
3383
+ let uid = parent;
3384
+ if (typeof value._uid === 'string' && typeof value._type === 'string') {
3385
+ uid = value._uid;
3386
+ parents.set(uid, parent);
3387
+ names.set(uid, humanize(value._type));
3388
+ }
3389
+ Object.values(value).forEach((item)=>readNames(item, uid));
3263
3390
  }
3264
3391
  };
3392
+ // The block around `uid`, null at the top of the page: by the page as it
3393
+ // is now, else by its view, for a block whose wrapper the site doesn't mark.
3394
+ const parentOf = (uid)=>doc.querySelector(blockSelector(uid))?.parentElement?.closest(`[${preview.UID_ATTRIBUTE}]`)?.getAttribute(preview.UID_ATTRIBUTE) ?? parents.get(uid) ?? null;
3265
3395
  const readPageNames = ()=>{
3266
3396
  if (pageNames !== 'idle' || bridge.entryId === null) return;
3267
3397
  pageNames = 'loading';
@@ -3555,9 +3685,17 @@ const mountSiteEditor = (doc, bridge, options = {})=>{
3555
3685
  } else {
3556
3686
  noteText.textContent = labels.scopeNotePage;
3557
3687
  }
3688
+ const parent = state.selectedUid ? parentOf(state.selectedUid) : null;
3689
+ const upText = parent ? fill(labels.scopeUp, {
3690
+ name: nameOf(parent)
3691
+ }) : labels.scopeEntry;
3692
+ for (const link of [
3693
+ up,
3694
+ upFields
3695
+ ])if (link.textContent !== upText) link.textContent = upText;
3558
3696
  fieldsNote.hidden = creating || !state.selectedUid;
3559
3697
  note.hidden = offerShop || pageless && !creating && !options.promptWithoutPage;
3560
- wholePage.hidden = creating || !state.selectedUid;
3698
+ up.hidden = creating || !state.selectedUid;
3561
3699
  cancelNew.hidden = !creating || onlyNew;
3562
3700
  intro.hidden = answers.length > 0 || offerShop || creating;
3563
3701
  shop.hidden = !offerShop;
@@ -3667,13 +3805,19 @@ const mountSiteEditor = (doc, bridge, options = {})=>{
3667
3805
  refresh();
3668
3806
  (picking ? picker.search : pageButton).focus();
3669
3807
  };
3670
- // Escape, on the page or in the form: from the pages, from a block to the
3671
- // whole page. True when it did something.
3808
+ // True once back at the page.
3809
+ const stepUp = ()=>{
3810
+ const { selectedUid } = bridge.getState();
3811
+ if (selectedUid) bridge.select(parentOf(selectedUid));
3812
+ return !bridge.getState().selectedUid;
3813
+ };
3814
+ // Escape, on the page or in the form: from the pages, from a block a level
3815
+ // up. True when it did something.
3672
3816
  const stepBack = ()=>{
3673
3817
  const state = bridge.getState();
3674
3818
  if (!panelShown(state)) return false;
3675
3819
  if (picking) setPicking(false);
3676
- else if (state.selectedUid) bridge.select(null);
3820
+ else if (state.selectedUid) stepUp();
3677
3821
  else return false;
3678
3822
  return true;
3679
3823
  };
@@ -3787,13 +3931,11 @@ const mountSiteEditor = (doc, bridge, options = {})=>{
3787
3931
  pageButton.addEventListener('click', ()=>setPicking(!picking));
3788
3932
  newPage.addEventListener('click', ()=>setCreating(true));
3789
3933
  cancelNew.addEventListener('click', ()=>setCreating(false));
3790
- wholePage.addEventListener('click', ()=>{
3791
- bridge.select(null);
3792
- input.focus();
3934
+ up.addEventListener('click', ()=>{
3935
+ if (stepUp()) input.focus();
3793
3936
  });
3794
- wholePageFields.addEventListener('click', ()=>{
3795
- bridge.select(null);
3796
- focusForm();
3937
+ upFields.addEventListener('click', ()=>{
3938
+ if (stepUp()) focusForm();
3797
3939
  });
3798
3940
  shopButton.addEventListener('click', async ()=>{
3799
3941
  shopMessage = null;