@obelism/content 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bridge.mjs CHANGED
@@ -229,6 +229,12 @@ const INERT_METHODS = {
229
229
  createPage: ()=>Promise.reject(new Error('New pages are made on the site itself')),
230
230
  view: onlyWhileEditingSite,
231
231
  applyJson: onlyWhileEditingSite,
232
+ openForm: (_container, options = {})=>{
233
+ queueMicrotask(()=>options.onError?.('Only while signed in to edit on the site'));
234
+ return {
235
+ close: ()=>{}
236
+ };
237
+ },
232
238
  pages: onlyWhileEditingSite,
233
239
  openPage: ()=>{},
234
240
  undo: ()=>{},
@@ -518,16 +524,9 @@ const DEFAULT_PROMPT_LABELS = {
518
524
  created: 'Opening the new page…',
519
525
  modes: 'Edit with',
520
526
  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',
527
+ form: 'Form',
528
+ formTitle: 'The content form',
529
+ formLoading: 'Opening the form…',
531
530
  pages: 'Pages',
532
531
  pagesSearch: 'Find a page',
533
532
  pagesLoading: 'Loading pages…',
@@ -543,7 +542,6 @@ const ICONS = {
543
542
  block: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M7 8h10"/><path d="M7 12h6"/>',
544
543
  undo: '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/>',
545
544
  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
545
  chevrons: '<path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/>',
548
546
  search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
549
547
  check: '<path d="M20 6 9 17l-5-5"/>',
@@ -648,13 +646,13 @@ textarea {
648
646
  font-size: 16px; line-height: 1.6; color: rgb(var(--theme-foreground-rgb));
649
647
  }
650
648
  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;
649
+ /* Form: the CMS's entry form, edited live. */
650
+ .form { padding: 12px 12px 0; }
651
+ .form iframe {
652
+ display: block; width: 100%; height: min(560px, 60vh); border: none; border-radius: 12px;
653
+ background: rgb(var(--theme-background-actions-rgb) / .45);
657
654
  }
655
+ .form.loading iframe { visibility: hidden; }
658
656
  /* New page: where it goes, above what it is for. */
659
657
  .path {
660
658
  display: flex; align-items: center; gap: 10px; padding: 16px 22px 0;
@@ -698,7 +696,7 @@ button.chip:disabled { opacity: .5; }
698
696
  .icon-button.primary { background: rgb(var(--theme-accent-rgb)); color: #fff; }
699
697
  .icon-button.primary:hover:not(:disabled) { background: rgb(var(--theme-accent-rgb)); color: #fff; opacity: .88; }
700
698
  .icon-button.primary:disabled { opacity: .35; }
701
- /* Prompt or JSON: two small pills in one track. */
699
+ /* Prompt or Form: two small pills in one track. */
702
700
  .modes {
703
701
  display: inline-flex; flex: none; gap: 2px; padding: 2px; border-radius: var(--border-radius-pill);
704
702
  border: 1px solid rgb(var(--theme-border-rgb) / .2); background: rgb(var(--theme-background-actions-rgb) / .6);
@@ -781,101 +779,6 @@ const icon = (doc, name)=>{
781
779
  span.innerHTML = `<svg viewBox="0 0 24 24">${ICONS[name]}</svg>`;
782
780
  return span;
783
781
  };
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
- };
879
782
  // Mounts the box on the page; returns a function that removes it.
880
783
  const mountPromptBox = (doc, bridge, options = {})=>{
881
784
  const labels = {
@@ -919,14 +822,10 @@ const mountPromptBox = (doc, bridge, options = {})=>{
919
822
  rows: 1
920
823
  });
921
824
  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'
825
+ // Form mode (on the site): the CMS's form, mounted while shown.
826
+ const formBox = element(doc, 'div', {
827
+ className: 'form'
927
828
  });
928
- code.setAttribute('aria-label', labels.json);
929
- code.setAttribute('autocapitalize', 'off');
930
829
  // What the prompt acts on: the whole page, or the selected block with a
931
830
  // way back to the page.
932
831
  const pageIcon = icon(doc, 'page');
@@ -945,7 +844,7 @@ const mountPromptBox = (doc, bridge, options = {})=>{
945
844
  className: 'action chip',
946
845
  type: 'button'
947
846
  }, icon(doc, 'undo'), labels.undo);
948
- // Prompt or JSON (on the site).
847
+ // Prompt or Form (on the site).
949
848
  const modeButton = (text)=>{
950
849
  const button = element(doc, 'button', {
951
850
  type: 'button',
@@ -955,18 +854,12 @@ const mountPromptBox = (doc, bridge, options = {})=>{
955
854
  return button;
956
855
  };
957
856
  const promptMode = modeButton(labels.modePrompt);
958
- const jsonMode = modeButton(labels.modeJson);
857
+ const formMode = modeButton(labels.form);
959
858
  const modes = element(doc, 'span', {
960
859
  className: 'modes'
961
- }, promptMode, jsonMode);
860
+ }, promptMode, formMode);
962
861
  modes.setAttribute('role', 'group');
963
862
  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);
970
863
  // New page (on the site, for people who may add pages): the scope becomes
971
864
  // the new page, with a way back, and a path field shows above the prompt.
972
865
  const newPage = element(doc, 'button', {
@@ -1009,13 +902,14 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1009
902
  textContent: labels.send
1010
903
  }), icon(doc, 'send'));
1011
904
  send.title = labels.send;
905
+ const divider = element(doc, 'span', {
906
+ className: 'divider'
907
+ });
1012
908
  const toolbar = element(doc, 'div', {
1013
909
  className: 'toolbar'
1014
910
  }, scope, newScope, modes, undo, newPage, element(doc, 'span', {
1015
911
  className: 'spacer'
1016
- }), reload, close, element(doc, 'span', {
1017
- className: 'divider'
1018
- }), send);
912
+ }), close, divider, send);
1019
913
  // The pages (on the site): a chip naming this page, opening a search
1020
914
  // and a list of every page in place of the prompt.
1021
915
  const pageChipText = element(doc, 'span', {
@@ -1083,7 +977,7 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1083
977
  }), signOut, save, publish);
1084
978
  const panel = element(doc, 'form', {
1085
979
  className: 'panel'
1086
- }, status, pathRow, input, code, toolbar, pagesSection, footer);
980
+ }, status, pathRow, input, formBox, toolbar, pagesSection, footer);
1087
981
  const root = element(doc, 'div', {
1088
982
  className: 'root'
1089
983
  }, element(doc, 'div', {
@@ -1095,15 +989,11 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1095
989
  let open = false;
1096
990
  let creating = false;
1097
991
  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;
992
+ // Form mode, and the form while mounted: a fresh one (and one-time
993
+ // address) every time it shows.
994
+ let form = false;
995
+ let formHandle = null;
996
+ let formLoading = false;
1107
997
  // The pages, fetched once the panel opens and kept; `active` is the
1108
998
  // option arrow keys are on.
1109
999
  let picking = false;
@@ -1130,40 +1020,43 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1130
1020
  const onlyNew = pageless && canCreate;
1131
1021
  if (!canCreate) creating = false;
1132
1022
  if (onlyNew) creating = true;
1133
- if (creating || pageless) json = false;
1023
+ if (creating || pageless || !site) form = false;
1024
+ // The form is there only while shown in the open panel.
1025
+ const showForm = open && available && form;
1026
+ if (showForm && !formHandle) mountForm();
1027
+ else if (!showForm && formHandle) unmountForm();
1134
1028
  pillText.textContent = onlyNew ? labels.newPage : labels.open;
1135
1029
  cancelNew.hidden = onlyNew;
1136
1030
  scope.hidden = creating;
1137
1031
  newScope.hidden = !creating;
1138
- newPage.hidden = creating || json || !canCreate;
1032
+ newPage.hidden = creating || form || !canCreate;
1139
1033
  newPage.disabled = busy;
1140
1034
  pathRow.hidden = !creating || picking;
1141
1035
  pathInput.disabled = busy;
1142
- input.hidden = json || picking;
1143
- code.hidden = !json || picking;
1036
+ input.hidden = form || picking;
1037
+ formBox.hidden = !form || picking;
1038
+ formBox.classList.toggle('loading', formLoading);
1144
1039
  toolbar.hidden = picking;
1145
1040
  pagesSection.hidden = !picking;
1146
1041
  input.placeholder = creating ? labels.newPagePlaceholder : labels.placeholder;
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;
1042
+ scopeText.textContent = state.selectedUid ? labels.scopeBlock : labels.scopeEntry;
1150
1043
  clearBlock.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));
1044
+ pageIcon.hidden = !!state.selectedUid;
1045
+ blockIcon.hidden = !state.selectedUid;
1046
+ // A CMS from before the form doesn't offer it.
1047
+ modes.hidden = !site || creating || pageless || !state.features.includes('form');
1048
+ promptMode.setAttribute('aria-pressed', String(!form));
1049
+ formMode.setAttribute('aria-pressed', String(form));
1156
1050
  modes.querySelectorAll('button').forEach((button)=>{
1157
- button.disabled = busy || loadingView;
1051
+ button.disabled = busy;
1158
1052
  });
1159
- reload.hidden = !json;
1160
- reload.disabled = busy || loadingView;
1161
1053
  undo.hidden = creating || !state.canUndo;
1162
- undo.disabled = busy || loadingView;
1163
- const text = json ? code.value : input.value;
1164
- send.disabled = busy || loadingView || text.trim() === '' || creating && pathInput.value.trim() === '';
1054
+ undo.disabled = busy;
1055
+ // The form applies edits as they are made.
1056
+ send.hidden = form;
1057
+ divider.hidden = form;
1058
+ send.disabled = busy || input.value.trim() === '' || creating && pathInput.value.trim() === '';
1165
1059
  input.disabled = busy;
1166
- code.disabled = busy || loadingView;
1167
1060
  save.hidden = !state.features.includes('save') || pageless;
1168
1061
  save.disabled = busy || !state.unsaved;
1169
1062
  save.textContent = state.saving ? labels.saving : labels.save;
@@ -1176,141 +1069,57 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1176
1069
  pageChip.hidden = !site;
1177
1070
  pageChip.setAttribute('aria-expanded', String(picking));
1178
1071
  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 : '');
