@obelism/content 0.5.0 → 0.7.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.js CHANGED
@@ -4,6 +4,167 @@ var preview = require('./preview-mpwfmnla.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/visual-editor.mdx, "Click rates").
16
+ const DEFAULT_STATS_LABELS = {
17
+ clicked: '{rate}% clicked',
18
+ title: '{clickers} of {viewers} visitors of its page clicked it in the last 30 days ({clicks} clicks)'
19
+ };
20
+ const fill = (template, values)=>template.replace(/\{(\w+)\}/g, (match, key)=>key in values ? String(values[key]) : match);
21
+ // The same tokens as the prompt box: near-black pill, beige text, the orange
22
+ // accent for the rate.
23
+ const CSS$1 = `
24
+ :host { all: initial; }
25
+ .layer {
26
+ position: fixed; inset: 0; pointer-events: none;
27
+ z-index: 2147483646;
28
+ font: 600 12px/1 'InterVariable', 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
29
+ -webkit-font-smoothing: antialiased;
30
+ }
31
+ .badge {
32
+ position: absolute; transform: translateX(-100%);
33
+ display: inline-flex; gap: 6px; align-items: center;
34
+ padding: 6px 10px; white-space: nowrap; cursor: pointer;
35
+ pointer-events: auto; border: 0;
36
+ color: rgb(238 236 233); background: rgb(12 12 12 / .88);
37
+ border-radius: 5em; box-shadow: 0 6px 16px -8px rgb(12 12 12 / .5);
38
+ font: inherit;
39
+ }
40
+ .badge::before {
41
+ content: ''; width: 8px; height: 8px; border-radius: 50%;
42
+ background: rgb(212 130 30);
43
+ }
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
+ `;
48
+ // Inset from a block's top-right corner, or gap beside a small element.
49
+ 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.
52
+ 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',
60
+ top: Math.max(rect.top, 0) + INSET,
61
+ 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
+ const doc = win.document;
76
+ const labels = {
77
+ ...DEFAULT_STATS_LABELS,
78
+ ...labelOverrides
79
+ };
80
+ const host = doc.createElement('div');
81
+ host.setAttribute('data-obelism-stats', '');
82
+ const shadow = host.attachShadow({
83
+ mode: 'open'
84
+ });
85
+ const style = doc.createElement('style');
86
+ style.textContent = CSS$1;
87
+ const layer = doc.createElement('div');
88
+ layer.className = 'layer';
89
+ shadow.append(style, layer);
90
+ doc.body.appendChild(host);
91
+ let stats = {};
92
+ let visible = false;
93
+ let frame = null;
94
+ const render = ()=>{
95
+ frame = null;
96
+ layer.replaceChildren();
97
+ layer.hidden = !visible;
98
+ 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(labels.clicked, {
112
+ rate: Math.round(block.rate * 100)
113
+ });
114
+ badge.title = fill(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);
125
+ }
126
+ };
127
+ // At most once a frame, however many scroll and mutation events come in.
128
+ const schedule = ()=>{
129
+ if (frame !== null || !visible) return;
130
+ frame = win.requestAnimationFrame(render);
131
+ };
132
+ win.addEventListener('scroll', schedule, {
133
+ capture: true,
134
+ passive: true
135
+ });
136
+ win.addEventListener('resize', schedule);
137
+ // The site re-renders blocks on every `update`; follow them. Our own
138
+ // layer lives in a shadow root, so its changes don't come back here.
139
+ const Observer = win.MutationObserver;
140
+ const observer = typeof Observer === 'function' ? new Observer(schedule) : null;
141
+ observer?.observe(doc.body, {
142
+ childList: true,
143
+ subtree: true
144
+ });
145
+ return {
146
+ update (nextStats, nextVisible) {
147
+ stats = nextStats;
148
+ visible = nextVisible;
149
+ if (frame !== null) {
150
+ win.cancelAnimationFrame?.(frame);
151
+ frame = null;
152
+ }
153
+ render();
154
+ },
155
+ refresh: schedule,
156
+ destroy () {
157
+ if (frame !== null) win.cancelAnimationFrame?.(frame);
158
+ win.removeEventListener('scroll', schedule, {
159
+ capture: true
160
+ });
161
+ win.removeEventListener('resize', schedule);
162
+ observer?.disconnect();
163
+ host.remove();
164
+ }
165
+ };
166
+ };
167
+
7
168
  // The site's half of the visual editor (the CMS's public/bridge.js, as a
