@obelism/content 0.13.0 → 0.15.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
@@ -231,6 +231,12 @@ const INERT_METHODS = {
231
231
  createPage: ()=>Promise.reject(new Error('New pages are made on the site itself')),
232
232
  view: onlyWhileEditingSite,
233
233
  applyJson: onlyWhileEditingSite,
234
+ openForm: (_container, options = {})=>{
235
+ queueMicrotask(()=>options.onError?.('Only while signed in to edit on the site'));
236
+ return {
237
+ close: ()=>{}
238
+ };
239
+ },
234
240
  pages: onlyWhileEditingSite,
235
241
  openPage: ()=>{},
236
242
  undo: ()=>{},
@@ -497,6 +503,8 @@ const DEFAULT_PROMPT_LABELS = {
497
503
  send: 'Apply',
498
504
  undo: 'Undo',
499
505
  close: 'Close',
506
+ minimize: 'Minimize, to see the page',
507
+ expand: 'Expand',
500
508
  scopeEntry: 'Whole page',
501
509
  scopeBlock: 'Selected block',
502
510
  clearBlock: 'Edit the whole page instead',
@@ -520,16 +528,9 @@ const DEFAULT_PROMPT_LABELS = {
520
528
  created: 'Opening the new page…',
521
529
  modes: 'Edit with',
522
530
  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',
531
+ form: 'Form',
532
+ formTitle: 'The content form',
533
+ formLoading: 'Opening the form…',
533
534
  pages: 'Pages',
534
535
  pagesSearch: 'Find a page',
535
536
  pagesLoading: 'Loading pages…',
@@ -545,7 +546,8 @@ const ICONS = {
545
546
  block: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M7 8h10"/><path d="M7 12h6"/>',
546
547
  undo: '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/>',
547
548
  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
+ minimize: '<path d="m6 9 6 6 6-6"/>',
550
+ expand: '<path d="m18 15-6-6-6 6"/>',
549
551
  chevrons: '<path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/>',
550
552
  search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
551
553
  check: '<path d="M20 6 9 17l-5-5"/>',
@@ -650,13 +652,13 @@ textarea {
650
652
  font-size: 16px; line-height: 1.6; color: rgb(var(--theme-foreground-rgb));
651
653
  }
652
654
  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;
655
+ /* Form: the CMS's entry form, edited live. */
656
+ .form { padding: 12px 12px 0; }
657
+ .form iframe {
658
+ display: block; width: 100%; height: min(560px, 60vh); border: none; border-radius: 12px;
659
+ background: rgb(var(--theme-background-actions-rgb) / .45);
659
660
  }
661
+ .form.loading iframe { visibility: hidden; }
660
662
  /* New page: where it goes, above what it is for. */
661
663
  .path {
662
664
  display: flex; align-items: center; gap: 10px; padding: 16px 22px 0;
@@ -700,7 +702,7 @@ button.chip:disabled { opacity: .5; }
700
702
  .icon-button.primary { background: rgb(var(--theme-accent-rgb)); color: #fff; }
701
703
  .icon-button.primary:hover:not(:disabled) { background: rgb(var(--theme-accent-rgb)); color: #fff; opacity: .88; }
702
704
  .icon-button.primary:disabled { opacity: .35; }
703
- /* Prompt or JSON: two small pills in one track. */
705
+ /* Prompt or Form: two small pills in one track. */
704
706
  .modes {
705
707
  display: inline-flex; flex: none; gap: 2px; padding: 2px; border-radius: var(--border-radius-pill);
706
708
  border: 1px solid rgb(var(--theme-border-rgb) / .2); background: rgb(var(--theme-background-actions-rgb) / .6);
@@ -783,101 +785,6 @@ const icon = (doc, name)=>{
783
785
  span.innerHTML = `<svg viewBox="0 0 24 24">${ICONS[name]}</svg>`;
784
786
  return span;
785
787
  };
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
- };
881
788
  // Mounts the box on the page; returns a function that removes it.
882
789
  const mountPromptBox = (doc, bridge, options = {})=>{
883
790
  const labels = {
@@ -921,14 +828,10 @@ const mountPromptBox = (doc, bridge, options = {})=>{
921
828
  rows: 1
922
829
  });
923
830
  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'
831
+ // Form mode (on the site): the CMS's form, mounted while shown.
832
+ const formBox = element(doc, 'div', {
833
+ className: 'form'
929
834
  });
930
- code.setAttribute('aria-label', labels.json);
931
- code.setAttribute('autocapitalize', 'off');
932
835
  // What the prompt acts on: the whole page, or the selected block with a
933
836
  // way back to the page.
934
837
  const pageIcon = icon(doc, 'page');
@@ -947,7 +850,7 @@ const mountPromptBox = (doc, bridge, options = {})=>{
947
850
  className: 'action chip',
948
851
  type: 'button'
949
852
  }, icon(doc, 'undo'), labels.undo);
950
- // Prompt or JSON (on the site).
853
+ // Prompt or Form (on the site).
951
854
  const modeButton = (text)=>{
952
855
  const button = element(doc, 'button', {
953
856
  type: 'button',
@@ -957,18 +860,12 @@ const mountPromptBox = (doc, bridge, options = {})=>{
957
860
  return button;
958
861
  };
959
862
  const promptMode = modeButton(labels.modePrompt);
960
- const jsonMode = modeButton(labels.modeJson);
863
+ const formMode = modeButton(labels.form);
961
864
  const modes = element(doc, 'span', {
962
865
  className: 'modes'
963
- }, promptMode, jsonMode);
866
+ }, promptMode, formMode);
964
867
  modes.setAttribute('role', 'group');
965
868
  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);
972
869
  // New page (on the site, for people who may add pages): the scope becomes
973
870
  // the new page, with a way back, and a path field shows above the prompt.
974
871
  const newPage = element(doc, 'button', {
@@ -1003,6 +900,14 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1003
900
  title: labels.close
1004
901
  }, icon(doc, 'close'));
1005
902
  close.setAttribute('aria-label', labels.close);
903
+ // Minimized, only the toolbar and footer show, so a short page stays in
904
+ // view; the form stays mounted (and its edits) meanwhile.
905
+ const minimizeIcon = icon(doc, 'minimize');
906
+ const expandIcon = icon(doc, 'expand');
907
+ const minimize = element(doc, 'button', {
908
+ className: 'icon-button',
909
+ type: 'button'
910
+ }, minimizeIcon, expandIcon);
1006
911
  const send = element(doc, 'button', {
1007
912
  className: 'action primary icon-button',
1008
913
  type: 'submit'
@@ -1011,13 +916,14 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1011
916
  textContent: labels.send
1012
917
  }), icon(doc, 'send'));
1013
918
  send.title = labels.send;
919
+ const divider = element(doc, 'span', {
920
+ className: 'divider'
921
+ });
1014
922
  const toolbar = element(doc, 'div', {
1015
923
  className: 'toolbar'
1016
924
  }, scope, newScope, modes, undo, newPage, element(doc, 'span', {
1017
925
  className: 'spacer'
1018
- }), reload, close, element(doc, 'span', {
1019
- className: 'divider'
1020
- }), send);
926
+ }), minimize, close, divider, send);
1021
927
  // The pages (on the site): a chip naming this page, opening a search
1022
928
  // and a list of every page in place of the prompt.
1023
929
  const pageChipText = element(doc, 'span', {
@@ -1085,7 +991,7 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1085
991
  }), signOut, save, publish);
1086
992
  const panel = element(doc, 'form', {
1087
993
  className: 'panel'
1088
- }, status, pathRow, input, code, toolbar, pagesSection, footer);
994
+ }, status, pathRow, input, formBox, toolbar, pagesSection, footer);
1089
995
  const root = element(doc, 'div', {
1090
996
  className: 'root'
1091
997
  }, element(doc, 'div', {
@@ -1095,17 +1001,14 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1095
1001
  textContent: CSS
1096
1002
  }), root);