1072
+ const loadingForm = form && formLoading && !picking;
1073
+ const working = state.prompting || loadingForm;
1074
+ const note = state.prompting ? creating ? labels.creating : labels.working : loadingForm ? labels.formLoading : message?.text ?? (site && !creating && !picking && !state.selectedUid ? labels.selectHint : '');
1181
1075
  status.textContent = note;
1182
1076
  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
- }
1077
+ status.className = `status${working ? ' working' : ''}${!busy && !loadingForm && message?.error ? ' error' : ''}`;
1190
1078
  };
1191
1079
  const refresh = ()=>render(bridge.getState());
1192
1080
  const grow = ()=>{
1193
1081
  input.style.height = 'auto';
1194
1082
  input.style.height = `${input.scrollHeight}px`;
1195
1083
  };
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;
1084
+ // Callbacks come later (the address is fetched first), never during a
1085
+ // render.
1086
+ const mountForm = ()=>{
1087
+ formLoading = true;
1088
+ formBox.classList.add('loading');
1089
+ const handle = bridge.openForm(formBox, {
1090
+ title: labels.formTitle,
1091
+ onReady: ()=>{
1092
+ if (formHandle !== handle) return;
1093
+ formLoading = false;
1094
+ message = null;
1095
+ refresh();
1096
+ },
1097
+ onError: (text)=>{
1098
+ if (formHandle !== handle) return;
1099
+ formLoading = false;
1100
+ message = {
1101
+ text,
1102
+ error: true
1103
+ };
1229
1104
  refresh();
1230
1105
  }
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();
1106
+ });
1107
+ formHandle = handle;
1244
1108
  };
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
- };
1109
+ const unmountForm = ()=>{
1110
+ formHandle?.close();
1111
+ formHandle = null;
1112
+ formLoading = false;
1261
1113
  };
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
- }
1114
+ const setForm = (next)=>{
1115
+ if (form === next) return;
1116
+ form = next;
1288
1117
  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
1118
  refresh();
1310
- code.focus();
1119
+ if (!form) input.focus();
1311
1120
  };
