superdoc-macros 0.3.0 → 0.4.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,7 +76,9 @@ 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
 
@@ -134,6 +136,16 @@ kit.importState(json, { merge: true });
134
136
 
135
137
  `MacroStorage` is a two-method interface — implement it to persist to a file (e.g. a plugin workspace).
136
138
 
139
+ Imports are strictly validated: every item and every recorded step is type-checked and size-bounded (see `IMPORT_LIMITS`), and one invalid item rejects the whole file — no partial imports.
140
+
141
+ ## Shortcut safety
142
+
143
+ 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:
144
+
145
+ ```ts
146
+ const kit = new MacroKit({ host, reservedShortcuts: ['Ctrl+S', 'Ctrl+P', /* … the editor's registry … */] });
147
+ ```
148
+
137
149
  ## Connecting a different host
138
150
 
139
151
  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';
@@ -9,5 +9,5 @@ export type { MacroRunner, MacroRunOptions, MacroRunResult } from './scripting/r
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
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';
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, 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, } from './storage.js';
package/dist/manager.d.ts CHANGED
@@ -31,7 +31,20 @@ 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[];
34
41
  }
42
+ export type ShortcutValidation = {
43
+ ok: true;
44
+ } | {
45
+ ok: false;
46
+ message: string;
47
+ };
35
48
  export declare class MacroKit {
36
49
  private readonly host;
37
50
  private readonly storage;
@@ -41,8 +54,20 @@ export declare class MacroKit {
41
54
  private state;
42
55
  private readonly recorder;
43
56
  private readonly autoText;
57
+ private readonly reservedSignatures;
44
58
  private running;
45
59
  constructor(options: MacroKitOptions);
60
+ /**
61
+ * Whether a shortcut is acceptable for a saved binding: parseable, carries
62
+ * a real modifier, not reserved by the host, and not already used by
63
+ * another saved item (`excludeId` skips the item being edited). Empty or
64
+ * undefined means "no shortcut" and is fine. The save paths enforce this;
65
+ * UIs call it directly to show the message before saving.
66
+ */
67
+ validateShortcut(shortcut: string | undefined, excludeId?: string): ShortcutValidation;
68
+ private findShortcutOwner;
69
+ /** Throws a MacroError when the shortcut is unacceptable. The save paths call this. */
70
+ private requireValidShortcut;
46
71
  listScripts(): readonly SavedScript[];
47
72
  saveScript(input: {
48
73
  id?: string;
package/dist/manager.js CHANGED
@@ -13,7 +13,8 @@ import { createIframeRunner } from './scripting/iframe-runner.js';
13
13
  import { MacroRecorder, replayMacro } from './recorder/recorder.js';
14
14
  import { AutoText } from './snippets/autotext.js';
15
15
  import { expandSnippet } from './snippets/snippets.js';
16
- import { bindShortcuts } from './shortcuts.js';
16
+ import { bindShortcuts, hasBindingModifier, parseShortcut, shortcutSignatures, } from './shortcuts.js';
17
+ import { MacroError } from './scripting/macro-api.js';
17
18
  import { createLocalStorage, emptyState, parsePersistedState } from './storage.js';
18
19
  import { macroMessages } from './messages.js';
19
20
  let idCounter = 0;
@@ -35,6 +36,7 @@ export class MacroKit {
35
36
  state;
36
37
  recorder;
37
38
  autoText;
39
+ reservedSignatures;
38
40
  running = false;
39
41
  constructor(options) {
40
42
  this.host = options.host;
@@ -47,12 +49,71 @@ export class MacroKit {
47
49
  this.state = this.storage.load() ?? emptyState();
48
50
  this.recorder = new MacroRecorder(this.host);
49
51
  this.autoText = new AutoText(this.host, () => this.state.snippets, options.autoText);
52
+ const reserved = new Set();
53
+ for (const shortcut of options.reservedShortcuts ?? []) {
54
+ const parsed = parseShortcut(shortcut);
55
+ if (parsed)
56
+ for (const signature of shortcutSignatures(parsed))
57
+ reserved.add(signature);
58
+ }
59
+ this.reservedSignatures = reserved;
60
+ }
61
+ /* ---------- Shortcut validation ---------- */
62
+ /**
63
+ * Whether a shortcut is acceptable for a saved binding: parseable, carries
64
+ * a real modifier, not reserved by the host, and not already used by
65
+ * another saved item (`excludeId` skips the item being edited). Empty or
66
+ * undefined means "no shortcut" and is fine. The save paths enforce this;
67
+ * UIs call it directly to show the message before saving.
68
+ */
69
+ validateShortcut(shortcut, excludeId) {
70
+ const trimmed = shortcut?.trim();
71
+ if (!trimmed)
72
+ return { ok: true };
73
+ const parsed = parseShortcut(trimmed);
74
+ if (!parsed)
75
+ return { ok: false, message: macroMessages().shortcutInvalid };
76
+ if (!hasBindingModifier(parsed))
77
+ return { ok: false, message: macroMessages().shortcutNeedsModifier };
78
+ const signatures = shortcutSignatures(parsed);
79
+ if (signatures.some((signature) => this.reservedSignatures.has(signature))) {
80
+ return { ok: false, message: macroMessages().shortcutReserved };
81
+ }
82
+ const owner = this.findShortcutOwner(signatures, excludeId);
83
+ if (owner)
84
+ return { ok: false, message: macroMessages().shortcutTaken(owner) };
85
+ return { ok: true };
86
+ }
87
+ findShortcutOwner(signatures, excludeId) {
88
+ const items = [
89
+ ...this.state.scripts,
90
+ ...this.state.recordings,
91
+ ...this.state.snippets,
92
+ ];
93
+ for (const item of items) {
94
+ if (!item.shortcut || item.id === excludeId)
95
+ continue;
96
+ const parsed = parseShortcut(item.shortcut);
97
+ if (!parsed)
98
+ continue;
99
+ const existing = shortcutSignatures(parsed);
100
+ if (existing.some((signature) => signatures.includes(signature)))
101
+ return item.name;
102
+ }
103
+ return null;
104
+ }
105
+ /** Throws a MacroError when the shortcut is unacceptable. The save paths call this. */
106
+ requireValidShortcut(shortcut, excludeId) {
107
+ const validation = this.validateShortcut(shortcut, excludeId);
108
+ if (!validation.ok)
109
+ throw new MacroError(validation.message, 'invalid-shortcut');
50
110
  }
51
111
  /* ---------- Scripts ---------- */
52
112
  listScripts() {
53
113
  return this.state.scripts;
54
114
  }
55
115
  saveScript(input) {
116
+ this.requireValidShortcut(input.shortcut, input.id);
56
117
  const script = {
57
118
  id: input.id ?? newId(),
58
119
  name: input.name,
@@ -101,6 +162,7 @@ export class MacroKit {
101
162
  }
102
163
  /** Stops and saves. `null` when no step was recorded — there is nothing to save. */
103
164
  stopRecording(name, shortcut) {
165
+ this.requireValidShortcut(shortcut);
104
166
  const steps = this.recorder.stop();
105
167
  if (steps.length === 0)
106
168
  return null;
@@ -131,6 +193,8 @@ export class MacroKit {
131
193
  const recording = this.state.recordings.find((entry) => entry.id === input.id);
132
194
  if (!recording)
133
195
  return null;
196
+ if (input.shortcut !== undefined)
197
+ this.requireValidShortcut(input.shortcut, input.id);
134
198
  if (input.name !== undefined)
135
199
  recording.name = input.name;
136
200
  if (input.shortcut !== undefined) {
@@ -168,6 +232,7 @@ export class MacroKit {
168
232
  return this.state.snippets;
169
233
  }
170
234
  saveSnippet(input) {
235
+ this.requireValidShortcut(input.shortcut, input.id);
171
236
  const snippet = {
172
237
  id: input.id ?? newId(),
173
238
  name: input.name,
@@ -27,14 +27,19 @@ export interface MacroMessages {
27
27
  syntaxError: (detail: string) => string;
28
28
  timedOut: (seconds: number) => string;
29
29
  callLimitExceeded: (limit: number) => string;
30
- deleteForwardUnsupported: string;
30
+ macroStopped: string;
31
31
  scriptNotFound: string;
32
32
  recordingNotFound: string;
33
33
  snippetNotFound: string;
34
34
  cannotRunWhileRecording: string;
35
35
  anotherMacroRunning: string;
36
36
  invalidImport: string;
37
+ shortcutInvalid: string;
38
+ shortcutNeedsModifier: string;
39
+ shortcutReserved: string;
40
+ shortcutTaken: (ownerName: string) => string;
37
41
  noDocument: string;
42
+ selectionUnavailable: string;
38
43
  unknownCommand: (id: string) => string;
39
44
  actionFailed: string;
40
45
  deletionUnavailable: string;
package/dist/messages.js CHANGED
@@ -27,14 +27,19 @@ export const ENGLISH_MESSAGES = {
27
27
  syntaxError: (detail) => `Macro syntax error: ${detail}`,
28
28
  timedOut: (seconds) => `The macro did not finish within ${seconds} seconds and was stopped`,
29
29
  callLimitExceeded: (limit) => `The macro exceeded the API call limit (${limit}) and was stopped`,
30
- deleteForwardUnsupported: 'Forward deletion is not supported during replay',
30
+ macroStopped: 'The macro was stopped the call was not executed',
31
31
  scriptNotFound: 'Macro not found',
32
32
  recordingNotFound: 'Recording not found',
33
33
  snippetNotFound: 'Snippet not found',
34
34
  cannotRunWhileRecording: 'Cannot run a macro while recording',
35
35
  anotherMacroRunning: 'Another macro is still running',
36
36
  invalidImport: 'The file is not a valid macro export',
37
+ shortcutInvalid: 'Invalid shortcut — use a form like Ctrl+Alt+M',
38
+ shortcutNeedsModifier: 'A shortcut must include Ctrl, Alt or Meta',
39
+ shortcutReserved: 'This shortcut is reserved by the editor',
40
+ shortcutTaken: (ownerName) => `This shortcut is already used by "${ownerName}"`,
37
41
  noDocument: 'No document is open',
42
+ selectionUnavailable: 'The caret position could not be read — nothing was inserted',
38
43
  unknownCommand: (id) => `The engine does not recognize the command ${id}`,
39
44
  actionFailed: 'The operation failed',
40
45
  deletionUnavailable: 'Deletion is not available in this document',
@@ -53,14 +58,19 @@ export const HEBREW_MESSAGES = {
53
58
  syntaxError: (detail) => `שגיאת תחביר במאקרו: ${detail}`,
54
59
  timedOut: (seconds) => `המאקרו לא הסתיים תוך ${seconds} שניות ונעצר`,
55
60
  callLimitExceeded: (limit) => `המאקרו חצה את תקרת הקריאות (${limit}) ונעצר`,
56
- deleteForwardUnsupported: 'מחיקה קדימה אינה נתמכת בניגון',
61
+ macroStopped: 'המאקרו נעצר הקריאה לא בוצעה',
57
62
  scriptNotFound: 'המאקרו לא נמצא',
58
63
  recordingNotFound: 'ההקלטה לא נמצאה',
59
64
  snippetNotFound: 'הקטע לא נמצא',
60
65
  cannotRunWhileRecording: 'אי אפשר להריץ מאקרו בזמן הקלטה',
61
66
  anotherMacroRunning: 'מאקרו אחר עדיין רץ',
62
67
  invalidImport: 'הקובץ אינו ייצוא מאקרו תקין',
68
+ shortcutInvalid: 'קיצור לא תקין — הצורה הנדרשת היא למשל Ctrl+Alt+M',
69
+ shortcutNeedsModifier: 'קיצור חייב לכלול Ctrl,‏ Alt או Meta',
70
+ shortcutReserved: 'הקיצור הזה שמור לעורך',
71
+ shortcutTaken: (ownerName) => `הקיצור כבר בשימוש של "${ownerName}"`,
63
72
  noDocument: 'אין מסמך פתוח',
73
+ selectionUnavailable: 'קריאת מיקום הסמן נכשלה — לא הוכנס דבר',
64
74
  unknownCommand: (id) => `הפקודה ${id} אינה מוכרת למנוע`,
65
75
  actionFailed: 'הפעולה נכשלה',
66
76
  deletionUnavailable: 'מחיקה אינה זמינה במסמך הזה',
@@ -1,3 +1,17 @@
1
+ /**
2
+ * A Word-style macro recorder: records **commands** and typing, not caret
3
+ * positions.
4
+ *
5
+ * That choice is deliberate. Recording raw ProseMirror steps (with absolute
6
+ * positions) breaks the moment the document differs from what it was at
7
+ * recording time; recording commands ("bold", "bullet-list", typing a
8
+ * greeting) behaves like Word's recorder — the actions apply wherever the
9
+ * caret is at replay time. It is also what makes a recording saveable and
10
+ * shareable: the steps are plain JSON.
11
+ *
12
+ * What is not recorded: caret movement and mouse selection. As in Word, a
13
+ * recorded macro acts from wherever the caret stands when it runs.
14
+ */
1
15
  import type { MacroHost, MacroStep } from '../types.js';
2
16
  export interface RecorderOptions {
3
17
  /** Command filter. The default records everything except undo/redo. */
@@ -1,18 +1,3 @@
1
- /**
2
- * A Word-style macro recorder: records **commands** and typing, not caret
3
- * positions.
4
- *
5
- * That choice is deliberate. Recording raw ProseMirror steps (with absolute
6
- * positions) breaks the moment the document differs from what it was at
7
- * recording time; recording commands ("bold", "bullet-list", typing a
8
- * greeting) behaves like Word's recorder — the actions apply wherever the
9
- * caret is at replay time. It is also what makes a recording saveable and
10
- * shareable: the steps are plain JSON.
11
- *
12
- * What is not recorded: caret movement and mouse selection. As in Word, a
13
- * recorded macro acts from wherever the caret stands when it runs.
14
- */
15
- import { macroMessages } from '../messages.js';
16
1
  const DEFAULT_MAX_STEPS = 5_000;
17
2
  /** Undo/redo during recording fix the recording itself — replaying them would replay the mistake too. */
18
3
  function defaultShouldRecord(id) {
@@ -124,8 +109,7 @@ async function runStep(host, step) {
124
109
  case 'delete-backward':
125
110
  return host.deleteBackward(step.count);
126
111
  case 'delete-forward':
127
- // The engine exposes no separate forward deletion; report an explicit failure rather than skipping silently.
128
- return { ok: false, message: macroMessages().deleteForwardUnsupported, reason: 'unsupported-step' };
112
+ return host.deleteForward(step.count);
129
113
  }
130
114
  }
131
115
  export async function replayMacro(host, steps, options = {}) {
@@ -2,4 +2,17 @@ import type { MacroBridge } from './macro-api.js';
2
2
  import { type MacroRunner } from './runner.js';
3
3
  /** Wraps a bridge with a call cap. Exposed so both runners share the same enforcement. */
4
4
  export declare function limitCalls(bridge: MacroBridge, maxCalls: number): MacroBridge;
5
+ /**
6
+ * Wraps a bridge with a kill switch. After `revoke()` every new call is
7
+ * rejected — so a script that keeps running past its timeout (the eval
8
+ * runner cannot stop it) can no longer touch the document.
9
+ *
10
+ * What this cannot do: abort a host call that already reached the engine.
11
+ * The engine's public surfaces expose no cancellation, so an in-flight
12
+ * operation completes; what is guaranteed is that nothing *new* starts.
13
+ */
14
+ export declare function revocable(bridge: MacroBridge): {
15
+ bridge: MacroBridge;
16
+ revoke: () => void;
17
+ };
5
18
  export declare function createEvalRunner(): MacroRunner;
@@ -29,6 +29,32 @@ export function limitCalls(bridge, maxCalls) {
29
29
  },
30
30
  };
31
31
  }
32
+ /**
33
+ * Wraps a bridge with a kill switch. After `revoke()` every new call is
34
+ * rejected — so a script that keeps running past its timeout (the eval
35
+ * runner cannot stop it) can no longer touch the document.
36
+ *
37
+ * What this cannot do: abort a host call that already reached the engine.
38
+ * The engine's public surfaces expose no cancellation, so an in-flight
39
+ * operation completes; what is guaranteed is that nothing *new* starts.
40
+ */
41
+ export function revocable(bridge) {
42
+ let revoked = false;
43
+ return {
44
+ revoke: () => {
45
+ revoked = true;
46
+ },
47
+ bridge: {
48
+ api: bridge.api,
49
+ callCount: bridge.callCount,
50
+ call(method, args) {
51
+ if (revoked)
52
+ return Promise.reject(new Error(macroMessages().macroStopped));
53
+ return bridge.call(method, args);
54
+ },
55
+ },
56
+ };
57
+ }
32
58
  /** An api proxy that routes everything through `bridge.call`, so the cap applies here too. */
33
59
  function apiThroughBridge(bridge) {
34
60
  return new Proxy({}, {
@@ -43,7 +69,9 @@ export function createEvalRunner() {
43
69
  return {
44
70
  async run(source, bridge, options = {}) {
45
71
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
46
- const limited = limitCalls(bridge, options.maxApiCalls ?? DEFAULT_MAX_API_CALLS);
72
+ // The revocable wrapper is what contains a timed-out script: eval cannot
73
+ // stop it from running, but it can no longer reach the document.
74
+ const { bridge: guarded, revoke } = revocable(limitCalls(bridge, options.maxApiCalls ?? DEFAULT_MAX_API_CALLS));
47
75
  let fn;
48
76
  try {
49
77
  fn = new AsyncFunction('api', `"use strict";\n${source}`);
@@ -61,7 +89,7 @@ export function createEvalRunner() {
61
89
  });
62
90
  const run = (async () => {
63
91
  try {
64
- const value = await fn(apiThroughBridge(limited));
92
+ const value = await fn(apiThroughBridge(guarded));
65
93
  return { ok: true, value };
66
94
  }
67
95
  catch (error) {
@@ -77,6 +105,7 @@ export function createEvalRunner() {
77
105
  }
78
106
  finally {
79
107
  clearTimeout(timer);
108
+ revoke();
80
109
  }
81
110
  },
82
111
  };
@@ -16,7 +16,7 @@
16
16
  * `ScriptSelection`).
17
17
  */
18
18
  import { macroMessages } from '../messages.js';
19
- import { limitCalls } from './eval-runner.js';
19
+ import { limitCalls, revocable } from './eval-runner.js';
20
20
  import { DEFAULT_MAX_API_CALLS, DEFAULT_TIMEOUT_MS, } from './runner.js';
21
21
  /** Protocol marker, so the messages cannot collide with others on the page. */
22
22
  export const PROTOCOL_MARK = '__otzariaMacro';
@@ -109,18 +109,29 @@ export function createIframeRunner(doc = document) {
109
109
  return {
110
110
  run(source, bridge, options = {}) {
111
111
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
112
- const limited = limitCalls(bridge, options.maxApiCalls ?? DEFAULT_MAX_API_CALLS);
112
+ // The revocable wrapper closes a small race: a call message that was
113
+ // already queued when the run finished must not dispatch to the host
114
+ // after the iframe is gone.
115
+ const { bridge: guarded, revoke } = revocable(limitCalls(bridge, options.maxApiCalls ?? DEFAULT_MAX_API_CALLS));
113
116
  return new Promise((resolve) => {
114
117
  const iframe = doc.createElement('iframe');
115
118
  iframe.setAttribute('sandbox', 'allow-scripts');
116
119
  iframe.style.display = 'none';
117
- iframe.srcdoc = `<!doctype html><meta charset="utf-8"><script>${SANDBOX_BOOTSTRAP}</script>`;
120
+ // The CSP closes the sandbox's remaining hole: an opaque-origin iframe
121
+ // cannot reach the app, but it can still fetch the public internet.
122
+ // `default-src 'none'` blocks fetch/XHR/WebSocket/resources inside it;
123
+ // only the inline bootstrap (and the AsyncFunction it compiles) runs.
124
+ iframe.srcdoc =
125
+ `<!doctype html><meta charset="utf-8">` +
126
+ `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval'">` +
127
+ `<script>${SANDBOX_BOOTSTRAP}</script>`;
118
128
  let settled = false;
119
129
  let timer;
120
130
  const finish = (result) => {
121
131
  if (settled)
122
132
  return;
123
133
  settled = true;
134
+ revoke();
124
135
  clearTimeout(timer);
125
136
  removeEventListener('message', onMessage);
126
137
  iframe.remove();
@@ -139,7 +150,7 @@ export function createIframeRunner(doc = document) {
139
150
  }
140
151
  if (data.kind === 'call') {
141
152
  const { id, method, args } = data;
142
- limited
153
+ guarded
143
154
  .call(method, args)
144
155
  .then((value) => {
145
156
  iframe.contentWindow?.postMessage({ [PROTOCOL_MARK]: true, kind: 'result', id, ok: true, value: toCloneSafe(value) }, '*');
@@ -25,6 +25,18 @@ export interface KeyEventLike {
25
25
  }
26
26
  export declare function parseShortcut(shortcut: string): ParsedShortcut | null;
27
27
  export declare function eventMatches(parsed: ParsedShortcut, event: KeyEventLike): boolean;
28
+ /**
29
+ * Comparable signatures for collision checks. `Mod` matches either Ctrl or
30
+ * Meta at runtime, so it expands to both — a `Mod+K` binding collides with
31
+ * `Ctrl+K` and with `Meta+K`.
32
+ */
33
+ export declare function shortcutSignatures(parsed: ParsedShortcut): string[];
34
+ /**
35
+ * Whether the shortcut is acceptable as a *saved binding*: it must carry a
36
+ * real modifier (Ctrl/Alt/Meta/Mod). A bare letter would fire on ordinary
37
+ * typing, and Shift alone is just an uppercase letter.
38
+ */
39
+ export declare function hasBindingModifier(parsed: ParsedShortcut): boolean;
28
40
  export interface ShortcutBinding {
29
41
  shortcut: string;
30
42
  run(): void | Promise<unknown>;
package/dist/shortcuts.js CHANGED
@@ -58,6 +58,25 @@ export function eventMatches(parsed, event) {
58
58
  event.shiftKey === parsed.shift &&
59
59
  event.metaKey === parsed.meta);
60
60
  }
61
+ /**
62
+ * Comparable signatures for collision checks. `Mod` matches either Ctrl or
63
+ * Meta at runtime, so it expands to both — a `Mod+K` binding collides with
64
+ * `Ctrl+K` and with `Meta+K`.
65
+ */
66
+ export function shortcutSignatures(parsed) {
67
+ const suffix = `${parsed.alt ? 'alt+' : ''}${parsed.shift ? 'shift+' : ''}${parsed.key}`;
68
+ if (parsed.mod)
69
+ return [`ctrl+${suffix}`, `meta+${suffix}`];
70
+ return [`${parsed.ctrl ? 'ctrl+' : ''}${parsed.meta ? 'meta+' : ''}${suffix}`];
71
+ }
72
+ /**
73
+ * Whether the shortcut is acceptable as a *saved binding*: it must carry a
74
+ * real modifier (Ctrl/Alt/Meta/Mod). A bare letter would fire on ordinary
75
+ * typing, and Shift alone is just an uppercase letter.
76
+ */
77
+ export function hasBindingModifier(parsed) {
78
+ return parsed.ctrl || parsed.alt || parsed.meta || parsed.mod;
79
+ }
61
80
  /**
62
81
  * Binds shortcuts to a target. `getBindings` is called on every keystroke —
63
82
  * so the macro list can change without rebinding. Returns a dispose
package/dist/storage.d.ts CHANGED
@@ -16,7 +16,30 @@ export interface MacroStorage {
16
16
  save(state: PersistedMacroState): void;
17
17
  }
18
18
  export declare function emptyState(): PersistedMacroState;
19
- /** Parses saved state. `null` on any unexpected shape — never throws. */
19
+ /**
20
+ * Caps on imported data. Imports come from files users share with each
21
+ * other, so every field is validated and bounded — a malformed or oversized
22
+ * export must fail closed, not wedge the store or the UI.
23
+ */
24
+ export declare const IMPORT_LIMITS: {
25
+ /** Whole-file size, in UTF-16 code units of the JSON string. */
26
+ readonly maxJsonLength: 5000000;
27
+ /** Per list: scripts, recordings, snippets. */
28
+ readonly maxItems: 500;
29
+ readonly maxStepsPerRecording: 5000;
30
+ readonly maxNameLength: 200;
31
+ readonly maxShortcutLength: 60;
32
+ readonly maxTriggerLength: 60;
33
+ /** Snippet text and single recorded insert-text step. */
34
+ readonly maxTextLength: 100000;
35
+ readonly maxSourceLength: 200000;
36
+ };
37
+ /**
38
+ * Parses saved/imported state. `null` on any unexpected shape, oversized
39
+ * field or oversized file — never throws, never partially accepts: one
40
+ * invalid item rejects the whole document, so the caller can tell the user
41
+ * the file is bad instead of silently importing a subset.
42
+ */
20
43
  export declare function parsePersistedState(json: string): PersistedMacroState | null;
21
44
  export declare const DEFAULT_STORAGE_KEY = "superdoc-macros:v1";
22
45
  /** localStorage with guards: blocked or full storage must not take the toolkit down. */
package/dist/storage.js CHANGED
@@ -1,17 +1,105 @@
1
1
  export function emptyState() {
2
2
  return { version: 1, scripts: [], recordings: [], snippets: [] };
3
3
  }
4
+ /**
5
+ * Caps on imported data. Imports come from files users share with each
6
+ * other, so every field is validated and bounded — a malformed or oversized
7
+ * export must fail closed, not wedge the store or the UI.
8
+ */
9
+ export const IMPORT_LIMITS = {
10
+ /** Whole-file size, in UTF-16 code units of the JSON string. */
11
+ maxJsonLength: 5_000_000,
12
+ /** Per list: scripts, recordings, snippets. */
13
+ maxItems: 500,
14
+ maxStepsPerRecording: 5_000,
15
+ maxNameLength: 200,
16
+ maxShortcutLength: 60,
17
+ maxTriggerLength: 60,
18
+ /** Snippet text and single recorded insert-text step. */
19
+ maxTextLength: 100_000,
20
+ maxSourceLength: 200_000,
21
+ };
22
+ function boundedString(value, maxLength, allowEmpty = false) {
23
+ return typeof value === 'string' && value.length <= maxLength && (allowEmpty || value.length > 0);
24
+ }
25
+ function optionalBoundedString(value, maxLength) {
26
+ return value === undefined || boundedString(value, maxLength);
27
+ }
28
+ function isValidStep(value) {
29
+ if (typeof value !== 'object' || value === null)
30
+ return false;
31
+ const step = value;
32
+ switch (step.type) {
33
+ case 'command':
34
+ // The payload is opaque engine data; the id is what replay dispatches on.
35
+ return boundedString(step.id, IMPORT_LIMITS.maxNameLength);
36
+ case 'insert-text':
37
+ return boundedString(step.text, IMPORT_LIMITS.maxTextLength, true);
38
+ case 'insert-paragraph':
39
+ return true;
40
+ case 'delete-backward':
41
+ case 'delete-forward':
42
+ return typeof step.count === 'number' && Number.isInteger(step.count) && step.count > 0 && step.count <= IMPORT_LIMITS.maxTextLength;
43
+ default:
44
+ return false;
45
+ }
46
+ }
47
+ function isValidScript(value) {
48
+ if (typeof value !== 'object' || value === null)
49
+ return false;
50
+ const script = value;
51
+ return (boundedString(script.id, IMPORT_LIMITS.maxNameLength) &&
52
+ boundedString(script.name, IMPORT_LIMITS.maxNameLength) &&
53
+ boundedString(script.source, IMPORT_LIMITS.maxSourceLength, true) &&
54
+ optionalBoundedString(script.shortcut, IMPORT_LIMITS.maxShortcutLength));
55
+ }
56
+ function isValidRecording(value) {
57
+ if (typeof value !== 'object' || value === null)
58
+ return false;
59
+ const recording = value;
60
+ return (recording.version === 1 &&
61
+ boundedString(recording.id, IMPORT_LIMITS.maxNameLength) &&
62
+ boundedString(recording.name, IMPORT_LIMITS.maxNameLength) &&
63
+ optionalBoundedString(recording.createdAt, IMPORT_LIMITS.maxNameLength) &&
64
+ optionalBoundedString(recording.shortcut, IMPORT_LIMITS.maxShortcutLength) &&
65
+ Array.isArray(recording.steps) &&
66
+ recording.steps.length <= IMPORT_LIMITS.maxStepsPerRecording &&
67
+ recording.steps.every(isValidStep));
68
+ }
69
+ function isValidSnippet(value) {
70
+ if (typeof value !== 'object' || value === null)
71
+ return false;
72
+ const snippet = value;
73
+ return (boundedString(snippet.id, IMPORT_LIMITS.maxNameLength) &&
74
+ boundedString(snippet.name, IMPORT_LIMITS.maxNameLength) &&
75
+ boundedString(snippet.text, IMPORT_LIMITS.maxTextLength, true) &&
76
+ optionalBoundedString(snippet.trigger, IMPORT_LIMITS.maxTriggerLength) &&
77
+ optionalBoundedString(snippet.shortcut, IMPORT_LIMITS.maxShortcutLength));
78
+ }
4
79
  function isValidState(value) {
5
80
  if (typeof value !== 'object' || value === null)
6
81
  return false;
7
82
  const state = value;
8
83
  return (state.version === 1 &&
9
84
  Array.isArray(state.scripts) &&
85
+ state.scripts.length <= IMPORT_LIMITS.maxItems &&
86
+ state.scripts.every(isValidScript) &&
10
87
  Array.isArray(state.recordings) &&
11
- Array.isArray(state.snippets));
88
+ state.recordings.length <= IMPORT_LIMITS.maxItems &&
89
+ state.recordings.every(isValidRecording) &&
90
+ Array.isArray(state.snippets) &&
91
+ state.snippets.length <= IMPORT_LIMITS.maxItems &&
92
+ state.snippets.every(isValidSnippet));
12
93
  }
13
- /** Parses saved state. `null` on any unexpected shape — never throws. */
94
+ /**
95
+ * Parses saved/imported state. `null` on any unexpected shape, oversized
96
+ * field or oversized file — never throws, never partially accepts: one
97
+ * invalid item rejects the whole document, so the caller can tell the user
98
+ * the file is bad instead of silently importing a subset.
99
+ */
14
100
  export function parsePersistedState(json) {
101
+ if (json.length > IMPORT_LIMITS.maxJsonLength)
102
+ return null;
15
103
  try {
16
104
  const parsed = JSON.parse(json);
17
105
  return isValidState(parsed) ? parsed : null;
package/dist/types.d.ts CHANGED
@@ -56,6 +56,8 @@ export interface MacroHost {
56
56
  insertText(text: string): Promise<MacroOutcome>;
57
57
  /** Deletes characters backwards from the caret. */
58
58
  deleteBackward(count: number): Promise<MacroOutcome>;
59
+ /** Deletes characters forwards from the caret. */
60
+ deleteForward(count: number): Promise<MacroOutcome>;
59
61
  /** Snapshot of the current selection. Never throws. */
60
62
  getSelection(options?: {
61
63
  includeText?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superdoc-macros",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Macro toolkit for SuperDoc-based editors: sandboxed scripted macros, a Word-style macro recorder, and snippets with auto-text expansion.",
5
5
  "license": "MIT",
6
6
  "type": "module",