1097
1003
  let open = false;
1004
+ let minimized = false;
1098
1005
  let creating = false;
1099
1006
  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;
1007
+ // Form mode, and the form while mounted: a fresh one (and one-time
1008
+ // address) every time it shows.
1009
+ let form = false;
1010
+ let formHandle = null;
1011
+ let formLoading = false;
1109
1012
  // The pages, fetched once the panel opens and kept; `active` is the
1110
1013
  // option arrow keys are on.
1111
1014
  let picking = false;
@@ -1132,40 +1035,50 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1132
1035
  const onlyNew = pageless && canCreate;
1133
1036
  if (!canCreate) creating = false;
1134
1037
  if (onlyNew) creating = true;
1135
- if (creating || pageless) json = false;
1038
+ if (creating || pageless || !site) form = false;
1039
+ // The form is there only while shown in the open panel.
1040
+ const showForm = open && available && form;
1041
+ if (showForm && !formHandle) mountForm();
1042
+ else if (!showForm && formHandle) unmountForm();
1136
1043
  pillText.textContent = onlyNew ? labels.newPage : labels.open;
1137
1044
  cancelNew.hidden = onlyNew;
1138
1045
  scope.hidden = creating;
1139
1046
  newScope.hidden = !creating;
1140
- newPage.hidden = creating || json || !canCreate;
1047
+ newPage.hidden = creating || form || !canCreate;
1141
1048
  newPage.disabled = busy;
