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