superdoc-macros 0.4.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
@@ -82,6 +82,8 @@ Honest limits: the browser offers no per-iframe memory cap, and a host call that
82
82
 
83
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.
84
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
+
85
87
  ## 2. Macro recorder
86
88
 
87
89
  ```ts
@@ -136,7 +138,9 @@ kit.importState(json, { merge: true });
136
138
 
137
139
  `MacroStorage` is a two-method interface — implement it to persist to a file (e.g. a plugin workspace).
138
140
 
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.
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.
140
144
 
141
145
  ## Shortcut safety
142
146
 
package/dist/index.d.ts CHANGED
@@ -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';
11
+ export { AutoText, type AutoTextOptions, type AutoTextExpansion } from './snippets/autotext.js';
12
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';
13
+ export { createLocalStorage, createMemoryStorage, emptyState, parsePersistedState, DEFAULT_STORAGE_KEY, IMPORT_LIMITS, isPersistableState, type MacroStorage, type PersistedMacroState, } from './storage.js';
package/dist/index.js CHANGED
@@ -8,4 +8,4 @@ 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
10
  export { parseShortcut, eventMatches, bindShortcuts, shortcutSignatures, hasBindingModifier, } from './shortcuts.js';
11
- export { createLocalStorage, createMemoryStorage, emptyState, parsePersistedState, DEFAULT_STORAGE_KEY, IMPORT_LIMITS, } from './storage.js';
11
+ export { createLocalStorage, createMemoryStorage, emptyState, parsePersistedState, DEFAULT_STORAGE_KEY, IMPORT_LIMITS, isPersistableState, } from './storage.js';
package/dist/manager.d.ts CHANGED
@@ -38,6 +38,20 @@ export interface MacroKitOptions {
38
38
  * editor shortcut. Unparseable entries are ignored.
39
39
  */
40
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;
41
55
  }
42
56
  export type ShortcutValidation = {
43
57
  ok: true;
@@ -55,6 +69,7 @@ export declare class MacroKit {
55
69
  private readonly recorder;
56
70
  private readonly autoText;
57
71
  private readonly reservedSignatures;
72
+ private readonly scriptsEnabled;
58
73
  private running;
59
74
  constructor(options: MacroKitOptions);
60
75
  /**
@@ -123,6 +138,12 @@ export declare class MacroKit {
123
138
  * Imports JSON produced by `exportState`. With `merge: true` an imported
124
139
  * item with an existing `id` replaces it; without merge the whole state is
125
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.
126
147
  */
127
148
  importState(json: string, options?: {
128
149
  merge?: boolean;
@@ -130,7 +151,23 @@ export declare class MacroKit {
130
151
  ok: boolean;
131
152
  message?: string;
132
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;
133
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;
134
171
  private upsert;
135
172
  private persist;
136
173
  }
package/dist/manager.js CHANGED
@@ -13,6 +13,7 @@ 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 { IMPORT_LIMITS, isPersistableState } from './storage.js';
16
17
  import { bindShortcuts, hasBindingModifier, parseShortcut, shortcutSignatures, } from './shortcuts.js';
17
18
  import { MacroError } from './scripting/macro-api.js';
18
19
  import { createLocalStorage, emptyState, parsePersistedState } from './storage.js';
@@ -37,6 +38,7 @@ export class MacroKit {
37
38
  recorder;
38
39
  autoText;
39
40
  reservedSignatures;
41
+ scriptsEnabled;
40
42
  running = false;
41
43
  constructor(options) {
42
44
  this.host = options.host;
@@ -46,9 +48,19 @@ export class MacroKit {
46
48
  const runner = options.runner ?? 'iframe';
47
49
  this.runner =
48
50
  runner === 'iframe' ? createIframeRunner() : runner === 'eval' ? createEvalRunner() : runner;
51
+ this.scriptsEnabled = options.scriptsEnabled ?? true;
49
52
  this.state = this.storage.load() ?? emptyState();
50
- this.recorder = new MacroRecorder(this.host);
51
- this.autoText = new AutoText(this.host, () => this.state.snippets, options.autoText);
53
+ this.recorder = new MacroRecorder(this.host, { onAutoStop: options.onRecordingAutoStop });
54
+ this.autoText = new AutoText(this.host, () => this.state.snippets, {
55
+ ...options.autoText,
56
+ // The recorder rewrite keeps recordings truthful: the user typed a
57
+ // trigger word, the document holds the expanded text, and a replay of
58
+ // the raw keystrokes would diverge. See applyAutoTextExpansion.
59
+ onExpand: (snippet, expansion) => {
60
+ this.recorder.applyAutoTextExpansion(expansion.trigger.length + expansion.expandChar.length, expansion.rendered + expansion.expandChar);
61
+ options.autoText?.onExpand?.(snippet, expansion);
62
+ },
63
+ });
52
64
  const reserved = new Set();
53
65
  for (const shortcut of options.reservedShortcuts ?? []) {
54
66
  const parsed = parseShortcut(shortcut);
@@ -114,6 +126,8 @@ export class MacroKit {
114
126
  }
115
127
  saveScript(input) {
116
128
  this.requireValidShortcut(input.shortcut, input.id);
129
+ this.requireItemLimits({ name: input.name, source: input.source });
130
+ this.requireRoom(this.state.scripts, input.id);
117
131
  const script = {
118
132
  id: input.id ?? newId(),
119
133
  name: input.name,
@@ -136,6 +150,11 @@ export class MacroKit {
136
150
  }
137
151
  /** Runs an unsaved script — e.g. from the macro editor before saving. */
138
152
  async runSource(source) {
153
+ // The real gate: with scripts disabled nothing executes through any
154
+ // path — not a saved script, not an imported one, not its shortcut.
155
+ if (!this.scriptsEnabled) {
156
+ return { ok: false, reason: 'error', message: macroMessages().scriptsDisabled };
157
+ }
139
158
  const guard = this.guardRun();
140
159
  if (guard)
141
160
  return guard;
@@ -163,7 +182,9 @@ export class MacroKit {
163
182
  /** Stops and saves. `null` when no step was recorded — there is nothing to save. */
164
183
  stopRecording(name, shortcut) {
165
184
  this.requireValidShortcut(shortcut);
166
- const steps = this.recorder.stop();
185
+ this.requireItemLimits({ name });
186
+ this.requireRoom(this.state.recordings);
187
+ const steps = splitOversizedSteps(this.recorder.stop());
167
188
  if (steps.length === 0)
168
189
  return null;
169
190
  const recording = {
@@ -195,6 +216,8 @@ export class MacroKit {
195
216
  return null;
196
217
  if (input.shortcut !== undefined)
197
218
  this.requireValidShortcut(input.shortcut, input.id);
219
+ if (input.name !== undefined)
220
+ this.requireItemLimits({ name: input.name });
198
221
  if (input.name !== undefined)
199
222
  recording.name = input.name;
200
223
  if (input.shortcut !== undefined) {
@@ -233,6 +256,8 @@ export class MacroKit {
233
256
  }
234
257
  saveSnippet(input) {
235
258
  this.requireValidShortcut(input.shortcut, input.id);
259
+ this.requireItemLimits({ name: input.name, text: input.text, trigger: input.trigger });
260
+ this.requireRoom(this.state.snippets, input.id);
236
261
  const snippet = {
237
262
  id: input.id ?? newId(),
238
263
  name: input.name,
@@ -274,9 +299,14 @@ export class MacroKit {
274
299
  }
275
300
  currentBindings() {
276
301
  const bindings = [];
277
- for (const script of this.state.scripts) {
278
- if (script.shortcut)
279
- bindings.push({ shortcut: script.shortcut, run: () => this.runScript(script.id) });
302
+ // Part of the scripts gate: with scripts disabled their shortcuts are
303
+ // not bound at all — runScript would refuse anyway, but an unbound key
304
+ // is better than a key that swallows the event just to show an error.
305
+ if (this.scriptsEnabled) {
306
+ for (const script of this.state.scripts) {
307
+ if (script.shortcut)
308
+ bindings.push({ shortcut: script.shortcut, run: () => this.runScript(script.id) });
309
+ }
280
310
  }
281
311
  for (const recording of this.state.recordings) {
282
312
  if (recording.shortcut)
@@ -296,25 +326,81 @@ export class MacroKit {
296
326
  * Imports JSON produced by `exportState`. With `merge: true` an imported
297
327
  * item with an existing `id` replaces it; without merge the whole state is
298
328
  * replaced.
329
+ *
330
+ * The check is **atomic, on the final result**: a candidate state is built
331
+ * first, its size limits and every shortcut in it are validated (the same
332
+ * rules the save paths enforce — a file cannot smuggle in what typing
333
+ * cannot), and only a candidate that passed in full is committed. On any
334
+ * failure the current state is untouched.
299
335
  */
300
336
  importState(json, options = {}) {
301
337
  const imported = parsePersistedState(json);
302
338
  if (!imported)
303
339
  return { ok: false, message: macroMessages().invalidImport };
340
+ let candidate;
304
341
  if (options.merge) {
342
+ candidate = {
343
+ version: 1,
344
+ scripts: [...this.state.scripts.map((item) => ({ ...item }))],
345
+ recordings: [...this.state.recordings.map((item) => ({ ...item }))],
346
+ snippets: [...this.state.snippets.map((item) => ({ ...item }))],
347
+ };
305
348
  for (const script of imported.scripts)
306
- this.upsert(this.state.scripts, script);
349
+ this.upsert(candidate.scripts, script);
307
350
  for (const recording of imported.recordings)
308
- this.upsert(this.state.recordings, recording);
351
+ this.upsert(candidate.recordings, recording);
309
352
  for (const snippet of imported.snippets)
310
- this.upsert(this.state.snippets, snippet);
353
+ this.upsert(candidate.snippets, snippet);
311
354
  }
312
355
  else {
313
- this.state = imported;
356
+ candidate = imported;
314
357
  }
358
+ // The merged result can exceed what each file alone respected.
359
+ if (!isPersistableState(candidate))
360
+ return { ok: false, message: macroMessages().importTooLarge };
361
+ const shortcutsOk = this.validateStateShortcuts(candidate);
362
+ if (!shortcutsOk.ok)
363
+ return shortcutsOk;
364
+ this.state = candidate;
315
365
  this.persist();
316
366
  return { ok: true };
317
367
  }
368
+ /**
369
+ * Every shortcut in a candidate state, under the exact rules of
370
+ * `validateShortcut`: parseable, real modifier, not host-reserved, and
371
+ * unique within the candidate. `importState` is the only caller — the
372
+ * save paths enforce the same rules one item at a time.
373
+ */
374
+ validateStateShortcuts(candidate) {
375
+ const seen = new Map();
376
+ const items = [
377
+ ...candidate.scripts,
378
+ ...candidate.recordings,
379
+ ...candidate.snippets,
380
+ ];
381
+ for (const item of items) {
382
+ if (!item.shortcut)
383
+ continue;
384
+ const parsed = parseShortcut(item.shortcut);
385
+ if (!parsed) {
386
+ return { ok: false, message: macroMessages().importRejectedShortcut(item.name, macroMessages().shortcutInvalid) };
387
+ }
388
+ if (!hasBindingModifier(parsed)) {
389
+ return { ok: false, message: macroMessages().importRejectedShortcut(item.name, macroMessages().shortcutNeedsModifier) };
390
+ }
391
+ for (const signature of shortcutSignatures(parsed)) {
392
+ if (this.reservedSignatures.has(signature)) {
393
+ return { ok: false, message: macroMessages().importRejectedShortcut(item.name, macroMessages().shortcutReserved) };
394
+ }
395
+ const owner = seen.get(signature);
396
+ if (owner !== undefined) {
397
+ return { ok: false, message: macroMessages().importRejectedShortcut(item.name, macroMessages().shortcutTaken(owner)) };
398
+ }
399
+ seen.set(signature, item.name);
400
+ }
401
+ }
402
+ return { ok: true };
403
+ }
318
404
  /* ---------- Internal ---------- */
319
405
  guardRun() {
320
406
  if (this.recorder.recording) {
@@ -325,6 +411,37 @@ export class MacroKit {
325
411
  }
326
412
  return null;
327
413
  }
414
+ /**
415
+ * The save-path half of the persistence invariant: field lengths that the
416
+ * loader would reject are refused at the door. Without this, one oversized
417
+ * save would make the whole store unloadable — and the next startup would
418
+ * silently fall back to an empty state, losing everything.
419
+ */
420
+ requireItemLimits(fields) {
421
+ const limits = IMPORT_LIMITS;
422
+ if (fields.name.length === 0)
423
+ throw new MacroError(macroMessages().nameRequired, 'invalid-item');
424
+ if (fields.name.length > limits.maxNameLength) {
425
+ throw new MacroError(macroMessages().fieldTooLong('name', limits.maxNameLength), 'invalid-item');
426
+ }
427
+ if (fields.text !== undefined && fields.text.length > limits.maxTextLength) {
428
+ throw new MacroError(macroMessages().fieldTooLong('text', limits.maxTextLength), 'invalid-item');
429
+ }
430
+ if (fields.source !== undefined && fields.source.length > limits.maxSourceLength) {
431
+ throw new MacroError(macroMessages().fieldTooLong('source', limits.maxSourceLength), 'invalid-item');
432
+ }
433
+ if (fields.trigger !== undefined && fields.trigger.length > limits.maxTriggerLength) {
434
+ throw new MacroError(macroMessages().fieldTooLong('trigger', limits.maxTriggerLength), 'invalid-item');
435
+ }
436
+ }
437
+ /** The item-count half of the invariant. `existingId` exempts an in-place update. */
438
+ requireRoom(list, existingId) {
439
+ if (existingId && list.some((entry) => entry.id === existingId))
440
+ return;
441
+ if (list.length >= IMPORT_LIMITS.maxItems) {
442
+ throw new MacroError(macroMessages().tooManyItems, 'too-many-items');
443
+ }
444
+ }
328
445
  upsert(list, item) {
329
446
  const index = list.findIndex((entry) => entry.id === item.id);
330
447
  if (index >= 0)
@@ -333,6 +450,39 @@ export class MacroKit {
333
450
  list.push(item);
334
451
  }
335
452
  persist() {
453
+ // Final safety net for the invariant: state the loader would reject is
454
+ // never written. Reaching this branch is a bug in a save path above —
455
+ // the warning is what surfaces it — but the user's stored macros
456
+ // surviving that bug is the point.
457
+ if (!isPersistableState(this.state)) {
458
+ console.warn('[superdoc-macros] refusing to persist state that would fail to load');
459
+ return;
460
+ }
336
461
  this.storage.save(this.state);
337
462
  }
338
463
  }
464
+ /**
465
+ * A recorded insert-text step can exceed the loader's per-step cap (one huge
466
+ * paste coalesces into one step). Splitting preserves the exact text while
467
+ * keeping the recording loadable.
468
+ */
469
+ function splitOversizedSteps(steps) {
470
+ const max = IMPORT_LIMITS.maxTextLength;
471
+ const split = steps.flatMap((step) => {
472
+ if (step.type !== 'insert-text' || step.text.length <= max)
473
+ return [step];
474
+ const chunks = [];
475
+ for (let offset = 0; offset < step.text.length; offset += max) {
476
+ chunks.push({ type: 'insert-text', text: step.text.slice(offset, offset + max) });
477
+ }
478
+ return chunks;
479
+ });
480
+ // Splitting can push a cap-length recording past the loader's step limit.
481
+ // Truncating the tail is the honest option left: the alternative is a
482
+ // recording the loader rejects, which loses the whole store's worth more.
483
+ if (split.length > IMPORT_LIMITS.maxStepsPerRecording) {
484
+ console.warn('[superdoc-macros] recording truncated to the step limit');
485
+ return split.slice(0, IMPORT_LIMITS.maxStepsPerRecording);
486
+ }
487
+ return split;
488
+ }
@@ -33,7 +33,13 @@ export interface MacroMessages {
33
33
  snippetNotFound: string;
34
34
  cannotRunWhileRecording: string;
35
35
  anotherMacroRunning: string;
36
+ scriptsDisabled: string;
37
+ nameRequired: string;
36
38
  invalidImport: string;
39
+ importRejectedShortcut: (itemName: string, detail: string) => string;
40
+ importTooLarge: string;
41
+ tooManyItems: string;
42
+ fieldTooLong: (field: string, max: number) => string;
37
43
  shortcutInvalid: string;
38
44
  shortcutNeedsModifier: string;
39
45
  shortcutReserved: string;
package/dist/messages.js CHANGED
@@ -33,7 +33,13 @@ export const ENGLISH_MESSAGES = {
33
33
  snippetNotFound: 'Snippet not found',
34
34
  cannotRunWhileRecording: 'Cannot run a macro while recording',
35
35
  anotherMacroRunning: 'Another macro is still running',
36
+ scriptsDisabled: 'Scripted macros are disabled',
36
37
  invalidImport: 'The file is not a valid macro export',
38
+ importRejectedShortcut: (itemName, detail) => `Import rejected: the shortcut of "${itemName}" is not acceptable — ${detail}`,
39
+ importTooLarge: 'Import rejected: the merged result exceeds the item limits',
40
+ tooManyItems: 'The list is full — delete items before adding new ones',
41
+ nameRequired: 'A name is required',
42
+ fieldTooLong: (field, max) => `${field} is too long (limit: ${max} characters)`,
37
43
  shortcutInvalid: 'Invalid shortcut — use a form like Ctrl+Alt+M',
38
44
  shortcutNeedsModifier: 'A shortcut must include Ctrl, Alt or Meta',
39
45
  shortcutReserved: 'This shortcut is reserved by the editor',
@@ -64,7 +70,13 @@ export const HEBREW_MESSAGES = {
64
70
  snippetNotFound: 'הקטע לא נמצא',
65
71
  cannotRunWhileRecording: 'אי אפשר להריץ מאקרו בזמן הקלטה',
66
72
  anotherMacroRunning: 'מאקרו אחר עדיין רץ',
73
+ scriptsDisabled: 'מאקרו כתובים מושבתים',
67
74
  invalidImport: 'הקובץ אינו ייצוא מאקרו תקין',
75
+ importRejectedShortcut: (itemName, detail) => `הייבוא נדחה: הקיצור של "${itemName}" אינו קביל — ${detail}`,
76
+ importTooLarge: 'הייבוא נדחה: התוצאה הממוזגת חורגת מתקרת הפריטים',
77
+ tooManyItems: 'הרשימה מלאה — יש למחוק פריטים לפני הוספה',
78
+ nameRequired: 'חובה לתת שם',
79
+ fieldTooLong: (field, max) => `${field} ארוך מדי (התקרה: ${max} תווים)`,
68
80
  shortcutInvalid: 'קיצור לא תקין — הצורה הנדרשת היא למשל Ctrl+Alt+M',
69
81
  shortcutNeedsModifier: 'קיצור חייב לכלול Ctrl,‏ Alt או Meta',
70
82
  shortcutReserved: 'הקיצור הזה שמור לעורך',
@@ -18,11 +18,19 @@ export interface RecorderOptions {
18
18
  shouldRecordCommand?: (id: string) => boolean;
19
19
  /** Step cap per recording, against a recording left running by mistake. */
20
20
  maxSteps?: number;
21
+ /**
22
+ * Called when the step cap stops the recording. The steps are kept —
23
+ * `stop()` still returns them — but listening has ceased, and a UI that
24
+ * shows "recording" must be told, or its indicator would keep promising a
25
+ * recording that is no longer happening.
26
+ */
27
+ onAutoStop?: () => void;
21
28
  }
22
29
  export declare class MacroRecorder {
23
30
  private readonly host;
24
31
  private readonly shouldRecordCommand;
25
32
  private readonly maxSteps;
33
+ private readonly onAutoStop?;
26
34
  private steps;
27
35
  private disposers;
28
36
  private active;
@@ -30,10 +38,28 @@ export declare class MacroRecorder {
30
38
  get recording(): boolean;
31
39
  get stepCount(): number;
32
40
  start(): void;
33
- /** Stops and returns the steps. Empty when nothing was recorded. */
41
+ /**
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.
45
+ */
34
46
  stop(): MacroStep[];
35
47
  /** Stops and discards whatever was recorded. */
36
48
  cancel(): void;
49
+ /**
50
+ * Rewrites the recorded tail after an auto-text expansion: the user typed
51
+ * a trigger word plus the expansion character, but what the document now
52
+ * holds is the expanded text — a replay of the raw keystrokes would
53
+ * diverge (and would depend on auto-text being active at replay time).
54
+ * The trailing `consumed` characters are removed from the recorded
55
+ * insert-text steps and the expanded text is recorded in their place.
56
+ *
57
+ * If the tail does not hold `consumed` plain characters (a command landed
58
+ * mid-word, or the recording started mid-trigger), the rewrite is skipped
59
+ * and the raw keystrokes stay — a truthful raw recording beats a guessed
60
+ * edit of steps that do not match.
61
+ */
62
+ applyAutoTextExpansion(consumed: number, replacement: string): void;
37
63
  private teardown;
38
64
  private push;
39
65
  private recordCommand;
@@ -7,6 +7,7 @@ export class MacroRecorder {
7
7
  host;
8
8
  shouldRecordCommand;
9
9
  maxSteps;
10
+ onAutoStop;
10
11
  steps = [];
11
12
  disposers = [];
12
13
  active = false;
@@ -14,6 +15,7 @@ export class MacroRecorder {
14
15
  this.host = host;
15
16
  this.shouldRecordCommand = options.shouldRecordCommand ?? defaultShouldRecord;
16
17
  this.maxSteps = options.maxSteps ?? DEFAULT_MAX_STEPS;
18
+ this.onAutoStop = options.onAutoStop;
17
19
  }
18
20
  get recording() {
19
21
  return this.active;
@@ -31,10 +33,12 @@ export class MacroRecorder {
31
33
  this.host.onTextInput((event) => this.recordTextInput(event)),
32
34
  ];
33
35
  }
34
- /** Stops and returns the steps. Empty when nothing was recorded. */
36
+ /**
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.
40
+ */
35
41
  stop() {
36
- if (!this.active)
37
- return [];
38
42
  this.teardown();
39
43
  const recorded = this.steps;
40
44
  this.steps = [];
@@ -42,22 +46,64 @@ export class MacroRecorder {
42
46
  }
43
47
  /** Stops and discards whatever was recorded. */
44
48
  cancel() {
45
- if (!this.active)
46
- return;
47
49
  this.teardown();
48
50
  this.steps = [];
49
51
  }
52
+ /**
53
+ * Rewrites the recorded tail after an auto-text expansion: the user typed
54
+ * a trigger word plus the expansion character, but what the document now
55
+ * holds is the expanded text — a replay of the raw keystrokes would
56
+ * diverge (and would depend on auto-text being active at replay time).
57
+ * The trailing `consumed` characters are removed from the recorded
58
+ * insert-text steps and the expanded text is recorded in their place.
59
+ *
60
+ * If the tail does not hold `consumed` plain characters (a command landed
61
+ * mid-word, or the recording started mid-trigger), the rewrite is skipped
62
+ * and the raw keystrokes stay — a truthful raw recording beats a guessed
63
+ * edit of steps that do not match.
64
+ */
65
+ applyAutoTextExpansion(consumed, replacement) {
66
+ if (!this.active || consumed <= 0)
67
+ return;
68
+ // Verify the tail is entirely typed text before touching anything.
69
+ let remaining = consumed;
70
+ let index = this.steps.length - 1;
71
+ while (remaining > 0 && index >= 0) {
72
+ const step = this.steps[index];
73
+ if (step?.type !== 'insert-text')
74
+ return;
75
+ remaining -= step.text.length;
76
+ index -= 1;
77
+ }
78
+ if (remaining > 0)
79
+ return;
80
+ let toRemove = consumed;
81
+ while (toRemove > 0) {
82
+ const last = this.steps[this.steps.length - 1];
83
+ if (last?.type !== 'insert-text')
84
+ return; // unreachable after the check above
85
+ if (last.text.length > toRemove) {
86
+ last.text = last.text.slice(0, -toRemove);
87
+ break;
88
+ }
89
+ toRemove -= last.text.length;
90
+ this.steps.pop();
91
+ }
92
+ this.recordTextInput({ kind: 'insert-text', text: replacement });
93
+ }
50
94
  teardown() {
51
95
  this.active = false;
52
96
  for (const dispose of this.disposers.splice(0))
53
97
  dispose();
54
98
  }
55
99
  push(step) {
100
+ this.steps.push(step);
56
101
  if (this.steps.length >= this.maxSteps) {
102
+ // The cap stops the *listening*, not the data: the steps stay for
103
+ // stop() to collect, and the owner is told the recording ended.
57
104
  this.teardown();
58
- return;
105
+ this.onAutoStop?.();
59
106
  }
60
- this.steps.push(step);
61
107
  }
62
108
  recordCommand(id, payload) {
63
109
  if (!this.shouldRecordCommand(id))
@@ -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
@@ -34,6 +34,13 @@ export declare const IMPORT_LIMITS: {
34
34
  readonly maxTextLength: 100000;
35
35
  readonly maxSourceLength: 200000;
36
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;
37
44
  /**
38
45
  * Parses saved/imported state. `null` on any unexpected shape, oversized
39
46
  * field or oversized file — never throws, never partially accepts: one
package/dist/storage.js CHANGED
@@ -91,6 +91,15 @@ function isValidState(value) {
91
91
  state.snippets.length <= IMPORT_LIMITS.maxItems &&
92
92
  state.snippets.every(isValidSnippet));
93
93
  }
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
+ }
94
103
  /**
95
104
  * Parses saved/imported state. `null` on any unexpected shape, oversized
96
105
  * field or oversized file — never throws, never partially accepts: one
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superdoc-macros",
3
- "version": "0.4.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",