1142
- pathRow.hidden = !creating || picking;
1049
+ if (minimized) picking = false;
1050
+ pathRow.hidden = !creating || picking || minimized;
1143
1051
  pathInput.disabled = busy;
1144
- input.hidden = json || picking;
1145
- code.hidden = !json || picking;
1052
+ input.hidden = form || picking || minimized;
1053
+ formBox.hidden = !form || picking || minimized;
1054
+ formBox.classList.toggle('loading', formLoading);
1146
1055
  toolbar.hidden = picking;
1147
1056
  pagesSection.hidden = !picking;
1148
1057
  input.placeholder = creating ? labels.newPagePlaceholder : labels.placeholder;
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;
1058
+ scopeText.textContent = state.selectedUid ? labels.scopeBlock : labels.scopeEntry;
1152
1059
  clearBlock.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));
1060
+ pageIcon.hidden = !!state.selectedUid;
1061
+ blockIcon.hidden = !state.selectedUid;
1062
+ // A CMS from before the form doesn't offer it.
1063
+ modes.hidden = !site || creating || pageless || !state.features.includes('form');
1064
+ promptMode.setAttribute('aria-pressed', String(!form));
1065
+ formMode.setAttribute('aria-pressed', String(form));
1158
1066
  modes.querySelectorAll('button').forEach((button)=>{
1159
- button.disabled = busy || loadingView;
1067
+ button.disabled = busy;
1160
1068
  });
1161
- reload.hidden = !json;
1162
- reload.disabled = busy || loadingView;
1163
1069
  undo.hidden = creating || !state.canUndo;
1164
- undo.disabled = busy || loadingView;
1165
- const text = json ? code.value : input.value;
1166
- send.disabled = busy || loadingView || text.trim() === '' || creating && pathInput.value.trim() === '';
1070
+ undo.disabled = busy;
1071
+ // The form applies edits as they are made.
1072
+ send.hidden = form || minimized;
1073
+ divider.hidden = form || minimized;
1074
+ const toggleLabel = minimized ? labels.expand : labels.minimize;
1075
+ minimize.title = toggleLabel;
1076
+ minimize.setAttribute('aria-label', toggleLabel);
1077
+ minimize.setAttribute('aria-expanded', String(!minimized));
1078
+ minimizeIcon.hidden = minimized;
1079
+ expandIcon.hidden = !minimized;
1080
+ send.disabled = busy || input.value.trim() === '' || creating && pathInput.value.trim() === '';
1167
1081
  input.disabled = busy;
