superdoc-macros 0.7.1 → 0.9.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.
@@ -0,0 +1,35 @@
1
+ export declare const ZIP_LIMITS: {
2
+ readonly maxEntries: 5000;
3
+ /** Uncompressed size of any single part this reader will inflate. */
4
+ readonly maxEntryBytes: number;
5
+ readonly maxNameLength: 1024;
6
+ };
7
+ export interface ZipEntry {
8
+ readonly name: string;
9
+ readonly compressedSize: number;
10
+ readonly uncompressedSize: number;
11
+ }
12
+ export interface ZipReadOptions {
13
+ /**
14
+ * Hard ceiling on the bytes this read may produce, defaulting to
15
+ * `ZIP_LIMITS.maxEntryBytes`. Enforced *during* decompression, so a part
16
+ * that inflates past it is abandoned rather than buffered and then
17
+ * rejected. Callers who know a part should be small (a `.rels` file) should
18
+ * say so — that is the difference between refusing a bomb and hosting it.
19
+ */
20
+ maxBytes?: number;
21
+ }
22
+ export interface ZipArchive {
23
+ readonly entries: readonly ZipEntry[];
24
+ has(name: string): boolean;
25
+ /**
26
+ * The inflated bytes of one entry.
27
+ *
28
+ * @throws {VbaParseError} when the entry is absent, encrypted, compressed
29
+ * with an unsupported method, larger than the cap, or its data runs past
30
+ * the end of the file.
31
+ */
32
+ read(name: string, options?: ZipReadOptions): Promise<Uint8Array>;
33
+ }
34
+ /** Parses the central directory. Reading an entry's data stays lazy. */
35
+ export declare function openZip(bytes: Uint8Array): ZipArchive;
@@ -0,0 +1,253 @@
1
+ /**
2
+ * A read-only ZIP reader, enough to pull named parts out of an OOXML package.
3
+ *
4
+ * A `.docm` is a ZIP whose entries are the document's parts. To reach the
5
+ * macro project we need two or three specific parts by name, never the whole
6
+ * archive — so this reader parses the central directory up front (cheap) and
7
+ * inflates only what a caller asks for.
8
+ *
9
+ * Inflation uses the platform's own `DecompressionStream` — Baseline across
10
+ * browsers since 2023, and present in Node 18+. That keeps the toolkit
11
+ * dependency-free and puts the decompression itself in audited native code
12
+ * rather than in a hand-rolled inflater. Where it is genuinely absent, the
13
+ * reader says so rather than failing obscurely. Nothing else is assumed of the
14
+ * platform: no `Blob`, no `fetch`, no `Response` — only streams, so this also
15
+ * runs under jsdom, where a host's tests live.
16
+ *
17
+ * Safety posture, because these bytes come from a file someone was sent:
18
+ *
19
+ * - Entry count and name length are capped, and the per-part size cap is
20
+ * enforced against the bytes decompression actually produces — not against
21
+ * the size the archive claims, which a decompression bomb simply lies about.
22
+ * - Encrypted and ZIP64 entries are refused explicitly rather than guessed at.
23
+ * - Nothing here writes, and no entry name is ever used as a filesystem path,
24
+ * so the classic zip-slip traversal has no surface to attack.
25
+ */
26
+ import { VbaParseError } from './errors.js';
27
+ export const ZIP_LIMITS = {
28
+ maxEntries: 5_000,
29
+ /** Uncompressed size of any single part this reader will inflate. */
30
+ maxEntryBytes: 64 * 1024 * 1024,
31
+ maxNameLength: 1_024,
32
+ };
33
+ const EOCD_SIGNATURE = 0x06054b50;
34
+ const CENTRAL_SIGNATURE = 0x02014b50;
35
+ const LOCAL_SIGNATURE = 0x04034b50;
36
+ const ZIP64_LOCATOR_SIGNATURE = 0x07064b50;
37
+ const EOCD_MIN_BYTES = 22;
38
+ /** The trailing comment a ZIP may carry is at most 64 KiB. */
39
+ const MAX_COMMENT_BYTES = 0xffff;
40
+ const ZIP64_MARKER_32 = 0xffffffff;
41
+ const ZIP64_MARKER_16 = 0xffff;
42
+ const METHOD_STORED = 0;
43
+ const METHOD_DEFLATE = 8;
44
+ /** Parses the central directory. Reading an entry's data stays lazy. */
45
+ export function openZip(bytes) {
46
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
47
+ const u16 = (offset) => {
48
+ if (offset < 0 || offset + 2 > bytes.length) {
49
+ throw new VbaParseError('malformed', 'package: read past the end of the file');
50
+ }
51
+ return view.getUint16(offset, true);
52
+ };
53
+ const u32 = (offset) => {
54
+ if (offset < 0 || offset + 4 > bytes.length) {
55
+ throw new VbaParseError('malformed', 'package: read past the end of the file');
56
+ }
57
+ return view.getUint32(offset, true);
58
+ };
59
+ if (bytes.length < EOCD_MIN_BYTES) {
60
+ throw new VbaParseError('malformed', 'package: file is too short to be a ZIP archive');
61
+ }
62
+ // The end-of-central-directory record sits at the very end, behind an
63
+ // optional comment — so it has to be found by scanning backwards.
64
+ const scanFloor = Math.max(0, bytes.length - EOCD_MIN_BYTES - MAX_COMMENT_BYTES);
65
+ let eocd = -1;
66
+ for (let at = bytes.length - EOCD_MIN_BYTES; at >= scanFloor; at -= 1) {
67
+ if (view.getUint32(at, true) === EOCD_SIGNATURE) {
68
+ eocd = at;
69
+ break;
70
+ }
71
+ }
72
+ if (eocd < 0) {
73
+ throw new VbaParseError('malformed', 'package: not a ZIP archive (no end-of-directory record)');
74
+ }
75
+ const entryCount = u16(eocd + 10);
76
+ const directorySize = u32(eocd + 12);
77
+ const directoryOffset = u32(eocd + 16);
78
+ if (entryCount === ZIP64_MARKER_16 ||
79
+ directoryOffset === ZIP64_MARKER_32 ||
80
+ directorySize === ZIP64_MARKER_32 ||
81
+ (eocd >= 20 && view.getUint32(eocd - 20, true) === ZIP64_LOCATOR_SIGNATURE)) {
82
+ throw new VbaParseError('unsupported', 'package: ZIP64 archives are not supported');
83
+ }
84
+ if (entryCount > ZIP_LIMITS.maxEntries) {
85
+ throw new VbaParseError('too-large', 'package: too many entries');
86
+ }
87
+ if (directoryOffset + directorySize > bytes.length) {
88
+ throw new VbaParseError('malformed', 'package: central directory lies outside the file');
89
+ }
90
+ const decoder = new TextDecoder('utf-8');
91
+ const byName = new Map();
92
+ const entries = [];
93
+ const directoryEnd = directoryOffset + directorySize;
94
+ let at = directoryOffset;
95
+ for (let i = 0; i < entryCount; i += 1) {
96
+ // Confined to the region the archive declared for its directory, so a
97
+ // crafted file cannot have us read "entries" out of arbitrary bytes
98
+ // elsewhere in the package.
99
+ if (at + 46 > directoryEnd) {
100
+ throw new VbaParseError('malformed', 'package: central directory is shorter than it claims');
101
+ }
102
+ if (u32(at) !== CENTRAL_SIGNATURE) {
103
+ throw new VbaParseError('malformed', 'package: damaged central directory');
104
+ }
105
+ const flags = u16(at + 8);
106
+ const method = u16(at + 10);
107
+ const compressedSize = u32(at + 20);
108
+ const uncompressedSize = u32(at + 24);
109
+ const nameLength = u16(at + 28);
110
+ const extraLength = u16(at + 30);
111
+ const commentLength = u16(at + 32);
112
+ const localOffset = u32(at + 42);
113
+ if (nameLength > ZIP_LIMITS.maxNameLength) {
114
+ throw new VbaParseError('too-large', 'package: entry name is too long');
115
+ }
116
+ if (compressedSize === ZIP64_MARKER_32 || uncompressedSize === ZIP64_MARKER_32) {
117
+ throw new VbaParseError('unsupported', 'package: ZIP64 entry sizes are not supported');
118
+ }
119
+ const nameStart = at + 46;
120
+ if (nameStart + nameLength > directoryEnd) {
121
+ throw new VbaParseError('malformed', 'package: entry name lies outside the central directory');
122
+ }
123
+ // OOXML part names are ASCII, which UTF-8 and CP437 agree on — so the
124
+ // flag-11 distinction cannot change the names we look up.
125
+ const name = decoder.decode(bytes.subarray(nameStart, nameStart + nameLength));
126
+ const entry = {
127
+ name,
128
+ method,
129
+ compressedSize,
130
+ uncompressedSize,
131
+ localOffset,
132
+ encrypted: (flags & 0x0001) !== 0,
133
+ };
134
+ entries.push({ name, compressedSize, uncompressedSize });
135
+ // First entry wins, so a duplicated name cannot shadow the part that was
136
+ // already announced in `entries`.
137
+ if (!byName.has(name))
138
+ byName.set(name, entry);
139
+ at = nameStart + nameLength + extraLength + commentLength;
140
+ }
141
+ return {
142
+ entries,
143
+ has: (name) => byName.has(name),
144
+ async read(name, options) {
145
+ const entry = byName.get(name);
146
+ if (!entry) {
147
+ throw new VbaParseError('malformed', `package: no such part (${name})`);
148
+ }
149
+ if (entry.encrypted) {
150
+ throw new VbaParseError('unsupported', `package: ${name} is encrypted`);
151
+ }
152
+ const requested = options?.maxBytes;
153
+ const cap = Math.min(typeof requested === 'number' && Number.isFinite(requested) && requested > 0
154
+ ? Math.floor(requested)
155
+ : ZIP_LIMITS.maxEntryBytes, ZIP_LIMITS.maxEntryBytes);
156
+ // The declared size is only a claim, so it is a cheap early rejection
157
+ // rather than the real defence — the real one is enforced against the
158
+ // bytes actually produced, below.
159
+ if (entry.uncompressedSize > cap) {
160
+ throw new VbaParseError('too-large', `package: ${name} exceeds the part size cap`);
161
+ }
162
+ if (u32(entry.localOffset) !== LOCAL_SIGNATURE) {
163
+ throw new VbaParseError('malformed', `package: ${name} has a damaged local header`);
164
+ }
165
+ const localNameLength = u16(entry.localOffset + 26);
166
+ const localExtraLength = u16(entry.localOffset + 28);
167
+ const dataStart = entry.localOffset + 30 + localNameLength + localExtraLength;
168
+ const dataEnd = dataStart + entry.compressedSize;
169
+ if (dataEnd > bytes.length) {
170
+ throw new VbaParseError('malformed', `package: ${name} runs past the end of the file`);
171
+ }
172
+ const data = bytes.subarray(dataStart, dataEnd);
173
+ if (entry.method === METHOD_STORED) {
174
+ // A stored entry's real length is its compressed length; the declared
175
+ // uncompressed size may disagree, so the cap is applied to what we
176
+ // would actually return.
177
+ if (data.length > cap) {
178
+ throw new VbaParseError('too-large', `package: ${name} exceeds the part size cap`);
179
+ }
180
+ return data.slice();
181
+ }
182
+ if (entry.method !== METHOD_DEFLATE) {
183
+ throw new VbaParseError('unsupported', `package: ${name} uses compression method ${entry.method}`);
184
+ }
185
+ return inflateRaw(data, name, cap);
186
+ },
187
+ };
188
+ }
189
+ /**
190
+ * Inflates one entry, enforcing `cap` as the bytes arrive.
191
+ *
192
+ * Reading the stream chunk by chunk rather than with `Response.arrayBuffer()`
193
+ * is the whole point. Buffering first and checking the length afterwards
194
+ * means a part that declares a kilobyte and expands to a gigabyte has already
195
+ * been fully materialized by the time the limit is consulted — the classic
196
+ * decompression bomb. Here the read is abandoned the moment the total would
197
+ * exceed the cap.
198
+ */
199
+ async function inflateRaw(data, name, cap) {
200
+ if (typeof DecompressionStream !== 'function' || typeof ReadableStream !== 'function') {
201
+ throw new VbaParseError('unsupported', 'package: this environment cannot decompress ZIP entries');
202
+ }
203
+ // The bytes are wrapped in a stream directly rather than via
204
+ // `new Blob([data]).stream()`. Both work in a browser, but `Blob.stream` is
205
+ // one of the pieces jsdom does not implement — and a host's own test suite
206
+ // running in jsdom is a place this code has to work, not fall over.
207
+ // `BufferSource` and not `Uint8Array`: that is what `DecompressionStream`
208
+ // declares its writable side accepts, and `pipeThrough` matches on it.
209
+ const source = new ReadableStream({
210
+ start(controller) {
211
+ // The cast covers a modelling gap, not a real one: since TypeScript 5.7
212
+ // a bare `Uint8Array` may be backed by a `SharedArrayBuffer`, which
213
+ // `BufferSource` excludes. These bytes are a view over the caller's
214
+ // package, and the stream accepts any array-buffer view at runtime.
215
+ // Copying to prove it to the compiler would duplicate the whole part.
216
+ controller.enqueue(data);
217
+ controller.close();
218
+ },
219
+ });
220
+ const stream = source.pipeThrough(new DecompressionStream('deflate-raw'));
221
+ const reader = stream.getReader();
222
+ const chunks = [];
223
+ let total = 0;
224
+ try {
225
+ for (;;) {
226
+ const { done, value } = await reader.read();
227
+ if (done)
228
+ break;
229
+ if (!value)
230
+ continue;
231
+ total += value.byteLength;
232
+ if (total > cap) {
233
+ throw new VbaParseError('too-large', `package: ${name} inflates past the part size cap`);
234
+ }
235
+ chunks.push(value);
236
+ }
237
+ }
238
+ catch (error) {
239
+ // Release the decompressor before rethrowing, so an abandoned bomb does
240
+ // not leave a stream pulling in the background.
241
+ await reader.cancel().catch(() => undefined);
242
+ if (error instanceof VbaParseError)
243
+ throw error;
244
+ throw new VbaParseError('malformed', `package: ${name} could not be decompressed`);
245
+ }
246
+ const out = new Uint8Array(total);
247
+ let at = 0;
248
+ for (const chunk of chunks) {
249
+ out.set(chunk, at);
250
+ at += chunk.byteLength;
251
+ }
252
+ return out;
253
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type { MacroHost, MacroOutcome, MacroStep, RecordedMacro, SavedScript, SelectionSnapshot, Snippet, TextInputEvent, } from './types.js';
1
+ export type { BuiltinTool, BuiltinToolInfo, MacroHost, MacroOutcome, MacroStep, RecordedMacro, SavedScript, SelectionSnapshot, Snippet, TextInputEvent, } from './types.js';
2
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';
@@ -10,4 +10,14 @@ export { MacroRecorder, replayMacro, type RecorderOptions, type RecordingWarning
10
10
  export { renderSnippet, renderSnippetForHost, expandSnippet, usesSelection, type ExpandOptions, type RenderContext, } from './snippets/snippets.js';
11
11
  export { AutoText, type AutoTextOptions, type AutoTextExpansion } from './snippets/autotext.js';
12
12
  export { parseShortcut, eventMatches, bindShortcuts, shortcutSignatures, hasBindingModifier, isBindableKey, codesForKey, type ParsedShortcut, type ShortcutBinding, type ShortcutTarget, } from './shortcuts.js';
13
+ /**
14
+ * Reading the VBA macros already inside a `.docm`, for review only. Separate
15
+ * from `MacroKit` on purpose: extraction touches no saved macro, binds no
16
+ * shortcut, and executes nothing — see `src/import/vba.ts`.
17
+ */
18
+ export { extractVbaFromDocx, extractVbaProject, findVbaPart, scanForAutoRunProcedures, VBA_LIMITS, type VbaExtraction, type VbaFailureReason, type VbaModule, type VbaModuleKind, type VbaProcedureRef, type VbaProject, type VbaWarning, } from './import/vba.js';
19
+ export { VbaParseError, isVbaParseError, type VbaParseErrorCode } from './import/errors.js';
20
+ export { readCfb, CFB_LIMITS, type CfbContainer, type CfbEntry, type CfbEntryType } from './import/cfb.js';
21
+ export { openZip, ZIP_LIMITS, type ZipArchive, type ZipEntry } from './import/zip.js';
22
+ export { decompressOvba, OVBA_LIMITS, type DecompressOvbaOptions } from './import/ms-ovba.js';
13
23
  export { createLocalStorage, createMemoryStorage, emptyState, parsePersistedState, DEFAULT_STORAGE_KEY, IMPORT_LIMITS, isPersistableState, serializePersistable, type MacroStorage, type PersistedMacroState, } from './storage.js';
package/dist/index.js CHANGED
@@ -8,4 +8,14 @@ export { MacroRecorder, replayMacro, } from './recorder/recorder.js';
8
8
  export { renderSnippet, renderSnippetForHost, expandSnippet, usesSelection, } from './snippets/snippets.js';
9
9
  export { AutoText } from './snippets/autotext.js';
10
10
  export { parseShortcut, eventMatches, bindShortcuts, shortcutSignatures, hasBindingModifier, isBindableKey, codesForKey, } from './shortcuts.js';
11
+ /**
12
+ * Reading the VBA macros already inside a `.docm`, for review only. Separate
13
+ * from `MacroKit` on purpose: extraction touches no saved macro, binds no
14
+ * shortcut, and executes nothing — see `src/import/vba.ts`.
15
+ */
16
+ export { extractVbaFromDocx, extractVbaProject, findVbaPart, scanForAutoRunProcedures, VBA_LIMITS, } from './import/vba.js';
17
+ export { VbaParseError, isVbaParseError } from './import/errors.js';
18
+ export { readCfb, CFB_LIMITS } from './import/cfb.js';
19
+ export { openZip, ZIP_LIMITS } from './import/zip.js';
20
+ export { decompressOvba, OVBA_LIMITS } from './import/ms-ovba.js';
11
21
  export { createLocalStorage, createMemoryStorage, emptyState, parsePersistedState, DEFAULT_STORAGE_KEY, IMPORT_LIMITS, isPersistableState, serializePersistable, } from './storage.js';
package/dist/manager.d.ts CHANGED
@@ -14,7 +14,7 @@ import { type AutoTextOptions } from './snippets/autotext.js';
14
14
  import { type ExpandOptions } from './snippets/snippets.js';
15
15
  import { type ShortcutTarget } from './shortcuts.js';
16
16
  import { type MacroStorage } from './storage.js';
17
- import type { MacroHost, RecordedMacro, SavedScript, Snippet } from './types.js';
17
+ import type { BuiltinTool, BuiltinToolInfo, MacroHost, MacroOutcome, RecordedMacro, SavedScript, Snippet } from './types.js';
18
18
  export interface MacroKitOptions {
19
19
  host: MacroHost;
20
20
  /** Default: localStorage. */
@@ -66,6 +66,8 @@ export declare class MacroKit {
66
66
  private readonly runOptions;
67
67
  private readonly onLog?;
68
68
  private state;
69
+ /** Built-in tools, by id. Runtime registrations — never persisted (see BuiltinTool). */
70
+ private readonly tools;
69
71
  private readonly recorder;
70
72
  private readonly autoText;
71
73
  private readonly reservedSignatures;
@@ -160,6 +162,25 @@ export declare class MacroKit {
160
162
  /** Enables auto-text (trigger + space). Returns a disable function. */
161
163
  enableAutoText(): () => void;
162
164
  disableAutoText(): void;
165
+ /**
166
+ * Registers a built-in tool (see BuiltinTool in types.ts). Throws on a
167
+ * duplicate id or an invalid name — a silent replace would let two host
168
+ * modules fight over one id without anyone noticing.
169
+ */
170
+ registerTool(tool: BuiltinTool): void;
171
+ /** The registered tools, each with its persisted shortcut (if any). */
172
+ listTools(): readonly BuiltinToolInfo[];
173
+ /**
174
+ * Runs a registered tool under the same guard as scripts and replays: not
175
+ * while recording, and never two runs at once. A thrown error is reported
176
+ * as a failed outcome, never rethrown.
177
+ */
178
+ runTool(id: string): Promise<MacroOutcome>;
179
+ /**
180
+ * Sets or clears (undefined/empty) the persisted shortcut of a registered
181
+ * tool, under the exact validation of every other saved binding.
182
+ */
183
+ setToolShortcut(id: string, shortcut: string | undefined): void;
163
184
  /**
164
185
  * Binds the shortcuts of everything saved (scripts, recordings, snippets)
165
186
  * to a target — usually the editor container or `window`. The list is
package/dist/manager.js CHANGED
@@ -35,6 +35,8 @@ export class MacroKit {
35
35
  runOptions;
36
36
  onLog;
37
37
  state;
38
+ /** Built-in tools, by id. Runtime registrations — never persisted (see BuiltinTool). */
39
+ tools = new Map();
38
40
  recorder;
39
41
  autoText;
40
42
  reservedSignatures;
@@ -142,6 +144,22 @@ export class MacroKit {
142
144
  for (const signature of signatures)
143
145
  seen.add(signature);
144
146
  }
147
+ const toolShortcuts = this.state.toolShortcuts;
148
+ if (!toolShortcuts)
149
+ return;
150
+ for (const [toolId, shortcut] of Object.entries(toolShortcuts)) {
151
+ if (this.bindingIssue(shortcut)) {
152
+ delete toolShortcuts[toolId];
153
+ continue;
154
+ }
155
+ const signatures = shortcutSignatures(parseShortcut(shortcut));
156
+ if (signatures.some((signature) => seen.has(signature))) {
157
+ delete toolShortcuts[toolId];
158
+ continue;
159
+ }
160
+ for (const signature of signatures)
161
+ seen.add(signature);
162
+ }
145
163
  }
146
164
  findShortcutOwner(signatures, excludeId) {
147
165
  const items = [
@@ -159,6 +177,19 @@ export class MacroKit {
159
177
  if (existing.some((signature) => signatures.includes(signature)))
160
178
  return item.name;
161
179
  }
180
+ for (const [toolId, shortcut] of Object.entries(this.state.toolShortcuts ?? {})) {
181
+ if (toolId === excludeId)
182
+ continue;
183
+ const parsed = parseShortcut(shortcut);
184
+ if (!parsed)
185
+ continue;
186
+ const existing = shortcutSignatures(parsed);
187
+ if (existing.some((signature) => signatures.includes(signature))) {
188
+ // A stored shortcut can belong to a tool that is not registered in
189
+ // this session; its id is then the only name there is.
190
+ return this.tools.get(toolId)?.name ?? toolId;
191
+ }
192
+ }
162
193
  return null;
163
194
  }
164
195
  /** Throws a MacroError when the shortcut is unacceptable. The save paths call this. */
@@ -399,6 +430,80 @@ export class MacroKit {
399
430
  disableAutoText() {
400
431
  this.autoText.detach();
401
432
  }
433
+ /* ---------- Built-in tools ---------- */
434
+ /**
435
+ * Registers a built-in tool (see BuiltinTool in types.ts). Throws on a
436
+ * duplicate id or an invalid name — a silent replace would let two host
437
+ * modules fight over one id without anyone noticing.
438
+ */
439
+ registerTool(tool) {
440
+ if (!tool.id || tool.id.length > IMPORT_LIMITS.maxNameLength) {
441
+ throw new MacroError(macroMessages().fieldTooLong('id', IMPORT_LIMITS.maxNameLength), 'invalid-item');
442
+ }
443
+ this.requireItemLimits({ name: tool.name });
444
+ if (this.tools.has(tool.id)) {
445
+ const messages = macroMessages();
446
+ throw new MacroError(messages.toolAlreadyRegistered?.(tool.id) ?? messages.saveFailed, 'invalid-item');
447
+ }
448
+ this.tools.set(tool.id, tool);
449
+ }
450
+ /** The registered tools, each with its persisted shortcut (if any). */
451
+ listTools() {
452
+ return [...this.tools.values()].map((tool) => ({
453
+ id: tool.id,
454
+ name: tool.name,
455
+ ...(tool.description ? { description: tool.description } : {}),
456
+ ...(this.state.toolShortcuts?.[tool.id] ? { shortcut: this.state.toolShortcuts[tool.id] } : {}),
457
+ }));
458
+ }
459
+ /**
460
+ * Runs a registered tool under the same guard as scripts and replays: not
461
+ * while recording, and never two runs at once. A thrown error is reported
462
+ * as a failed outcome, never rethrown.
463
+ */
464
+ async runTool(id) {
465
+ const tool = this.tools.get(id);
466
+ if (!tool) {
467
+ const messages = macroMessages();
468
+ return { ok: false, message: messages.toolNotFound ?? messages.actionFailed, reason: 'tool-not-found' };
469
+ }
470
+ const guard = this.guardRun();
471
+ if (guard)
472
+ return { ok: false, message: guard.message };
473
+ this.running = true;
474
+ try {
475
+ return await tool.run();
476
+ }
477
+ catch (error) {
478
+ return { ok: false, message: error instanceof Error ? error.message : macroMessages().actionFailed, reason: 'threw' };
479
+ }
480
+ finally {
481
+ this.running = false;
482
+ }
483
+ }
484
+ /**
485
+ * Sets or clears (undefined/empty) the persisted shortcut of a registered
486
+ * tool, under the exact validation of every other saved binding.
487
+ */
488
+ setToolShortcut(id, shortcut) {
489
+ if (!this.tools.has(id)) {
490
+ const messages = macroMessages();
491
+ throw new MacroError(messages.toolNotFound ?? messages.actionFailed, 'tool-not-found');
492
+ }
493
+ const trimmed = shortcut?.trim();
494
+ if (trimmed) {
495
+ this.requireValidShortcut(trimmed, id);
496
+ this.requireItemLimits({ name: this.tools.get(id).name, shortcut: trimmed });
497
+ }
498
+ this.commit((draft) => {
499
+ const map = draft.toolShortcuts ?? {};
500
+ if (trimmed)
501
+ map[id] = trimmed;
502
+ else
503
+ delete map[id];
504
+ draft.toolShortcuts = map;
505
+ });
506
+ }
402
507
  /* ---------- Keyboard shortcuts ---------- */
403
508
  /**
404
509
  * Binds the shortcuts of everything saved (scripts, recordings, snippets)
@@ -428,6 +533,12 @@ export class MacroKit {
428
533
  if (snippet.shortcut)
429
534
  bindings.push({ shortcut: snippet.shortcut, run: () => this.expandSnippet(snippet.id) });
430
535
  }
536
+ for (const [toolId, shortcut] of Object.entries(this.state.toolShortcuts ?? {})) {
537
+ // Only registered tools bind: a stored shortcut of a tool the host did
538
+ // not register this session must not swallow the key.
539
+ if (this.tools.has(toolId))
540
+ bindings.push({ shortcut, run: () => this.runTool(toolId) });
541
+ }
431
542
  return bindings;
432
543
  }
433
544
  /* ---------- Import/export ---------- */
@@ -456,6 +567,9 @@ export class MacroKit {
456
567
  scripts: [...this.state.scripts.map((item) => ({ ...item }))],
457
568
  recordings: [...this.state.recordings.map((item) => ({ ...item }))],
458
569
  snippets: [...this.state.snippets.map((item) => ({ ...item }))],
570
+ ...(this.state.toolShortcuts || imported.toolShortcuts
571
+ ? { toolShortcuts: { ...this.state.toolShortcuts, ...imported.toolShortcuts } }
572
+ : {}),
459
573
  };
460
574
  for (const script of imported.scripts)
461
575
  this.upsert(candidate.scripts, script);
@@ -493,6 +607,10 @@ export class MacroKit {
493
607
  ...candidate.scripts,
494
608
  ...candidate.recordings,
495
609
  ...candidate.snippets,
610
+ ...Object.entries(candidate.toolShortcuts ?? {}).map(([toolId, shortcut]) => ({
611
+ name: this.tools.get(toolId)?.name ?? toolId,
612
+ shortcut,
613
+ })),
496
614
  ];
497
615
  for (const item of items) {
498
616
  if (!item.shortcut)
@@ -49,6 +49,12 @@ export interface MacroMessages {
49
49
  * objects compiled against 0.7.0.
50
50
  */
51
51
  recordingUncapturable?: (commandIds: string) => string;
52
+ /**
53
+ * Built-in tools. Optional for source compatibility with full locale
54
+ * objects compiled against 0.8.0 (same reasoning as recordingUncapturable).
55
+ */
56
+ toolNotFound?: string;
57
+ toolAlreadyRegistered?: (id: string) => string;
52
58
  shortcutInvalid: string;
53
59
  shortcutNeedsModifier: string;
54
60
  shortcutReserved: string;
package/dist/messages.js CHANGED
@@ -44,6 +44,8 @@ export const ENGLISH_MESSAGES = {
44
44
  recordingTooLarge: 'The recording is too large to save',
45
45
  recordingIncomplete: (commandIds) => `The recording is missing actions that cannot be recorded (${commandIds})`,
46
46
  recordingUncapturable: (commandIds) => `The recording contains only actions that cannot be recorded (${commandIds})`,
47
+ toolNotFound: 'Tool not found',
48
+ toolAlreadyRegistered: (id) => `A tool with the id "${id}" is already registered`,
47
49
  shortcutInvalid: 'Invalid shortcut — use a form like Ctrl+Alt+M',
48
50
  shortcutNeedsModifier: 'A shortcut must include Ctrl, Alt or Meta',
49
51
  shortcutReserved: 'This shortcut is reserved by the editor',
@@ -85,6 +87,8 @@ export const HEBREW_MESSAGES = {
85
87
  recordingTooLarge: 'ההקלטה גדולה מכדי להישמר',
86
88
  recordingIncomplete: (commandIds) => `בהקלטה חסרות פעולות שאינן ניתנות להקלטה (${commandIds})`,
87
89
  recordingUncapturable: (commandIds) => `ההקלטה מכילה רק פעולות שאינן ניתנות להקלטה (${commandIds})`,
90
+ toolNotFound: 'הכלי לא נמצא',
91
+ toolAlreadyRegistered: (id) => `כלי עם המזהה "${id}" כבר רשום`,
88
92
  shortcutInvalid: 'קיצור לא תקין — הצורה הנדרשת היא למשל Ctrl+Alt+M',
89
93
  shortcutNeedsModifier: 'קיצור חייב לכלול Ctrl,‏ Alt או Meta',
90
94
  shortcutReserved: 'הקיצור הזה שמור לעורך',
package/dist/storage.d.ts CHANGED
@@ -9,6 +9,12 @@ export interface PersistedMacroState {
9
9
  scripts: SavedScript[];
10
10
  recordings: RecordedMacro[];
11
11
  snippets: Snippet[];
12
+ /**
13
+ * Keyboard shortcuts of built-in tools, keyed by tool id. Optional for
14
+ * backward compatibility: stores written before tools existed lack it,
15
+ * and they must keep loading as-is.
16
+ */
17
+ toolShortcuts?: Record<string, string>;
12
18
  }
13
19
  export interface MacroStorage {
14
20
  /** `null` when there is no saved state or the saved state is unreadable. */
package/dist/storage.js CHANGED
@@ -90,11 +90,24 @@ function isValidSnippet(value) {
90
90
  optionalBoundedString(snippet.trigger, IMPORT_LIMITS.maxTriggerLength) &&
91
91
  optionalBoundedString(snippet.shortcut, IMPORT_LIMITS.maxShortcutLength));
92
92
  }
93
+ /** Tool-shortcut map: bounded ids to bounded shortcut strings, capped like the item lists. */
94
+ function isValidToolShortcuts(value) {
95
+ if (value === undefined)
96
+ return true;
97
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
98
+ return false;
99
+ const entries = Object.entries(value);
100
+ if (entries.length > IMPORT_LIMITS.maxItems)
101
+ return false;
102
+ return entries.every(([id, shortcut]) => boundedString(id, IMPORT_LIMITS.maxNameLength) &&
103
+ boundedString(shortcut, IMPORT_LIMITS.maxShortcutLength));
104
+ }
93
105
  function isValidState(value) {
94
106
  if (typeof value !== 'object' || value === null)
95
107
  return false;
96
108
  const state = value;
97
109
  return (state.version === 1 &&
110
+ isValidToolShortcuts(state.toolShortcuts) &&
98
111
  Array.isArray(state.scripts) &&
99
112
  state.scripts.length <= IMPORT_LIMITS.maxItems &&
100
113
  state.scripts.every(isValidScript) &&
package/dist/types.d.ts CHANGED
@@ -125,6 +125,31 @@ export interface RecordedMacro {
125
125
  shortcut?: string;
126
126
  steps: MacroStep[];
127
127
  }
128
+ /**
129
+ * A built-in tool the host registers on the kit: a native document-processing
130
+ * action (implemented by the host against its own engine access) that the kit
131
+ * exposes alongside recordings and scripts — listable in a management UI,
132
+ * runnable under the same one-run-at-a-time guard, and bindable to a
133
+ * persisted keyboard shortcut. Tools are runtime registrations, never
134
+ * persisted themselves; only their shortcuts are.
135
+ */
136
+ export interface BuiltinTool {
137
+ /** Stable identifier, e.g. `'shulchan.first-word'`. Shortcut persistence is keyed by it. */
138
+ id: string;
139
+ /** Display name. */
140
+ name: string;
141
+ /** One-line description for management UIs. */
142
+ description?: string;
143
+ /** Runs the tool. A thrown error is reported as a failed outcome. */
144
+ run(): Promise<MacroOutcome> | MacroOutcome;
145
+ }
146
+ /** A registered tool as listed to UIs — the registration plus its persisted shortcut. */
147
+ export interface BuiltinToolInfo {
148
+ id: string;
149
+ name: string;
150
+ description?: string;
151
+ shortcut?: string;
152
+ }
128
153
  /** A written macro — a JavaScript script that runs against the toolkit's API. */
129
154
  export interface SavedScript {
130
155
  id: string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "superdoc-macros",
3
- "version": "0.7.1",
4
- "description": "Macro toolkit for SuperDoc-based editors: sandboxed scripted macros, a Word-style macro recorder, and snippets with auto-text expansion.",
3
+ "version": "0.9.0",
4
+ "description": "Macro toolkit for SuperDoc-based editors: sandboxed scripted macros, a Word-style macro recorder, snippets with auto-text expansion, and read-only VBA import from .docm files.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",
@@ -34,6 +34,8 @@
34
34
  "snippets",
35
35
  "autotext",
36
36
  "docx",
37
+ "docm",
38
+ "vba",
37
39
  "editor",
38
40
  "otzaria"
39
41
  ],