1312
1121
  const submit = async ()=>{
1313
- if (json) return applyJson();
1122
+ if (form) return;
1314
1123
  const prompt = input.value.trim();
1315
1124
  if (!prompt || bridge.getState().prompting) return;
1316
1125
  message = null;
@@ -1431,7 +1240,6 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1431
1240
  refresh();
1432
1241
  if (site) bridge.setSelecting(open && !creating);
1433
1242
  if (open && site) void fetchPages();
1434
- if (open && json) requestView();
1435
1243
  };
1436
1244
  const setCreating = (next)=>{
1437
1245
  creating = next;
@@ -1456,7 +1264,7 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1456
1264
  };
1457
1265
  pill.addEventListener('click', ()=>{
1458
1266
  setOpen(true);
1459
- (creating ? pathInput : json ? code : input).focus();
1267
+ if (!form) (creating ? pathInput : input).focus();
1460
1268
  });
1461
1269
  close.addEventListener('click', ()=>setOpen(false));
1462
1270
  save.addEventListener('click', ()=>void store('save'));
@@ -1466,12 +1274,8 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1466
1274
  clearBlock.addEventListener('click', ()=>bridge.select(null));
1467
1275
  newPage.addEventListener('click', ()=>setCreating(true));
1468
1276
  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
- });
1277
+ promptMode.addEventListener('click', ()=>setForm(false));
1278
+ formMode.addEventListener('click', ()=>setForm(true));
1475
1279
  pageChip.addEventListener('click', ()=>setPicking(!picking));
