@obelism/content 0.11.0 → 0.13.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,15 @@ 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
+ pages: onlyWhileEditingSite,
235
+ openPage: ()=>{},
231
236
  undo: ()=>{},
232
237
  save: onlyOnSite,
233
238
  publish: onlyOnSite,
@@ -512,7 +517,26 @@ const DEFAULT_PROMPT_LABELS = {
512
517
  path: 'Path',
513
518
  pathPlaceholder: '/pricing',
514
519
  creating: 'Writing the page…',
515
- created: 'Opening the new page…'
520
+ created: 'Opening the new page…',
521
+ modes: 'Edit with',
522
+ modePrompt: 'Prompt',
523
+ modeJson: 'JSON',
524
+ json: 'The content as JSON',
525
+ jsonLoading: 'Loading the JSON…',
526
+ jsonReload: 'Reload the JSON',
527
+ jsonKept: 'Kept your unapplied JSON. Reload to see the current content.',
528
+ jsonInvalidAt: 'Not valid JSON at line {line}, column {column}',
529
+ jsonInvalid: 'Not valid JSON',
530
+ jsonNotObject: 'The JSON has to be an object of fields, like { "title": … }',
531
+ applied: 'Applied',
532
+ unchanged: 'Nothing changed',
533
+ pages: 'Pages',
534
+ pagesSearch: 'Find a page',
535
+ pagesLoading: 'Loading pages…',
536
+ pagesEmpty: 'No pages match',
537
+ pageCurrent: 'This page',
538
+ pageDraft: 'Draft',
539
+ pageChanged: 'Changed'
516
540
  };
517
541
  // Icons (Lucide), drawn in the text colour.