1168
- code.disabled = busy || loadingView;
1169
1082
  save.hidden = !state.features.includes('save') || pageless;
1170
1083
  save.disabled = busy || !state.unsaved;
1171
1084
  save.textContent = state.saving ? labels.saving : labels.save;
@@ -1178,141 +1091,57 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1178
1091
  pageChip.hidden = !site;
1179
1092
  pageChip.setAttribute('aria-expanded', String(picking));
1180
1093
  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 : '');
1094
+ const loadingForm = form && formLoading && !picking;
1095
+ const working = state.prompting || loadingForm;
1096
+ const note = state.prompting ? creating ? labels.creating : labels.working : loadingForm ? labels.formLoading : message?.text ?? (site && !creating && !picking && !state.selectedUid ? labels.selectHint : '');
1183
1097
  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
- }
1098
+ status.hidden = !note || minimized;
1099
+ status.className = `status${working ? ' working' : ''}${!busy && !loadingForm && message?.error ? ' error' : ''}`;
1192
1100
  };
1193
1101
  const refresh = ()=>render(bridge.getState());
1194
1102
  const grow = ()=>{
1195
1103
  input.style.height = 'auto';
1196
1104
  input.style.height = `${input.scrollHeight}px`;
1197
1105
  };
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;
1106
+ // Callbacks come later (the address is fetched first), never during a
1107
+ // render.
1108
+ const mountForm = ()=>{
1109
+ formLoading = true;
1110
+ formBox.classList.add('loading');
1111
+ const handle = bridge.openForm(formBox, {
1112
+ title: labels.formTitle,
1113
+ onReady: ()=>{
1114
+ if (formHandle !== handle) return;
1115
+ formLoading = false;
1116
+ message = null;
1117
+ refresh();
1118
+ },
1119
+ onError: (text)=>{
1120
+ if (formHandle !== handle) return;
1121
+ formLoading = false;
1122
+ message = {
1123
+ text,
1124
+ error: true
1125
+ };
1231
1126
  refresh();
1232
1127
  }
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();
1128
+ });
1129
+ formHandle = handle;
1246
1130
  };
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
- };
1131
+ const unmountForm = ()=>{
1132
+ formHandle?.close();
1133
+ formHandle = null;
1134
+ formLoading = false;
1263
1135
  };
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
- }
1136
+ const setForm = (next)=>{
1137
+ if (form === next) return;
1138
+ form = next;
1290
1139
  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
1140
  refresh();
1312
- code.focus();
1141
+ if (!form) input.focus();
1313
1142
  };
1314
1143
  const submit = async ()=>{
1315
- if (json) return applyJson();
1144
+ if (form) return;
1316
1145
  const prompt = input.value.trim();
1317
1146
  if (!prompt || bridge.getState().prompting) return;
1318
1147
  message = null;
@@ -1429,11 +1258,13 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1429
1258
  };
1430
1259
  const setOpen = (next)=>{
1431
1260
  open = next;
1432
- if (!open) picking = false;
1261
+ if (!open) {
1262
+ picking = false;
1263
+ minimized = false;
1264
+ }
1433
1265
  refresh();
1434
1266
  if (site) bridge.setSelecting(open && !creating);
1435
1267
  if (open && site) void fetchPages();
1436
- if (open && json) requestView();
1437
1268
  };
1438
1269
  const setCreating = (next)=>{
1439
1270
  creating = next;
@@ -1458,9 +1289,14 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1458
1289
  };
1459
1290
  pill.addEventListener('click', ()=>{
1460
1291
  setOpen(true);
1461
- (creating ? pathInput : json ? code : input).focus();
1292
+ if (!form) (creating ? pathInput : input).focus();
1462
1293
  });
1463
1294
  close.addEventListener('click', ()=>setOpen(false));