1476
1280
  pathInput.addEventListener('input', refresh);
1477
1281
  pathInput.addEventListener('keydown', (event)=>{
@@ -1505,7 +1309,6 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1505
1309
  undo.addEventListener('click', ()=>{
1506
1310
  message = null;
1507
1311
  bridge.undo();
1508
- if (json) requestView();
1509
1312
  });
1510
1313
  panel.addEventListener('submit', (event)=>{
1511
1314
  event.preventDefault();
@@ -1523,37 +1326,92 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1523
1326
  setOpen(false);
1524
1327
  }
1525
1328
  });
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
- });
1548
1329
  const unsubscribe = bridge.on('change', render);
1549
1330
  refresh();
1550
1331
  doc.body.appendChild(host);
1551
1332
  return ()=>{
1552
1333
  unsubscribe();
1334
+ unmountForm();
1553
1335
  host.remove();
1554
1336
  };
1555
1337
  };
1556
1338
 
1339
+ // The CMS's entry form in an iframe, for editing on the site (the prompt
1340
+ // box's Form mode). The CMS renders the form at a one-time URL (`/site/form#
1341
+ // code=…`, from the site's `/cms/api/form`); this keeps it in step with the
1342
+ // bridge's in-memory content.
1343
+ //
1344
+ // Messages are only exchanged with the URL's origin, and only with the
1345
+ // iframe's window:
1346
+ // - form → bridge: { source: 'obelism-content-form', type: 'ready' |
1347
+ // 'change' | 'error', content?, message? } — `change` carries the whole
1348
+ // entry content after an edit
1349
+ // - bridge → form: { source: 'obelism-content-bridge', type: 'content',
1350
+ // entryId, content, locale, selectedUid }
1351
+ const FORM_SOURCE = 'obelism-content-form';
1352
+ // How long the form may take to say it is ready.
1353
+ const FORM_READY_TIMEOUT_MS = 30_000;
1354
+ const isObject = (value)=>!!value && typeof value === 'object' && !Array.isArray(value);
1355
+ // Mounts the form at `url` in `container`. The URL is single use: mount a
1356
+ // fresh one every time.
1357
+ const mountFormFrame = (win, container, url, title, handlers)=>{
1358
+ let parsed = null;
1359
+ try {
1360
+ parsed = new URL(url);
1361
+ } catch {}
1362
+ if (!parsed || !/^https?:$/.test(parsed.protocol)) {
1363
+ throw new Error('Obelism Content sent no form to open');
1364
+ }
1365
+ const origin = parsed.origin;
1366
+ const iframe = container.ownerDocument.createElement('iframe');
1367
+ iframe.title = title;
1368
+ iframe.src = url;
1369
+ let ready = false;
1370
+ let latest = null;
1371
+ const post = ()=>{
1372
+ if (!ready || !latest) return;
1373
+ iframe.contentWindow?.postMessage({
1374
+ source: BRIDGE_SOURCE,
1375
+ type: 'content',
1376
+ ...latest
1377
+ }, origin);
1378
+ };
1379
+ const timer = setTimeout(()=>{
1380
+ if (!ready) handlers.onError('The form did not open');
1381
+ }, FORM_READY_TIMEOUT_MS);
1382
+ const onMessage = (event)=>{
1383
+ if (event.origin !== origin || !iframe.contentWindow || event.source !== iframe.contentWindow) {
1384
+ return;
1385
+ }
1386
+ const data = event.data;
1387
+ if (!data || data.source !== FORM_SOURCE) return;
1388
+ if (data.type === 'ready') {
1389
+ // Again after the form reloads itself: it gets the content again.
1390
+ ready = true;
1391
+ clearTimeout(timer);
1392
+ handlers.onReady();
1393
+ post();
1394
+ } else if (data.type === 'change' && isObject(data.content)) {
1395
+ handlers.onChange(data.content, typeof data.entryId === 'string' ? data.entryId : undefined);
1396
+ } else if (data.type === 'error') {
1397
+ handlers.onError(typeof data.message === 'string' && data.message ? data.message : 'The form failed');
1398
+ }
1399
+ };
1400
+ win.addEventListener('message', onMessage);
1401
+ container.appendChild(iframe);
1402
+ return {
1403
+ show (content) {
1404
+ latest = content;
1405
+ post();
1406
+ },
1407
+ destroy () {
1408
+ clearTimeout(timer);
1409
+ win.removeEventListener('message', onMessage);
1410
+ iframe.remove();
1411
+ }
1412
+ };
1413
+ };
1414
+
1557
1415
  // The bridge on a site signed in to edit (`/cms/login`, from
