@obelism/content 0.12.0 → 0.14.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
@@ -224,10 +224,21 @@ const createEmitter = ()=>{
224
224
  };
225
225
  const inertPrompt = ()=>Promise.reject(new Error('Prompts only work inside the visual editor'));
226
226
  const onlyOnSite = ()=>Promise.reject(new Error('Save and publish in the visual editor itself'));
227
+ const onlyWhileEditingSite = ()=>Promise.reject(new Error('Only while signed in to edit on the site'));
227
228
  // What every bridge without a feature does for it.
228
229
  const INERT_METHODS = {
229
230
  prompt: inertPrompt,
230
231
  createPage: ()=>Promise.reject(new Error('New pages are made on the site itself')),
232
+ view: onlyWhileEditingSite,
233
+ applyJson: onlyWhileEditingSite,
234
+ openForm: (_container, options = {})=>{
235
+ queueMicrotask(()=>options.onError?.('Only while signed in to edit on the site'));
236
+ return {
237
+ close: ()=>{}
238
+ };
239
+ },
240
+ pages: onlyWhileEditingSite,
241
+ openPage: ()=>{},
231
242
  undo: ()=>{},
232
243
  save: onlyOnSite,
233
244
  publish: onlyOnSite,
@@ -512,7 +523,19 @@ const DEFAULT_PROMPT_LABELS = {
512
523
  path: 'Path',
513
524
  pathPlaceholder: '/pricing',
514
525
  creating: 'Writing the page…',
515
- created: 'Opening the new page…'
526
+ created: 'Opening the new page…',
527
+ modes: 'Edit with',
528
+ modePrompt: 'Prompt',
529
+ form: 'Form',
530
+ formTitle: 'The content form',
531
+ formLoading: 'Opening the form…',
532
+ pages: 'Pages',
533
+ pagesSearch: 'Find a page',
534
+ pagesLoading: 'Loading pages…',
535
+ pagesEmpty: 'No pages match',
536
+ pageCurrent: 'This page',
537
+ pageDraft: 'Draft',
538
+ pageChanged: 'Changed'
516
539
  };
517
540
  // Icons (Lucide), drawn in the text colour.
518
541
  const ICONS = {
@@ -521,6 +544,9 @@ const ICONS = {
521
544
  block: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M7 8h10"/><path d="M7 12h6"/>',
522
545
  undo: '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/>',
523
546
  close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
547
+ chevrons: '<path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/>',
548
+ search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
549
+ check: '<path d="M20 6 9 17l-5-5"/>',
524
550
  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"/>'
525
551
  };
526
552
  // Obelism's design tokens (obelism.studio/design.md), under their own names:
@@ -622,6 +648,13 @@ textarea {
622
648
  font-size: 16px; line-height: 1.6; color: rgb(var(--theme-foreground-rgb));
623
649
  }
624
650
  textarea::placeholder { color: rgb(var(--theme-foreground-muted-rgb)); }
651
+ /* Form: the CMS's entry form, edited live. */
652
+ .form { padding: 12px 12px 0; }
653
+ .form iframe {
654
+ display: block; width: 100%; height: min(560px, 60vh); border: none; border-radius: 12px;
655
+ background: rgb(var(--theme-background-actions-rgb) / .45);
656
+ }
657
+ .form.loading iframe { visibility: hidden; }
625
658
  /* New page: where it goes, above what it is for. */
626
659
  .path {
627
660
  display: flex; align-items: center; gap: 10px; padding: 16px 22px 0;
@@ -665,6 +698,21 @@ button.chip:disabled { opacity: .5; }
665
698
  .icon-button.primary { background: rgb(var(--theme-accent-rgb)); color: #fff; }
666
699
  .icon-button.primary:hover:not(:disabled) { background: rgb(var(--theme-accent-rgb)); color: #fff; opacity: .88; }
667
700
  .icon-button.primary:disabled { opacity: .35; }
701
+ /* Prompt or Form: two small pills in one track. */
702
+ .modes {
703
+ display: inline-flex; flex: none; gap: 2px; padding: 2px; border-radius: var(--border-radius-pill);
704
+ border: 1px solid rgb(var(--theme-border-rgb) / .2); background: rgb(var(--theme-background-actions-rgb) / .6);
705
+ }
706
+ .modes button {
707
+ height: 28px; padding: 0 12px; border-radius: var(--border-radius-pill); font-size: 13px;
708
+ color: rgb(var(--theme-foreground-muted-rgb));
709
+ transition: background-color .25s var(--ease-roll), color .25s var(--ease-roll);
710
+ }
711
+ .modes button:hover { color: rgb(var(--theme-foreground-rgb)); }
712
+ .modes button[aria-pressed='true'] {
713
+ background: rgb(var(--theme-background-alt-rgb)); color: rgb(var(--theme-foreground-rgb));
714
+ box-shadow: 0 1px 2px rgb(var(--color-black-rgb) / .08);
715
+ }
668
716
  .divider { width: 1px; height: 20px; background: rgb(var(--theme-border-rgb) / .3); margin: 0 4px; }
669
717
 
670
718
  /* On the site: storing the draft, and the way out. */
@@ -681,6 +729,38 @@ button.chip:disabled { opacity: .5; }
681
729
  display: inline-block; width: 7px; height: 7px; border-radius: 50%;
682
730
  background: rgb(var(--theme-accent-rgb)); margin-right: 8px;
683
731
  }
732
+
733
+ /* The pages: a search above a list, in place of the prompt while open. */
734
+ .page-chip { min-width: 0; max-width: 50%; gap: 6px; }
735
+ .page-chip > span:not(.icon) { overflow: hidden; text-overflow: ellipsis; }
736
+ .page-chip .icon:last-child { color: rgb(var(--theme-foreground-muted-rgb)); margin-right: -4px; }
737
+ .page-chip .icon:last-child svg { width: 14px; height: 14px; }
738
+ .pages { display: grid; gap: 8px; padding: 16px 12px 12px; }
739
+ .search {
740
+ display: flex; align-items: center; gap: 8px; height: 38px; padding: 0 14px; margin: 0 4px;
741
+ border: 1px solid rgb(var(--theme-border-rgb) / .25); border-radius: var(--border-radius-pill);
742
+ background: rgb(var(--theme-background-actions-rgb) / .45); color: rgb(var(--theme-foreground-muted-rgb));
743
+ transition: border-color .25s var(--ease-roll);
744
+ }
745
+ .search:focus-within { border-color: rgb(var(--theme-accent-rgb)); }
746
+ .search input { flex: 1; min-width: 0; height: 100%; border: 0; outline: 0; background: transparent; color: rgb(var(--theme-foreground-rgb)); }
747
+ .search input::placeholder { color: rgb(var(--theme-foreground-muted-rgb)); }
748
+ .options { list-style: none; padding: 0; max-height: min(300px, 40vh); overflow-y: auto; display: grid; gap: 2px; }
749
+ .option {
750
+ display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-radius: 12px; cursor: pointer;
751
+ transition: background-color .15s var(--ease-roll);
752
+ }
753
+ .option:hover, .option.active { background: rgb(var(--theme-background-actions-rgb) / .7); }
754
+ .option-text { flex: 1; min-width: 0; display: grid; }
755
+ .option-text > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
756
+ .option-path { font-size: 12px; color: rgb(var(--theme-foreground-muted-rgb)); }
757
+ .badge {
758
+ flex: none; font-size: 12px; padding: 1px 8px; border-radius: var(--border-radius-pill);
759
+ border: 1px solid rgb(var(--theme-border-rgb) / .3); color: rgb(var(--theme-foreground-muted-rgb));
760
+ }
761
+ .option .icon { color: rgb(var(--theme-accent-rgb)); }
762
+ .empty { padding: 8px 12px; color: rgb(var(--theme-foreground-muted-rgb)); }
763
+ .empty.error { color: var(--theme-error); }
684
764
  [hidden] { display: none !important; }
685
765
  `;
686
766
  const element = (doc, tag, props = {}, ...children)=>{
@@ -744,6 +824,10 @@ const mountPromptBox = (doc, bridge, options = {})=>{
744
824
  rows: 1
745
825
  });
746
826
  input.setAttribute('aria-label', labels.open);
827
+ // Form mode (on the site): the CMS's form, mounted while shown.
828
+ const formBox = element(doc, 'div', {
829
+ className: 'form'
830
+ });
747
831
  // What the prompt acts on: the whole page, or the selected block with a
748
832
  // way back to the page.
749
833
  const pageIcon = icon(doc, 'page');
@@ -762,6 +846,22 @@ const mountPromptBox = (doc, bridge, options = {})=>{
762
846
  className: 'action chip',
763
847
  type: 'button'
764
848
  }, icon(doc, 'undo'), labels.undo);
849
+ // Prompt or Form (on the site).
850
+ const modeButton = (text)=>{
851
+ const button = element(doc, 'button', {
852
+ type: 'button',
853
+ textContent: text
854
+ });
855
+ button.setAttribute('aria-pressed', 'false');
856
+ return button;
857
+ };
858
+ const promptMode = modeButton(labels.modePrompt);
859
+ const formMode = modeButton(labels.form);
860
+ const modes = element(doc, 'span', {
861
+ className: 'modes'
862
+ }, promptMode, formMode);
863
+ modes.setAttribute('role', 'group');
864
+ modes.setAttribute('aria-label', labels.modes);
765
865
  // New page (on the site, for people who may add pages): the scope becomes
766
866
  // the new page, with a way back, and a path field shows above the prompt.
767
867
  const newPage = element(doc, 'button', {
@@ -804,6 +904,52 @@ const mountPromptBox = (doc, bridge, options = {})=>{
804
904
  textContent: labels.send
805
905
  }), icon(doc, 'send'));
806
906
  send.title = labels.send;
907
+ const divider = element(doc, 'span', {
908
+ className: 'divider'
909
+ });
910
+ const toolbar = element(doc, 'div', {
911
+ className: 'toolbar'
912
+ }, scope, newScope, modes, undo, newPage, element(doc, 'span', {
913
+ className: 'spacer'
914
+ }), close, divider, send);
915
+ // The pages (on the site): a chip naming this page, opening a search
916
+ // and a list of every page in place of the prompt.
917
+ const pageChipText = element(doc, 'span', {
918
+ textContent: labels.pages
919
+ });
920
+ const pageChip = element(doc, 'button', {
921
+ className: 'chip page-chip',
922
+ type: 'button',
923
+ title: labels.pages
924
+ }, icon(doc, 'page'), pageChipText, icon(doc, 'chevrons'));
925
+ pageChip.setAttribute('aria-haspopup', 'listbox');
926
+ pageChip.setAttribute('aria-expanded', 'false');
927
+ pageChip.setAttribute('aria-controls', 'obelism-pages');
928
+ const search = element(doc, 'input', {
929
+ type: 'text',
930
+ placeholder: labels.pagesSearch,
931
+ autocomplete: 'off',
932
+ spellcheck: false
933
+ });
934
+ search.setAttribute('role', 'combobox');
935
+ search.setAttribute('aria-label', labels.pagesSearch);
936
+ search.setAttribute('aria-autocomplete', 'list');
937
+ search.setAttribute('aria-expanded', 'true');
938
+ search.setAttribute('aria-controls', 'obelism-pages');
939
+ const optionList = element(doc, 'ul', {
940
+ className: 'options',
941
+ id: 'obelism-pages'
942
+ });
943
+ optionList.setAttribute('role', 'listbox');
944
+ optionList.setAttribute('aria-label', labels.pages);
945
+ const pagesNote = element(doc, 'p', {
946
+ className: 'empty'
947
+ });
948
+ const pagesSection = element(doc, 'div', {
949
+ className: 'pages'
950
+ }, element(doc, 'label', {
951
+ className: 'search'
952
+ }, icon(doc, 'search'), search), optionList, pagesNote);
807
953
  const unsaved = element(doc, 'span', {
808
954
  className: 'unsaved'
809
955
  });
@@ -828,18 +974,12 @@ const mountPromptBox = (doc, bridge, options = {})=>{
828
974
  });
829
975
  const footer = element(doc, 'div', {
830
976
  className: 'footer'
831
- }, unsaved, element(doc, 'span', {
977
+ }, pageChip, unsaved, element(doc, 'span', {
832
978
  className: 'spacer'
833
979
  }), signOut, save, publish);
834
980
  const panel = element(doc, 'form', {
835
981
  className: 'panel'
836
- }, status, pathRow, input, element(doc, 'div', {
837
- className: 'toolbar'
838
- }, scope, newScope, undo, newPage, element(doc, 'span', {
839
- className: 'spacer'
840
- }), close, element(doc, 'span', {
841
- className: 'divider'
842
- }), send), footer);
982
+ }, status, pathRow, input, formBox, toolbar, pagesSection, footer);
843
983
  const root = element(doc, 'div', {
844
984
  className: 'root'
845
985
  }, element(doc, 'div', {
@@ -851,6 +991,22 @@ const mountPromptBox = (doc, bridge, options = {})=>{
851
991
  let open = false;
852
992
  let creating = false;
853
993
  let message = null;
994
+ // Form mode, and the form while mounted: a fresh one (and one-time
995
+ // address) every time it shows.
996
+ let form = false;
997
+ let formHandle = null;
998
+ let formLoading = false;
999
+ // The pages, fetched once the panel opens and kept; `active` is the
1000
+ // option arrow keys are on.
1001
+ let picking = false;
1002
+ let pages = null;
1003
+ let pagesLoad = null;
1004
+ let pagesError = null;
1005
+ let active = 0;
1006
+ const failure = (error)=>({
1007
+ text: error instanceof Error ? error.message : String(error),
1008
+ error: true
1009
+ });
854
1010
  const render = (state)=>{
855
1011
  const available = state.features.includes('prompt');
856
1012
  const storing = state.saving || state.publishing;
@@ -866,21 +1022,41 @@ const mountPromptBox = (doc, bridge, options = {})=>{
866
1022
  const onlyNew = pageless && canCreate;
867
1023
  if (!canCreate) creating = false;
868
1024
  if (onlyNew) creating = true;
1025
+ if (creating || pageless || !site) form = false;
1026
+ // The form is there only while shown in the open panel.
1027
+ const showForm = open && available && form;
1028
+ if (showForm && !formHandle) mountForm();
1029
+ else if (!showForm && formHandle) unmountForm();
869
1030
  pillText.textContent = onlyNew ? labels.newPage : labels.open;
870
1031
  cancelNew.hidden = onlyNew;
871
1032
  scope.hidden = creating;
872
1033
  newScope.hidden = !creating;
873
- newPage.hidden = creating || !canCreate;
1034
+ newPage.hidden = creating || form || !canCreate;
874
1035
  newPage.disabled = busy;
875
- pathRow.hidden = !creating;
1036
+ pathRow.hidden = !creating || picking;
876
1037
  pathInput.disabled = busy;
1038
+ input.hidden = form || picking;
1039
+ formBox.hidden = !form || picking;
1040
+ formBox.classList.toggle('loading', formLoading);
1041
+ toolbar.hidden = picking;
1042
+ pagesSection.hidden = !picking;
877
1043
  input.placeholder = creating ? labels.newPagePlaceholder : labels.placeholder;
878
1044
  scopeText.textContent = state.selectedUid ? labels.scopeBlock : labels.scopeEntry;
879
1045
  clearBlock.hidden = !state.selectedUid;
880
1046
  pageIcon.hidden = !!state.selectedUid;
881
1047
  blockIcon.hidden = !state.selectedUid;
1048
+ // A CMS from before the form doesn't offer it.
1049
+ modes.hidden = !site || creating || pageless || !state.features.includes('form');
1050
+ promptMode.setAttribute('aria-pressed', String(!form));
1051
+ formMode.setAttribute('aria-pressed', String(form));
1052
+ modes.querySelectorAll('button').forEach((button)=>{
1053
+ button.disabled = busy;
1054
+ });
882
1055
  undo.hidden = creating || !state.canUndo;
883
1056
  undo.disabled = busy;
1057
+ // The form applies edits as they are made.
1058
+ send.hidden = form;
1059
+ divider.hidden = form;
884
1060
  send.disabled = busy || input.value.trim() === '' || creating && pathInput.value.trim() === '';
885
1061
  input.disabled = busy;
886
1062
  save.hidden = !state.features.includes('save') || pageless;
@@ -892,17 +1068,60 @@ const mountPromptBox = (doc, bridge, options = {})=>{
892
1068
  signOut.hidden = !site;
893
1069
  footer.hidden = !site;
894
1070
  unsaved.hidden = !state.unsaved;
895
- const text = state.prompting ? creating ? labels.creating : labels.working : message?.text ?? (site && !creating && !state.selectedUid ? labels.selectHint : '');
896
- status.textContent = text;
897
- status.hidden = !text;
898
- status.className = `status${state.prompting ? ' working' : ''}${!busy && message?.error ? ' error' : ''}`;
1071
+ pageChip.hidden = !site;
1072
+ pageChip.setAttribute('aria-expanded', String(picking));
1073
+ pageChipText.textContent = pages?.find(({ entryId })=>entryId === bridge.entryId)?.name ?? labels.pages;
1074
+ const loadingForm = form && formLoading && !picking;
1075
+ const working = state.prompting || loadingForm;
1076
+ const note = state.prompting ? creating ? labels.creating : labels.working : loadingForm ? labels.formLoading : message?.text ?? (site && !creating && !picking && !state.selectedUid ? labels.selectHint : '');
1077
+ status.textContent = note;
1078
+ status.hidden = !note;
1079
+ status.className = `status${working ? ' working' : ''}${!busy && !loadingForm && message?.error ? ' error' : ''}`;
899
1080
  };
900
1081
  const refresh = ()=>render(bridge.getState());
901
1082
  const grow = ()=>{
902
1083
  input.style.height = 'auto';
903
1084
  input.style.height = `${input.scrollHeight}px`;
904
1085
  };
1086
+ // Callbacks come later (the address is fetched first), never during a
1087
+ // render.
1088
+ const mountForm = ()=>{
1089
+ formLoading = true;
1090
+ formBox.classList.add('loading');
1091
+ const handle = bridge.openForm(formBox, {
1092
+ title: labels.formTitle,
1093
+ onReady: ()=>{
1094
+ if (formHandle !== handle) return;
1095
+ formLoading = false;
1096
+ message = null;
1097
+ refresh();
1098
+ },
1099
+ onError: (text)=>{
1100
+ if (formHandle !== handle) return;
1101
+ formLoading = false;
1102
+ message = {
1103
+ text,
1104
+ error: true
1105
+ };
1106
+ refresh();
1107
+ }
1108
+ });
1109
+ formHandle = handle;
1110
+ };
1111
+ const unmountForm = ()=>{
1112
+ formHandle?.close();
1113
+ formHandle = null;
1114
+ formLoading = false;
1115
+ };
1116
+ const setForm = (next)=>{
1117
+ if (form === next) return;
1118
+ form = next;
1119
+ message = null;
1120
+ refresh();
1121
+ if (!form) input.focus();
1122
+ };
905
1123
  const submit = async ()=>{
1124
+ if (form) return;
906
1125
  const prompt = input.value.trim();
907
1126
  if (!prompt || bridge.getState().prompting) return;
908
1127
  message = null;
@@ -912,6 +1131,8 @@ const mountPromptBox = (doc, bridge, options = {})=>{
912
1131
  if (!path) return pathInput.focus();
913
1132
  // The bridge opens the new page; this shows until it does.
914
1133
  await bridge.createPage(path, prompt);
1134
+ pages = null;
1135
+ pagesLoad = null;
915
1136
  message = {
916
1137
  text: labels.created,
917
1138
  error: false
@@ -926,18 +1147,101 @@ const mountPromptBox = (doc, bridge, options = {})=>{
926
1147
  input.value = '';
927
1148
  grow();
928
1149
  } catch (error) {
929
- message = {
930
- text: error instanceof Error ? error.message : String(error),
931
- error: true
932
- };
1150
+ message = failure(error);
933
1151
  }
934
1152
  refresh();
935
1153
  input.focus();
936
1154
  };
1155
+ const fetchPages = ()=>{
1156
+ pagesLoad ??= bridge.pages().then((list)=>{
1157
+ pages = list;
1158
+ pagesError = null;
1159
+ renderPages();
1160
+ refresh();
1161
+ }, (error)=>{
1162
+ pagesLoad = null;
1163
+ pagesError = failure(error).text;
1164
+ renderPages();
1165
+ });
1166
+ return pagesLoad;
1167
+ };
1168
+ const matching = ()=>{
1169
+ const query = search.value.trim().toLowerCase();
1170
+ return (pages ?? []).filter((page)=>!query || page.name.toLowerCase().includes(query) || page.url.toLowerCase().includes(query) || `/${page.path}`.toLowerCase().includes(query));
1171
+ };
1172
+ const renderPages = ()=>{
1173
+ const list = matching();
1174
+ active = Math.max(0, Math.min(active, list.length - 1));
1175
+ optionList.textContent = '';
1176
+ list.forEach((page, index)=>{
1177
+ const current = page.entryId === bridge.entryId;
1178
+ const option = element(doc, 'li', {
1179
+ className: `option${index === active ? ' active' : ''}`,
1180
+ id: `obelism-page-${index}`
1181
+ }, element(doc, 'span', {
1182
+ className: 'option-text'
1183
+ }, element(doc, 'span', {
1184
+ textContent: page.name
1185
+ }), element(doc, 'span', {
1186
+ className: 'option-path',
1187
+ textContent: page.url
1188
+ })));
1189
+ option.setAttribute('role', 'option');
1190
+ option.setAttribute('aria-selected', String(index === active));
1191
+ if (page.status !== 'published') {
1192
+ option.appendChild(element(doc, 'span', {
1193
+ className: 'badge',
1194
+ textContent: page.status === 'draft' ? labels.pageDraft : labels.pageChanged
1195
+ }));
1196
+ }
1197
+ if (current) {
1198
+ option.setAttribute('aria-current', 'page');
1199
+ const check = icon(doc, 'check');
1200
+ check.removeAttribute('aria-hidden');
1201
+ check.setAttribute('role', 'img');
1202
+ check.setAttribute('aria-label', labels.pageCurrent);
1203
+ option.appendChild(check);
1204
+ }
1205
+ option.addEventListener('click', ()=>choose(page));
1206
+ optionList.appendChild(option);
1207
+ });
1208
+ const activeOption = optionList.children[active];
1209
+ if (activeOption) {
1210
+ search.setAttribute('aria-activedescendant', activeOption.id);
1211
+ activeOption.scrollIntoView?.({
1212
+ block: 'nearest'
1213
+ });
1214
+ } else {
1215
+ search.removeAttribute('aria-activedescendant');
1216
+ }
1217
+ const note = pagesError ?? (pages ? '' : labels.pagesLoading);
1218
+ pagesNote.textContent = note || (list.length === 0 ? labels.pagesEmpty : '');
1219
+ pagesNote.hidden = !pagesNote.textContent;
1220
+ pagesNote.className = `empty${pagesError ? ' error' : ''}`;
1221
+ };
1222
+ const setPicking = (next)=>{
1223
+ picking = next;
1224
+ message = null;
1225
+ if (picking) {
1226
+ search.value = '';
1227
+ active = Math.max(0, (pages ?? []).findIndex(({ entryId })=>entryId === bridge.entryId));
1228
+ void fetchPages();
1229
+ renderPages();
1230
+ }
1231
+ refresh();
1232
+ (picking ? search : pageChip).focus();
1233
+ };
1234
+ const choose = (page)=>{
1235
+ if (page.entryId === bridge.entryId) return setPicking(false);
1236
+ // Asks first when changes aren't saved; stays here if told no.
1237
+ bridge.openPage(page);
1238
+ };
937
1239
  const setOpen = (next)=>{
938
1240
  open = next;
1241
+ if (!open) picking = false;
939
1242
  refresh();
940
1243
  if (site) bridge.setSelecting(open && !creating);
1244
+ if (open && site) void fetchPages();
941
1245
  };
942
1246
  const setCreating = (next)=>{
943
1247
  creating = next;
@@ -956,16 +1260,13 @@ const mountPromptBox = (doc, bridge, options = {})=>{
956
1260
  error: false
957
1261
  };
958
1262
  } catch (error) {
959
- message = {
960
- text: error instanceof Error ? error.message : String(error),
961
- error: true
962
- };
1263
+ message = failure(error);
963
1264
  }
964
1265
  refresh();
965
1266
  };
966
1267
  pill.addEventListener('click', ()=>{
967
1268
  setOpen(true);
968
- (creating ? pathInput : input).focus();
1269
+ if (!form) (creating ? pathInput : input).focus();
969
1270
  });
970
1271
  close.addEventListener('click', ()=>setOpen(false));
971
1272
  save.addEventListener('click', ()=>void store('save'));
@@ -975,6 +1276,9 @@ const mountPromptBox = (doc, bridge, options = {})=>{
975
1276
  clearBlock.addEventListener('click', ()=>bridge.select(null));
976
1277
  newPage.addEventListener('click', ()=>setCreating(true));
977
1278
  cancelNew.addEventListener('click', ()=>setCreating(false));
1279
+ promptMode.addEventListener('click', ()=>setForm(false));
1280
+ formMode.addEventListener('click', ()=>setForm(true));
1281
+ pageChip.addEventListener('click', ()=>setPicking(!picking));
978
1282
  pathInput.addEventListener('input', refresh);
979
1283
  pathInput.addEventListener('keydown', (event)=>{
980
1284
  if (event.key === 'Enter' && !event.isComposing) {
@@ -984,6 +1288,26 @@ const mountPromptBox = (doc, bridge, options = {})=>{
984
1288
  setOpen(false);
985
1289
  }
986
1290
  });
1291
+ search.addEventListener('input', ()=>{
1292
+ active = 0;
1293
+ renderPages();
1294
+ });
1295
+ search.addEventListener('keydown', (event)=>{
1296
+ const count = optionList.children.length;
1297
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
1298
+ event.preventDefault();
1299
+ if (count === 0) return;
1300
+ active = (active + (event.key === 'ArrowDown' ? 1 : -1) + count) % count;
1301
+ renderPages();
1302
+ } else if (event.key === 'Enter' && !event.isComposing) {
1303
+ event.preventDefault();
1304
+ const page = matching()[active];
1305
+ if (page) choose(page);
1306
+ } else if (event.key === 'Escape') {
1307
+ event.preventDefault();
1308
+ setPicking(false);
1309
+ }
1310
+ });
987
1311
  undo.addEventListener('click', ()=>{
988
1312
  message = null;
989
1313
  bridge.undo();
@@ -1009,10 +1333,87 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1009
1333
  doc.body.appendChild(host);
1010
1334
  return ()=>{
1011
1335
  unsubscribe();
1336
+ unmountForm();
1012
1337
  host.remove();
1013
1338
  };
1014
1339
  };
1015
1340
 
1341
+ // The CMS's entry form in an iframe, for editing on the site (the prompt
1342
+ // box's Form mode). The CMS renders the form at a one-time URL (`/site/form#
1343
+ // code=…`, from the site's `/cms/api/form`); this keeps it in step with the
1344
+ // bridge's in-memory content.
1345
+ //
1346
+ // Messages are only exchanged with the URL's origin, and only with the
1347
+ // iframe's window:
1348
+ // - form → bridge: { source: 'obelism-content-form', type: 'ready' |
1349
+ // 'change' | 'error', content?, message? } — `change` carries the whole
1350
+ // entry content after an edit
1351
+ // - bridge → form: { source: 'obelism-content-bridge', type: 'content',
1352
+ // entryId, content, locale, selectedUid }
1353
+ const FORM_SOURCE = 'obelism-content-form';
1354
+ // How long the form may take to say it is ready.
1355
+ const FORM_READY_TIMEOUT_MS = 30_000;
1356
+ const isObject = (value)=>!!value && typeof value === 'object' && !Array.isArray(value);
1357
+ // Mounts the form at `url` in `container`. The URL is single use: mount a
1358
+ // fresh one every time.
1359
+ const mountFormFrame = (win, container, url, title, handlers)=>{
1360
+ let parsed = null;
1361
+ try {
1362
+ parsed = new URL(url);
1363
+ } catch {}
1364
+ if (!parsed || !/^https?:$/.test(parsed.protocol)) {
1365
+ throw new Error('Obelism Content sent no form to open');
1366
+ }
1367
+ const origin = parsed.origin;
1368
+ const iframe = container.ownerDocument.createElement('iframe');
1369
+ iframe.title = title;
1370
+ iframe.src = url;
1371
+ let ready = false;
1372
+ let latest = null;
1373
+ const post = ()=>{
1374
+ if (!ready || !latest) return;
1375
+ iframe.contentWindow?.postMessage({
1376
+ source: BRIDGE_SOURCE,
1377
+ type: 'content',
1378
+ ...latest
1379
+ }, origin);
1380
+ };
1381
+ const timer = setTimeout(()=>{
1382
+ if (!ready) handlers.onError('The form did not open');
1383
+ }, FORM_READY_TIMEOUT_MS);
1384
+ const onMessage = (event)=>{
1385
+ if (event.origin !== origin || !iframe.contentWindow || event.source !== iframe.contentWindow) {
1386
+ return;
1387
+ }
1388
+ const data = event.data;
1389
+ if (!data || data.source !== FORM_SOURCE) return;
1390
+ if (data.type === 'ready') {
1391
+ // Again after the form reloads itself: it gets the content again.
1392
+ ready = true;
1393
+ clearTimeout(timer);
1394
+ handlers.onReady();
1395
+ post();
1396
+ } else if (data.type === 'change' && isObject(data.content)) {
1397
+ handlers.onChange(data.content, typeof data.entryId === 'string' ? data.entryId : undefined);
1398
+ } else if (data.type === 'error') {
1399
+ handlers.onError(typeof data.message === 'string' && data.message ? data.message : 'The form failed');
1400
+ }
1401
+ };
1402
+ win.addEventListener('message', onMessage);
1403
+ container.appendChild(iframe);
1404
+ return {
1405
+ show (content) {
1406
+ latest = content;
1407
+ post();
1408
+ },
1409
+ destroy () {
1410
+ clearTimeout(timer);
1411
+ win.removeEventListener('message', onMessage);
1412
+ iframe.remove();
1413
+ }
1414
+ };
1415
+ };
1416
+
1016
1417
  // The bridge on a site signed in to edit (`/cms/login`, from
1017
1418
  // @obelism/content/site), outside the visual editor. It plays the editor's
1018
1419
  // part itself: it keeps the changed content in memory, asks the site's
@@ -1022,6 +1423,10 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1022
1423
  //
1023
1424
  // What it edits is the page's entry — the outermost element marked with
1024
1425
  // `editable(entry)` — or, for a selected block, the entry around that block.
1426
+ // Besides prompts it shows the CMS's own entry form (`openForm`, an iframe
1427
+ // kept in step with the content here, see formFrame.ts), edits JSON (`view`,
1428
+ // then `applyJson`: the raw alternative), and lists the space's pages to go
1429
+ // to one.
1025
1430
  // The base path of the site's handler while signed in to edit, else null.
1026
1431
  const readSiteEditing = (doc)=>{
1027
1432
  for (const part of doc.cookie.split(';')){
@@ -1037,6 +1442,11 @@ const readSiteEditing = (doc)=>{
1037
1442
  return null;
1038
1443
  };
1039
1444
  const same = (a, b)=>JSON.stringify(a) === JSON.stringify(b);
1445
+ // Edits in the form further apart than this are separate undo steps.
1446
+ const FORM_BURST_MS = 1000;
1447
+ // How long the page waits for the form's edits to settle before it shows
1448
+ // them.
1449
+ const FORM_RENDER_MS = 300;
1040
1450
  const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})=>{
1041
1451
  const doc = win.document;
1042
1452
  const emitter = createEmitter();
@@ -1047,6 +1457,11 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1047
1457
  // Entries changed here since the last publish.
1048
1458
  const touched = new Set();
1049
1459
  let session = null;
1460
+ // The open form's, if any; and when the form last changed content, for
1461
+ // undo steps (-Infinity: the next edit starts a new step).
1462
+ let closeForm = null;
1463
+ let lastFormEdit = -Infinity;
1464
+ const renderTimers = new Map();
1050
1465
  let state = INITIAL_STATE;
1051
1466
  const setState = (next)=>{
1052
1467
  state = {
@@ -1100,17 +1515,39 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1100
1515
  const base = lang.split('-')[0] ?? '';
1101
1516
  return codes.includes(base) ? base : session.space.defaultLocale;
1102
1517
  };
1518
+ // One request per entry at a time: a second would overwrite edits made
1519
+ // while the first was out.
1520
+ const loading = new Map();
1103
1521
  const load = async (entryId)=>{
1104
1522
  const loaded = working.get(entryId);
1105
1523
  if (loaded) return loaded;
1106
- const entry = await api(`entries/${encodeURIComponent(entryId)}`);
1107
- const fresh = {
1108
- content: entry.content,
1109
- saved: entry.content,
1110
- revision: entry.draftRevision
1524
+ let pending = loading.get(entryId);
1525
+ if (!pending) {
1526
+ pending = api(`entries/${encodeURIComponent(entryId)}`).then((entry)=>{
1527
+ const fresh = working.get(entryId) ?? {
1528
+ content: entry.content,
1529
+ saved: entry.content,
1530
+ revision: entry.draftRevision
1531
+ };
1532
+ working.set(entryId, fresh);
1533
+ return fresh;
1534
+ }).finally(()=>loading.delete(entryId));
1535
+ loading.set(entryId, pending);
1536
+ }
1537
+ return pending;
1538
+ };
1539
+ // The entry a prompt or JSON acts on, for the block `uid` (the selected
1540
+ // one when left out).
1541
+ const target = (option)=>{
1542
+ const uid = option === undefined ? state.selectedUid : option;
1543
+ const entryId = entryFor(uid);
1544
+ if (!entryId) {
1545
+ throw new Error('Nothing on this page is marked as an entry: add editable(entry)');
1546
+ }
1547
+ return {
1548
+ uid,
1549
+ entryId
1111
1550
  };
1112
- working.set(entryId, fresh);
1113
- return fresh;
1114
1551
  };
1115
1552
  const highlight = (uid)=>{
1116
1553
  doc.querySelectorAll('[data-cms-selected]').forEach((element)=>{
@@ -1136,6 +1573,15 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1136
1573
  });
1137
1574
  win.requestAnimationFrame(()=>highlight(state.selectedUid));
1138
1575
  };
1576
+ const logError = (error)=>console.error('[@obelism/content bridge]', error);
1577
+ // Renders once the form's edits settle.
1578
+ const renderSoon = (entryId)=>{
1579
+ clearTimeout(renderTimers.get(entryId));
1580
+ renderTimers.set(entryId, setTimeout(()=>{
1581
+ renderTimers.delete(entryId);
1582
+ void render(entryId).catch(logError);
1583
+ }, FORM_RENDER_MS));
1584
+ };
1139
1585
  const afterStore = (event)=>{
1140
1586
  emitter.emit(event, {});
1141
1587
  if (emitter.listeners.update.size === 0) win.location.reload();
@@ -1152,6 +1598,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1152
1598
  touched.add(entryId);
1153
1599
  }
1154
1600
  history.length = 0;
1601
+ lastFormEdit = -Infinity;
1155
1602
  refreshUnsaved();
1156
1603
  };
1157
1604
  const has = (feature)=>state.features.includes(feature);
@@ -1168,11 +1615,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1168
1615
  const text = prompt.trim();
1169
1616
  if (!text) throw new Error('Say what to change');
1170
1617
  if (!has('prompt')) throw new Error('You may not edit content here');
1171
- const uid = options.uid === undefined ? state.selectedUid : options.uid;
1172
- const entryId = entryFor(uid);
1173
- if (!entryId) {
1174
- throw new Error('Nothing on this page is marked as an entry: add editable(entry)');
1175
- }
1618
+ const { uid, entryId } = target(options.uid);
1176
1619
  setState({
1177
1620
  prompting: true
1178
1621
  });
@@ -1188,6 +1631,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1188
1631
  entryId,
1189
1632
  content: entry.content
1190
1633
  });
1634
+ lastFormEdit = -Infinity;
1191
1635
  entry.content = result.content;
1192
1636
  refreshUnsaved();
1193
1637
  await render(entryId);
@@ -1233,13 +1677,182 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1233
1677
  });
1234
1678
  }
1235
1679
  },
