superdoc-macros 0.3.0 → 0.5.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/README.md CHANGED
@@ -76,10 +76,14 @@ if (!result.ok) console.warn(result.message);
76
76
 
77
77
  ### Security
78
78
 
79
- The default is a **real sandbox**: scripts run in an iframe with `sandbox="allow-scripts"` only — an opaque origin, no access to the application's DOM, localStorage, cookies, or credentialed network. The script's only way to touch the document is the API above, with a time cap (default 30 s — enforced even against infinite loops, by removing the iframe) and a call cap (10,000).
79
+ The default is a **real sandbox**: scripts run in an iframe with `sandbox="allow-scripts"` only — an opaque origin, no access to the application's DOM, localStorage or cookies — plus a `default-src 'none'` CSP inside the iframe, so the script cannot fetch or open sockets to the public internet either. The script's only way to touch the document is the API above, with a time cap (default 30 s — enforced even against infinite loops, by removing the iframe) and a call cap (10,000). When a run ends (result, error or timeout), its bridge is revoked: any late call is rejected and can no longer touch the document.
80
+
81
+ Honest limits: the browser offers no per-iframe memory cap, and a host call that already reached the engine cannot be aborted mid-flight (the engine exposes no cancellation) — what is guaranteed is that nothing new starts.
80
82
 
81
83
  If you must waive isolation (e.g. a CSP that blocks `srcdoc`), switch to the direct runner: `new MacroKit({ host, runner: 'eval' })` — see the warnings in the code.
82
84
 
85
+ **A real off switch:** `new MacroKit({ host, scriptsEnabled: false })` gates script *execution*, not just UI — `runScript`/`runSource` refuse and saved script shortcuts are not bound, so a pre-existing or imported script cannot run through any path. Recordings and snippets are unaffected.
86
+
83
87
  ## 2. Macro recorder
84
88
 