518
542
  const ICONS = {
@@ -521,6 +545,10 @@ const ICONS = {
521
545
  block: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M7 8h10"/><path d="M7 12h6"/>',
522
546
  undo: '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/>',
523
547
  close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
548
+ reload: '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>',
549
+ chevrons: '<path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/>',
550
+ search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
551
+ check: '<path d="M20 6 9 17l-5-5"/>',
524
552
  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
553
  };
526
554
  // Obelism's design tokens (obelism.studio/design.md), under their own names:
@@ -622,6 +650,13 @@ textarea {
622
650
  font-size: 16px; line-height: 1.6; color: rgb(var(--theme-foreground-rgb));
623
651
  }
624
652
  textarea::placeholder { color: rgb(var(--theme-foreground-muted-rgb)); }
653
+ /* JSON: a plain code editor, taller, scrolling instead of wrapping. */
654
+ textarea.code {
655
+ height: 40vh; min-height: 160px; max-height: 50vh; overflow: auto; resize: vertical;
656
+ white-space: pre; overflow-wrap: normal; tab-size: 2;
657
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
658
+ font-size: 13px; line-height: 1.55;
659
+ }
625
660
  /* New page: where it goes, above what it is for. */
626
661
  .path {
627
662
  display: flex; align-items: center; gap: 10px; padding: 16px 22px 0;
@@ -665,6 +700,21 @@ button.chip:disabled { opacity: .5; }
665
700
  .icon-button.primary { background: rgb(var(--theme-accent-rgb)); color: #fff; }
666
701
  .icon-button.primary:hover:not(:disabled) { background: rgb(var(--theme-accent-rgb)); color: #fff; opacity: .88; }
667
702
  .icon-button.primary:disabled { opacity: .35; }
703
+ /* Prompt or JSON: two small pills in one track. */
704
+ .modes {
705
+ display: inline-flex; flex: none; gap: 2px; padding: 2px; border-radius: var(--border-radius-pill);
706
+ border: 1px solid rgb(var(--theme-border-rgb) / .2); background: rgb(var(--theme-background-actions-rgb) / .6);
707
+ }
708
+ .modes button {
709
+ height: 28px; padding: 0 12px; border-radius: var(--border-radius-pill); font-size: 13px;
710
+ color: rgb(var(--theme-foreground-muted-rgb));
711
+ transition: background-color .25s var(--ease-roll), color .25s var(--ease-roll);
712
+ }
713
+ .modes button:hover { color: rgb(var(--theme-foreground-rgb)); }
714
+ .modes button[aria-pressed='true'] {
715
+ background: rgb(var(--theme-background-alt-rgb)); color: rgb(var(--theme-foreground-rgb));
716
+ box-shadow: 0 1px 2px rgb(var(--color-black-rgb) / .08);
717
+ }
668
718
  .divider { width: 1px; height: 20px; background: rgb(var(--theme-border-rgb) / .3); margin: 0 4px; }
669
719
 
670
720
  /* On the site: storing the draft, and the way out. */
@@ -681,6 +731,38 @@ button.chip:disabled { opacity: .5; }
681
731
  display: inline-block; width: 7px; height: 7px; border-radius: 50%;
682
732
  background: rgb(var(--theme-accent-rgb)); margin-right: 8px;
683
733
  }
734
+
735
+ /* The pages: a search above a list, in place of the prompt while open. */
736
+ .page-chip { min-width: 0; max-width: 50%; gap: 6px; }
737
+ .page-chip > span:not(.icon) { overflow: hidden; text-overflow: ellipsis; }
738
+ .page-chip .icon:last-child { color: rgb(var(--theme-foreground-muted-rgb)); margin-right: -4px; }
739
+ .page-chip .icon:last-child svg { width: 14px; height: 14px; }
740
+ .pages { display: grid; gap: 8px; padding: 16px 12px 12px; }
741
+ .search {
742
+ display: flex; align-items: center; gap: 8px; height: 38px; padding: 0 14px; margin: 0 4px;
743
+ border: 1px solid rgb(var(--theme-border-rgb) / .25); border-radius: var(--border-radius-pill);
744
+ background: rgb(var(--theme-background-actions-rgb) / .45); color: rgb(var(--theme-foreground-muted-rgb));
745
+ transition: border-color .25s var(--ease-roll);
746
+ }
747
+ .search:focus-within { border-color: rgb(var(--theme-accent-rgb)); }
748
+ .search input { flex: 1; min-width: 0; height: 100%; border: 0; outline: 0; background: transparent; color: rgb(var(--theme-foreground-rgb)); }
749
+ .search input::placeholder { color: rgb(var(--theme-foreground-muted-rgb)); }
750
+ .options { list-style: none; padding: 0; max-height: min(300px, 40vh); overflow-y: auto; display: grid; gap: 2px; }
751
+ .option {
752
+ display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-radius: 12px; cursor: pointer;
753
+ transition: background-color .15s var(--ease-roll);
754
+ }
755
+ .option:hover, .option.active { background: rgb(var(--theme-background-actions-rgb) / .7); }
756
+ .option-text { flex: 1; min-width: 0; display: grid; }
757
+ .option-text > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
758
+ .option-path { font-size: 12px; color: rgb(var(--theme-foreground-muted-rgb)); }
759
+ .badge {
760
+ flex: none; font-size: 12px; padding: 1px 8px; border-radius: var(--border-radius-pill);
761
+ border: 1px solid rgb(var(--theme-border-rgb) / .3); color: rgb(var(--theme-foreground-muted-rgb));
762
+ }
763
+ .option .icon { color: rgb(var(--theme-accent-rgb)); }
764
+ .empty { padding: 8px 12px; color: rgb(var(--theme-foreground-muted-rgb)); }
765
+ .empty.error { color: var(--theme-error); }
684
766
  [hidden] { display: none !important; }
685
767
  `;
686
768
  const element = (doc, tag, props = {}, ...children)=>{
@@ -701,6 +783,101 @@ const icon = (doc, name)=>{
701
783
  span.innerHTML = `<svg viewBox="0 0 24 24">${ICONS[name]}</svg>`;
702
784
  return span;
703
785
  };
786
+ // Where `text` stops being JSON: the offset of the first character that
787
+ // doesn't fit, or -1 when it is JSON. JSON.parse's errors don't all say
788
+ // where (V8's "Unexpected token" doesn't, Safari's never do).
789
+ const jsonErrorAt = (text)=>{
790
+ let i = 0;
791
+ const fail = ()=>{
792
+ throw i;
793
+ };
794
+ const space = ()=>{
795
+ while(i < text.length && ' \t\n\r'.includes(text[i]))i++;
796
+ };
797
+ const string = ()=>{
798
+ i++;
799
+ while(i < text.length){
800
+ const char = text[i];
801
+ if (char === '"') {
802
+ i++;
803
+ return;
804
+ }
805
+ if (char === '\\') {
806
+ const next = text[i + 1] ?? '';
807
+ if (next === 'u' && /^[\da-f]{4}$/i.test(text.slice(i + 2, i + 6))) {
808
+ i += 6;
809
+ } else if (next !== '' && '"\\/bfnrt'.includes(next)) {
810
+ i += 2;
811
+ } else {
812
+ i++;
813
+ fail();
814
+ }
815
+ } else if (char < ' ') {
816
+ fail();
817
+ } else {
818
+ i++;
819
+ }
820
+ }
821
+ fail();
822
+ };
823
+ const items = (close, item)=>{
824
+ i++;
825
+ space();
826
+ if (text[i] === close) {
827
+ i++;
828
+ return;
829
+ }
830
+ for(;;){
831
+ item();
832
+ space();
833
+ if (text[i] === close) {
834
+ i++;
835
+ return;
836
+ }
837
+ if (text[i] !== ',') fail();
838
+ i++;
839
+ }
840
+ };
841
+ const value = ()=>{
842
+ space();
843
+ const char = text[i] ?? '';
844
+ if (char === '{') {
845
+ return items('}', ()=>{
846
+ space();
847
+ if (text[i] !== '"') fail();
848
+ string();
849
+ space();
850
+ if (text[i] !== ':') fail();
851
+ i++;
852
+ value();
853
+ });
854
+ }
855
+ if (char === '[') return items(']', value);
856
+ if (char === '"') return string();
857
+ for (const word of [
858
+ 'true',
859
+ 'false',
860
+ 'null'
861
+ ]){
862
+ if (text.startsWith(word, i)) {
863
+ i += word.length;
864
+ return;
865
+ }
866
+ }
867
+ const number = /^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?/.exec(text.slice(i));
868
+ if (!number) return fail();
869
+ i += number[0].length;
870
+ };
871
+ try {
872
+ value();
873
+ space();
874
+ if (i < text.length) fail();
875
+ return -1;
876
+ } catch (at) {
877
+ if (typeof at !== 'number') throw at;
878
+ return Math.min(at, text.length);
879
+ }
880
+ };
704
881
  // Mounts the box on the page; returns a function that removes it.
705
882
  const mountPromptBox = (doc, bridge, options = {})=>{
706
883
  const labels = {
@@ -744,6 +921,14 @@ const mountPromptBox = (doc, bridge, options = {})=>{
744
921
  rows: 1
745
922
  });
746
923
  input.setAttribute('aria-label', labels.open);
924
+ // JSON mode (on the site): the entry or block as JSON, edited by hand.
925
+ const code = element(doc, 'textarea', {
926
+ className: 'code',
927
+ spellcheck: false,
928
+ autocomplete: 'off'
929
+ });
930
+ code.setAttribute('aria-label', labels.json);
931
+ code.setAttribute('autocapitalize', 'off');
747
932
  // What the prompt acts on: the whole page, or the selected block with a
748
933
  // way back to the page.
749
934
  const pageIcon = icon(doc, 'page');
@@ -762,6 +947,28 @@ const mountPromptBox = (doc, bridge, options = {})=>{
762
947
  className: 'action chip',
763
948
  type: 'button'
764
949
  }, icon(doc, 'undo'), labels.undo);
950
+ // Prompt or JSON (on the site).
951
+ const modeButton = (text)=>{
952
+ const button = element(doc, 'button', {
953
+ type: 'button',
954
+ textContent: text
955
+ });
956
+ button.setAttribute('aria-pressed', 'false');
957
+ return button;
958
+ };
959
+ const promptMode = modeButton(labels.modePrompt);
960
+ const jsonMode = modeButton(labels.modeJson);
961
+ const modes = element(doc, 'span', {
962
+ className: 'modes'
963
+ }, promptMode, jsonMode);
964
+ modes.setAttribute('role', 'group');
965
+ modes.setAttribute('aria-label', labels.modes);
966
+ const reload = element(doc, 'button', {
967
+ className: 'icon-button',
968
+ type: 'button',
969
+ title: labels.jsonReload
970
+ }, icon(doc, 'reload'));
971
+ reload.setAttribute('aria-label', labels.jsonReload);
765
972
  // New page (on the site, for people who may add pages): the scope becomes
766
973
  // the new page, with a way back, and a path field shows above the prompt.
767
974
  const newPage = element(doc, 'button', {
@@ -804,6 +1011,51 @@ const mountPromptBox = (doc, bridge, options = {})=>{
804
1011
  textContent: labels.send
805
1012
  }), icon(doc, 'send'));
806
1013
  send.title = labels.send;
1014
+ const toolbar = element(doc, 'div', {
1015
+ className: 'toolbar'
1016
+ }, scope, newScope, modes, undo, newPage, element(doc, 'span', {
1017
+ className: 'spacer'
1018
+ }), reload, close, element(doc, 'span', {
1019
+ className: 'divider'
1020
+ }), send);
1021
+ // The pages (on the site): a chip naming this page, opening a search
1022
+ // and a list of every page in place of the prompt.
1023
+ const pageChipText = element(doc, 'span', {
1024
+ textContent: labels.pages
1025
+ });
1026
+ const pageChip = element(doc, 'button', {
1027
+ className: 'chip page-chip',
1028
+ type: 'button',
1029
+ title: labels.pages
1030
+ }, icon(doc, 'page'), pageChipText, icon(doc, 'chevrons'));
1031
+ pageChip.setAttribute('aria-haspopup', 'listbox');
1032
+ pageChip.setAttribute('aria-expanded', 'false');
1033
+ pageChip.setAttribute('aria-controls', 'obelism-pages');
1034
+ const search = element(doc, 'input', {
1035
+ type: 'text',
1036
+ placeholder: labels.pagesSearch,
1037
+ autocomplete: 'off',
1038
+ spellcheck: false
1039
+ });
1040
+ search.setAttribute('role', 'combobox');
1041
+ search.setAttribute('aria-label', labels.pagesSearch);
1042
+ search.setAttribute('aria-autocomplete', 'list');
1043
+ search.setAttribute('aria-expanded', 'true');
1044
+ search.setAttribute('aria-controls', 'obelism-pages');
1045
+ const optionList = element(doc, 'ul', {
1046
+ className: 'options',
1047
+ id: 'obelism-pages'
1048
+ });
1049
+ optionList.setAttribute('role', 'listbox');
1050
+ optionList.setAttribute('aria-label', labels.pages);
1051
+ const pagesNote = element(doc, 'p', {
1052
+ className: 'empty'
1053
+ });
1054
+ const pagesSection = element(doc, 'div', {
1055
+ className: 'pages'
1056
+ }, element(doc, 'label', {
1057
+ className: 'search'
1058
+ }, icon(doc, 'search'), search), optionList, pagesNote);
807
1059
  const unsaved = element(doc, 'span', {
808
1060
  className: 'unsaved'
809
1061
  });
@@ -828,18 +1080,12 @@ const mountPromptBox = (doc, bridge, options = {})=>{
828
1080
  });
829
1081
  const footer = element(doc, 'div', {
830
1082
  className: 'footer'
831
- }, unsaved, element(doc, 'span', {
1083
+ }, pageChip, unsaved, element(doc, 'span', {
832
1084
  className: 'spacer'
833
1085
  }), signOut, save, publish);
834
1086
  const panel = element(doc, 'form', {
835
1087
  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);
1088
+ }, status, pathRow, input, code, toolbar, pagesSection, footer);
843
1089
  const root = element(doc, 'div', {
844
1090
  className: 'root'
845
1091
  }, element(doc, 'div', {
@@ -851,6 +1097,26 @@ const mountPromptBox = (doc, bridge, options = {})=>{
851
1097
  let open = false;
852
1098
  let creating = false;
853
1099
  let message = null;
1100
+ // JSON mode: the text as last loaded, and what it is of (the block's
1101
+ // uid, null for the whole entry). `seenUid` is the selection last loaded
1102
+ // for: a new one loads its JSON, and undefined loads it again.
1103
+ let json = false;
1104
+ let loaded = '';
1105
+ let viewUid = null;
1106
+ let seenUid;
1107
+ let loadingView = false;
1108
+ let viewRequest = 0;
1109
+ // The pages, fetched once the panel opens and kept; `active` is the
1110
+ // option arrow keys are on.
1111
+ let picking = false;
1112
+ let pages = null;
1113
+ let pagesLoad = null;
1114
+ let pagesError = null;
1115
+ let active = 0;
1116
+ const failure = (error)=>({
1117
+ text: error instanceof Error ? error.message : String(error),
1118
+ error: true
1119
+ });
854
1120
  const render = (state)=>{
855
1121
  const available = state.features.includes('prompt');
856
1122
  const storing = state.saving || state.publishing;
@@ -866,23 +1132,40 @@ const mountPromptBox = (doc, bridge, options = {})=>{
866
1132
  const onlyNew = pageless && canCreate;
867
1133
  if (!canCreate) creating = false;
868
1134
  if (onlyNew) creating = true;
1135
+ if (creating || pageless) json = false;
869
1136
  pillText.textContent = onlyNew ? labels.newPage : labels.open;
870
1137
  cancelNew.hidden = onlyNew;
871
1138
  scope.hidden = creating;
872
1139
  newScope.hidden = !creating;
873
- newPage.hidden = creating || !canCreate;
1140
+ newPage.hidden = creating || json || !canCreate;
874
1141
  newPage.disabled = busy;
875
- pathRow.hidden = !creating;
1142
+ pathRow.hidden = !creating || picking;
876
1143
  pathInput.disabled = busy;
1144
+ input.hidden = json || picking;
1145
+ code.hidden = !json || picking;
1146
+ toolbar.hidden = picking;
1147
+ pagesSection.hidden = !picking;
877
1148
  input.placeholder = creating ? labels.newPagePlaceholder : labels.placeholder;
878
- scopeText.textContent = state.selectedUid ? labels.scopeBlock : labels.scopeEntry;
1149
+ // In JSON mode the scope is what the JSON is of.
1150
+ const scopeUid = json ? viewUid : state.selectedUid;
1151
+ scopeText.textContent = scopeUid ? labels.scopeBlock : labels.scopeEntry;
879
1152
  clearBlock.hidden = !state.selectedUid;
880
- pageIcon.hidden = !!state.selectedUid;
881
- blockIcon.hidden = !state.selectedUid;
1153
+ pageIcon.hidden = !!scopeUid;
1154
+ blockIcon.hidden = !scopeUid;
1155
+ modes.hidden = !site || creating || pageless;
1156
+ promptMode.setAttribute('aria-pressed', String(!json));
1157
+ jsonMode.setAttribute('aria-pressed', String(json));
1158
+ modes.querySelectorAll('button').forEach((button)=>{
1159
+ button.disabled = busy || loadingView;
1160
+ });
1161
+ reload.hidden = !json;
1162
+ reload.disabled = busy || loadingView;
882
1163
  undo.hidden = creating || !state.canUndo;
883
- undo.disabled = busy;
884
- send.disabled = busy || input.value.trim() === '' || creating && pathInput.value.trim() === '';
1164
+ undo.disabled = busy || loadingView;
1165
+ const text = json ? code.value : input.value;
1166
+ send.disabled = busy || loadingView || text.trim() === '' || creating && pathInput.value.trim() === '';
885
1167
  input.disabled = busy;
1168
+ code.disabled = busy || loadingView;
886
1169
  save.hidden = !state.features.includes('save') || pageless;
887
1170
  save.disabled = busy || !state.unsaved;
888
1171
  save.textContent = state.saving ? labels.saving : labels.save;
@@ -892,17 +1175,144 @@ const mountPromptBox = (doc, bridge, options = {})=>{
892
1175
  signOut.hidden = !site;
893
1176
  footer.hidden = !site;
894
1177
  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' : ''}`;
1178
+ pageChip.hidden = !site;
1179
+ pageChip.setAttribute('aria-expanded', String(picking));
1180
+ pageChipText.textContent = pages?.find(({ entryId })=>entryId === bridge.entryId)?.name ?? labels.pages;
1181
+ const working = state.prompting || loadingView;
1182
+ const note = state.prompting ? creating ? labels.creating : labels.working : loadingView ? labels.jsonLoading : message?.text ?? (site && !creating && !picking && !state.selectedUid ? labels.selectHint : '');
1183
+ status.textContent = note;
1184
+ status.hidden = !note;
1185
+ status.className = `status${working ? ' working' : ''}${!busy && !loadingView && message?.error ? ' error' : ''}`;
1186
+ // A new selection while editing JSON (or a reload asked for): its
1187
+ // JSON, after this render.
1188
+ if (open && json && !picking && state.selectedUid !== seenUid) {
1189
+ seenUid = state.selectedUid;
1190
+ queueMicrotask(()=>void loadView(false));
1191
+ }
899
1192
  };
900
1193
  const refresh = ()=>render(bridge.getState());
901
1194
  const grow = ()=>{
902
1195
  input.style.height = 'auto';
903
1196
  input.style.height = `${input.scrollHeight}px`;
904
1197
  };
1198
+ // Loads the JSON of the selection. Unless `force`, keeps text edited
1199
+ // since the last load, with a note. True once loaded.
1200
+ const loadView = async (force)=>{
1201
+ const uid = bridge.getState().selectedUid;
1202
+ seenUid = uid;
1203
+ if (!force && code.value !== loaded) {
1204
+ message = {
1205
+ text: labels.jsonKept,
1206
+ error: false
1207
+ };
1208
+ refresh();
1209
+ return false;
1210
+ }
1211
+ const request = ++viewRequest;
1212
+ loadingView = true;
1213
+ message = null;
1214
+ refresh();
1215
+ try {
1216
+ const view = await bridge.view({
1217
+ uid
1218
+ });
1219
+ if (request !== viewRequest) return false;
1220
+ loaded = `${JSON.stringify(view, null, 2)}\n`;
1221
+ code.value = loaded;
1222
+ code.scrollTop = 0;
1223
+ viewUid = uid;
1224
+ return true;
1225
+ } catch (error) {
1226
+ if (request === viewRequest) message = failure(error);
1227
+ return false;
1228
+ } finally{
1229
+ if (request === viewRequest) {
1230
+ loadingView = false;
1231
+ refresh();
1232
+ }
1233
+ }
1234
+ };
1235
+ // Loads the JSON again once rendered, keeping edited text.
1236
+ const requestView = ()=>{
1237
+ seenUid = undefined;
1238
+ refresh();
1239
+ };
1240
+ const setJson = (next)=>{
1241
+ if (json === next) return;
1242
+ json = next;
1243
+ message = null;
1244
+ requestView();
1245
+ (json ? code : input).focus();
1246
+ };
1247
+ // Where the text stops being JSON, as a line and column.
1248
+ const parseProblem = (text, error)=>{
1249
+ const offset = jsonErrorAt(text);
1250
+ if (offset < 0) {
1251
+ const reason = error instanceof Error ? error.message : String(error);
1252
+ return {
1253
+ text: `${labels.jsonInvalid}: ${reason}`
1254
+ };
1255
+ }
1256
+ const before = text.slice(0, offset);
1257
+ const line = before.split('\n').length;
1258
+ const column = offset - before.lastIndexOf('\n');
1259
+ return {
1260
+ text: labels.jsonInvalidAt.replace('{line}', String(line)).replace('{column}', String(column)),
1261
+ offset
1262
+ };
1263
+ };
1264
+ const applyJson = async ()=>{
1265
+ const text = code.value;
1266
+ if (!text.trim() || bridge.getState().prompting || loadingView) return;
1267
+ let changes;
1268
+ try {
1269
+ changes = JSON.parse(text);
1270
+ } catch (error) {
1271
+ const problem = parseProblem(text, error);
1272
+ message = {
1273
+ text: problem.text,
1274
+ error: true
1275
+ };
1276
+ refresh();
1277
+ code.focus();
1278
+ if (problem.offset !== undefined) {
1279
+ code.setSelectionRange(problem.offset, problem.offset);
1280
+ }
1281
+ return;
1282
+ }
1283
+ if (!changes || typeof changes !== 'object' || Array.isArray(changes)) {
1284
+ message = {
1285
+ text: labels.jsonNotObject,
1286
+ error: true
1287
+ };
1288
+ return refresh();
1289
+ }
1290
+ message = null;
1291
+ try {
1292
+ const { changed, view } = await bridge.applyJson(changes, {
1293
+ uid: viewUid
1294
+ });
1295
+ // What the CMS made of it, from now on what's edited: in the
1296
+ // answer, or asked for (a CMS from before `view` in it).
1297
+ if (view) {
1298
+ ++viewRequest;
1299
+ loaded = `${JSON.stringify(view, null, 2)}\n`;
1300
+ code.value = loaded;
1301
+ }
1302
+ if (view || await loadView(true)) {
1303
+ message = {
1304
+ text: changed.length > 0 ? labels.applied : labels.unchanged,
1305
+ error: false
1306
+ };
1307
+ }
1308
+ } catch (error) {
1309
+ message = failure(error);
1310
+ }
1311
+ refresh();
1312
+ code.focus();
1313
+ };
905
1314
  const submit = async ()=>{
1315
+ if (json) return applyJson();
906
1316
  const prompt = input.value.trim();
907
1317
  if (!prompt || bridge.getState().prompting) return;
908
1318
  message = null;
@@ -912,6 +1322,8 @@ const mountPromptBox = (doc, bridge, options = {})=>{
912
1322
  if (!path) return pathInput.focus();
913
1323
  // The bridge opens the new page; this shows until it does.
914
1324
  await bridge.createPage(path, prompt);
1325
+ pages = null;
1326
+ pagesLoad = null;
915
1327
  message = {
916
1328
  text: labels.created,
917
1329
  error: false
@@ -926,18 +1338,102 @@ const mountPromptBox = (doc, bridge, options = {})=>{
926
1338
  input.value = '';
927
1339
  grow();
928
1340
  } catch (error) {
929
- message = {
930
- text: error instanceof Error ? error.message : String(error),
931
- error: true
932
- };
1341
+ message = failure(error);
933
1342
  }
934
1343
  refresh();
935
1344
  input.focus();
936
1345
  };
1346
+ const fetchPages = ()=>{
1347
+ pagesLoad ??= bridge.pages().then((list)=>{
1348
+ pages = list;
1349
+ pagesError = null;
1350
+ renderPages();
1351
+ refresh();
1352
+ }, (error)=>{
1353
+ pagesLoad = null;
1354
+ pagesError = failure(error).text;
1355
+ renderPages();
1356
+ });
1357
+ return pagesLoad;
1358
+ };
1359
+ const matching = ()=>{
1360
+ const query = search.value.trim().toLowerCase();
1361
+ return (pages ?? []).filter((page)=>!query || page.name.toLowerCase().includes(query) || page.url.toLowerCase().includes(query) || `/${page.path}`.toLowerCase().includes(query));
1362
+ };
1363
+ const renderPages = ()=>{
1364
+ const list = matching();
1365
+ active = Math.max(0, Math.min(active, list.length - 1));
1366
+ optionList.textContent = '';
1367
+ list.forEach((page, index)=>{
1368
+ const current = page.entryId === bridge.entryId;
1369
+ const option = element(doc, 'li', {
1370
+ className: `option${index === active ? ' active' : ''}`,
1371
+ id: `obelism-page-${index}`
1372
+ }, element(doc, 'span', {
1373
+ className: 'option-text'
1374
+ }, element(doc, 'span', {
1375
+ textContent: page.name
1376
+ }), element(doc, 'span', {
1377
+ className: 'option-path',
1378
+ textContent: page.url
1379
+ })));
1380
+ option.setAttribute('role', 'option');
1381
+ option.setAttribute('aria-selected', String(index === active));
1382
+ if (page.status !== 'published') {
1383
+ option.appendChild(element(doc, 'span', {
1384
+ className: 'badge',
1385
+ textContent: page.status === 'draft' ? labels.pageDraft : labels.pageChanged
1386
+ }));
1387
+ }
1388
+ if (current) {
1389
+ option.setAttribute('aria-current', 'page');
1390
+ const check = icon(doc, 'check');
1391
+ check.removeAttribute('aria-hidden');
1392
+ check.setAttribute('role', 'img');
1393
+ check.setAttribute('aria-label', labels.pageCurrent);
1394
+ option.appendChild(check);
1395
+ }
1396
+ option.addEventListener('click', ()=>choose(page));
1397
+ optionList.appendChild(option);
1398
+ });
1399
+ const activeOption = optionList.children[active];
1400
+ if (activeOption) {
1401
+ search.setAttribute('aria-activedescendant', activeOption.id);
1402
+ activeOption.scrollIntoView?.({
1403
+ block: 'nearest'
1404
+ });
1405
+ } else {
1406
+ search.removeAttribute('aria-activedescendant');
1407
+ }
1408
+ const note = pagesError ?? (pages ? '' : labels.pagesLoading);
1409
+ pagesNote.textContent = note || (list.length === 0 ? labels.pagesEmpty : '');
1410
+ pagesNote.hidden = !pagesNote.textContent;
1411
+ pagesNote.className = `empty${pagesError ? ' error' : ''}`;
1412
+ };
1413
+ const setPicking = (next)=>{
1414
+ picking = next;
1415
+ message = null;
1416
+ if (picking) {
1417
+ search.value = '';
1418
+ active = Math.max(0, (pages ?? []).findIndex(({ entryId })=>entryId === bridge.entryId));
1419
+ void fetchPages();
1420
+ renderPages();
1421
+ }
1422
+ refresh();
1423
+ (picking ? search : pageChip).focus();
1424
+ };
1425
+ const choose = (page)=>{
1426
+ if (page.entryId === bridge.entryId) return setPicking(false);
1427
+ // Asks first when changes aren't saved; stays here if told no.
1428
+ bridge.openPage(page);
1429
+ };
937
1430
  const setOpen = (next)=>{
938
1431
  open = next;
1432
+ if (!open) picking = false;
939
1433
  refresh();
940
1434
  if (site) bridge.setSelecting(open && !creating);
1435
+ if (open && site) void fetchPages();
1436
+ if (open && json) requestView();
941
1437
  };
942
1438
  const setCreating = (next)=>{
943
1439
  creating = next;
@@ -956,16 +1452,13 @@ const mountPromptBox = (doc, bridge, options = {})=>{
956
1452
  error: false
957
1453
  };
958
1454
  } catch (error) {
959
- message = {
960
- text: error instanceof Error ? error.message : String(error),
961
- error: true
962
- };
1455
+ message = failure(error);
963
1456
  }
964
1457
  refresh();
965
1458
  };
966
1459
  pill.addEventListener('click', ()=>{
967
1460
  setOpen(true);
968
- (creating ? pathInput : input).focus();
1461
+ (creating ? pathInput : json ? code : input).focus();
969
1462
  });
970
1463
  close.addEventListener('click', ()=>setOpen(false));
971
1464
  save.addEventListener('click', ()=>void store('save'));
@@ -975,6 +1468,13 @@ const mountPromptBox = (doc, bridge, options = {})=>{
975
1468
  clearBlock.addEventListener('click', ()=>bridge.select(null));
976
1469
  newPage.addEventListener('click', ()=>setCreating(true));
977
1470
  cancelNew.addEventListener('click', ()=>setCreating(false));
1471
+ promptMode.addEventListener('click', ()=>setJson(false));
1472
+ jsonMode.addEventListener('click', ()=>setJson(true));
1473
+ reload.addEventListener('click', ()=>{
1474
+ void loadView(true);
1475
+ code.focus();
1476
+ });
1477
+ pageChip.addEventListener('click', ()=>setPicking(!picking));
978
1478
  pathInput.addEventListener('input', refresh);
979
1479
  pathInput.addEventListener('keydown', (event)=>{
980
1480
  if (event.key === 'Enter' && !event.isComposing) {
@@ -984,9 +1484,30 @@ const mountPromptBox = (doc, bridge, options = {})=>{
984
1484
  setOpen(false);
985
1485
  }
986
1486
  });
1487
+ search.addEventListener('input', ()=>{
1488
+ active = 0;
1489
+ renderPages();
1490
+ });
1491
+ search.addEventListener('keydown', (event)=>{
1492
+ const count = optionList.children.length;
1493
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
1494
+ event.preventDefault();
1495
+ if (count === 0) return;
1496
+ active = (active + (event.key === 'ArrowDown' ? 1 : -1) + count) % count;
1497
+ renderPages();
1498
+ } else if (event.key === 'Enter' && !event.isComposing) {
1499
+ event.preventDefault();
1500
+ const page = matching()[active];
1501
+ if (page) choose(page);
1502
+ } else if (event.key === 'Escape') {
1503
+ event.preventDefault();
1504
+ setPicking(false);
1505
+ }
1506
+ });
987
1507
  undo.addEventListener('click', ()=>{
988
1508
  message = null;
989
1509
  bridge.undo();
1510
+ if (json) requestView();
990
1511
  });
991
1512
  panel.addEventListener('submit', (event)=>{
992
1513
  event.preventDefault();
@@ -1004,6 +1525,28 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1004
1525
  setOpen(false);
1005
1526
  }
1006
1527
  });
1528
+ code.addEventListener('input', refresh);
1529
+ code.addEventListener('keydown', (event)=>{
1530
+ // Enter is a new line here; Ctrl or Cmd + Enter applies.
1531
+ if (event.key === 'Enter' && (event.ctrlKey || event.metaKey) && !event.isComposing) {
1532
+ event.preventDefault();
1533
+ void submit();
1534
+ } else if (event.key === 'Tab' && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) {
1535
+ // A tab, not the next button (Shift + Tab still leaves). Through
1536
+ // execCommand where it works, so the browser's undo has it.
1537
+ event.preventDefault();
1538
+ let inserted = false;
1539
+ try {
1540
+ inserted = doc.execCommand('insertText', false, '\t');
1541
+ } catch {}
1542
+ if (!inserted) {
1543
+ code.setRangeText('\t', code.selectionStart, code.selectionEnd, 'end');
1544
+ }
1545
+ refresh();
1546
+ } else if (event.key === 'Escape') {
1547
+ setOpen(false);
1548
+ }
1549
+ });
1007
1550
  const unsubscribe = bridge.on('change', render);
1008
1551
  refresh();
1009
1552
  doc.body.appendChild(host);
@@ -1022,6 +1565,9 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1022
1565
  //
1023
1566
  // What it edits is the page's entry — the outermost element marked with
1024
1567
  // `editable(entry)` — or, for a selected block, the entry around that block.
1568
+ // Besides prompts it edits JSON (`view`, then `applyJson`: the entry as a
1569
+ // language model sees it, changed by hand), and lists the space's pages to
1570
+ // go to one.
1025
1571
  // The base path of the site's handler while signed in to edit, else null.
1026
1572
  const readSiteEditing = (doc)=>{
1027
1573
  for (const part of doc.cookie.split(';')){
@@ -1112,6 +1658,19 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1112
1658
  working.set(entryId, fresh);
1113
1659
  return fresh;
1114
1660
  };
1661
+ // The entry a prompt or JSON acts on, for the block `uid` (the selected
1662
+ // one when left out).
1663
+ const target = (option)=>{
1664
+ const uid = option === undefined ? state.selectedUid : option;
1665
+ const entryId = entryFor(uid);
1666
+ if (!entryId) {
1667
+ throw new Error('Nothing on this page is marked as an entry: add editable(entry)');
1668
+ }
1669
+ return {
1670
+ uid,
1671
+ entryId
1672
+ };
1673
+ };
1115
1674
  const highlight = (uid)=>{
1116
1675
  doc.querySelectorAll('[data-cms-selected]').forEach((element)=>{
1117
1676
  element.removeAttribute('data-cms-selected');
@@ -1168,11 +1727,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1168
1727
  const text = prompt.trim();
1169
1728
  if (!text) throw new Error('Say what to change');
1170
1729
  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
- }
1730
+ const { uid, entryId } = target(options.uid);
1176
1731
  setState({
1177
1732
  prompting: true
1178
1733
  });
@@ -1233,6 +1788,88 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1233
1788
  });
1234
1789
  }
1235
1790
  },
1791
+ async view (options = {}) {
1792
+ const { uid, entryId } = target(options.uid);
1793
+ const entry = await load(entryId);
1794
+ const { view } = await api(`entries/${encodeURIComponent(entryId)}/view`, {
1795
+ content: entry.content,
1796
+ locale: locale(),
1797
+ uid
1798
+ });
1799
+ return view;
1800
+ },
1801
+ async applyJson (changes, options = {}) {
1802
+ if (!has('prompt')) throw new Error('You may not edit content here');
1803
+ if (!changes || typeof changes !== 'object' || Array.isArray(changes)) {
1804
+ throw new Error('Send the fields to change as an object');
1805
+ }
1806
+ const { uid, entryId } = target(options.uid);
1807
+ setState({
1808
+ prompting: true
1809
+ });
1810
+ try {
1811
+ const entry = await load(entryId);
1812
+ const result = await api(`entries/${encodeURIComponent(entryId)}/apply`, {
1813
+ changes,
1814
+ content: entry.content,
1815
+ locale: locale(),
1816
+ uid
1817
+ });
1818
+ // Nothing to undo when nothing changed.
1819
+ if (!same(result.content, entry.content)) {
1820
+ history.push({
1821
+ entryId,
1822
+ content: entry.content
1823
+ });
1824
+ entry.content = result.content;
1825
+ refreshUnsaved();
1826
+ await render(entryId);
1827
+ }
1828
+ return {
1829
+ changed: result.changed ?? [],
1830
+ ...result.view && {
1831
+ view: result.view
1832
+ }
1833
+ };
1834
+ } finally{
1835
+ setState({
1836
+ prompting: false
1837
+ });
1838
+ }
1839
+ },
1840
+ async pages () {
1841
+ const { pages } = await api('pages');
1842
+ const preferred = [
1843
+ locale(),
1844
+ session?.space.defaultLocale
1845
+ ];
1846
+ return pages.flatMap((page)=>{
1847
+ // The page's path in this locale, else the default's, else any.
1848
+ const code = preferred.find((code)=>code !== undefined && page.paths[code] !== undefined) ?? Object.keys(page.paths)[0];
1849
+ const path = code === undefined ? undefined : page.paths[code];
1850
+ if (code === undefined || path === undefined) return [];
1851
+ return [
1852
+ {
1853
+ entryId: page.entryId,
1854
+ name: page.name,
1855
+ contentType: page.contentType,
1856
+ status: page.status,
1857
+ path,
1858
+ url: pagePath(path, code)
1859
+ }
1860
+ ];
1861
+ });
1862
+ },
1863
+ openPage (page) {
1864
+ if (state.unsaved && !win.confirm('Leave this page and lose the changes you have not saved?')) {
1865
+ return;
1866
+ }
1867
+ // Asked already: no second question from `beforeunload`.
1868
+ working.clear();
1869
+ history.length = 0;
1870
+ refreshUnsaved();
1871
+ win.location.assign(page.url);
1872
+ },
1236
1873
  undo () {
1237
1874
  const last = history.pop();
1238
1875
  const entry = last && working.get(last.entryId);