1680
+ async view (options = {}) {
1681
+ const { uid, entryId } = target(options.uid);
1682
+ const entry = await load(entryId);
1683
+ const { view } = await api(`entries/${encodeURIComponent(entryId)}/view`, {
1684
+ content: entry.content,
1685
+ locale: locale(),
1686
+ uid
1687
+ });
1688
+ return view;
1689
+ },
1690
+ async applyJson (changes, options = {}) {
1691
+ if (!has('prompt')) throw new Error('You may not edit content here');
1692
+ if (!changes || typeof changes !== 'object' || Array.isArray(changes)) {
1693
+ throw new Error('Send the fields to change as an object');
1694
+ }
1695
+ const { uid, entryId } = target(options.uid);
1696
+ setState({
1697
+ prompting: true
1698
+ });
1699
+ try {
1700
+ const entry = await load(entryId);
1701
+ const result = await api(`entries/${encodeURIComponent(entryId)}/apply`, {
1702
+ changes,
1703
+ content: entry.content,
1704
+ locale: locale(),
1705
+ uid
1706
+ });
1707
+ // Nothing to undo when nothing changed.
1708
+ if (!same(result.content, entry.content)) {
1709
+ history.push({
1710
+ entryId,
1711
+ content: entry.content
1712
+ });
1713
+ lastFormEdit = -Infinity;
1714
+ entry.content = result.content;
1715
+ refreshUnsaved();
1716
+ await render(entryId);
1717
+ }
1718
+ return {
1719
+ changed: result.changed ?? [],
1720
+ ...result.view && {
1721
+ view: result.view
1722
+ }
1723
+ };
1724
+ } finally{
1725
+ setState({
1726
+ prompting: false
1727
+ });
1728
+ }
1729
+ },
1730
+ openForm (container, options = {}) {
1731
+ const { title = 'Content form', onReady, onError } = options;
1732
+ closeForm?.();
1733
+ let closed = false;
1734
+ let frame = null;
1735
+ // What the form was last sent, or sent back itself.
1736
+ let shown = null;
1737
+ let unsubscribe = ()=>{};
1738
+ const fail = (error)=>{
1739
+ if (!closed) onError?.(error instanceof Error ? error.message : String(error));
1740
+ };
1741
+ const close = ()=>{
1742
+ if (closed) return;
1743
+ closed = true;
1744
+ unsubscribe();
1745
+ frame?.destroy();
1746
+ if (closeForm === close) closeForm = null;
1747
+ };
1748
+ closeForm = close;
1749
+ // Sends the form the entry for the selection, when anything of it
1750
+ // changed: prompts, undo, a reload after saving, the selection.
1751
+ const show = ()=>{
1752
+ if (closed || !frame) return;
1753
+ const entryId = entryFor(state.selectedUid);
1754
+ if (!entryId) return;
1755
+ const entry = working.get(entryId);
1756
+ if (!entry) {
1757
+ load(entryId).then(show, fail);
1758
+ return;
1759
+ }
1760
+ const next = {
1761
+ entryId,
1762
+ content: entry.content,
1763
+ locale: locale(),
1764
+ selectedUid: state.selectedUid
1765
+ };
1766
+ if (shown && shown.entryId === next.entryId && shown.content === next.content && shown.locale === next.locale && shown.selectedUid === next.selectedUid) {
1767
+ return;
1768
+ }
1769
+ shown = next;
1770
+ frame.show(next);
1771
+ };
1772
+ const change = (content, from)=>{
1773
+ const entryId = from ?? shown?.entryId;
1774
+ const entry = entryId === undefined ? undefined : working.get(entryId);
1775
+ if (closed || entryId === undefined || !entry || same(content, entry.content)) {
1776
+ return;
1777
+ }
1778
+ const now = Date.now();
1779
+ if (now - lastFormEdit >= FORM_BURST_MS) {
1780
+ history.push({
1781
+ entryId,
1782
+ content: entry.content
1783
+ });
1784
+ }
1785
+ lastFormEdit = now;
1786
+ entry.content = content;
1787
+ // Not sent back: the form has it.
1788
+ if (shown?.entryId === entryId) shown = {
1789
+ ...shown,
1790
+ content
1791
+ };
1792
+ refreshUnsaved();
1793
+ renderSoon(entryId);
1794
+ };
1795
+ const start = async ()=>{
1796
+ if (!has('form')) throw new Error('You may not edit content here');
1797
+ await load(target(undefined).entryId);
1798
+ // A fresh one every time: the address works once, for a minute.
1799
+ const { url } = await api('form', {});
1800
+ if (closed) return;
1801
+ const address = typeof url === 'string' ? url : '';
1802
+ frame = mountFormFrame(win, container, address, title, {
1803
+ onReady: ()=>onReady?.(),
1804
+ onChange: change,
1805
+ onError: (message)=>onError?.(message)
1806
+ });
1807
+ unsubscribe = emitter.on('change', show);
1808
+ show();
1809
+ };
1810
+ start().catch(fail);
1811
+ return {
1812
+ close
1813
+ };
1814
+ },
1815
+ async pages () {
1816
+ const { pages } = await api('pages');
1817
+ const preferred = [
1818
+ locale(),
1819
+ session?.space.defaultLocale
1820
+ ];
1821
+ return pages.flatMap((page)=>{
1822
+ // The page's path in this locale, else the default's, else any.
1823
+ const code = preferred.find((code)=>code !== undefined && page.paths[code] !== undefined) ?? Object.keys(page.paths)[0];
1824
+ const path = code === undefined ? undefined : page.paths[code];
1825
+ if (code === undefined || path === undefined) return [];
1826
+ return [
1827
+ {
1828
+ entryId: page.entryId,
1829
+ name: page.name,
1830
+ contentType: page.contentType,
1831
+ status: page.status,
1832
+ path,
1833
+ url: pagePath(path, code)
1834
+ }
1835
+ ];
1836
+ });
1837
+ },
1838
+ openPage (page) {
1839
+ if (state.unsaved && !win.confirm('Leave this page and lose the changes you have not saved?')) {
1840
+ return;
1841
+ }
1842
+ // Asked already: no second question from `beforeunload`.
1843
+ working.clear();
1844
+ history.length = 0;
1845
+ refreshUnsaved();
1846
+ win.location.assign(page.url);
1847
+ },
1236
1848
  undo () {
1237
1849
  const last = history.pop();
1238
1850
  const entry = last && working.get(last.entryId);
1239
1851
  if (!last || !entry) return;
1852
+ lastFormEdit = -Infinity;
1240
1853
  entry.content = last.content;
1241
1854
  refreshUnsaved();
1242
- void render(last.entryId).catch((error)=>console.error('[@obelism/content bridge]', error));
1855
+ void render(last.entryId).catch(logError);
1243
1856
  },
1244
1857
  async save () {
1245
1858
  if (!has('save')) throw new Error('You may not edit content here');
@@ -1302,6 +1915,9 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1302
1915
  });
1303
1916
  },
1304
1917
  destroy () {
1918
+ closeForm?.();
1919
+ renderTimers.forEach((timer)=>clearTimeout(timer));
1920
+ renderTimers.clear();
1305
1921
  cleanup.splice(0).forEach((undo)=>undo());
1306
1922
  }
1307
1923
  };