1558
1416
  // @obelism/content/site), outside the visual editor. It plays the editor's
1559
1417
  // part itself: it keeps the changed content in memory, asks the site's
@@ -1563,9 +1421,10 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1563
1421
  //
1564
1422
  // What it edits is the page's entry — the outermost element marked with
1565
1423
  // `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.
1424
+ // Besides prompts it shows the CMS's own entry form (`openForm`, an iframe
1425
+ // kept in step with the content here, see formFrame.ts), edits JSON (`view`,
1426
+ // then `applyJson`: the raw alternative), and lists the space's pages to go
1427
+ // to one.
1569
1428
  // The base path of the site's handler while signed in to edit, else null.
1570
1429
  const readSiteEditing = (doc)=>{
1571
1430
  for (const part of doc.cookie.split(';')){
@@ -1581,6 +1440,11 @@ const readSiteEditing = (doc)=>{
1581
1440
  return null;
1582
1441
  };
1583
1442
  const same = (a, b)=>JSON.stringify(a) === JSON.stringify(b);
1443
+ // Edits in the form further apart than this are separate undo steps.
1444
+ const FORM_BURST_MS = 1000;
1445
+ // How long the page waits for the form's edits to settle before it shows
1446
+ // them.
1447
+ const FORM_RENDER_MS = 300;
1584
1448
  const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})=>{
1585
1449
  const doc = win.document;
1586
1450
  const emitter = createEmitter();
@@ -1591,6 +1455,11 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1591
1455
  // Entries changed here since the last publish.
1592
1456
  const touched = new Set();
1593
1457
  let session = null;
1458
+ // The open form's, if any; and when the form last changed content, for
1459
+ // undo steps (-Infinity: the next edit starts a new step).
1460
+ let closeForm = null;
1461
+ let lastFormEdit = -Infinity;
1462
+ const renderTimers = new Map();
1594
1463
  let state = INITIAL_STATE;
1595
1464
  const setState = (next)=>{
1596
1465
  state = {
@@ -1644,17 +1513,26 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1644
1513
  const base = lang.split('-')[0] ?? '';
1645
1514
  return codes.includes(base) ? base : session.space.defaultLocale;
1646
1515
  };
1516
+ // One request per entry at a time: a second would overwrite edits made
1517
+ // while the first was out.
1518
+ const loading = new Map();
1647
1519
  const load = async (entryId)=>{
1648
1520
  const loaded = working.get(entryId);
1649
1521
  if (loaded) return loaded;
1650
- const entry = await api(`entries/${encodeURIComponent(entryId)}`);
1651
- const fresh = {
1652
- content: entry.content,
1653
- saved: entry.content,
1654
- revision: entry.draftRevision
1655
- };
1656
- working.set(entryId, fresh);
1657
- return fresh;
1522
+ let pending = loading.get(entryId);
1523
+ if (!pending) {
1524
+ pending = api(`entries/${encodeURIComponent(entryId)}`).then((entry)=>{
1525
+ const fresh = working.get(entryId) ?? {
1526
+ content: entry.content,
1527
+ saved: entry.content,
1528
+ revision: entry.draftRevision
1529
+ };
1530
+ working.set(entryId, fresh);
1531
+ return fresh;
1532
+ }).finally(()=>loading.delete(entryId));
1533
+ loading.set(entryId, pending);
1534
+ }
1535
+ return pending;
1658
1536
  };
1659
1537
  // The entry a prompt or JSON acts on, for the block `uid` (the selected
1660
1538
  // one when left out).
@@ -1693,6 +1571,15 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1693
1571
  });