1295
+ minimize.addEventListener('click', ()=>{
1296
+ minimized = !minimized;
1297
+ refresh();
1298
+ if (!minimized) (form ? minimize : input).focus();
1299
+ });
1464
1300
  save.addEventListener('click', ()=>void store('save'));
1465
1301
  publish.addEventListener('click', ()=>void store('publish'));
1466
1302
  signOut.addEventListener('click', ()=>bridge.signOut());
@@ -1468,12 +1304,8 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1468
1304
  clearBlock.addEventListener('click', ()=>bridge.select(null));
1469
1305
  newPage.addEventListener('click', ()=>setCreating(true));
1470
1306
  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
- });
1307
+ promptMode.addEventListener('click', ()=>setForm(false));
1308
+ formMode.addEventListener('click', ()=>setForm(true));
1477
1309
  pageChip.addEventListener('click', ()=>setPicking(!picking));
1478
1310
  pathInput.addEventListener('input', refresh);
1479
1311
  pathInput.addEventListener('keydown', (event)=>{
@@ -1507,7 +1339,6 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1507
1339
  undo.addEventListener('click', ()=>{
1508
1340
  message = null;
1509
1341
  bridge.undo();
1510
- if (json) requestView();
1511
1342
  });
1512
1343
  panel.addEventListener('submit', (event)=>{
1513
1344
  event.preventDefault();
@@ -1525,37 +1356,100 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1525
1356
  setOpen(false);
1526
1357
  }
1527
1358
  });
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
- });
1550
1359
  const unsubscribe = bridge.on('change', render);
1551
1360
  refresh();
1552
1361
  doc.body.appendChild(host);
1553
1362
  return ()=>{
1554
1363
  unsubscribe();
1364
+ unmountForm();
1555
1365
  host.remove();
1556
1366
  };
1557
1367
  };
1558
1368
 
1369
+ // The CMS's entry form in an iframe, for editing on the site (the prompt
1370
+ // box's Form mode). The CMS renders the form at a one-time URL (`/site/form#
1371
+ // code=…`, from the site's `/cms/api/form`); this keeps it in step with the
1372
+ // bridge's in-memory content.
1373
+ //
1374
+ // Messages are only exchanged with the URL's origin, and only with the
1375
+ // iframe's window:
1376
+ // - form → bridge: { source: 'obelism-content-form', type: 'ready' |
1377
+ // 'change' | 'error' | 'height', content?, message?, height? } —
1378
+ // `change` carries the whole entry content after an edit, `height` the
1379
+ // form's own height in px, which the iframe takes (up to 60vh)
1380
+ // - bridge → form: { source: 'obelism-content-bridge', type: 'content',
1381
+ // entryId, content, locale, selectedUid }
1382
+ const FORM_SOURCE = 'obelism-content-form';
1383
+ // How long the form may take to say it is ready.
1384
+ const FORM_READY_TIMEOUT_MS = 30_000;
1385
+ const isObject = (value)=>!!value && typeof value === 'object' && !Array.isArray(value);
1386
+ // Mounts the form at `url` in `container`. The URL is single use: mount a
1387
+ // fresh one every time.
1388
+ const mountFormFrame = (win, container, url, title, handlers)=>{
1389
+ let parsed = null;
1390
+ try {
1391
+ parsed = new URL(url);
1392
+ } catch {}
1393
+ if (!parsed || !/^https?:$/.test(parsed.protocol)) {
1394
+ throw new Error('Obelism Content sent no form to open');
1395
+ }
1396
+ const origin = parsed.origin;
1397
+ const iframe = container.ownerDocument.createElement('iframe');
1398
+ iframe.title = title;
1399
+ iframe.src = url;
1400
+ let ready = false;
1401
+ let latest = null;
1402
+ const post = ()=>{
1403
+ if (!ready || !latest) return;
1404
+ iframe.contentWindow?.postMessage({
1405
+ source: BRIDGE_SOURCE,
1406
+ type: 'content',
1407
+ ...latest
1408
+ }, origin);
1409
+ };
1410
+ const timer = setTimeout(()=>{
1411
+ if (!ready) handlers.onError('The form did not open');
1412
+ }, FORM_READY_TIMEOUT_MS);
1413
+ const onMessage = (event)=>{
1414
+ if (event.origin !== origin || !iframe.contentWindow || event.source !== iframe.contentWindow) {
1415
+ return;
1416
+ }
1417
+ const data = event.data;
1418
+ if (!data || data.source !== FORM_SOURCE) return;
1419
+ if (data.type === 'ready') {
1420
+ // Again after the form reloads itself: it gets the content again.
1421
+ ready = true;
1422
+ clearTimeout(timer);
1423
+ handlers.onReady();
1424
+ post();
1425
+ } else if (data.type === 'change' && isObject(data.content)) {
1426
+ handlers.onChange(data.content, typeof data.entryId === 'string' ? data.entryId : undefined);
1427
+ } else if (data.type === 'height') {
1428
+ const height = data.height;
1429
+ if (typeof height === 'number' && Number.isFinite(height)) {
1430
+ // A short page's form is short; a long one scrolls inside.
1431
+ const px = Math.round(Math.min(Math.max(height, 80), 4000));
1432
+ iframe.style.height = `min(${px}px, 60vh)`;
1433
+ }
1434
+ } else if (data.type === 'error') {
1435
+ handlers.onError(typeof data.message === 'string' && data.message ? data.message : 'The form failed');
1436
+ }
1437
+ };
1438
+ win.addEventListener('message', onMessage);
1439
+ container.appendChild(iframe);
1440
+ return {
1441
+ show (content) {
1442
+ latest = content;
1443
+ post();
1444
+ },
1445
+ destroy () {
1446
+ clearTimeout(timer);
1447
+ win.removeEventListener('message', onMessage);
1448
+ iframe.remove();
1449
+ }
1450
+ };
1451
+ };
1452
+
1559
1453
  // The bridge on a site signed in to edit (`/cms/login`, from
