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