superdoc-macros 0.5.0 → 0.7.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/messages.js CHANGED
@@ -40,6 +40,9 @@ export const ENGLISH_MESSAGES = {
40
40
  tooManyItems: 'The list is full — delete items before adding new ones',
41
41
  nameRequired: 'A name is required',
42
42
  fieldTooLong: (field, max) => `${field} is too long (limit: ${max} characters)`,
43
+ saveFailed: 'Saving failed — the change was not applied',
44
+ recordingTooLarge: 'The recording is too large to save',
45
+ recordingIncomplete: (commandIds) => `The recording is missing actions that cannot be recorded (${commandIds})`,
43
46
  shortcutInvalid: 'Invalid shortcut — use a form like Ctrl+Alt+M',
44
47
  shortcutNeedsModifier: 'A shortcut must include Ctrl, Alt or Meta',
45
48
  shortcutReserved: 'This shortcut is reserved by the editor',
@@ -77,6 +80,9 @@ export const HEBREW_MESSAGES = {
77
80
  tooManyItems: 'הרשימה מלאה — יש למחוק פריטים לפני הוספה',
78
81
  nameRequired: 'חובה לתת שם',
79
82
  fieldTooLong: (field, max) => `${field} ארוך מדי (התקרה: ${max} תווים)`,
83
+ saveFailed: 'השמירה נכשלה — השינוי לא הוחל',
84
+ recordingTooLarge: 'ההקלטה גדולה מכדי להישמר',
85
+ recordingIncomplete: (commandIds) => `בהקלטה חסרות פעולות שאינן ניתנות להקלטה (${commandIds})`,
80
86
  shortcutInvalid: 'קיצור לא תקין — הצורה הנדרשת היא למשל Ctrl+Alt+M',
81
87
  shortcutNeedsModifier: 'קיצור חייב לכלול Ctrl,‏ Alt או Meta',
82
88
  shortcutReserved: 'הקיצור הזה שמור לעורך',
@@ -1,18 +1,13 @@
1
+ import type { MacroHost, MacroStep } from '../types.js';
1
2
  /**
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.
3
+ * A user action the recorder could not capture faithfully. Surfaced, never
4
+ * swallowed: a macro that "saved successfully" but silently misses an image
5
+ * the user watched go in is a lie told at replay time.
14
6
  */
15
- import type { MacroHost, MacroStep } from '../types.js';
7
+ export interface RecordingWarning {
8
+ commandId: string;
9
+ reason: 'payload-too-large' | 'payload-not-serializable';
10
+ }
16
11
  export interface RecorderOptions {
17
12
  /** Command filter. The default records everything except undo/redo. */
18
13
  shouldRecordCommand?: (id: string) => boolean;
@@ -32,19 +27,42 @@ export declare class MacroRecorder {
32
27
  private readonly maxSteps;
33
28
  private readonly onAutoStop?;
34
29
  private steps;
30
+ private warnings;
31
+ /**
32
+ * A stopped recording, held until `discard()`: the owner may fail to save
33
+ * it (storage quota, an incomplete recording awaiting confirmation) and
34
+ * must be able to come back for it. Clearing on stop() was the old
35
+ * behavior, and it lost recordings at exactly the moments they were
36
+ * hardest to redo.
37
+ */
38
+ private pending;
35
39
  private disposers;
36
40
  private active;
41
+ /**
42
+ * Set by a caret move: the next typed character starts a fresh step
43
+ * instead of coalescing — text typed at a new position is not a
44
+ * continuation of the text typed at the old one.
45
+ */
46
+ private tailInterrupted;
37
47
  constructor(host: MacroHost, options?: RecorderOptions);
38
48
  get recording(): boolean;
39
49
  get stepCount(): number;
50
+ /** Whether a stopped recording is waiting to be saved or discarded. */
51
+ get hasPending(): boolean;
40
52
  start(): void;
41
53
  /**
42
- * Stops and returns the steps. Empty when nothing was recorded. Also the
43
- * way to collect a recording that auto-stopped at the cap — the steps are
44
- * kept until someone asks for them.
54
+ * Stops and returns the recording steps plus any warnings about actions
55
+ * that could not be captured. The result stays retrievable (calling
56
+ * `stop()` again returns the same recording) until `discard()`, `cancel()`
57
+ * or a new `start()`: a save that fails must be retryable.
45
58
  */
46
- stop(): MacroStep[];
47
- /** Stops and discards whatever was recorded. */
59
+ stop(): {
60
+ steps: MacroStep[];
61
+ warnings: RecordingWarning[];
62
+ };
63
+ /** Releases a stopped recording after it was successfully saved (or knowingly dropped). */
64
+ discard(): void;
65
+ /** Stops and discards whatever was recorded — including a pending stopped recording. */
48
66
  cancel(): void;
49
67
  /**
50
68
  * Rewrites the recorded tail after an auto-text expansion: the user typed
@@ -62,6 +80,13 @@ export declare class MacroRecorder {
62
80
  applyAutoTextExpansion(consumed: number, replacement: string): void;
63
81
  private teardown;
64
82
  private push;
83
+ /**
84
+ * Records a programmatic insertion the host will not report as typing —
85
+ * e.g. a snippet expanded from a button or shortcut, which writes through
86
+ * the document API and never fires beforeinput. Without this, a replay
87
+ * would silently miss text the user watched appear.
88
+ */
89
+ recordInsert(text: string): void;
65
90
  private recordCommand;
66
91
  private recordTextInput;
67
92
  }
@@ -1,3 +1,18 @@
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 { IMPORT_LIMITS } from '../storage.js';
1
16
  const DEFAULT_MAX_STEPS = 5_000;
2
17
  /** Undo/redo during recording fix the recording itself — replaying them would replay the mistake too. */
3
18
  function defaultShouldRecord(id) {
@@ -9,8 +24,23 @@ export class MacroRecorder {
9
24
  maxSteps;
10
25
  onAutoStop;
11
26
  steps = [];
27
+ warnings = [];
28
+ /**
29
+ * A stopped recording, held until `discard()`: the owner may fail to save
30
+ * it (storage quota, an incomplete recording awaiting confirmation) and
31
+ * must be able to come back for it. Clearing on stop() was the old
32
+ * behavior, and it lost recordings at exactly the moments they were
33
+ * hardest to redo.
34
+ */
35
+ pending = null;
12
36
  disposers = [];
13
37
  active = false;
38
+ /**
39
+ * Set by a caret move: the next typed character starts a fresh step
40
+ * instead of coalescing — text typed at a new position is not a
41
+ * continuation of the text typed at the old one.
42
+ */
43
+ tailInterrupted = false;
14
44
  constructor(host, options = {}) {
15
45
  this.host = host;
16
46
  this.shouldRecordCommand = options.shouldRecordCommand ?? defaultShouldRecord;
@@ -23,31 +53,49 @@ export class MacroRecorder {
23
53
  get stepCount() {
24
54
  return this.steps.length;
25
55
  }
56
+ /** Whether a stopped recording is waiting to be saved or discarded. */
57
+ get hasPending() {
58
+ return this.pending !== null;
59
+ }
26
60
  start() {
27
61
  if (this.active)
28
62
  return;
29
63
  this.active = true;
30
64
  this.steps = [];
65
+ this.warnings = [];
66
+ // Starting anew is the explicit "I no longer want the unsaved one".
67
+ this.pending = null;
31
68
  this.disposers = [
32
69
  this.host.onCommand((id, payload) => this.recordCommand(id, payload)),
33
70
  this.host.onTextInput((event) => this.recordTextInput(event)),
34
71
  ];
35
72
  }
36
73
  /**
37
- * Stops and returns the steps. Empty when nothing was recorded. Also the
38
- * way to collect a recording that auto-stopped at the cap — the steps are
39
- * kept until someone asks for them.
74
+ * Stops and returns the recording steps plus any warnings about actions
75
+ * that could not be captured. The result stays retrievable (calling
76
+ * `stop()` again returns the same recording) until `discard()`, `cancel()`
77
+ * or a new `start()`: a save that fails must be retryable.
40
78
  */
41
79
  stop() {
42
- this.teardown();
43
- const recorded = this.steps;
44
- this.steps = [];
45
- return recorded;
80
+ if (this.active)
81
+ this.teardown();
82
+ if (!this.pending && (this.steps.length > 0 || this.warnings.length > 0)) {
83
+ this.pending = { steps: this.steps, warnings: this.warnings };
84
+ this.steps = [];
85
+ this.warnings = [];
86
+ }
87
+ return this.pending ?? { steps: [], warnings: [] };
46
88
  }
47
- /** Stops and discards whatever was recorded. */
89
+ /** Releases a stopped recording after it was successfully saved (or knowingly dropped). */
90
+ discard() {
91
+ this.pending = null;
92
+ }
93
+ /** Stops and discards whatever was recorded — including a pending stopped recording. */
48
94
  cancel() {
49
95
  this.teardown();
50
96
  this.steps = [];
97
+ this.warnings = [];
98
+ this.pending = null;
51
99
  }
52
100
  /**
53
101
  * Rewrites the recorded tail after an auto-text expansion: the user typed
@@ -105,17 +153,59 @@ export class MacroRecorder {
105
153
  this.onAutoStop?.();
106
154
  }
107
155
  }
156
+ /**
157
+ * Records a programmatic insertion the host will not report as typing —
158
+ * e.g. a snippet expanded from a button or shortcut, which writes through
159
+ * the document API and never fires beforeinput. Without this, a replay
160
+ * would silently miss text the user watched appear.
161
+ */
162
+ recordInsert(text) {
163
+ if (!this.active || text.length === 0)
164
+ return;
165
+ this.recordTextInput({ kind: 'insert-text', text });
166
+ }
108
167
  recordCommand(id, payload) {
109
168
  if (!this.shouldRecordCommand(id))
110
169
  return;
111
- this.push(payload === undefined ? { type: 'command', id } : { type: 'command', id, payload });
170
+ if (payload === undefined) {
171
+ this.push({ type: 'command', id });
172
+ return;
173
+ }
174
+ // The payload is opaque engine data, but not unlimited: it must survive
175
+ // a JSON round-trip within the persistence cap, or the recording would
176
+ // be rejected by the loader. A command whose payload cannot be kept
177
+ // faithfully is not stepped — replaying it with a mangled payload would
178
+ // do something other than what was recorded — but it is never dropped
179
+ // *silently*: the warning is what lets the owner refuse to present the
180
+ // recording as complete. Inserting an image is the concrete case — its
181
+ // payload carries the whole file as a data URL.
182
+ let json;
183
+ try {
184
+ json = JSON.stringify(payload);
185
+ }
186
+ catch {
187
+ this.warnings.push({ commandId: id, reason: 'payload-not-serializable' });
188
+ return;
189
+ }
190
+ if (typeof json !== 'string') {
191
+ this.warnings.push({ commandId: id, reason: 'payload-not-serializable' });
192
+ return;
193
+ }
194
+ if (json.length > IMPORT_LIMITS.maxPayloadLength) {
195
+ this.warnings.push({ commandId: id, reason: 'payload-too-large' });
196
+ return;
197
+ }
198
+ this.push({ type: 'command', id, payload: JSON.parse(json) });
112
199
  }
113
200
  recordTextInput(event) {
201
+ const interrupted = this.tailInterrupted;
202
+ this.tailInterrupted = false;
114
203
  const last = this.steps[this.steps.length - 1];
115
204
  switch (event.kind) {
116
205
  case 'insert-text': {
117
- // Consecutive keystrokes coalesce into one step — more readable, faster to replay.
118
- if (last?.type === 'insert-text') {
206
+ // Consecutive keystrokes coalesce into one step — more readable,
207
+ // faster to replay — but never across a caret move.
208
+ if (!interrupted && last?.type === 'insert-text') {
119
209
  last.text += event.text;
120
210
  return;
121
211
  }
@@ -126,7 +216,7 @@ export class MacroRecorder {
126
216
  this.push({ type: 'insert-paragraph' });
127
217
  return;
128
218
  case 'delete-backward': {
129
- if (last?.type === 'delete-backward') {
219
+ if (!interrupted && last?.type === 'delete-backward') {
130
220
  last.count += 1;
131
221
  return;
132
222
  }
@@ -134,13 +224,18 @@ export class MacroRecorder {
134
224
  return;
135
225
  }
136
226
  case 'delete-forward': {
137
- if (last?.type === 'delete-forward') {
227
+ if (!interrupted && last?.type === 'delete-forward') {
138
228
  last.count += 1;
139
229
  return;
140
230
  }
141
231
  this.push({ type: 'delete-forward', count: 1 });
142
232
  return;
143
233
  }
234
+ case 'caret-moved':
235
+ // Not a step — replay acts from the live caret — but the recorded
236
+ // tail is no longer "where the user is typing".
237
+ this.tailInterrupted = true;
238
+ return;
144
239
  }
145
240
  }
146
241
  }
@@ -16,14 +16,35 @@ export interface ParsedShortcut {
16
16
  /** The subset of KeyboardEvent that matching needs. Enables DOM-free tests. */
17
17
  export interface KeyEventLike {
18
18
  key: string;
19
+ /** The physical key. When present, letters and digits match by it — see `eventMatches`. */
20
+ code?: string;
19
21
  ctrlKey: boolean;
20
22
  altKey: boolean;
21
23
  shiftKey: boolean;
22
24
  metaKey: boolean;
25
+ /** Key held down — auto-repeat must not re-fire a macro. */
26
+ repeat?: boolean;
27
+ /** Mid-IME-composition — keys belong to the composition, not to bindings. */
28
+ isComposing?: boolean;
29
+ /** Legacy IME marker: some WebViews report keyCode 229 without isComposing. */
30
+ keyCode?: number;
23
31
  preventDefault?(): void;
24
32
  stopPropagation?(): void;
25
33
  }
26
34
  export declare function parseShortcut(shortcut: string): ParsedShortcut | null;
35
+ /**
36
+ * The physical `event.code` values a binding key stands for. Letters and
37
+ * digits get a deterministic mapping; anything else returns empty and falls
38
+ * back to `event.key`.
39
+ *
40
+ * Physical-key matching is what keeps a binding alive across keyboard
41
+ * layouts: on a Hebrew layout Ctrl+Alt+R reports `key: 'ר'`, and a
42
+ * key-based match would die the moment the user switches to Hebrew — the
43
+ * exact bug the host editor once had with its own shortcuts.
44
+ */
45
+ export declare function codesForKey(key: string): readonly string[];
46
+ /** Whether the key can be bound reliably (has a physical-code mapping). */
47
+ export declare function isBindableKey(parsed: ParsedShortcut): boolean;
27
48
  export declare function eventMatches(parsed: ParsedShortcut, event: KeyEventLike): boolean;
28
49
  /**
29
50
  * Comparable signatures for collision checks. `Mod` matches either Ctrl or
package/dist/shortcuts.js CHANGED
@@ -44,8 +44,40 @@ function normalizeKey(key) {
44
44
  return 'escape';
45
45
  return lower;
46
46
  }
47
+ /**
48
+ * The physical `event.code` values a binding key stands for. Letters and
49
+ * digits get a deterministic mapping; anything else returns empty and falls
50
+ * back to `event.key`.
51
+ *
52
+ * Physical-key matching is what keeps a binding alive across keyboard
53
+ * layouts: on a Hebrew layout Ctrl+Alt+R reports `key: 'ר'`, and a
54
+ * key-based match would die the moment the user switches to Hebrew — the
55
+ * exact bug the host editor once had with its own shortcuts.
56
+ */
57
+ export function codesForKey(key) {
58
+ if (/^[a-z]$/.test(key))
59
+ return [`Key${key.toUpperCase()}`];
60
+ if (/^[0-9]$/.test(key))
61
+ return [`Digit${key}`, `Numpad${key}`];
62
+ if (/^f([1-9]|1[0-2])$/.test(key))
63
+ return [key.toUpperCase()];
64
+ if (key === ' ')
65
+ return ['Space'];
66
+ if (key === 'escape')
67
+ return ['Escape'];
68
+ return [];
69
+ }
70
+ /** Whether the key can be bound reliably (has a physical-code mapping). */
71
+ export function isBindableKey(parsed) {
72
+ return codesForKey(parsed.key).length > 0;
73
+ }
47
74
  export function eventMatches(parsed, event) {
48
- if (normalizeKey(event.key) !== parsed.key)
75
+ const codes = codesForKey(parsed.key);
76
+ const keyMatched = normalizeKey(event.key) === parsed.key;
77
+ // The physical code decides whenever both sides have one; `event.key` is
78
+ // the fallback for keys with no mapping or hosts that do not report codes.
79
+ const matched = codes.length > 0 && event.code !== undefined ? codes.includes(event.code) : keyMatched;
80
+ if (!matched)
49
81
  return false;
50
82
  if (parsed.mod) {
51
83
  if (!event.ctrlKey && !event.metaKey)
@@ -84,6 +116,11 @@ export function hasBindingModifier(parsed) {
84
116
  */
85
117
  export function bindShortcuts(target, getBindings) {
86
118
  const listener = (event) => {
119
+ // Auto-repeat must not replay a macro per repeat tick, and keys mid-IME
120
+ // composition belong to the composition — including the legacy keyCode
121
+ // 229 marker some WebViews report without setting isComposing.
122
+ if (event.repeat || event.isComposing || event.keyCode === 229)
123
+ return;
87
124
  for (const binding of getBindings()) {
88
125
  const parsed = parseShortcut(binding.shortcut);
89
126
  if (!parsed || !eventMatches(parsed, event))
@@ -50,6 +50,12 @@ export class AutoText {
50
50
  case 'delete-forward':
51
51
  this.buffer = '';
52
52
  return;
53
+ // A click or navigation key moved the caret: the buffer no longer
54
+ // describes what sits before it, and expanding on it would delete
55
+ // text at the new position. Missing an expansion is the cheap error.
56
+ case 'caret-moved':
57
+ this.buffer = '';
58
+ return;
53
59
  case 'delete-backward':
54
60
  this.buffer = this.buffer.slice(0, -1);
55
61
  return;
@@ -78,19 +84,44 @@ export class AutoText {
78
84
  // to the document. Deferring to the task queue guarantees the expansion
79
85
  // character is already in before it is deleted along with the trigger.
80
86
  await new Promise((resolve) => setTimeout(resolve, 0));
81
- const selectionText = usesSelection(snippet.text)
82
- ? (await this.host.getSelection({ includeText: true })).text
83
- : undefined;
84
- const rendered = renderSnippet(snippet.text, { selectionText });
85
- // The expansion character is already in the document by now, so it is
86
- // included in the deletion and restored at the end.
87
- const deleted = await this.host.deleteBackward(trigger.length + 1);
87
+ // Fail closed, in both directions: the document must *verifiably*
88
+ // hold the trigger right before the caret. A mismatch means the caret
89
+ // moved (or another writer raced us); `null`/`undefined` means the
90
+ // host cannot tell — and a feature that deletes text on its own
91
+ // initiative does not get the benefit of the doubt. Auto-text simply
92
+ // does not expand where it cannot verify.
93
+ const expected = trigger + expandChar;
94
+ const rendered = renderSnippet(snippet.text, {
95
+ selectionText: usesSelection(snippet.text)
96
+ ? (await this.host.getSelection({ includeText: true })).text
97
+ : undefined,
98
+ });
99
+ const replacement = rendered + expandChar;
100
+ // Preferred path: one atomic engine transaction that verifies and
101
+ // replaces together — nothing to roll back.
102
+ if (this.host.replaceTextBefore) {
103
+ const replaced = await this.host.replaceTextBefore(expected, replacement);
104
+ if (!replaced.ok) {
105
+ this.onError?.(replaced.message);
106
+ return;
107
+ }
108
+ this.onExpand?.(snippet, { trigger, expandChar, rendered });
109
+ return;
110
+ }
111
+ const actual = await this.host.getTextBefore?.(expected.length);
112
+ if (actual !== expected)
113
+ return;
114
+ // Two-operation fallback for hosts without an atomic replace. If the
115
+ // insertion fails after the deletion succeeded, the trigger is put
116
+ // back — the user must not be left with their word silently eaten.
117
+ const deleted = await this.host.deleteBackward(expected.length);
88
118
  if (!deleted.ok) {
89
119
  this.onError?.(deleted.message);
90
120
  return;
91
121
  }
92
- const inserted = await this.host.insertText(rendered + expandChar);
122
+ const inserted = await this.host.insertText(replacement);
93
123
  if (!inserted.ok) {
124
+ await this.host.insertText(expected);
94
125
  this.onError?.(inserted.message);
95
126
  return;
96
127
  }
@@ -26,5 +26,11 @@ export interface ExpandOptions {
26
26
  now?: Date;
27
27
  locale?: string;
28
28
  }
29
+ /**
30
+ * Renders a snippet against the live document (reads the selection only
31
+ * when the snippet needs it). Split from the insertion so a caller that
32
+ * must know what text actually landed — e.g. a recorder — can.
33
+ */
34
+ export declare function renderSnippetForHost(host: MacroHost, snippet: Pick<Snippet, 'text'>, options?: ExpandOptions): Promise<string>;
29
35
  /** Expands a snippet at the caret. */
30
36
  export declare function expandSnippet(host: MacroHost, snippet: Pick<Snippet, 'text'>, options?: ExpandOptions): Promise<MacroOutcome>;
@@ -24,17 +24,23 @@ export function renderSnippet(text, context = {}) {
24
24
  export function usesSelection(text) {
25
25
  return /\{\{\s*selection\s*\}\}/iu.test(text);
26
26
  }
27
- /** Expands a snippet at the caret. */
28
- export async function expandSnippet(host, snippet, options = {}) {
29
- // The selection is read only when needed: extracting its text has an engine cost.
27
+ /**
28
+ * Renders a snippet against the live document (reads the selection only
29
+ * when the snippet needs it). Split from the insertion so a caller that
30
+ * must know what text actually landed — e.g. a recorder — can.
31
+ */
32
+ export async function renderSnippetForHost(host, snippet, options = {}) {
30
33
  const selectionText = usesSelection(snippet.text)
31
34
  ? (await host.getSelection({ includeText: true })).text
32
35
  : undefined;
33
- const rendered = renderSnippet(snippet.text, {
36
+ return renderSnippet(snippet.text, {
34
37
  variables: options.variables,
35
38
  selectionText,
36
39
  now: options.now,
37
40
  locale: options.locale,
38
41
  });
39
- return host.insertText(rendered);
42
+ }
43
+ /** Expands a snippet at the caret. */
44
+ export async function expandSnippet(host, snippet, options = {}) {
45
+ return host.insertText(await renderSnippetForHost(host, snippet, options));
40
46
  }
package/dist/storage.d.ts CHANGED
@@ -13,7 +13,12 @@ export interface PersistedMacroState {
13
13
  export interface MacroStorage {
14
14
  /** `null` when there is no saved state or the saved state is unreadable. */
15
15
  load(): PersistedMacroState | null;
16
- save(state: PersistedMacroState): void;
16
+ /**
17
+ * Persists the state. Returns whether it actually landed — quota and
18
+ * serialization failures come back as `false`, never as a throw, so the
19
+ * caller can refuse to adopt an in-memory change its storage rejected.
20
+ */
21
+ save(state: PersistedMacroState): boolean;
17
22
  }
18
23
  export declare function emptyState(): PersistedMacroState;
19
24
  /**
@@ -33,13 +38,18 @@ export declare const IMPORT_LIMITS: {
33
38
  /** Snippet text and single recorded insert-text step. */
34
39
  readonly maxTextLength: 100000;
35
40
  readonly maxSourceLength: 200000;
41
+ /** A recorded command payload, serialized. Engine payloads are small config objects. */
42
+ readonly maxPayloadLength: 10000;
36
43
  };
37
44
  /**
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.
45
+ * Serializes state iff it passes the exact validation the loader applies
46
+ * including the whole-file size cap the loader enforces on read. `null`
47
+ * otherwise. The save paths hold this as an invariant: state that would be
48
+ * rejected on the next load must never be persisted — otherwise a single
49
+ * oversized save silently wipes everything at the next startup.
42
50
  */
51
+ export declare function serializePersistable(value: unknown): string | null;
52
+ /** Whether `serializePersistable` would accept the state. */
43
53
  export declare function isPersistableState(value: unknown): value is PersistedMacroState;
44
54
  /**
45
55
  * Parses saved/imported state. `null` on any unexpected shape, oversized
package/dist/storage.js CHANGED
@@ -18,6 +18,8 @@ export const IMPORT_LIMITS = {
18
18
  /** Snippet text and single recorded insert-text step. */
19
19
  maxTextLength: 100_000,
20
20
  maxSourceLength: 200_000,
21
+ /** A recorded command payload, serialized. Engine payloads are small config objects. */
22
+ maxPayloadLength: 10_000,
21
23
  };
22
24
  function boundedString(value, maxLength, allowEmpty = false) {
23
25
  return typeof value === 'string' && value.length <= maxLength && (allowEmpty || value.length > 0);
@@ -25,14 +27,26 @@ function boundedString(value, maxLength, allowEmpty = false) {
25
27
  function optionalBoundedString(value, maxLength) {
26
28
  return value === undefined || boundedString(value, maxLength);
27
29
  }
30
+ /** Whether a recorded payload is JSON-clean and bounded. Opaque otherwise — but not unlimited. */
31
+ function isValidPayload(payload) {
32
+ if (payload === undefined)
33
+ return true;
34
+ let json;
35
+ try {
36
+ json = JSON.stringify(payload);
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ return typeof json === 'string' && json.length <= IMPORT_LIMITS.maxPayloadLength;
42
+ }
28
43
  function isValidStep(value) {
29
44
  if (typeof value !== 'object' || value === null)
30
45
  return false;
31
46
  const step = value;
32
47
  switch (step.type) {
33
48
  case 'command':
34
- // The payload is opaque engine data; the id is what replay dispatches on.
35
- return boundedString(step.id, IMPORT_LIMITS.maxNameLength);
49
+ return boundedString(step.id, IMPORT_LIMITS.maxNameLength) && isValidPayload(step.payload);
36
50
  case 'insert-text':
37
51
  return boundedString(step.text, IMPORT_LIMITS.maxTextLength, true);
38
52
  case 'insert-paragraph':
@@ -92,13 +106,27 @@ function isValidState(value) {
92
106
  state.snippets.every(isValidSnippet));
93
107
  }
94
108
  /**
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.
109
+ * Serializes state iff it passes the exact validation the loader applies
110
+ * including the whole-file size cap the loader enforces on read. `null`
111
+ * otherwise. The save paths hold this as an invariant: state that would be
112
+ * rejected on the next load must never be persisted — otherwise a single
113
+ * oversized save silently wipes everything at the next startup.
99
114
  */
115
+ export function serializePersistable(value) {
116
+ if (!isValidState(value))
117
+ return null;
118
+ let json;
119
+ try {
120
+ json = JSON.stringify(value);
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ return json.length <= IMPORT_LIMITS.maxJsonLength ? json : null;
126
+ }
127
+ /** Whether `serializePersistable` would accept the state. */
100
128
  export function isPersistableState(value) {
101
- return isValidState(value);
129
+ return serializePersistable(value) !== null;
102
130
  }
103
131
  /**
104
132
  * Parses saved/imported state. `null` on any unexpected shape, oversized
@@ -142,10 +170,17 @@ export function createLocalStorage(key = DEFAULT_STORAGE_KEY, storage) {
142
170
  },
143
171
  save(state) {
144
172
  try {
145
- backing()?.setItem(key, JSON.stringify(state));
173
+ const store = backing();
174
+ if (!store)
175
+ return false;
176
+ store.setItem(key, JSON.stringify(state));
177
+ return true;
146
178
  }
147
179
  catch (error) {
180
+ // Quota or serialization — reported, not swallowed: the caller must
181
+ // know the change did not land.
148
182
  console.warn('[superdoc-macros] saving macros failed', error);
183
+ return false;
149
184
  }
150
185
  },
151
186
  };
@@ -157,6 +192,7 @@ export function createMemoryStorage() {
157
192
  load: () => (saved ? JSON.parse(JSON.stringify(saved)) : null),
158
193
  save(state) {
159
194
  saved = JSON.parse(JSON.stringify(state));
195
+ return true;
160
196
  },
161
197
  };
162
198
  }