1560
1454
  // @obelism/content/site), outside the visual editor. It plays the editor's
1561
1455
  // part itself: it keeps the changed content in memory, asks the site's
@@ -1565,9 +1459,10 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1565
1459
  //
1566
1460
  // What it edits is the page's entry — the outermost element marked with
1567
1461
  // `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.
1462
+ // Besides prompts it shows the CMS's own entry form (`openForm`, an iframe
1463
+ // kept in step with the content here, see formFrame.ts), edits JSON (`view`,
1464
+ // then `applyJson`: the raw alternative), and lists the space's pages to go
1465
+ // to one.
1571
1466
  // The base path of the site's handler while signed in to edit, else null.
1572
1467
  const readSiteEditing = (doc)=>{
1573
1468
  for (const part of doc.cookie.split(';')){
@@ -1583,6 +1478,11 @@ const readSiteEditing = (doc)=>{
1583
1478
  return null;
1584
1479
  };
1585
1480
  const same = (a, b)=>JSON.stringify(a) === JSON.stringify(b);
1481
+ // Edits in the form further apart than this are separate undo steps.
1482
+ const FORM_BURST_MS = 1000;
1483
+ // How long the page waits for the form's edits to settle before it shows
1484
+ // them.
1485
+ const FORM_RENDER_MS = 300;
1586
1486
  const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})=>{
1587
1487
  const doc = win.document;
1588
1488
  const emitter = createEmitter();
@@ -1593,6 +1493,11 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1593
1493
  // Entries changed here since the last publish.
1594
1494
  const touched = new Set();
1595
1495
  let session = null;
1496
+ // The open form's, if any; and when the form last changed content, for
1497
+ // undo steps (-Infinity: the next edit starts a new step).
1498
+ let closeForm = null;
1499
+ let lastFormEdit = -Infinity;
1500
+ const renderTimers = new Map();
1596
1501
  let state = INITIAL_STATE;
1597
1502
  const setState = (next)=>{
1598
1503
  state = {
@@ -1646,17 +1551,26 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1646
1551
  const base = lang.split('-')[0] ?? '';
1647
1552
  return codes.includes(base) ? base : session.space.defaultLocale;
1648
1553
  };
1554
+ // One request per entry at a time: a second would overwrite edits made
1555
+ // while the first was out.
1556
+ const loading = new Map();
1649
1557
  const load = async (entryId)=>{
1650
1558
  const loaded = working.get(entryId);
1651
1559
  if (loaded) return loaded;
1652
- const entry = await api(`entries/${encodeURIComponent(entryId)}`);
1653
- const fresh = {
1654
- content: entry.content,
1655
- saved: entry.content,
1656
- revision: entry.draftRevision
1657
- };
1658
- working.set(entryId, fresh);
1659
- return fresh;
1560
+ let pending = loading.get(entryId);
1561
+ if (!pending) {
1562
+ pending = api(`entries/${encodeURIComponent(entryId)}`).then((entry)=>{
1563
+ const fresh = working.get(entryId) ?? {
1564
+ content: entry.content,
1565
+ saved: entry.content,
1566
+ revision: entry.draftRevision
1567
+ };
1568
+ working.set(entryId, fresh);
1569
+ return fresh;
1570
+ }).finally(()=>loading.delete(entryId));
1571
+ loading.set(entryId, pending);
1572
+ }
1573
+ return pending;
1660
1574
  };
1661
1575
  // The entry a prompt or JSON acts on, for the block `uid` (the selected
1662
1576
  // one when left out).
@@ -1695,6 +1609,15 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1695
1609
  });