1694
1572
  win.requestAnimationFrame(()=>highlight(state.selectedUid));
1695
1573
  };
1574
+ const logError = (error)=>console.error('[@obelism/content bridge]', error);
1575
+ // Renders once the form's edits settle.
1576
+ const renderSoon = (entryId)=>{
1577
+ clearTimeout(renderTimers.get(entryId));
1578
+ renderTimers.set(entryId, setTimeout(()=>{
1579
+ renderTimers.delete(entryId);
1580
+ void render(entryId).catch(logError);
1581
+ }, FORM_RENDER_MS));
1582
+ };
1696
1583
  const afterStore = (event)=>{
1697
1584
  emitter.emit(event, {});
1698
1585
  if (emitter.listeners.update.size === 0) win.location.reload();
@@ -1709,6 +1596,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1709
1596
  touched.add(entryId);
1710
1597
  }
1711
1598
  history.length = 0;
1599
+ lastFormEdit = -Infinity;
1712
1600
  refreshUnsaved();
1713
1601
  };
1714
1602
  const has = (feature)=>state.features.includes(feature);
@@ -1741,6 +1629,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1741
1629
  entryId,
1742
1630
  content: entry.content
1743
1631
  });
1632
+ lastFormEdit = -Infinity;
1744
1633
  entry.content = result.content;
1745
1634
  refreshUnsaved();
1746
1635
  await render(entryId);
@@ -1819,6 +1708,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1819
1708
  entryId,
1820
1709
  content: entry.content
1821
1710
  });
1711
+ lastFormEdit = -Infinity;
1822
1712
  entry.content = result.content;
1823
1713
  refreshUnsaved();
1824
1714
  await render(entryId);
@@ -1835,6 +1725,91 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1835
1725
  });
1836
1726
  }
1837
1727
  },