8
169
  // module). Inside the editor's iframe it outlines blocks, tells the editor
9
170
  // which one was clicked, hands the editor's live, unsaved entry to listeners
@@ -15,7 +176,7 @@ var config = require('./config-ci2hxmas.js');
15
176
  // 'select' | 'prompt' | 'undo', uid?, id?, prompt? }
16
177
  // - editor → site: { source: 'obelism-content-editor', type: 'connect' |
17
178
  // 'update' | 'select' | 'saved' | 'published' | 'features' |
18
- // 'promptResult' | 'promptState', … }
179
+ // 'promptResult' | 'promptState' | 'stats', … }
19
180
  const BRIDGE_SOURCE = 'obelism-content-bridge';
20
181
  const EDITOR_SOURCE = 'obelism-content-editor';
21
182
  // How long a prompt may take before it counts as failed.
@@ -31,7 +192,9 @@ const INITIAL_STATE = {
31
192
  unsaved: false,
32
193
  saving: false,
33
194
  publishing: false,
34
- selecting: false
195
+ selecting: false,
196
+ stats: {},
197
+ statsVisible: false
35
198
  };
36
199
  const createEmitter = ()=>{
37
200
  const listeners = {
@@ -72,6 +235,22 @@ const INERT_METHODS = {
72
235
  select: ()=>{},
73
236
  setSelecting: ()=>{}
74
237
  };
238
+ const isCount = (value)=>typeof value === 'number' && Number.isFinite(value) && value >= 0;
239
+ // The editor's `stats`, keeping only well-formed entries.
240
+ const readStats = (value)=>{
241
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
242
+ const stats = {};
243
+ for (const [uid, block] of Object.entries(value)){
244
+ const { clicks, clickers, viewers, rate } = block ?? {};
245
+ if (isCount(clicks) && isCount(clickers) && isCount(viewers) && isCount(rate)) stats[uid] = {
246
+ clicks,
247
+ clickers,
248
+ viewers,
249
+ rate
250
+ };
251
+ }
252
+ return stats;
253
+ };
75
254
  // The selector of the element marked with a block's uid.
76
255
  const blockSelector = (uid)=>`[${preview.UID_ATTRIBUTE}="${uid.replace(/["\\]/g, '\\$&')}"]`;
77
256
  // `cmsOrigin` is the only origin messages are accepted from and sent to.
@@ -200,6 +379,17 @@ const createBridge = (win, cmsOrigin)=>{
200
379
  uid
201
380
  });
202
381
  };