1696
1610
  win.requestAnimationFrame(()=>highlight(state.selectedUid));
1697
1611
  };
1612
+ const logError = (error)=>console.error('[@obelism/content bridge]', error);
1613
+ // Renders once the form's edits settle.
1614
+ const renderSoon = (entryId)=>{
1615
+ clearTimeout(renderTimers.get(entryId));
1616
+ renderTimers.set(entryId, setTimeout(()=>{
1617
+ renderTimers.delete(entryId);
1618
+ void render(entryId).catch(logError);
1619
+ }, FORM_RENDER_MS));
1620
+ };
1698
1621
  const afterStore = (event)=>{
1699
1622
  emitter.emit(event, {});
1700
1623
  if (emitter.listeners.update.size === 0) win.location.reload();
@@ -1711,6 +1634,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1711
1634
  touched.add(entryId);
1712
1635
  }
1713
1636
  history.length = 0;
1637
+ lastFormEdit = -Infinity;
1714
1638
  refreshUnsaved();
1715
1639
  };
1716
1640
  const has = (feature)=>state.features.includes(feature);
@@ -1743,6 +1667,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1743
1667
  entryId,
1744
1668
  content: entry.content
1745
1669
  });
1670
+ lastFormEdit = -Infinity;
1746
1671
  entry.content = result.content;
1747
1672
  refreshUnsaved();
1748
1673
  await render(entryId);
@@ -1821,6 +1746,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1821
1746
  entryId,
1822
1747
  content: entry.content
1823
1748
  });
1749
+ lastFormEdit = -Infinity;
1824
1750
  entry.content = result.content;
1825
1751
  refreshUnsaved();
1826
1752
  await render(entryId);
@@ -1837,6 +1763,91 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1837
1763
  });
1838
1764
  }
1839
1765
  },