85
89
  ```ts
@@ -134,6 +138,18 @@ kit.importState(json, { merge: true });
134
138
 
135
139
  `MacroStorage` is a two-method interface — implement it to persist to a file (e.g. a plugin workspace).
136
140
 
141
+ Imports are strictly validated **atomically on the final result**: every item and every recorded step is type-checked and size-bounded (see `IMPORT_LIMITS`), every shortcut in the merged state must pass the same binding rules the save paths enforce (modifier required, not host-reserved, no duplicates), and any failure rejects the whole file with the current state untouched — no partial imports.
142
+
143
+ The same limits hold as a persistence invariant: the save paths refuse oversized fields and full lists, an oversized recorded paste is split into loadable steps, and state that the loader would reject is never written — so a single bad save can never wipe the store on the next startup.
144
+
145
+ ## Shortcut safety
146
+
147
+ Saved bindings go through `kit.validateShortcut(shortcut, excludeId?)` — enforced on every save: a binding must parse, must carry a real modifier (Ctrl/Alt/Meta — a bare letter would fire on ordinary typing), must not collide with another saved item, and must not collide with shortcuts the host declared as reserved:
148
+
149
+ ```ts
150
+ const kit = new MacroKit({ host, reservedShortcuts: ['Ctrl+S', 'Ctrl+P', /* … the editor's registry … */] });
151
+ ```
152
+
137
153
  ## Connecting a different host
138
154
 
139
155
  The whole toolkit works against a single `MacroHost` interface (commands, text insertion, selection, replace, typing events). `createSuperdocHost` is the implementation for SuperDoc v2 in `ui: false` mode; another editor plugs in with its own implementation — see `src/types.ts` and the double in `tests/fake-host.ts`.
@@ -102,6 +102,14 @@ export interface SuperdocHostOptions {
102
102
  superdoc: SuperdocLike;
103
103
  /** The element the document renders in — typing events are captured on it. */
104
104
  container?: HTMLElement | null;
105
+ /**
106
+ * Whether the adapter may fall back to the engine's internal ProseMirror
107
+ * view for the operations that have no public surface: backward/forward
108
+ * deletion, full-document text, and insertion when the Document API is
109
+ * missing. Default: true. Hosts that want to stay strictly on public
110
+ * surfaces set false — those operations then fail closed.
111
+ */
112
+ viewFallback?: boolean;
105
113
  }
106
114
  export interface SuperdocMacroHost extends MacroHost {
107
115
  /** Removes the observation wrapper and the DOM listeners. Call before swapping documents. */
@@ -43,40 +43,101 @@ function emptySelection() {
43
43
  /* ---------- The implementation ---------- */
44
44
  export function createSuperdocHost(options) {
45
45
  const { superdoc, container } = options;
46
+ const viewFallback = options.viewFallback ?? true;
46
47
  // Read at call time, never cached: activeEditor is replaced on every document open.
47
48
  const commands = () => superdoc.ui?.commands ?? null;
48
49
  const doc = () => superdoc.activeEditor?.doc ?? null;
49
- const view = () => superdoc.activeEditor?.view ?? null;
50
50
  const search = () => superdoc.ui?.search ?? null;
51
+ /**
52
+ * The single gate to the internal ProseMirror view. Every use of a
53
+ * non-public surface goes through here, so disabling the fallback (or a
54
+ * future engine that hides the view) degrades to closed failures in one
55
+ * place.
56
+ */
57
+ const view = () => viewFallback ? (superdoc.activeEditor?.view ?? null) : null;
51
58
  const commandListeners = new Set();
52
59
  const inputListeners = new Set();
53
- /* Command observation: wrap executeAsync, once, restored on dispose. */
60
+ const emitInput = (mapped) => {
61
+ for (const listener of inputListeners) {
62
+ try {
63
+ listener(mapped);
64
+ }
65
+ catch (error) {
66
+ console.warn('[superdoc-macros] input listener threw', error);
67
+ }
68
+ }
69
+ };
70
+ /* Command observation: wrap executeAsync, once, restored on dispose.
71
+ Listeners are notified only after the engine reports success — a command
72
+ that was refused (not routed, or a failed receipt) must not enter a
73
+ recording, or replay would re-fail it or, worse, apply it in a context
74
+ where it now succeeds unintended. */
54
75
  const wrapped = commands();
55
76
  const originalExecuteAsync = wrapped?.executeAsync;
56
77
  if (wrapped && originalExecuteAsync) {
57
78
  wrapped.executeAsync = function (id, payload) {
58
- for (const listener of commandListeners) {
59
- try {
60
- listener(id, payload);
79
+ const result = originalExecuteAsync.call(wrapped, id, payload);
80
+ void Promise.resolve(result)
81
+ .then((value) => {
82
+ if (value === false)
83
+ return;
84
+ if (typeof value === 'object' && value !== null && value.success === false)
85
+ return;
86
+ for (const listener of commandListeners) {
87
+ try {
88
+ listener(id, payload);
89
+ }
90
+ catch (error) {
91
+ console.warn('[superdoc-macros] command listener threw', error);
92
+ }
61
93
  }
62
- catch (error) {
63
- console.warn('[superdoc-macros] command listener threw', error);
64
- }
65
- }
66
- return originalExecuteAsync.call(wrapped, id, payload);
94
+ })
95
+ .catch(() => undefined); // a thrown command is a failure — nothing to record.
96
+ return result;
67
97
  };
68
98
  }
69
- /* Typing: beforeinput on the container, capture phase. */
99
+ /* Typing: beforeinput on the container, capture phase.
100
+
101
+ IME composition is folded to a single event: while composing, the
102
+ engine fires insertCompositionText repeatedly with the growing
103
+ candidate text, and mapping each one would record the word once per
104
+ keystroke. The events are suppressed during composition and the final
105
+ text is emitted once, from compositionend. */
106
+ let composing = false;
107
+ const onCompositionStart = () => {
108
+ composing = true;
109
+ };
110
+ const onCompositionEnd = (event) => {
111
+ composing = false;
112
+ const data = event.data;
113
+ if (typeof data === 'string' && data.length > 0)
114
+ emitInput({ kind: 'insert-text', text: data });
115
+ };
70
116
  const onBeforeInput = (event) => {
71
117
  const input = event;
72
118
  let mapped = null;
73
119
  switch (input.inputType) {
74
120
  case 'insertText':
75
121
  case 'insertCompositionText':
122
+ if (composing)
123
+ break; // the final text arrives from compositionend.
76
124
  if (typeof input.data === 'string' && input.data.length > 0) {
77
125
  mapped = { kind: 'insert-text', text: input.data };
78
126
  }
79
127
  break;
128
+ // Paste, drop and spellcheck replacement carry their text either in
129
+ // `data` or in a dataTransfer — without this branch a recording
130
+ // silently missed everything the user pasted.
131
+ case 'insertFromPaste':
132
+ case 'insertFromDrop':
133
+ case 'insertReplacementText': {
134
+ const text = typeof input.data === 'string' && input.data.length > 0
135
+ ? input.data
136
+ : input.dataTransfer?.getData('text/plain') ?? '';
137
+ if (text.length > 0)
138
+ mapped = { kind: 'insert-text', text };
139
+ break;
140
+ }
80
141
  case 'insertParagraph':
81
142
  mapped = { kind: 'insert-paragraph' };
82
143
  break;
@@ -89,29 +150,32 @@ export function createSuperdocHost(options) {
89
150
  default:
90
151
  break;
91
152
  }
92
- if (!mapped)
93
- return;
94
- for (const listener of inputListeners) {
95
- try {
96
- listener(mapped);
97
- }
98
- catch (error) {
99
- console.warn('[superdoc-macros] input listener threw', error);
100
- }
101
- }
153
+ if (mapped)
154
+ emitInput(mapped);
102
155
  };
103
156
  container?.addEventListener('beforeinput', onBeforeInput, true);
104
- async function readSelection(includeText) {
157
+ container?.addEventListener('compositionstart', onCompositionStart, true);
158
+ container?.addEventListener('compositionend', onCompositionEnd, true);
159
+ /**
160
+ * `failed: true` means the engine call itself threw — as opposed to a
161
+ * clean "no selection" answer. Callers that write relative to the caret
162
+ * must fail closed on it: falling back to "no target" would send the text
163
+ * to the end of the document, far from where the user is looking.
164
+ */
165
+ async function readSelectionDetailed(includeText) {
105
166
  const current = doc()?.selection?.current;
106
167
  if (typeof current !== 'function')
107
- return emptySelection();
168
+ return { snapshot: emptySelection(), failed: false };
108
169
  let info;
109
170
  try {
110
171
  info = await current(includeText ? { includeText: true } : undefined);
111
172
  }
112
173
  catch {
113
- return emptySelection();
174
+ return { snapshot: emptySelection(), failed: true };
114
175
  }
176
+ return { snapshot: parseSelectionInfo(info), failed: false };
177
+ }
178
+ function parseSelectionInfo(info) {
115
179
  if (!info || typeof info !== 'object')
116
180
  return emptySelection();
117
181
  const segments = Array.isArray(info.target?.segments) ? info.target.segments : [];
@@ -168,7 +232,11 @@ export function createSuperdocHost(options) {
168
232
  const insert = doc()?.insert;
169
233
  if (typeof insert === 'function') {
170
234
  // Without a target the insertion falls to the end of the document — so the target comes from the live selection.
171
- const snapshot = await readSelection(false);
235
+ const { snapshot, failed: selectionFailed } = await readSelectionDetailed(false);
236
+ // A failed read is not "no selection": inserting without a target
237
+ // would land the text at the end of the document. Fail closed.
238
+ if (selectionFailed)
239
+ return failed(macroMessages().selectionUnavailable, 'selection-read-failed');
172
240
  try {
173
241
  const receipt = await insert({
174
242
  value: text,
@@ -216,8 +284,27 @@ export function createSuperdocHost(options) {
216
284
  return failed(error instanceof Error ? error.message : macroMessages().deleteFailed, 'threw');
217
285
  }
218
286
  },
219
- getSelection(options) {
220
- return readSelection(options?.includeText ?? false);
287
+ async deleteForward(count) {
288
+ const pm = view();
289
+ if (!pm)
290
+ return failed(macroMessages().deletionUnavailable, 'view-unavailable');
291
+ try {
292
+ const { from } = pm.state.selection;
293
+ const size = pm.state.doc.content.size;
294
+ const end = Math.min(size, from + Math.max(0, Math.trunc(count)));
295
+ if (end === from)
296
+ return { ok: true };
297
+ const tr = pm.state.tr;
298
+ tr.delete(from, end);
299
+ pm.dispatch(tr);
300
+ return { ok: true };
301
+ }
302
+ catch (error) {
303
+ return failed(error instanceof Error ? error.message : macroMessages().deleteFailed, 'threw');
304
+ }
305
+ },
306
+ async getSelection(options) {
307
+ return (await readSelectionDetailed(options?.includeText ?? false)).snapshot;
221
308
  },
222
309
  async replaceAll(query, replacement) {
223
310
  const handle = search();
@@ -278,6 +365,8 @@ export function createSuperdocHost(options) {
278
365
  if (wrapped && originalExecuteAsync)
279
366
  wrapped.executeAsync = originalExecuteAsync;
280
367
  container?.removeEventListener('beforeinput', onBeforeInput, true);
368
+ container?.removeEventListener('compositionstart', onCompositionStart, true);
369
+ container?.removeEventListener('compositionend', onCompositionEnd, true);
281
370
  commandListeners.clear();
282
371
  inputListeners.clear();
283
372
  },
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export type { MacroHost, MacroOutcome, MacroStep, RecordedMacro, SavedScript, SelectionSnapshot, Snippet, TextInputEvent, } from './types.js';
2
- export { MacroKit, type MacroKitOptions } from './manager.js';
2
+ export { MacroKit, type MacroKitOptions, type ShortcutValidation } from './manager.js';
3
3
  export { ENGLISH_MESSAGES, HEBREW_MESSAGES, setMacroMessages, type MacroMessages, } from './messages.js';
4
4
  export { createSuperdocHost, type SuperdocHostOptions, type SuperdocLike, type SuperdocMacroHost } from './host/superdoc-host.js';
5
5
  export { createMacroApi, MacroError, type MacroApi, type MacroBridge, type ScriptSelection } from './scripting/macro-api.js';
@@ -8,6 +8,6 @@ export { createIframeRunner, SANDBOX_BOOTSTRAP, isProtocolMessage } from './scri
8
8
  export type { MacroRunner, MacroRunOptions, MacroRunResult } from './scripting/runner.js';
9
9
  export { MacroRecorder, replayMacro, type RecorderOptions, type ReplayOptions, type ReplayResult } from './recorder/recorder.js';
10
10
  export { renderSnippet, expandSnippet, usesSelection, type ExpandOptions, type RenderContext } from './snippets/snippets.js';
11
- export { AutoText, type AutoTextOptions } from './snippets/autotext.js';
12
- export { parseShortcut, eventMatches, bindShortcuts, type ParsedShortcut, type ShortcutBinding, type ShortcutTarget } from './shortcuts.js';
13
- export { createLocalStorage, createMemoryStorage, emptyState, parsePersistedState, DEFAULT_STORAGE_KEY, type MacroStorage, type PersistedMacroState, } from './storage.js';
11
+ export { AutoText, type AutoTextOptions, type AutoTextExpansion } from './snippets/autotext.js';
12
+ export { parseShortcut, eventMatches, bindShortcuts, shortcutSignatures, hasBindingModifier, type ParsedShortcut, type ShortcutBinding, type ShortcutTarget, } from './shortcuts.js';
13
+ export { createLocalStorage, createMemoryStorage, emptyState, parsePersistedState, DEFAULT_STORAGE_KEY, IMPORT_LIMITS, isPersistableState, type MacroStorage, type PersistedMacroState, } from './storage.js';
package/dist/index.js CHANGED
@@ -7,5 +7,5 @@ export { createIframeRunner, SANDBOX_BOOTSTRAP, isProtocolMessage } from './scri
7
7
  export { MacroRecorder, replayMacro } from './recorder/recorder.js';
8
8
  export { renderSnippet, expandSnippet, usesSelection } from './snippets/snippets.js';
9
9
  export { AutoText } from './snippets/autotext.js';
10
- export { parseShortcut, eventMatches, bindShortcuts } from './shortcuts.js';
11
- export { createLocalStorage, createMemoryStorage, emptyState, parsePersistedState, DEFAULT_STORAGE_KEY, } from './storage.js';
10
+ export { parseShortcut, eventMatches, bindShortcuts, shortcutSignatures, hasBindingModifier, } from './shortcuts.js';
11
+ export { createLocalStorage, createMemoryStorage, emptyState, parsePersistedState, DEFAULT_STORAGE_KEY, IMPORT_LIMITS, isPersistableState, } from './storage.js';
package/dist/manager.d.ts CHANGED
@@ -31,7 +31,34 @@ export interface MacroKitOptions {
31
31
  autoText?: Omit<AutoTextOptions, 'onExpand' | 'onError'> & AutoTextOptions;
32
32
  /** Run log for `api.log`. */
33
33
  onLog?: MacroApiOptions['onLog'];
34
+ /**
35
+ * Shortcuts the host already owns (e.g. its ribbon registry). A saved
36
+ * binding that collides with any of these is rejected — otherwise the
37
+ * macro binding, attached in the capture phase, would silently shadow an
38
+ * editor shortcut. Unparseable entries are ignored.
39
+ */
40
+ reservedShortcuts?: readonly string[];
41
+ /**
42
+ * Whether scripted macros may *execute*. Default: true. When false the
43
+ * gate is real, not cosmetic: `runScript`/`runSource` refuse, and saved
44
+ * script shortcuts are not bound — so a pre-existing or imported script
45
+ * cannot run through any path. Saving and listing still work (a UI may
46
+ * let the user manage scripts it will not run).
47
+ */
48
+ scriptsEnabled?: boolean;
49
+ /**
50
+ * Called when a recording hits the step cap and stops on its own. The
51
+ * steps are kept — `stopRecording(name)` still saves them — but a UI that
52
+ * shows a live "recording" indicator must update it.
53
+ */
54
+ onRecordingAutoStop?: () => void;
34
55
  }
56
+ export type ShortcutValidation = {
57
+ ok: true;
58
+ } | {
59
+ ok: false;
60
+ message: string;
61
+ };
35
62
  export declare class MacroKit {
36
63
  private readonly host;
37
64
  private readonly storage;
@@ -41,8 +68,21 @@ export declare class MacroKit {
41
68
  private state;
42
69
  private readonly recorder;
43
70
  private readonly autoText;
71
+ private readonly reservedSignatures;
72
+ private readonly scriptsEnabled;
44
73
  private running;
45
74
  constructor(options: MacroKitOptions);
75
+ /**
76
+ * Whether a shortcut is acceptable for a saved binding: parseable, carries
77
+ * a real modifier, not reserved by the host, and not already used by
78
+ * another saved item (`excludeId` skips the item being edited). Empty or
79
+ * undefined means "no shortcut" and is fine. The save paths enforce this;
80
+ * UIs call it directly to show the message before saving.
81
+ */
82
+ validateShortcut(shortcut: string | undefined, excludeId?: string): ShortcutValidation;
83
+ private findShortcutOwner;
84
+ /** Throws a MacroError when the shortcut is unacceptable. The save paths call this. */
85
+ private requireValidShortcut;
46
86
  listScripts(): readonly SavedScript[];
47
87
  saveScript(input: {
48
88
  id?: string;
@@ -98,6 +138,12 @@ export declare class MacroKit {
98
138
  * Imports JSON produced by `exportState`. With `merge: true` an imported
99
139
  * item with an existing `id` replaces it; without merge the whole state is
100
140
  * replaced.
141
+ *
142
+ * The check is **atomic, on the final result**: a candidate state is built
143
+ * first, its size limits and every shortcut in it are validated (the same
144
+ * rules the save paths enforce — a file cannot smuggle in what typing
145
+ * cannot), and only a candidate that passed in full is committed. On any
146
+ * failure the current state is untouched.
101
147
  */
102
148
  importState(json: string, options?: {
103
149
  merge?: boolean;
@@ -105,7 +151,23 @@ export declare class MacroKit {
105
151
  ok: boolean;
106
152
  message?: string;
107
153
  };
154
+ /**
155
+ * Every shortcut in a candidate state, under the exact rules of
156
+ * `validateShortcut`: parseable, real modifier, not host-reserved, and
157
+ * unique within the candidate. `importState` is the only caller — the
158
+ * save paths enforce the same rules one item at a time.
159
+ */
160
+ private validateStateShortcuts;
108
161
  private guardRun;
162
+ /**
163
+ * The save-path half of the persistence invariant: field lengths that the
164
+ * loader would reject are refused at the door. Without this, one oversized
165
+ * save would make the whole store unloadable — and the next startup would
166
+ * silently fall back to an empty state, losing everything.
167
+ */
168
+ private requireItemLimits;
169
+ /** The item-count half of the invariant. `existingId` exempts an in-place update. */
170
+ private requireRoom;
109
171
  private upsert;
110
172
  private persist;
111
173
  }