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.
@@ -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
@@ -15,13 +15,22 @@
15
15
  * better than an expansion that deletes the wrong text.
16
16
  */
17
17
  import type { MacroHost, Snippet } from '../types.js';
18
+ /** What an expansion actually did — what a recorder needs to stay truthful. */
19
+ export interface AutoTextExpansion {
20
+ /** The trigger word the user typed. */
21
+ trigger: string;
22
+ /** The character that fired the expansion (and was restored at the end). */
23
+ expandChar: string;
24
+ /** The rendered snippet text that replaced the trigger. */
25
+ rendered: string;
26
+ }
18
27
  export interface AutoTextOptions {
19
28
  /** The expansion characters. Default: space only. */
20
29
  expandOn?: readonly string[];
21
30
  /** Buffer size. A trigger word longer than this will not be recognized. */
22
31
  bufferSize?: number;
23
32
  /** Called after a successful expansion. */
24
- onExpand?: (snippet: Snippet) => void;
33
+ onExpand?: (snippet: Snippet, expansion: AutoTextExpansion) => void;
25
34
  /** Called when an expansion failed (e.g. a read-only document). */
26
35
  onError?: (message: string) => void;
27
36
  }
@@ -94,7 +94,7 @@ export class AutoText {
94
94
  this.onError?.(inserted.message);
95
95
  return;
96
96
  }
97
- this.onExpand?.(snippet);
97
+ this.onExpand?.(snippet, { trigger, expandChar, rendered });
98
98
  }
99
99
  finally {
100
100
  this.busy = false;
package/dist/storage.d.ts CHANGED
@@ -16,7 +16,37 @@ 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
+ * Whether a state object passes the exact validation the loader applies.
39
+ * The save paths hold this as an invariant: state that would be rejected on
40
+ * the next load must never be persisted — otherwise a single oversized save
41
+ * silently wipes everything at the next startup.
42
+ */
43
+ export declare function isPersistableState(value: unknown): value is PersistedMacroState;
44
+ /**
45
+ * Parses saved/imported state. `null` on any unexpected shape, oversized
46
+ * field or oversized file — never throws, never partially accepts: one
47
+ * invalid item rejects the whole document, so the caller can tell the user
48
+ * the file is bad instead of silently importing a subset.
49
+ */
20
50
  export declare function parsePersistedState(json: string): PersistedMacroState | null;
21
51
  export declare const DEFAULT_STORAGE_KEY = "superdoc-macros:v1";
22
52
  /** localStorage with guards: blocked or full storage must not take the toolkit down. */
package/dist/storage.js CHANGED
@@ -1,17 +1,114 @@
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
+ * Whether a state object passes the exact validation the loader applies.
96
+ * The save paths hold this as an invariant: state that would be rejected on
97
+ * the next load must never be persisted — otherwise a single oversized save
98
+ * silently wipes everything at the next startup.
99
+ */
100
+ export function isPersistableState(value) {
101
+ return isValidState(value);
102
+ }
103
+ /**
104
+ * Parses saved/imported state. `null` on any unexpected shape, oversized
105
+ * field or oversized file — never throws, never partially accepts: one
106
+ * invalid item rejects the whole document, so the caller can tell the user
107
+ * the file is bad instead of silently importing a subset.
108
+ */
14
109
  export function parsePersistedState(json) {
110
+ if (json.length > IMPORT_LIMITS.maxJsonLength)
111
+ return null;
15
112
  try {
16
113
  const parsed = JSON.parse(json);
17
114
  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.5.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",