1728
+ openForm (container, options = {}) {
1729
+ const { title = 'Content form', onReady, onError } = options;
1730
+ closeForm?.();
1731
+ let closed = false;
1732
+ let frame = null;
1733
+ // What the form was last sent, or sent back itself.
1734
+ let shown = null;
1735
+ let unsubscribe = ()=>{};
1736
+ const fail = (error)=>{
1737
+ if (!closed) onError?.(error instanceof Error ? error.message : String(error));
1738
+ };
1739
+ const close = ()=>{
1740
+ if (closed) return;
1741
+ closed = true;
1742
+ unsubscribe();
1743
+ frame?.destroy();
1744
+ if (closeForm === close) closeForm = null;
1745
+ };
1746
+ closeForm = close;
1747
+ // Sends the form the entry for the selection, when anything of it
1748
+ // changed: prompts, undo, a reload after saving, the selection.
1749
+ const show = ()=>{
1750
+ if (closed || !frame) return;
1751
+ const entryId = entryFor(state.selectedUid);
1752
+ if (!entryId) return;
1753
+ const entry = working.get(entryId);
1754
+ if (!entry) {
1755
+ load(entryId).then(show, fail);
1756
+ return;
1757
+ }
1758
+ const next = {
1759
+ entryId,
1760
+ content: entry.content,
1761
+ locale: locale(),
1762
+ selectedUid: state.selectedUid
1763
+ };
1764
+ if (shown && shown.entryId === next.entryId && shown.content === next.content && shown.locale === next.locale && shown.selectedUid === next.selectedUid) {
1765
+ return;
1766
+ }
1767
+ shown = next;
1768
+ frame.show(next);
1769
+ };
1770
+ const change = (content, from)=>{
1771
+ const entryId = from ?? shown?.entryId;
1772
+ const entry = entryId === undefined ? undefined : working.get(entryId);
1773
+ if (closed || entryId === undefined || !entry || same(content, entry.content)) {
1774
+ return;
1775
+ }
1776
+ const now = Date.now();
1777
+ if (now - lastFormEdit >= FORM_BURST_MS) {
1778
+ history.push({
1779
+ entryId,
1780
+ content: entry.content
1781
+ });
1782
+ }
1783
+ lastFormEdit = now;
1784
+ entry.content = content;
1785
+ // Not sent back: the form has it.
1786
+ if (shown?.entryId === entryId) shown = {
1787
+ ...shown,
1788
+ content
1789
+ };
1790
+ refreshUnsaved();
1791
+ renderSoon(entryId);
1792
+ };
1793
+ const start = async ()=>{
1794
+ if (!has('form')) throw new Error('You may not edit content here');
1795
+ await load(target(undefined).entryId);
1796
+ // A fresh one every time: the address works once, for a minute.
1797
+ const { url } = await api('form', {});
1798
+ if (closed) return;
1799
+ const address = typeof url === 'string' ? url : '';
1800
+ frame = mountFormFrame(win, container, address, title, {
1801
+ onReady: ()=>onReady?.(),
1802
+ onChange: change,
1803
+ onError: (message)=>onError?.(message)
1804
+ });
1805
+ unsubscribe = emitter.on('change', show);
1806
+ show();
1807
+ };
1808
+ start().catch(fail);
1809
+ return {
1810
+ close
1811
+ };
1812
+ },
1838
1813
  async pages () {
1839
1814
  const { pages } = await api('pages');
1840
1815
  const preferred = [
@@ -1872,9 +1847,10 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1872
1847
  const last = history.pop();
1873
1848
  const entry = last && working.get(last.entryId);
1874
1849
  if (!last || !entry) return;
1850
+ lastFormEdit = -Infinity;
1875
1851
  entry.content = last.content;
1876
1852
  refreshUnsaved();
1877
- void render(last.entryId).catch((error)=>console.error('[@obelism/content bridge]', error));
1853
+ void render(last.entryId).catch(logError);
1878
1854
  },
1879
1855
  async save () {
1880
1856
  if (!has('save')) throw new Error('You may not edit content here');
@@ -1937,6 +1913,9 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1937
1913
  });
1938
1914
  },
1939
1915
  destroy () {
1916
+ closeForm?.();
1917
+ renderTimers.forEach((timer)=>clearTimeout(timer));
1918
+ renderTimers.clear();
1940
1919
  cleanup.splice(0).forEach((undo)=>undo());
1941
1920
  }
1942
1921
  };