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