382
+ // Mounted with the first rates, so pages without numbers never get it.
383
+ let overlay = null;
384
+ const showStats = ()=>{
385
+ if (!overlay && !Object.keys(state.stats).length) return;
386
+ overlay ??= mountStatsOverlay(win, {
387
+ selector: blockSelector,
388
+ onSelect: (uid)=>bridge.select(uid)
389
+ });
390
+ overlay.update(state.stats, state.statsVisible);
391
+ };
392
+ cleanup.push(()=>overlay?.destroy());
203
393
  cleanup.push(()=>{
204
394
  for (const id of pending.keys()){
205
395
  settle(id)?.reject(new Error('The bridge was destroyed'));
@@ -220,6 +410,7 @@ const createBridge = (win, cmsOrigin)=>{
220
410
  });
221
411
  // Re-apply the selection to whatever the site rendered.
222
412
  win.requestAnimationFrame(()=>highlight(data.selectedUid, false));
413
+ overlay?.refresh();
223
414
  } else if (data.type === 'select') {
224
415
  highlight(data.uid, true);
225
416
  } else if (data.type === 'saved' || data.type === 'published') {
@@ -229,6 +420,13 @@ const createBridge = (win, cmsOrigin)=>{
229
420
  setState({
230
421
  features: data.features.filter((feature)=>typeof feature === 'string')
231
422
  });
423
+ } else if (data.type === 'stats') {
424
+ // An editor from before the switch sends no `visible`: show them.
425
+ setState({
426
+ stats: readStats(data.stats),
427
+ statsVisible: data.visible !== false
428
+ });
429
+ showStats();
232
430
  } else if (data.type === 'promptState') {
233
431
  setState({
234
432
  canUndo: data.canUndo === true
@@ -1190,7 +1388,9 @@ const getContentBridge = ({ url, prompt = true, pagePath } = {})=>{
1190
1388
  };
1191
1389
 
1192
1390
  exports.DEFAULT_PROMPT_LABELS = DEFAULT_PROMPT_LABELS;
1391
+ exports.DEFAULT_STATS_LABELS = DEFAULT_STATS_LABELS;
1193
1392
  exports.INITIAL_STATE = INITIAL_STATE;
1194
1393
  exports.getContentBridge = getContentBridge;
1195
1394
  exports.mountPromptBox = mountPromptBox;
1395
+ exports.mountStatsOverlay = mountStatsOverlay;
1196
1396
  exports.peekContentBridge = peekContentBridge;
package/dist/bridge.mjs CHANGED
@@ -2,6 +2,167 @@ import { P as PREVIEW_PARAMS, U as UID_ATTRIBUTE, E as ENTRY_ATTRIBUTE } from '.
2
2
  import { SITE_EDITING_COOKIE, SITE_REQUEST_HEADER } from './site.mjs';
3
3
  import { n as normalizeUrl, D as DEFAULT_URL } from './config-c6qktlz6.mjs';
4
4
 
5
+ // Click rates on the page: in the visual editor, a small label on each block
6
+ // that numbers has clicks for ("24% clicked"), from the editor's `stats`
7
+ // message. The editor's "Show click rates" switch turns it on and off. Plain
8
+ // DOM in a shadow root, like the prompt box, in a fixed layer that follows
9
+ // the blocks as the page scrolls, resizes and re-renders.
10
+ //
11
+ // Sites tag a block's clickable element with `data-numbers-id={block._uid}`
12
+ // and load Obelism numbers' script; the CMS reads the rates from numbers
13
+ // (the CMS's content/docs/guides/visual-editor.mdx, "Click rates").
14
+ const DEFAULT_STATS_LABELS = {
15
+ clicked: '{rate}% clicked',
16
+ title: '{clickers} of {viewers} visitors of its page clicked it in the last 30 days ({clicks} clicks)'
17
+ };
18
+ const fill = (template, values)=>template.replace(/\{(\w+)\}/g, (match, key)=>key in values ? String(values[key]) : match);
19
+ // The same tokens as the prompt box: near-black pill, beige text, the orange
20
+ // accent for the rate.
21
+ const CSS$1 = `
22
+ :host { all: initial; }
23
+ .layer {
24
+ position: fixed; inset: 0; pointer-events: none;
25
+ z-index: 2147483646;
26
+ font: 600 12px/1 'InterVariable', 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
27
+ -webkit-font-smoothing: antialiased;
28
+ }
29
+ .badge {
30
+ position: absolute; transform: translateX(-100%);
31
+ display: inline-flex; gap: 6px; align-items: center;
32
+ padding: 6px 10px; white-space: nowrap; cursor: pointer;
33
+ pointer-events: auto; border: 0;
34
+ color: rgb(238 236 233); background: rgb(12 12 12 / .88);
35
+ border-radius: 5em; box-shadow: 0 6px 16px -8px rgb(12 12 12 / .5);
36
+ font: inherit;
37
+ }
38
+ .badge::before {
39
+ content: ''; width: 8px; height: 8px; border-radius: 50%;
40
+ background: rgb(212 130 30);
41
+ }
42
+ .badge[data-place='beside'] { transform: translateY(-50%); }
43
+ .badge[data-place='above'] { transform: translate(-100%, -100%); }
44
+ .badge:focus-visible { outline: 2px solid rgb(212 130 30); outline-offset: 2px; }
45
+ `;
46
+ // Inset from a block's top-right corner, or gap beside a small element.
47
+ const INSET = 8;
48
+ // Below this an element is a button or a link, not a section: its label goes
49
+ // beside it (or above, without room on the right) so it doesn't cover it.
50
+ const SMALL_HEIGHT = 64;
51
+ // Room a label needs beside an element.
52
+ const LABEL_WIDTH = 140;
53
+ // Where a label goes for an element's box: inside the top-right corner of a
54
+ // block, beside a small element, or above one at the right edge.
55
+ const placeLabel = (rect, viewportWidth)=>{
56
+ if (rect.height >= SMALL_HEIGHT) return {
57
+ place: 'inside',
58
+ top: Math.max(rect.top, 0) + INSET,
59
+ left: rect.right - INSET
60
+ };
61
+ if (rect.right + INSET + LABEL_WIDTH <= viewportWidth) return {
62
+ place: 'beside',
63
+ top: rect.top + rect.height / 2,
64
+ left: rect.right + INSET
65
+ };
66
+ return {
67
+ place: 'above',
68
+ top: rect.top - INSET / 2,
69
+ left: rect.right
70
+ };
71
+ };
72
+ const mountStatsOverlay = (win, { selector, onSelect, labels: labelOverrides })=>{
73
+ const doc = win.document;
74
+ const labels = {
75
+ ...DEFAULT_STATS_LABELS,
76
+ ...labelOverrides
77
+ };
78
+ const host = doc.createElement('div');
79
+ host.setAttribute('data-obelism-stats', '');
80
+ const shadow = host.attachShadow({
81
+ mode: 'open'
82
+ });
83
+ const style = doc.createElement('style');
84
+ style.textContent = CSS$1;
85
+ const layer = doc.createElement('div');
86
+ layer.className = 'layer';
87
+ shadow.append(style, layer);
88
+ doc.body.appendChild(host);
89
+ let stats = {};
90
+ let visible = false;
91
+ let frame = null;
92
+ const render = ()=>{
93
+ frame = null;
94
+ layer.replaceChildren();
95
+ layer.hidden = !visible;
96
+ if (!visible) return;
97
+ const height = win.innerHeight;
98
+ for (const [uid, block] of Object.entries(stats)){
99
+ const element = doc.querySelector(selector(uid));
100
+ if (!element) continue;
101
+ const rect = element.getBoundingClientRect();
102
+ // Not rendered, or off screen.
103
+ if (!rect.width && !rect.height) continue;
104
+ if (rect.bottom < 0 || rect.top > height) continue;
105
+ const badge = doc.createElement('button');
106
+ badge.type = 'button';
107
+ badge.className = 'badge';
108
+ badge.dataset.uid = uid;
109
+ badge.textContent = fill(labels.clicked, {
110
+ rate: Math.round(block.rate * 100)
111
+ });
112
+ badge.title = fill(labels.title, {
113
+ clickers: block.clickers,
114
+ viewers: block.viewers,
115
+ clicks: block.clicks
116
+ });
117
+ const { place, top, left } = placeLabel(rect, win.innerWidth);
118
+ badge.dataset.place = place;
119
+ badge.style.top = `${top}px`;
120
+ badge.style.left = `${left}px`;
121
+ badge.addEventListener('click', ()=>onSelect(uid));
122
+ layer.appendChild(badge);
123
+ }
124
+ };
125
+ // At most once a frame, however many scroll and mutation events come in.
126
+ const schedule = ()=>{
127
+ if (frame !== null || !visible) return;
128
+ frame = win.requestAnimationFrame(render);
129
+ };
130
+ win.addEventListener('scroll', schedule, {
131
+ capture: true,
132
+ passive: true
133
+ });
134
+ win.addEventListener('resize', schedule);
135
+ // The site re-renders blocks on every `update`; follow them. Our own
136
+ // layer lives in a shadow root, so its changes don't come back here.
137
+ const Observer = win.MutationObserver;
138
+ const observer = typeof Observer === 'function' ? new Observer(schedule) : null;
139
+ observer?.observe(doc.body, {
140
+ childList: true,
141
+ subtree: true
142
+ });
143
+ return {
144
+ update (nextStats, nextVisible) {
145
+ stats = nextStats;
146
+ visible = nextVisible;
147
+ if (frame !== null) {
148
+ win.cancelAnimationFrame?.(frame);
149
+ frame = null;
150
+ }
151
+ render();
152
+ },
153
+ refresh: schedule,
154
+ destroy () {
155
+ if (frame !== null) win.cancelAnimationFrame?.(frame);
156
+ win.removeEventListener('scroll', schedule, {
157
+ capture: true
158
+ });
159
+ win.removeEventListener('resize', schedule);
160
+ observer?.disconnect();
161
+ host.remove();
162
+ }
163
+ };
164
+ };
165
+
5
166
  // The site's half of the visual editor (the CMS's public/bridge.js, as a
6
167
  // module). Inside the editor's iframe it outlines blocks, tells the editor
7
168
  // which one was clicked, hands the editor's live, unsaved entry to listeners
@@ -13,7 +174,7 @@ import { n as normalizeUrl, D as DEFAULT_URL } from './config-c6qktlz6.mjs';
13
174
  // 'select' | 'prompt' | 'undo', uid?, id?, prompt? }
14
175
  // - editor → site: { source: 'obelism-content-editor', type: 'connect' |
15
176
  // 'update' | 'select' | 'saved' | 'published' | 'features' |
16
- // 'promptResult' | 'promptState', … }
177
+ // 'promptResult' | 'promptState' | 'stats', … }
17
178
  const BRIDGE_SOURCE = 'obelism-content-bridge';
18
179
  const EDITOR_SOURCE = 'obelism-content-editor';
19
180
  // How long a prompt may take before it counts as failed.
@@ -29,7 +190,9 @@ const INITIAL_STATE = {
29
190
  unsaved: false,
30
191
  saving: false,
31
192
  publishing: false,
32
- selecting: false
193
+ selecting: false,
194
+ stats: {},
195
+ statsVisible: false
33
196
  };
34
197
  const createEmitter = ()=>{
35
198
  const listeners = {
@@ -70,6 +233,22 @@ const INERT_METHODS = {
70
233
  select: ()=>{},
71
234
  setSelecting: ()=>{}
72
235
  };
236
+ const isCount = (value)=>typeof value === 'number' && Number.isFinite(value) && value >= 0;
237
+ // The editor's `stats`, keeping only well-formed entries.
238
+ const readStats = (value)=>{
239
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
240
+ const stats = {};
241
+ for (const [uid, block] of Object.entries(value)){
242
+ const { clicks, clickers, viewers, rate } = block ?? {};
243
+ if (isCount(clicks) && isCount(clickers) && isCount(viewers) && isCount(rate)) stats[uid] = {
244
+ clicks,
245
+ clickers,
246
+ viewers,
247
+ rate
248
+ };
249
+ }
250
+ return stats;
251
+ };
73
252
  // The selector of the element marked with a block's uid.
74
253
  const blockSelector = (uid)=>`[${UID_ATTRIBUTE}="${uid.replace(/["\\]/g, '\\$&')}"]`;
75
254
  // `cmsOrigin` is the only origin messages are accepted from and sent to.
@@ -198,6 +377,17 @@ const createBridge = (win, cmsOrigin)=>{
198
377
  uid
199
378
  });
200
379
  };
380
+ // Mounted with the first rates, so pages without numbers never get it.
381
+ let overlay = null;
382
+ const showStats = ()=>{
383
+ if (!overlay && !Object.keys(state.stats).length) return;
384
+ overlay ??= mountStatsOverlay(win, {
385
+ selector: blockSelector,
386
+ onSelect: (uid)=>bridge.select(uid)
387
+ });
388
+ overlay.update(state.stats, state.statsVisible);
389
+ };
390
+ cleanup.push(()=>overlay?.destroy());
201
391
  cleanup.push(()=>{
202
392
  for (const id of pending.keys()){
203
393
  settle(id)?.reject(new Error('The bridge was destroyed'));
@@ -218,6 +408,7 @@ const createBridge = (win, cmsOrigin)=>{
218
408
  });
219
409
  // Re-apply the selection to whatever the site rendered.
220
410
  win.requestAnimationFrame(()=>highlight(data.selectedUid, false));
411
+ overlay?.refresh();
221
412
  } else if (data.type === 'select') {
222
413
  highlight(data.uid, true);
223
414
  } else if (data.type === 'saved' || data.type === 'published') {
@@ -227,6 +418,13 @@ const createBridge = (win, cmsOrigin)=>{
227
418
  setState({
228
419
  features: data.features.filter((feature)=>typeof feature === 'string')
229
420
  });
421
+ } else if (data.type === 'stats') {
422
+ // An editor from before the switch sends no `visible`: show them.
423
+ setState({
424
+ stats: readStats(data.stats),
425
+ statsVisible: data.visible !== false
426
+ });
427
+ showStats();
230
428
  } else if (data.type === 'promptState') {
231
429
  setState({
232
430
  canUndo: data.canUndo === true
@@ -1187,4 +1385,4 @@ const getContentBridge = ({ url, prompt = true, pagePath } = {})=>{
1187
1385
  return bridge;
1188
1386
  };
1189
1387
 
1190
- export { DEFAULT_PROMPT_LABELS, INITIAL_STATE, getContentBridge, mountPromptBox, peekContentBridge };
1388
+ export { DEFAULT_PROMPT_LABELS, DEFAULT_STATS_LABELS, INITIAL_STATE, getContentBridge, mountPromptBox, mountStatsOverlay, peekContentBridge };
package/dist/prompt.js CHANGED
@@ -1,5 +1,7 @@
1
1
  Object.defineProperty(exports, '__esModule', { value: true });
2
2
 
3
+ var standardWebhooks = require('./standardWebhooks-kh5dc0ff.js');
4
+
3
5
  // Your own model for prompt editing in the visual editor. Set the endpoint
4
6
  // under Space settings → Prompt editing; the CMS then sends each prompt there
5
7
  // instead of to Workers AI, as a signed POST (Standard Webhooks, the same
@@ -23,48 +25,25 @@ class PromptVerificationError extends Error {
23
25
  this.name = 'PromptVerificationError';
24
26
  }
25
27
  }
26
- const SECRET_PREFIX = 'whsec_';
27
- // Seconds a request's timestamp may be off, against replays.
28
- const DEFAULT_TOLERANCE = 5 * 60;
29
- const base64ToBytes = (value)=>Uint8Array.from(atob(value), (char)=>char.charCodeAt(0));
30
28
  // Checks a request's Standard Webhooks signature and returns its body.
31
29
  // Throws PromptVerificationError when it isn't from the CMS.
32
- const verifyPromptRequest = async (request, secret, { tolerance = DEFAULT_TOLERANCE, now = Date.now() } = {})=>{
33
- if (!secret?.startsWith(SECRET_PREFIX)) {
30
+ const verifyPromptRequest = async (request, secret, { tolerance = standardWebhooks.DEFAULT_TOLERANCE, now = Date.now() } = {})=>{
31
+ if (!secret?.startsWith(standardWebhooks.SECRET_PREFIX)) {
34
32
  throw new Error('The prompt secret must be the whsec_… from Space settings');
35
33
  }
36
- const id = request.headers.get('webhook-id');
37
- const timestamp = request.headers.get('webhook-timestamp');
38
- const signatures = request.headers.get('webhook-signature');
39
- if (!id || !timestamp || !signatures) {
40
- throw new PromptVerificationError('Missing signature headers');
41
- }
42
- const seconds = Number(timestamp);
43
- if (!Number.isFinite(seconds) || Math.abs(now / 1000 - seconds) > tolerance) {
44
- throw new PromptVerificationError('Timestamp outside the tolerance');
45
- }
46
34
  const body = await request.text();
47
- const key = await crypto.subtle.importKey('raw', base64ToBytes(secret.slice(SECRET_PREFIX.length)), {
48
- name: 'HMAC',
49
- hash: 'SHA-256'
50
- }, false, [
51
- 'verify'
52
- ]);
53
- const signed = new TextEncoder().encode(`${id}.${timestamp}.${body}`);
54
- let valid = false;
55
- for (const signature of signatures.split(' ')){
56
- const [version, value] = signature.split(',');
57
- if (version !== 'v1' || !value) continue;
58
- let bytes;
59
- try {
60
- bytes = base64ToBytes(value);
61
- } catch {
62
- continue;
35
+ await standardWebhooks.verifySignature({
36
+ body,
37
+ id: request.headers.get('webhook-id'),
38
+ timestamp: request.headers.get('webhook-timestamp'),
39
+ signatures: request.headers.get('webhook-signature')
40
+ }, secret, {
41
+ tolerance,
42
+ now,
43
+ fail: (message)=>{
44
+ throw new PromptVerificationError(message);
63
45
  }
64
- // crypto.subtle.verify compares in constant time.
65
- if (await crypto.subtle.verify('HMAC', key, bytes, signed)) valid = true;
66
- }
67
- if (!valid) throw new PromptVerificationError('Invalid signature');
46
+ });
68
47
  let payload;
69
48
  try {
70
49
  payload = JSON.parse(body);
package/dist/prompt.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { S as SECRET_PREFIX, v as verifySignature, D as DEFAULT_TOLERANCE } from './standardWebhooks-ew5mhpac.mjs';
2
+
1
3
  // Your own model for prompt editing in the visual editor. Set the endpoint
2
4
  // under Space settings → Prompt editing; the CMS then sends each prompt there
3
5
  // instead of to Workers AI, as a signed POST (Standard Webhooks, the same
@@ -21,48 +23,25 @@ class PromptVerificationError extends Error {
21
23
  this.name = 'PromptVerificationError';
22
24
  }
23
25
  }
24
- const SECRET_PREFIX = 'whsec_';
25
- // Seconds a request's timestamp may be off, against replays.
26
- const DEFAULT_TOLERANCE = 5 * 60;
27
- const base64ToBytes = (value)=>Uint8Array.from(atob(value), (char)=>char.charCodeAt(0));
28
26
  // Checks a request's Standard Webhooks signature and returns its body.
29
27
  // Throws PromptVerificationError when it isn't from the CMS.
30
28
  const verifyPromptRequest = async (request, secret, { tolerance = DEFAULT_TOLERANCE, now = Date.now() } = {})=>{
31
29
  if (!secret?.startsWith(SECRET_PREFIX)) {
32
30
  throw new Error('The prompt secret must be the whsec_… from Space settings');
33
31
  }
34
- const id = request.headers.get('webhook-id');
35
- const timestamp = request.headers.get('webhook-timestamp');
36
- const signatures = request.headers.get('webhook-signature');
37
- if (!id || !timestamp || !signatures) {
38
- throw new PromptVerificationError('Missing signature headers');
39
- }
40
- const seconds = Number(timestamp);
41
- if (!Number.isFinite(seconds) || Math.abs(now / 1000 - seconds) > tolerance) {
42
- throw new PromptVerificationError('Timestamp outside the tolerance');
43
- }
44
32
  const body = await request.text();
45
- const key = await crypto.subtle.importKey('raw', base64ToBytes(secret.slice(SECRET_PREFIX.length)), {
46
- name: 'HMAC',
47
- hash: 'SHA-256'
48
- }, false, [
49
- 'verify'
50
- ]);
51
- const signed = new TextEncoder().encode(`${id}.${timestamp}.${body}`);
52
- let valid = false;
53
- for (const signature of signatures.split(' ')){
54
- const [version, value] = signature.split(',');
55
- if (version !== 'v1' || !value) continue;
56
- let bytes;
57
- try {
58
- bytes = base64ToBytes(value);
59
- } catch {
60
- continue;
33
+ await verifySignature({
34
+ body,
35
+ id: request.headers.get('webhook-id'),
36
+ timestamp: request.headers.get('webhook-timestamp'),
37
+ signatures: request.headers.get('webhook-signature')
38
+ }, secret, {
39
+ tolerance,
40
+ now,
41
+ fail: (message)=>{
42
+ throw new PromptVerificationError(message);
61
43
  }
62
- // crypto.subtle.verify compares in constant time.
63
- if (await crypto.subtle.verify('HMAC', key, bytes, signed)) valid = true;
64
- }
65
- if (!valid) throw new PromptVerificationError('Invalid signature');
44
+ });
66
45
  let payload;
67
46
  try {
68
47
  payload = JSON.parse(body);