1766
+ openForm (container, options = {}) {
1767
+ const { title = 'Content form', onReady, onError } = options;
1768
+ closeForm?.();
1769
+ let closed = false;
1770
+ let frame = null;
1771
+ // What the form was last sent, or sent back itself.
1772
+ let shown = null;
1773
+ let unsubscribe = ()=>{};
1774
+ const fail = (error)=>{
1775
+ if (!closed) onError?.(error instanceof Error ? error.message : String(error));
1776
+ };
1777
+ const close = ()=>{
1778
+ if (closed) return;
1779
+ closed = true;
1780
+ unsubscribe();
1781
+ frame?.destroy();
1782
+ if (closeForm === close) closeForm = null;
1783
+ };
1784
+ closeForm = close;
1785
+ // Sends the form the entry for the selection, when anything of it
1786
+ // changed: prompts, undo, a reload after saving, the selection.
1787
+ const show = ()=>{
1788
+ if (closed || !frame) return;
1789
+ const entryId = entryFor(state.selectedUid);
1790
+ if (!entryId) return;
1791
+ const entry = working.get(entryId);
1792
+ if (!entry) {
1793
+ load(entryId).then(show, fail);
1794
+ return;
1795
+ }
1796
+ const next = {
1797
+ entryId,
1798
+ content: entry.content,
1799
+ locale: locale(),
1800
+ selectedUid: state.selectedUid
1801
+ };
1802
+ if (shown && shown.entryId === next.entryId && shown.content === next.content && shown.locale === next.locale && shown.selectedUid === next.selectedUid) {
1803
+ return;
1804
+ }
1805
+ shown = next;
1806
+ frame.show(next);
1807
+ };
1808
+ const change = (content, from)=>{
1809
+ const entryId = from ?? shown?.entryId;
1810
+ const entry = entryId === undefined ? undefined : working.get(entryId);
1811
+ if (closed || entryId === undefined || !entry || same(content, entry.content)) {
1812
+ return;
1813
+ }
1814
+ const now = Date.now();
1815
+ if (now - lastFormEdit >= FORM_BURST_MS) {
1816
+ history.push({
1817
+ entryId,
1818
+ content: entry.content
1819
+ });
1820
+ }
1821
+ lastFormEdit = now;
1822
+ entry.content = content;
1823
+ // Not sent back: the form has it.
1824
+ if (shown?.entryId === entryId) shown = {
1825
+ ...shown,
1826
+ content
1827
+ };
1828
+ refreshUnsaved();
1829
+ renderSoon(entryId);
1830
+ };
1831
+ const start = async ()=>{
1832
+ if (!has('form')) throw new Error('You may not edit content here');
1833
+ await load(target(undefined).entryId);
1834
+ // A fresh one every time: the address works once, for a minute.
1835
+ const { url } = await api('form', {});
1836
+ if (closed) return;
1837
+ const address = typeof url === 'string' ? url : '';
1838
+ frame = mountFormFrame(win, container, address, title, {
1839
+ onReady: ()=>onReady?.(),
1840
+ onChange: change,
1841
+ onError: (message)=>onError?.(message)
1842
+ });
1843
+ unsubscribe = emitter.on('change', show);
1844
+ show();
1845
+ };
1846
+ start().catch(fail);
1847
+ return {
1848
+ close
1849
+ };
1850
+ },
1840
1851
  async pages () {
1841
1852
  const { pages } = await api('pages');
1842
1853
  const preferred = [
@@ -1874,9 +1885,10 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1874
1885
  const last = history.pop();
1875
1886
  const entry = last && working.get(last.entryId);
1876
1887
  if (!last || !entry) return;
1888
+ lastFormEdit = -Infinity;
1877
1889
  entry.content = last.content;
1878
1890
  refreshUnsaved();
1879
- void render(last.entryId).catch((error)=>console.error('[@obelism/content bridge]', error));
1891
+ void render(last.entryId).catch(logError);
1880
1892
  },
1881
1893
  async save () {
1882
1894
  if (!has('save')) throw new Error('You may not edit content here');
@@ -1939,6 +1951,9 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1939
1951
  });
1940
1952
  },
1941
1953
  destroy () {
1954
+ closeForm?.();
1955
+ renderTimers.forEach((timer)=>clearTimeout(timer));
1956
+ renderTimers.clear();
1942
1957
  cleanup.splice(0).forEach((undo)=>undo());
1943
1958
  }
1944
1959
  };