@obelism/content 0.17.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bridge.js CHANGED
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, '__esModule', { value: true });
2
2
 
3
- var preview = require('./preview-mpwfmnla.js');
3
+ var preview = require('./preview-ijk3ixwb.js');
4
4
  var site = require('./site.js');
5
5
  var config = require('./config-ci2hxmas.js');
6
6
 
@@ -229,6 +229,7 @@ const onlyWhileEditingSite = ()=>Promise.reject(new Error('Only while signed in
229
229
  const INERT_METHODS = {
230
230
  prompt: inertPrompt,
231
231
  createPage: ()=>Promise.reject(new Error('New pages are made on the site itself')),
232
+ createProductPage: ()=>Promise.reject(new Error('Product pages are made on the site itself')),
232
233
  view: onlyWhileEditingSite,
233
234
  applyJson: onlyWhileEditingSite,
234
235
  openForm: (_container, options = {})=>{
@@ -286,6 +287,7 @@ const createBridge = (win, cmsOrigin)=>{
286
287
  mode: isEditing ? 'editor' : null,
287
288
  entryId: params.get(preview.PREVIEW_PARAMS.entry),
288
289
  environment: params.get(preview.PREVIEW_PARAMS.environment),
290
+ product: null,
289
291
  on: emitter.on,
290
292
  getState: ()=>state,
291
293
  ...INERT_METHODS,
@@ -478,6 +480,7 @@ const wrapScriptBridge = (script, params)=>{
478
480
  mode: script.isEditing ? 'editor' : null,
479
481
  entryId: script.entryId,
480
482
  environment: params.get(preview.PREVIEW_PARAMS.environment),
483
+ product: null,
481
484
  on: (event, listener)=>{
482
485
  if (event === 'update' && !forwardingUpdates) {
483
486
  forwardingUpdates = true;
@@ -537,8 +540,24 @@ const DEFAULT_PROMPT_LABELS = {
537
540
  pagesEmpty: 'No pages match',
538
541
  pageCurrent: 'This page',
539
542
  pageDraft: 'Draft',
540
- pageChanged: 'Changed'
543
+ pageChanged: 'Changed',
544
+ addProductPage: 'Add a product page',
545
+ addingProductPage: 'Making the product page…',
546
+ addedProductPage: 'Opening the product page…',
547
+ productPageHint: 'This product has no page yet. Add one for what the store doesn’t say.'
541
548
  };
549
+ const SIDE_KEY = 'obelism-content-prompt-side';
550
+ // How far the pointer moves before it drags the box, not clicks it.
551
+ const DRAG_THRESHOLD_PX = 6;
552
+ // How far ahead a flick carries the box: its speed for this long.
553
+ const FLICK_MS = 150;
554
+ // …at most this far, so one coarse pointer event isn't a flick across the
555
+ // screen…
556
+ const FLICK_MAX_PX = 120;
557
+ // …and none when the pointer stood still this long before letting go.
558
+ const FLICK_STALE_MS = 80;
559
+ // Its distance from the side of the screen, as in the CSS.
560
+ const EDGE_PX = 16;
542
561
  // Icons (Lucide), drawn in the text colour.
543
562
  const ICONS = {
544
563
  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"/>',
@@ -551,6 +570,7 @@ const ICONS = {
551
570
  chevrons: '<path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/>',
552
571
  search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
553
572
  check: '<path d="M20 6 9 17l-5-5"/>',
573
+ mark: '<path d="M12 3v18M3 12h18"/><g fill="currentColor" stroke="none"><circle cx="7" cy="7" r="1.75"/><circle cx="17" cy="7" r="1.75"/><circle cx="7" cy="17" r="1.75"/><circle cx="17" cy="17" r="1.75"/></g>',
554
574
  send: '<path d="M3.714 3.048a.498.498 0 0 0-.683.627l2.843 7.627a2 2 0 0 1 0 1.396l-2.842 7.627a.498.498 0 0 0 .682.627l18-8.5a.5.5 0 0 0 0-.904z"/><path d="M6 12h16"/>'
555
575
  };
556
576
  // Obelism's design tokens (obelism.studio/design.md), under their own names:
@@ -576,10 +596,20 @@ const CSS = `
576
596
  --font-inter: 'InterVariable', 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
577
597
  Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
578
598
  position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%);
599
+ transition: translate .45s cubic-bezier(0.34, 1.3, 0.64, 1);
579
600
  z-index: 2147483647; width: min(640px, calc(100vw - 32px)); color-scheme: light dark;
580
601
  font: 400 14px/1.4 var(--font-inter); color: rgb(var(--theme-foreground-rgb));
581
602
  -webkit-font-smoothing: antialiased;
582
603
  }
604
+ /* Dragged to a side of the screen, it stays there, springing into place. */
605
+ .root[data-side='left'] { left: 16px; transform: none; }
606
+ .root[data-side='right'] { left: auto; right: 16px; transform: none; }
607
+ .root[data-side='left'] .bar { justify-content: flex-start; }
608
+ .root[data-side='right'] .bar { justify-content: flex-end; }
609
+ .root.dragging { transition: none; user-select: none; -webkit-user-select: none; }
610
+ .root.dragging, .root.dragging * { cursor: grabbing !important; }
611
+ .toolbar, .footer, .status { cursor: grab; touch-action: none; }
612
+ .pill { touch-action: none; }
583
613
  @media (prefers-color-scheme: dark) {
584
614
  .root {
585
615
  --theme-foreground-rgb: var(--color-white-rgb);
@@ -615,7 +645,8 @@ button:focus-visible { outline: 2px solid rgb(var(--theme-accent-rgb)); outline-
615
645
  }
616
646
  button.pill:hover { color: rgb(var(--theme-foreground-rgb)); transform: translateY(-2px); }
617
647
  .pill > span:first-child { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
618
- .mark { color: rgb(var(--theme-accent-rgb)); font-size: 22px; line-height: 1; }
648
+ .mark { color: rgb(var(--theme-accent-rgb)); }
649
+ .mark svg { width: 20px; height: 20px; }
619
650
  .pill.compact { width: auto; height: 40px; padding: 0 18px; font-size: 14px; gap: 4px; }
620
651
 
621
652
  /* Open: a floating card, the text on top and a row of pill buttons below. */
@@ -633,7 +664,7 @@ button.pill:hover { color: rgb(var(--theme-foreground-rgb)); transform: translat
633
664
  @keyframes rise { from { opacity: 0; transform: translateY(8px) scale(.98); } }
634
665
  @keyframes spin { to { transform: rotate(1turn); } }
635
666
  @media (prefers-reduced-motion: reduce) {
636
- .panel { animation: none; }
667
+ .root, .panel { animation: none; transition: none; }
637
668
  .pill { transition: none; }
638
669
  }
639
670
  .status {
@@ -785,6 +816,11 @@ const icon = (doc, name)=>{
785
816
  span.innerHTML = `<svg viewBox="0 0 24 24">${ICONS[name]}</svg>`;
786
817
  return span;
787
818
  };
819
+ const mark = (doc)=>{
820
+ const span = icon(doc, 'mark');
821
+ span.classList.add('mark');
822
+ return span;
823
+ };
788
824
  // Mounts the box on the page; returns a function that removes it.
789
825
  const mountPromptBox = (doc, bridge, options = {})=>{
790
826
  const labels = {
@@ -803,11 +839,8 @@ const mountPromptBox = (doc, bridge, options = {})=>{
803
839
  const pill = element(doc, 'button', {
804
840
  className: 'pill',
805
841
  type: 'button'
806
- }, pillText, // The Obelism mark.
807
- element(doc, 'span', {
808
- className: 'mark',
809
- textContent: '⁜'
810
- }));
842
+ }, pillText, // The Obelism mark (⁜), drawn: the glyph sits low in most fonts.
843
+ mark(doc));
811
844
  // Someone signed in who may only read: a note that drafts show, and a
812
845
  // way out.
813
846
  const viewing = element(doc, 'span', {
@@ -872,6 +905,12 @@ const mountPromptBox = (doc, bridge, options = {})=>{
872
905
  className: 'action chip',
873
906
  type: 'button'
874
907
  }, icon(doc, 'newPage'), labels.newPage);
908
+ // A product the site marks (editableProduct) without a page of its own:
909
+ // one click makes its product page, which the page then shows.
910
+ const addProduct = element(doc, 'button', {
911
+ className: 'action chip product',
912
+ type: 'button'
913
+ }, icon(doc, 'newPage'), labels.addProductPage);
875
914
  const cancelNew = element(doc, 'button', {
876
915
  type: 'button',
877
916
  title: labels.newPageCancel
@@ -921,7 +960,7 @@ const mountPromptBox = (doc, bridge, options = {})=>{
921
960
  });
922
961
  const toolbar = element(doc, 'div', {
923
962
  className: 'toolbar'
924
- }, scope, newScope, modes, undo, newPage, element(doc, 'span', {
963
+ }, scope, newScope, modes, undo, addProduct, newPage, element(doc, 'span', {
925
964
  className: 'spacer'
926
965
  }), minimize, close, divider, send);
927
966
  // The pages (on the site): a chip naming this page, opening a search
@@ -1000,6 +1039,95 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1000
1039
  shadow.append(element(doc, 'style', {
1001
1040
  textContent: CSS
1002
1041
  }), root);
1042
+ // Dragged sideways, the box moves to the left, the middle or the right
1043
+ // of the screen, whichever third it is let go (or flicked) in; this
1044
+ // browser remembers where. From the pill, or the open box's bars, not its
1045
+ // fields and buttons.
1046
+ const win = doc.defaultView;
1047
+ const readSide = ()=>{
1048
+ try {
1049
+ const side = win?.localStorage.getItem(SIDE_KEY);
1050
+ return side === 'left' || side === 'right' ? side : 'center';
1051
+ } catch {
1052
+ return 'center';
1053
+ }
1054
+ };
1055
+ const placeAt = (side)=>{
1056
+ if (side === 'center') delete root.dataset.side;
1057
+ else root.dataset.side = side;
1058
+ };
1059
+ placeAt(readSide());
1060
+ let drag = null;
1061
+ // A drag that ends on the pill isn't a click on it.
1062
+ let dragged = false;
1063
+ root.addEventListener('pointerdown', (event)=>{
1064
+ dragged = false;
1065
+ if (event.button !== 0 || !event.isPrimary) return;
1066
+ const target = event.target;
1067
+ const control = target.closest('button, input, textarea, iframe, a, label, .pages, .form');
1068
+ if (control && control !== pill) return;
1069
+ const { left, right } = root.getBoundingClientRect();
1070
+ drag = {
1071
+ id: event.pointerId,
1072
+ startX: event.clientX,
1073
+ x: event.clientX,
1074
+ velocity: 0,
1075
+ time: event.timeStamp,
1076
+ moved: false,
1077
+ min: Math.min(0, EDGE_PX - left),
1078
+ max: Math.max(0, (win?.innerWidth ?? 0) - EDGE_PX - right)
1079
+ };
1080
+ });
1081
+ root.addEventListener('pointermove', (event)=>{
1082
+ if (!drag || event.pointerId !== drag.id) return;
1083
+ const dx = event.clientX - drag.startX;
1084
+ if (!drag.moved) {
1085
+ if (Math.abs(dx) < DRAG_THRESHOLD_PX) return;
1086
+ drag.moved = true;
1087
+ root.classList.add('dragging');
1088
+ try {
1089
+ root.setPointerCapture(event.pointerId);
1090
+ } catch {}
1091
+ }
1092
+ const elapsed = event.timeStamp - drag.time;
1093
+ if (elapsed > 0) drag.velocity = (event.clientX - drag.x) / elapsed;
1094
+ drag.x = event.clientX;
1095
+ drag.time = event.timeStamp;
1096
+ // Past the edge, a third as far.
1097
+ const clamped = Math.min(Math.max(dx, drag.min), drag.max);
1098
+ root.style.translate = `${clamped + (dx - clamped) / 3}px 0`;
1099
+ });
1100
+ const drop = (event)=>{
1101
+ if (!drag || event.pointerId !== drag.id) return;
1102
+ const { moved, x, velocity, time } = drag;
1103
+ drag = null;
1104
+ if (!moved) return;
1105
+ dragged = true;
1106
+ // Springs from where it was let go to its new place.
1107
+ const before = root.getBoundingClientRect().left;
1108
+ const width = win?.innerWidth ?? 0;
1109
+ const flick = event.timeStamp - time > FLICK_STALE_MS ? 0 : Math.min(Math.max(velocity * FLICK_MS, -FLICK_MAX_PX), FLICK_MAX_PX);
1110
+ const projected = x + flick;
1111
+ const side = projected < width / 3 ? 'left' : projected > width * 2 / 3 ? 'right' : 'center';
1112
+ placeAt(side);
1113
+ root.style.translate = '';
1114
+ const after = root.getBoundingClientRect().left;
1115
+ root.style.translate = `${before - after}px 0`;
1116
+ void root.offsetWidth;
1117
+ root.classList.remove('dragging');
1118
+ root.style.translate = '';
1119
+ try {
1120
+ win?.localStorage.setItem(SIDE_KEY, side);
1121
+ } catch {}
1122
+ };
1123
+ root.addEventListener('pointerup', drop);
1124
+ root.addEventListener('pointercancel', drop);
1125
+ root.addEventListener('click', (event)=>{
1126
+ if (!dragged) return;
1127
+ dragged = false;
1128
+ event.preventDefault();
1129
+ event.stopPropagation();
1130
+ }, true);
1003
1131
  let open = false;
1004
1132
  let minimized = false;
1005
1133
  let creating = false;
@@ -1032,7 +1160,9 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1032
1160
  // A page of the site that isn't an entry (no `editable(entry)`, like a
1033
1161
  // hand-written /about) has nothing to edit: only New page.
1034
1162
  const pageless = site && bridge.entryId === null;
1035
- const onlyNew = pageless && canCreate;
1163
+ // …unless it shows a product: then Add a product page first.
1164
+ const productHere = pageless && canCreate && bridge.product !== null;
1165
+ const onlyNew = pageless && canCreate && !productHere;
1036
1166
  if (!canCreate) creating = false;
1037
1167
  if (onlyNew) creating = true;
1038
1168
  if (creating || pageless || !site) form = false;
@@ -1040,16 +1170,19 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1040
1170
  const showForm = open && available && form;
1041
1171
  if (showForm && !formHandle) mountForm();
1042
1172
  else if (!showForm && formHandle) unmountForm();
1043
- pillText.textContent = onlyNew ? labels.newPage : labels.open;
1173
+ const offerProduct = productHere && !creating;
1174
+ pillText.textContent = offerProduct ? labels.addProductPage : onlyNew ? labels.newPage : labels.open;
1044
1175
  cancelNew.hidden = onlyNew;
1045
- scope.hidden = creating;
1176
+ scope.hidden = creating || offerProduct;
1046
1177
  newScope.hidden = !creating;
1178
+ addProduct.hidden = !offerProduct;
1179
+ addProduct.disabled = busy;
1047
1180
  newPage.hidden = creating || form || !canCreate;
1048
1181
  newPage.disabled = busy;
1049
1182
  if (minimized) picking = false;
1050
1183
  pathRow.hidden = !creating || picking || minimized;
1051
1184
  pathInput.disabled = busy;
1052
- input.hidden = form || picking || minimized;
1185
+ input.hidden = form || picking || minimized || offerProduct;
1053
1186
  formBox.hidden = !form || picking || minimized;
1054
1187
  formBox.classList.toggle('loading', formLoading);
1055
1188
  toolbar.hidden = picking;
@@ -1069,8 +1202,8 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1069
1202
  undo.hidden = creating || !state.canUndo;
1070
1203
  undo.disabled = busy;
1071
1204
  // The form applies edits as they are made.
1072
- send.hidden = form || minimized;
1073
- divider.hidden = form || minimized;
1205
+ send.hidden = form || minimized || offerProduct;
1206
+ divider.hidden = form || minimized || offerProduct;
1074
1207
  const toggleLabel = minimized ? labels.expand : labels.minimize;
1075
1208
  minimize.title = toggleLabel;
1076
1209
  minimize.setAttribute('aria-label', toggleLabel);
@@ -1093,7 +1226,7 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1093
1226
  pageChipText.textContent = pages?.find(({ entryId })=>entryId === bridge.entryId)?.name ?? labels.pages;
1094
1227
  const loadingForm = form && formLoading && !picking;
1095
1228
  const working = state.prompting || loadingForm;
1096
- const note = state.prompting ? creating ? labels.creating : labels.working : loadingForm ? labels.formLoading : message?.text ?? (site && !creating && !picking && !state.selectedUid ? labels.selectHint : '');
1229
+ const note = state.prompting ? creating ? labels.creating : offerProduct ? labels.addingProductPage : labels.working : loadingForm ? labels.formLoading : message?.text ?? (offerProduct ? labels.productPageHint : site && !creating && !picking && !state.selectedUid ? labels.selectHint : '');
1097
1230
  status.textContent = note;
1098
1231
  status.hidden = !note || minimized;
1099
1232
  status.className = `status${working ? ' working' : ''}${!busy && !loadingForm && message?.error ? ' error' : ''}`;
@@ -1297,12 +1430,35 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1297
1430
  refresh();
1298
1431
  if (!minimized) (form ? minimize : input).focus();
1299
1432
  });
1433
+ // Any other button of the minimized box expands it first (capture, so
1434
+ // its own handler then sees the box open), e.g. the pages or New page.
1435
+ panel.addEventListener('click', (event)=>{
1436
+ if (!minimized) return;
1437
+ const button = event.target.closest('button');
1438
+ if (!button || button === minimize || button === close) return;
1439
+ minimized = false;
1440
+ refresh();
1441
+ }, true);
1300
1442
  save.addEventListener('click', ()=>void store('save'));
1301
1443
  publish.addEventListener('click', ()=>void store('publish'));
1302
1444
  signOut.addEventListener('click', ()=>bridge.signOut());
1303
1445
  pillSignOut.addEventListener('click', ()=>bridge.signOut());
1304
1446
  clearBlock.addEventListener('click', ()=>bridge.select(null));
1305
1447
  newPage.addEventListener('click', ()=>setCreating(true));
1448
+ addProduct.addEventListener('click', async ()=>{
1449
+ message = null;
1450
+ try {
1451
+ // The bridge reloads the page; this shows until it does.
1452
+ await bridge.createProductPage();
1453
+ message = {
1454
+ text: labels.addedProductPage,
1455
+ error: false
1456
+ };
1457
+ } catch (error) {
1458
+ message = failure(error);
1459
+ }
1460
+ refresh();
1461
+ });
1306
1462
  cancelNew.addEventListener('click', ()=>setCreating(false));
1307
1463
  promptMode.addEventListener('click', ()=>setForm(false));
1308
1464
  formMode.addEventListener('click', ()=>setForm(true));
@@ -1374,13 +1530,15 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1374
1530
  // Messages are only exchanged with the URL's origin, and only with the
1375
1531
  // iframe's window:
1376
1532
  // - form → bridge: { source: 'obelism-content-form', type: 'ready' |
1377
- // 'change' | 'error' | 'height', content?, message?, height? } —
1378
- // `change` carries the whole entry content after an edit, `height` the
1379
- // form's own height in px, which the iframe takes (up to 60vh)
1533
+ // 'loaded' | 'change' | 'error' | 'height', loading?, content?, message?,
1534
+ // height? } — `ready` with `loading: true` when the form still loads the
1535
+ // entry after it and says `loaded` once it shows (a CMS from before says
1536
+ // only `ready`), `change` carries the whole entry content after an edit,
1537
+ // `height` the form's own height in px, which the iframe takes (up to 60vh)
1380
1538
  // - bridge → form: { source: 'obelism-content-bridge', type: 'content',
1381
1539
  // entryId, content, locale, selectedUid }
1382
1540
  const FORM_SOURCE = 'obelism-content-form';
1383
- // How long the form may take to say it is ready.
1541
+ // How long the form may take to show.
1384
1542
  const FORM_READY_TIMEOUT_MS = 30_000;
1385
1543
  const isObject = (value)=>!!value && typeof value === 'object' && !Array.isArray(value);
1386
1544
  // Mounts the form at `url` in `container`. The URL is single use: mount a
@@ -1398,6 +1556,7 @@ const mountFormFrame = (win, container, url, title, handlers)=>{
1398
1556
  iframe.title = title;
1399
1557
  iframe.src = url;
1400
1558
  let ready = false;
1559
+ let shown = false;
1401
1560
  let latest = null;
1402
1561
  const post = ()=>{
1403
1562
  if (!ready || !latest) return;
@@ -1408,8 +1567,13 @@ const mountFormFrame = (win, container, url, title, handlers)=>{
1408
1567
  }, origin);
1409
1568
  };
1410
1569
  const timer = setTimeout(()=>{
1411
- if (!ready) handlers.onError('The form did not open');
1570
+ if (!shown) handlers.onError('The form did not open');
1412
1571
  }, FORM_READY_TIMEOUT_MS);
1572
+ const show = ()=>{
1573
+ shown = true;
1574
+ clearTimeout(timer);
1575
+ handlers.onReady();
1576
+ };
1413
1577
  const onMessage = (event)=>{
1414
1578
  if (event.origin !== origin || !iframe.contentWindow || event.source !== iframe.contentWindow) {
1415
1579
  return;
@@ -1419,9 +1583,12 @@ const mountFormFrame = (win, container, url, title, handlers)=>{
1419
1583
  if (data.type === 'ready') {
1420
1584
  // Again after the form reloads itself: it gets the content again.
1421
1585
  ready = true;
1422
- clearTimeout(timer);
1423
- handlers.onReady();
1424
1586
  post();
1587
+ // Until the form loaded the entry, the box's own loading state
1588
+ // stands in for the form's.
1589
+ if (data.loading !== true) show();
1590
+ } else if (data.type === 'loaded') {
1591
+ if (ready) show();
1425
1592
  } else if (data.type === 'change' && isObject(data.content)) {
1426
1593
  handlers.onChange(data.content, typeof data.entryId === 'string' ? data.entryId : undefined);
1427
1594
  } else if (data.type === 'height') {
@@ -1483,7 +1650,7 @@ const FORM_BURST_MS = 1000;
1483
1650
  // How long the page waits for the form's edits to settle before it shows
1484
1651
  // them.
1485
1652
  const FORM_RENDER_MS = 300;
1486
- const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})=>{
1653
+ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}`, productPath: ownProductPath } = {})=>{
1487
1654
  const doc = win.document;
1488
1655
  const emitter = createEmitter();
1489
1656
  const cleanup = [];
@@ -1493,6 +1660,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1493
1660
  // Entries changed here since the last publish.
1494
1661
  const touched = new Set();
1495
1662
  let session = null;
1663
+ const productPath = (handle, locale)=>ownProductPath ? ownProductPath(handle, locale) : (session?.productPath ?? '/products/{handle}').replaceAll('{handle}', encodeURIComponent(handle));
1496
1664
  // The open form's, if any; and when the form last changed content, for
1497
1665
  // undo steps (-Infinity: the next edit starts a new step).
1498
1666
  let closeForm = null;
@@ -1537,6 +1705,16 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1537
1705
  return data;
1538
1706
  };
1539
1707
  const pageEntryId = ()=>doc.querySelector(`[${preview.ENTRY_ATTRIBUTE}]`)?.getAttribute(preview.ENTRY_ATTRIBUTE) ?? null;
1708
+ // The product the page shows (editableProduct), if it marks one.
1709
+ const pageProduct = ()=>{
1710
+ const element = doc.querySelector(`[${preview.PRODUCT_ATTRIBUTE}]`);
1711
+ const id = element?.getAttribute(preview.PRODUCT_ATTRIBUTE);
1712
+ const handle = element?.getAttribute(preview.PRODUCT_HANDLE_ATTRIBUTE);
1713
+ return id && handle ? {
1714
+ id,
1715
+ handle
1716
+ } : null;
1717
+ };
1540
1718
  const entryFor = (uid)=>{
1541
1719
  const block = uid ? doc.querySelector(blockSelector(uid)) : null;
1542
1720
  return block?.closest(`[${preview.ENTRY_ATTRIBUTE}]`)?.getAttribute(preview.ENTRY_ATTRIBUTE) ?? pageEntryId();
@@ -1644,6 +1822,9 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1644
1822
  get entryId () {
1645
1823
  return pageEntryId();
1646
1824
  },
1825
+ get product () {
1826
+ return pageProduct();
1827
+ },
1647
1828
  environment: null,
1648
1829
  on: emitter.on,
1649
1830
  getState: ()=>state,
@@ -1713,6 +1894,34 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1713
1894
  });
1714
1895
  }
1715
1896
  },
1897
+ async createProductPage (product = pageProduct() ?? undefined) {
1898
+ if (!product) {
1899
+ throw new Error('Nothing on this page is marked as a product: add editableProduct(product)');
1900
+ }
1901
+ if (!has('create')) throw new Error('You may not add pages here');
1902
+ if (state.unsaved) {
1903
+ throw new Error('Save or undo your changes before adding a page');
1904
+ }
1905
+ setState({
1906
+ prompting: true
1907
+ });
1908
+ try {
1909
+ const made = await api('product-pages', {
1910
+ product: {
1911
+ id: product.id,
1912
+ handle: product.handle
1913
+ }
1914
+ });
1915
+ // The site's product route finds its page now (drafts, while
1916
+ // editing): show it.
1917
+ win.location.reload();
1918
+ return made;
1919
+ } finally{
1920
+ setState({
1921
+ prompting: false
1922
+ });
1923
+ }
1924
+ },
1716
1925
  async view (options = {}) {
1717
1926
  const { uid, entryId } = target(options.uid);
1718
1927
  const entry = await load(entryId);
@@ -1866,7 +2075,8 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1866
2075
  contentType: page.contentType,
1867
2076
  status: page.status,
1868
2077
  path,
1869
- url: pagePath(path, code)
2078
+ url: page.product ? productPath(page.product.handle, code) : pagePath(path, code),
2079
+ product: page.product ?? null
1870
2080
  }
1871
2081
  ];
1872
2082
  });
@@ -2011,7 +2221,7 @@ const peekContentBridge = ()=>typeof window === 'undefined' ? null : window[KEY]
2011
2221
  // if (bridge.isEditing) bridge.on('update', ({ entry }) => render(entry))
2012
2222
  //
2013
2223
  // The first call's options win: there is one bridge per page.
2014
- const getContentBridge = ({ url, prompt = true, pagePath } = {})=>{
2224
+ const getContentBridge = ({ url, prompt = true, pagePath, productPath } = {})=>{
2015
2225
  if (typeof window === 'undefined') return SERVER_BRIDGE;
2016
2226
  const win = window;
2017
2227
  if (win[KEY]) return win[KEY];
@@ -2024,7 +2234,8 @@ const getContentBridge = ({ url, prompt = true, pagePath } = {})=>{
2024
2234
  // own `/cms/login` (@obelism/content/site).
2025
2235
  const sitePath = bridge.isEditing || win.parent !== win ? null : readSiteEditing(win.document);
2026
2236
  if (sitePath) bridge = createSiteBridge(win, sitePath, {
2027
- pagePath
2237
+ pagePath,
2238
+ productPath
2028
2239
  });
2029
2240
  if (bridge.isEditing && prompt) {
2030
2241
  const unmount = mountPromptBox(win.document, bridge, prompt === true ? {} : prompt);