superdoc-macros 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { type MacroRunner } from './runner.js';
2
- /** סימון ההודעות של הפרוטוקול, כדי לא להתנגש בהודעות אחרות בדף. */
2
+ /** Protocol marker, so the messages cannot collide with others on the page. */
3
3
  export declare const PROTOCOL_MARK: "__otzariaMacro";
4
4
  export type SandboxMessage = {
5
5
  [PROTOCOL_MARK]: true;
@@ -31,11 +31,12 @@ export type HostMessage = {
31
31
  value?: unknown;
32
32
  message?: string;
33
33
  };
34
- /** האם הודעה שייכת לפרוטוקול. חשופה לבדיקות. */
34
+ /** Whether a message belongs to the protocol. Exposed for tests. */
35
35
  export declare function isProtocolMessage(data: unknown): data is SandboxMessage;
36
36
  /**
37
- * הקוד שרץ בתוך ה-iframe. מחרוזת ולא פונקציה מוסרלת כדי שה-build לא ישנה
38
- * אותו (minify של שמות היה שובר את הפרוטוקול).
37
+ * The code that runs inside the iframe. A string rather than a serialized
38
+ * function, so the build cannot touch it (minifying names would break the
39
+ * protocol).
39
40
  */
40
41
  export declare const SANDBOX_BOOTSTRAP: string;
41
42
  export declare function createIframeRunner(doc?: Document): MacroRunner;
@@ -1,8 +1,26 @@
1
- import { limitCalls } from './eval-runner.js';
1
+ /**
2
+ * Sandboxed script runner — an iframe with `sandbox="allow-scripts"` only.
3
+ *
4
+ * The iframe gets an opaque origin: no access to the page's DOM, to
5
+ * localStorage, to cookies, or to the network with the user's credentials.
6
+ * Its only way to touch the document is RPC over postMessage to the
7
+ * `MacroApi` methods — every call goes through `bridge.call`, which enforces
8
+ * a closed method list and a call cap.
9
+ *
10
+ * The time cap here is real: when it expires the iframe is removed from the
11
+ * DOM, which also kills an infinite synchronous loop — it runs on the
12
+ * iframe's event loop, not the page's.
13
+ *
14
+ * Return values and arguments cross a structured-clone boundary; the API is
15
+ * already shaped so everything it returns is JSON-safe (see
16
+ * `ScriptSelection`).
17
+ */
18
+ import { macroMessages } from '../messages.js';
19
+ import { limitCalls, revocable } from './eval-runner.js';
2
20
  import { DEFAULT_MAX_API_CALLS, DEFAULT_TIMEOUT_MS, } from './runner.js';
3
- /** סימון ההודעות של הפרוטוקול, כדי לא להתנגש בהודעות אחרות בדף. */
21
+ /** Protocol marker, so the messages cannot collide with others on the page. */
4
22
  export const PROTOCOL_MARK = '__otzariaMacro';
5
- /** האם הודעה שייכת לפרוטוקול. חשופה לבדיקות. */
23
+ /** Whether a message belongs to the protocol. Exposed for tests. */
6
24
  export function isProtocolMessage(data) {
7
25
  return (typeof data === 'object' &&
8
26
  data !== null &&
@@ -10,8 +28,9 @@ export function isProtocolMessage(data) {
10
28
  typeof data.kind === 'string');
11
29
  }
12
30
  /**
13
- * הקוד שרץ בתוך ה-iframe. מחרוזת ולא פונקציה מוסרלת כדי שה-build לא ישנה
14
- * אותו (minify של שמות היה שובר את הפרוטוקול).
31
+ * The code that runs inside the iframe. A string rather than a serialized
32
+ * function, so the build cannot touch it (minifying names would break the
33
+ * protocol).
15
34
  */
16
35
  export const SANDBOX_BOOTSTRAP = `
17
36
  'use strict';
@@ -28,7 +47,7 @@ export const SANDBOX_BOOTSTRAP = `
28
47
  var api = new Proxy({}, {
29
48
  get: function (_target, method) {
30
49
  if (typeof method !== 'string') return undefined;
31
- if (method === 'then') return undefined; // ש-await api לא יתפרש כ-thenable
50
+ if (method === 'then') return undefined; // so "await api" is not treated as a thenable
32
51
  return function () {
33
52
  var args = Array.prototype.slice.call(arguments);
34
53
  return new Promise(function (resolve, reject) {
@@ -75,7 +94,7 @@ export const SANDBOX_BOOTSTRAP = `
75
94
  post({ kind: 'ready' });
76
95
  })();
77
96
  `;
78
- /** ערך בטוח למסירה חזרה ל-iframe (structured clone עלול להיכשל על אובייקטי מנוע). */
97
+ /** A value safe to hand back to the iframe (structured clone can fail on engine objects). */
79
98
  function toCloneSafe(value) {
80
99
  if (value === undefined || value === null)
81
100
  return value;
@@ -90,25 +109,36 @@ export function createIframeRunner(doc = document) {
90
109
  return {
91
110
  run(source, bridge, options = {}) {
92
111
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
93
- 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));
94
116
  return new Promise((resolve) => {
95
117
  const iframe = doc.createElement('iframe');
96
118
  iframe.setAttribute('sandbox', 'allow-scripts');
97
119
  iframe.style.display = 'none';
98
- 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>`;
99
128
  let settled = false;
100
129
  let timer;
101
130
  const finish = (result) => {
102
131
  if (settled)
103
132
  return;
104
133
  settled = true;
134
+ revoke();
105
135
  clearTimeout(timer);
106
136
  removeEventListener('message', onMessage);
107
137
  iframe.remove();
108
138
  resolve(result);
109
139
  };
110
140
  const onMessage = (event) => {
111
- // רק הודעות מה-iframe הזה: דף יכול להריץ כמה מאקרו במקביל.
141
+ // Only messages from this iframe: a page may run several macros at once.
112
142
  if (event.source !== iframe.contentWindow)
113
143
  return;
114
144
  const data = event.data;
@@ -120,7 +150,7 @@ export function createIframeRunner(doc = document) {
120
150
  }
121
151
  if (data.kind === 'call') {
122
152
  const { id, method, args } = data;
123
- limited
153
+ guarded
124
154
  .call(method, args)
125
155
  .then((value) => {
126
156
  iframe.contentWindow?.postMessage({ [PROTOCOL_MARK]: true, kind: 'result', id, ok: true, value: toCloneSafe(value) }, '*');
@@ -142,7 +172,7 @@ export function createIframeRunner(doc = document) {
142
172
  finish({ ok: false, reason: 'error', message: data.message });
143
173
  };
144
174
  addEventListener('message', onMessage);
145
- timer = setTimeout(() => finish({ ok: false, reason: 'timeout', message: `המאקרו לא הסתיים תוך ${timeoutMs / 1000} שניות ונעצר` }), timeoutMs);
175
+ timer = setTimeout(() => finish({ ok: false, reason: 'timeout', message: macroMessages().timedOut(timeoutMs / 1000) }), timeoutMs);
146
176
  doc.body.appendChild(iframe);
147
177
  });
148
178
  },
@@ -1,34 +1,23 @@
1
- /**
2
- * ה-API שסקריפט מאקרו מקבל.
3
- *
4
- * שני צרכנים לאותו מימוש: מריץ ה-eval מקבל את האובייקט `api` ישירות, ומריץ
5
- * ה-iframe מדבר איתו דרך `call(method, args)` — RPC על postMessage. לכן כל
6
- * מתודה רשומה במילון אחד, וה-proxy בתוך ה-iframe פונה לאותם שמות בדיוק.
7
- *
8
- * כללי כשל: פעולות כתיבה זורקות `MacroError` כשהן נכשלות, כדי שסקריפט ייעצר
9
- * במקום להמשיך על מסמך במצב לא צפוי. `command()` הגולמית מחזירה את התוצאה
10
- * ואינה זורקת — למי שרוצה לבדוק בעצמו.
11
- */
12
1
  import type { MacroHost, MacroOutcome } from '../types.js';
13
- /** כשל של פעולת מאקרו. השם מאפשר לסקריפט להבחין בינו ובין TypeError שלו. */
2
+ /** A macro operation failure. Named so a script can tell it apart from its own TypeError. */
14
3
  export declare class MacroError extends Error {
15
4
  readonly reason?: string;
16
5
  constructor(message: string, reason?: string);
17
6
  }
18
- /** תצלום בחירה בטוח למסירה ל-iframe (בלי היעד האטום של המנוע). */
7
+ /** Selection snapshot that is safe to hand to the iframe (without the engine's opaque target). */
19
8
  export interface ScriptSelection {
20
9
  text: string;
21
10
  hasRange: boolean;
22
11
  blockId: string | null;
23
12
  empty: boolean;
24
13
  }
25
- /** מה שסקריפט מקבל בתור `api`. כל המתודות א-סינכרוניות. */
14
+ /** What a script receives as `api`. Every method is async. */
26
15
  export interface MacroApi {
27
- /** מריצה פקודה מקטלוג המנוע. מחזירה תוצאה ואינה זורקת. */
16
+ /** Runs a command from the engine catalog. Returns the outcome, never throws. */
28
17
  command(id: string, payload?: unknown): Promise<MacroOutcome>;
29
- /** האם המנוע מכיר את הפקודה. */
18
+ /** Whether the engine recognizes the command. */
30
19
  hasCommand(id: string): Promise<boolean>;
31
- /** מזהי הפקודות המוכרות. */
20
+ /** The known command ids. */
32
21
  commandIds(): Promise<readonly string[]>;
33
22
  insertText(text: string): Promise<void>;
34
23
  insertParagraph(): Promise<void>;
@@ -36,7 +25,7 @@ export interface MacroApi {
36
25
  getSelection(): Promise<ScriptSelection>;
37
26
  getSelectionText(): Promise<string>;
38
27
  getDocumentText(): Promise<string>;
39
- /** מחליפה את כל המופעים. מחזירה כמה הוחלפו. */
28
+ /** Replaces every occurrence. Returns how many were replaced. */
40
29
  replaceAll(query: string, replacement: string): Promise<number>;
41
30
  bold(): Promise<void>;
42
31
  italic(): Promise<void>;
@@ -51,19 +40,19 @@ export interface MacroApi {
51
40
  directionLtr(): Promise<void>;
52
41
  undo(): Promise<void>;
53
42
  redo(): Promise<void>;
54
- /** כותבת שורה ליומן הריצה (מוצג למשתמש, לא ל-console). */
43
+ /** Writes a line to the run log (shown to the user, not to the console). */
55
44
  log(...parts: unknown[]): Promise<void>;
56
45
  }
57
46
  export interface MacroApiOptions {
58
- /** מקבלת כל שורת `api.log`. ברירת המחדל: console.info. */
47
+ /** Receives every `api.log` line. Default: console.info. */
59
48
  onLog?: (line: string) => void;
60
49
  }
61
50
  export interface MacroBridge {
62
51
  api: MacroApi;
63
- /** מסלול ה-RPC: מפעילה מתודה לפי שם. זורקת על מתודה שאינה קיימת. */
52
+ /** The RPC path: invokes a method by name. Throws on an unknown method. */
64
53
  call(method: string, args: readonly unknown[]): Promise<unknown>;
65
- /** מספר הקריאות שבוצעו עד כה. משמש לתקרת קריאות במריצים. */
54
+ /** Number of calls made so far. Used by the runners' call limit. */
66
55
  callCount(): number;
67
56
  }
68
- /** בונה את ה-API מעל מארח. */
57
+ /** Builds the API on top of a host. */
69
58
  export declare function createMacroApi(host: MacroHost, options?: MacroApiOptions): MacroBridge;
@@ -1,4 +1,19 @@
1
- /** כשל של פעולת מאקרו. השם מאפשר לסקריפט להבחין בינו ובין TypeError שלו. */
1
+ /**
2
+ * The API a macro script receives.
3
+ *
4
+ * Two consumers share one implementation: the eval runner hands the `api`
5
+ * object to the script directly, and the iframe runner talks to it through
6
+ * `call(method, args)` — RPC over postMessage. Every method therefore lives
7
+ * in a single dictionary, and the proxy inside the iframe addresses exactly
8
+ * the same names.
9
+ *
10
+ * Failure rules: write operations throw a `MacroError` when they fail, so a
11
+ * script stops instead of continuing against a document in an unexpected
12
+ * state. The raw `command()` returns the outcome and does not throw — for
13
+ * scripts that want to check it themselves.
14
+ */
15
+ import { macroMessages } from '../messages.js';
16
+ /** A macro operation failure. Named so a script can tell it apart from its own TypeError. */
2
17
  export class MacroError extends Error {
3
18
  reason;
4
19
  constructor(message, reason) {
@@ -14,7 +29,7 @@ function requireOk(outcome, action) {
14
29
  }
15
30
  function asText(value, name) {
16
31
  if (typeof value !== 'string')
17
- throw new MacroError(`${name} חייב להיות מחרוזת`);
32
+ throw new MacroError(macroMessages().mustBeString(name));
18
33
  return value;
19
34
  }
20
35
  function formatLogPart(part) {
@@ -27,27 +42,27 @@ function formatLogPart(part) {
27
42
  return String(part);
28
43
  }
29
44
  }
30
- /** בונה את ה-API מעל מארח. */
45
+ /** Builds the API on top of a host. */
31
46
  export function createMacroApi(host, options = {}) {
32
47
  const onLog = options.onLog ?? ((line) => console.info('[superdoc-macros]', line));
33
48
  const commandSugar = async (id) => {
34
- requireOk(await host.commands.execute(id), `הפקודה ${id} נכשלה`);
49
+ requireOk(await host.commands.execute(id), macroMessages().commandFailed(id));
35
50
  };
36
51
  const api = {
37
52
  command: (id, payload) => host.commands.execute(asText(id, 'id'), payload),
38
53
  hasCommand: async (id) => host.commands.has(asText(id, 'id')),
39
54
  commandIds: async () => host.commands.ids(),
40
55
  async insertText(text) {
41
- requireOk(await host.insertText(asText(text, 'text')), 'הכנסת הטקסט נכשלה');
56
+ requireOk(await host.insertText(asText(text, 'text')), macroMessages().insertTextFailed);
42
57
  },
43
58
  async insertParagraph() {
44
- requireOk(await host.insertText('\n'), 'הכנסת הפסקה נכשלה');
59
+ requireOk(await host.insertText('\n'), macroMessages().insertParagraphFailed);
45
60
  },
46
61
  async deleteBackward(count = 1) {
47
62
  const n = Math.max(0, Math.trunc(Number(count)));
48
63
  if (n === 0)
49
64
  return;
50
- requireOk(await host.deleteBackward(n), 'המחיקה נכשלה');
65
+ requireOk(await host.deleteBackward(n), macroMessages().deleteFailed);
51
66
  },
52
67
  async getSelection() {
53
68
  const snapshot = await host.getSelection({ includeText: true });
@@ -65,7 +80,7 @@ export function createMacroApi(host, options = {}) {
65
80
  async replaceAll(query, replacement) {
66
81
  const result = await host.replaceAll(asText(query, 'query'), asText(replacement, 'replacement'));
67
82
  if (!result.ok)
68
- throw new MacroError(result.message ?? 'ההחלפה נכשלה');
83
+ throw new MacroError(result.message ?? macroMessages().replaceFailed);
69
84
  return result.replaced;
70
85
  },
71
86
  bold: () => commandSugar('bold'),
@@ -92,7 +107,7 @@ export function createMacroApi(host, options = {}) {
92
107
  async call(method, args) {
93
108
  const fn = methods[method];
94
109
  if (typeof fn !== 'function' || !Object.prototype.hasOwnProperty.call(api, method)) {
95
- throw new MacroError(`מתודה לא מוכרת: ${String(method)}`);
110
+ throw new MacroError(macroMessages().unknownMethod(String(method)));
96
111
  }
97
112
  calls += 1;
98
113
  return fn.apply(api, args);
@@ -1,4 +1,4 @@
1
- /** חוזה משותף לשני המריצים (eval ו-iframe). */
1
+ /** Contract shared by the two runners (eval and iframe). */
2
2
  import type { MacroBridge } from './macro-api.js';
3
3
  export type MacroRunResult = {
4
4
  ok: true;
@@ -9,9 +9,9 @@ export type MacroRunResult = {
9
9
  reason?: 'timeout' | 'error' | 'call-limit';
10
10
  };
11
11
  export interface MacroRunOptions {
12
- /** תקרת זמן לריצה כולה. ברירת מחדל: 30 שניות. */
12
+ /** Time cap for the whole run. Default: 30 seconds. */
13
13
  timeoutMs?: number;
14
- /** תקרת קריאות API, נגד לולאה בורחת. ברירת מחדל: 10,000. */
14
+ /** API call cap, against runaway loops. Default: 10,000. */
15
15
  maxApiCalls?: number;
16
16
  }
17
17
  export declare const DEFAULT_TIMEOUT_MS = 30000;
@@ -1,7 +1,8 @@
1
1
  /**
2
- * קיצורי מקלדת למאקרו ולקטעים: ניתוח מחרוזת `Ctrl+Alt+M` והתאמה לאירוע.
2
+ * Keyboard shortcuts for macros and snippets: parsing a `Ctrl+Alt+M` string
3
+ * and matching it against an event.
3
4
  *
4
- * ההתאמה לפי `event.key` באותיות קטנות. `Mod` פירושו Ctrl (אובמק).
5
+ * Matching is by lowercased `event.key`. `Mod` means Ctrl (oron macOS).
5
6
  */
6
7
  export interface ParsedShortcut {
7
8
  key: string;
@@ -9,10 +10,10 @@ export interface ParsedShortcut {
9
10
  alt: boolean;
10
11
  shift: boolean;
11
12
  meta: boolean;
12
- /** Ctrl או Meta — לקיצורים שנכתבו עם `Mod`. */
13
+ /** Ctrl or Meta — for shortcuts written with `Mod`. */
13
14
  mod: boolean;
14
15
  }
15
- /** תת-הצורה של KeyboardEvent שההתאמה צריכה. מאפשר בדיקות בלי DOM. */
16
+ /** The subset of KeyboardEvent that matching needs. Enables DOM-free tests. */
16
17
  export interface KeyEventLike {
17
18
  key: string;
18
19
  ctrlKey: boolean;
@@ -24,6 +25,18 @@ export interface KeyEventLike {
24
25
  }
25
26
  export declare function parseShortcut(shortcut: string): ParsedShortcut | null;
26
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;
27
40
  export interface ShortcutBinding {
28
41
  shortcut: string;
29
42
  run(): void | Promise<unknown>;
@@ -33,7 +46,8 @@ export interface ShortcutTarget {
33
46
  removeEventListener(type: 'keydown', listener: (event: KeyboardEvent) => void, options?: boolean): void;
34
47
  }
35
48
  /**
36
- * קושרת קיצורים ליעד. `getBindings` נקראת בכל הקשה כך רשימת המאקרו יכולה
37
- * להשתנות בלי לקשור מחדש. מחזירה פונקציית ניתוק.
49
+ * Binds shortcuts to a target. `getBindings` is called on every keystroke
50
+ * so the macro list can change without rebinding. Returns a dispose
51
+ * function.
38
52
  */
39
53
  export declare function bindShortcuts(target: ShortcutTarget, getBindings: () => readonly ShortcutBinding[]): () => void;
package/dist/shortcuts.js CHANGED
@@ -29,7 +29,7 @@ export function parseShortcut(shortcut) {
29
29
  break;
30
30
  default: {
31
31
  if (parsed.key)
32
- return null; // שני מקשים שאינם modifiers קיצור פסול.
32
+ return null; // two non-modifier keysan invalid shortcut.
33
33
  parsed.key = normalizeKey(part);
34
34
  }
35
35
  }
@@ -50,7 +50,7 @@ export function eventMatches(parsed, event) {
50
50
  if (parsed.mod) {
51
51
  if (!event.ctrlKey && !event.metaKey)
52
52
  return false;
53
- // עם Mod לא בודקים ctrl/meta בנפרדאבל alt/shift חייבים להתאים בדיוק.
53
+ // With Mod, ctrl/meta are not checked individually but alt/shift must match exactly.
54
54
  return event.altKey === parsed.alt && event.shiftKey === parsed.shift;
55
55
  }
56
56
  return (event.ctrlKey === parsed.ctrl &&
@@ -59,8 +59,28 @@ export function eventMatches(parsed, event) {
59
59
  event.metaKey === parsed.meta);
60
60
  }
61
61
  /**
62
- * קושרת קיצורים ליעד. `getBindings` נקראת בכל הקשה — כך רשימת המאקרו יכולה
63
- * להשתנות בלי לקשור מחדש. מחזירה פונקציית ניתוק.
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
+ }
80
+ /**
81
+ * Binds shortcuts to a target. `getBindings` is called on every keystroke —
82
+ * so the macro list can change without rebinding. Returns a dispose
83
+ * function.
64
84
  */
65
85
  export function bindShortcuts(target, getBindings) {
66
86
  const listener = (event) => {
@@ -1,25 +1,28 @@
1
1
  /**
2
- * השלמה אוטומטית (AutoText): הקלדת מילת ההפעלה של קטע ואחריה רווח מחליפה את
3
- * המילה בתוכן הקטע.
2
+ * Auto-text: typing a snippet's trigger word followed by a space replaces
3
+ * the word with the snippet's content.
4
4
  *
5
- * איך זה עובד: נשמר חוצץ קטן של התווים שהוקלדו ברצף הנוכחי (מתוך אירועי
6
- * `TextInputEvent` של המארח). כשמוקלד תו הרחבה (רווח כברירת מחדל) והמילה
7
- * שלפניו היא trigger של קטע המילה ותו ההרחבה נמחקים לאחור, והתוכן המורחב
8
- * מוכנס במקומם עם תו ההרחבה בסופו.
5
+ * How it works: a small buffer keeps the characters typed in the current run
6
+ * (from the host's `TextInputEvent`s). When an expansion character (space by
7
+ * default) is typed and the word before it is some snippet's trigger, the
8
+ * word and the expansion character are deleted backwards and the rendered
9
+ * content is inserted in their place, with the expansion character restored
10
+ * at the end.
9
11
  *
10
- * החוצץ מתאפס על פסקה חדשה, על מחיקה קדימה ועל פקודה שרצה באמצע כל דבר
11
- * שמנתק את הרצף בין מה שהוקלד ובין מה שנמצא בפועל לפני הסמן. עדיף פספוס
12
- * הרחבה על הרחבה שמוחקת טקסט לא נכון.
12
+ * The buffer resets on a new paragraph, on forward deletion and on a command
13
+ * running mid-typing anything that breaks the correspondence between what
14
+ * was typed and what actually sits before the caret. A missed expansion is
15
+ * better than an expansion that deletes the wrong text.
13
16
  */
14
17
  import type { MacroHost, Snippet } from '../types.js';
15
18
  export interface AutoTextOptions {
16
- /** תווי ההרחבה. ברירת מחדל: רווח בלבד. */
19
+ /** The expansion characters. Default: space only. */
17
20
  expandOn?: readonly string[];
18
- /** גודל החוצץ. מילת הפעלה ארוכה מזה לא תזוהה. */
21
+ /** Buffer size. A trigger word longer than this will not be recognized. */
19
22
  bufferSize?: number;
20
- /** נקראת אחרי הרחבה מוצלחת. */
23
+ /** Called after a successful expansion. */
21
24
  onExpand?: (snippet: Snippet) => void;
22
- /** נקראת כשהרחבה נכשלה (למשל מסמך לקריאה בלבד). */
25
+ /** Called when an expansion failed (e.g. a read-only document). */
23
26
  onError?: (message: string) => void;
24
27
  }
25
28
  export declare class AutoText {
@@ -27,7 +27,7 @@ export class AutoText {
27
27
  return () => this.detach();
28
28
  this.buffer = '';
29
29
  this.disposeInput = this.host.onTextInput((event) => void this.handleInput(event));
30
- // פקודה באמצע הקלדה (עיצוב, הדבקה) מנתקת את הקשר בין החוצץ למסמך.
30
+ // A command mid-typing (formatting, paste) breaks the buffer's link to the document.
31
31
  this.disposeCommand = this.host.onCommand(() => {
32
32
  if (!this.busy)
33
33
  this.buffer = '';
@@ -42,7 +42,7 @@ export class AutoText {
42
42
  this.buffer = '';
43
43
  }
44
44
  async handleInput(event) {
45
- // קלט שנוצר בזמן שההרחבה עצמה כותבתלא חלק מההקלדה של המשתמש.
45
+ // Input generated while the expansion itself is writing not the user's typing.
46
46
  if (this.busy)
47
47
  return;
48
48
  switch (event.kind) {
@@ -74,14 +74,16 @@ export class AutoText {
74
74
  async expand(snippet, trigger, expandChar) {
75
75
  this.busy = true;
76
76
  try {
77
- // אירוע הקלט (beforeinput) נורה לפני שהתו נכתב למסמך. הדחייה לתור
78
- // המשימות מבטיחה שתו ההרחבה כבר בפנים לפני שמוחקים אותו יחד עם ה-trigger.
77
+ // The input event (beforeinput) fires before the character is written
78
+ // to the document. Deferring to the task queue guarantees the expansion
79
+ // character is already in before it is deleted along with the trigger.
79
80
  await new Promise((resolve) => setTimeout(resolve, 0));
80
81
  const selectionText = usesSelection(snippet.text)
81
82
  ? (await this.host.getSelection({ includeText: true })).text
82
83
  : undefined;
83
84
  const rendered = renderSnippet(snippet.text, { selectionText });
84
- // תו ההרחבה כבר נכתב למסמך כשמגיעים לכאן, ולכן הוא נכלל במחיקה ומוחזר בסוף.
85
+ // The expansion character is already in the document by now, so it is
86
+ // included in the deletion and restored at the end.
85
87
  const deleted = await this.host.deleteBackward(trigger.length + 1);
86
88
  if (!deleted.ok) {
87
89
  this.onError?.(deleted.message);
@@ -99,7 +101,7 @@ export class AutoText {
99
101
  }
100
102
  }
101
103
  }
102
- /** המילה שבסוף החוצץרצף שאינו רווח לבן. */
104
+ /** The word at the end of the buffer a run of non-whitespace. */
103
105
  function trailingWord(buffer) {
104
106
  const match = /(\S+)$/u.exec(buffer);
105
107
  return match?.[1] ?? null;
@@ -1,26 +1,30 @@
1
1
  /**
2
- * קטעי טקסט (Snippets): תבניות שמוכנסות במיקום הסמן, עם משתני `{{...}}`.
2
+ * Text snippets: templates inserted at the caret, with `{{...}}` variables.
3
3
  *
4
- * משתנים מובנים: `{{date}}`, `{{time}}`, `{{datetime}}` (בעברית, לפי שעון
5
- * המערכת), `{{selection}}` (הטקסט המסומן ברגע ההרחבה). כל שם אחר נפתר מתוך
6
- * `variables` שנמסרו בקריאה; משתנה שאין לו ערך נשאר כמו שהוא בטקסט — כדי
7
- * שטעות כתיב תיראה במסמך ולא תיעלם בשקט.
4
+ * Built-in variables: `{{date}}`, `{{time}}`, `{{datetime}}` (system clock,
5
+ * formatted with the configured locale) and `{{selection}}` (the selected
6
+ * text at expansion time). Any other name resolves from the `variables`
7
+ * passed to the call; a variable with no value stays visible in the text —
8
+ * so a typo shows up in the document instead of vanishing silently.
8
9
  */
9
10
  import type { MacroHost, MacroOutcome, Snippet } from '../types.js';
10
11
  export interface RenderContext {
11
- /** ערכים למשתנים מותאמים. */
12
+ /** Values for custom variables. */
12
13
  variables?: Readonly<Record<string, string>>;
13
- /** הטקסט שיוצב ב-`{{selection}}`. */
14
+ /** The text substituted for `{{selection}}`. */
14
15
  selectionText?: string;
15
- /** הזמן ל-`{{date}}`/`{{time}}`. ברירת מחדל: עכשיו. קיים בשביל בדיקות. */
16
+ /** The time for `{{date}}`/`{{time}}`. Default: now. Exists for tests. */
16
17
  now?: Date;
18
+ /** BCP-47 locale for date/time formatting. Default: the browser's. */
19
+ locale?: string;
17
20
  }
18
21
  export declare function renderSnippet(text: string, context?: RenderContext): string;
19
- /** האם הקטע משתמש ב-`{{selection}}` — ואז ההרחבה צריכה לקרוא את הבחירה. */
22
+ /** Whether the snippet uses `{{selection}}` — in which case expansion must read the selection. */
20
23
  export declare function usesSelection(text: string): boolean;
21
24
  export interface ExpandOptions {
22
25
  variables?: Readonly<Record<string, string>>;
23
26
  now?: Date;
27
+ locale?: string;
24
28
  }
25
- /** מרחיבה קטע במיקום הסמן. */
29
+ /** Expands a snippet at the caret. */
26
30
  export declare function expandSnippet(host: MacroHost, snippet: Pick<Snippet, 'text'>, options?: ExpandOptions): Promise<MacroOutcome>;
@@ -1,15 +1,16 @@
1
1
  const VARIABLE_PATTERN = /\{\{\s*([\p{L}\p{N}_-]+)\s*\}\}/gu;
2
2
  export function renderSnippet(text, context = {}) {
3
3
  const now = context.now ?? new Date();
4
+ const locale = context.locale;
4
5
  return text.replace(VARIABLE_PATTERN, (whole, rawName) => {
5
6
  const name = rawName.toLowerCase();
6
7
  switch (name) {
7
8
  case 'date':
8
- return now.toLocaleDateString('he-IL');
9
+ return now.toLocaleDateString(locale);
9
10
  case 'time':
10
- return now.toLocaleTimeString('he-IL', { hour: '2-digit', minute: '2-digit' });
11
+ return now.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' });
11
12
  case 'datetime':
12
- return `${now.toLocaleDateString('he-IL')} ${now.toLocaleTimeString('he-IL', { hour: '2-digit', minute: '2-digit' })}`;
13
+ return `${now.toLocaleDateString(locale)} ${now.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })}`;
13
14
  case 'selection':
14
15
  return context.selectionText ?? '';
15
16
  default: {
@@ -19,13 +20,13 @@ export function renderSnippet(text, context = {}) {
19
20
  }
20
21
  });
21
22
  }
22
- /** האם הקטע משתמש ב-`{{selection}}` — ואז ההרחבה צריכה לקרוא את הבחירה. */
23
+ /** Whether the snippet uses `{{selection}}` — in which case expansion must read the selection. */
23
24
  export function usesSelection(text) {
24
25
  return /\{\{\s*selection\s*\}\}/iu.test(text);
25
26
  }
26
- /** מרחיבה קטע במיקום הסמן. */
27
+ /** Expands a snippet at the caret. */
27
28
  export async function expandSnippet(host, snippet, options = {}) {
28
- // הבחירה נקראת רק כשנחוצה: חילוץ טקסט הבחירה עולה בביצועים במנוע.
29
+ // The selection is read only when needed: extracting its text has an engine cost.
29
30
  const selectionText = usesSelection(snippet.text)
30
31
  ? (await host.getSelection({ includeText: true })).text
31
32
  : undefined;
@@ -33,6 +34,7 @@ export async function expandSnippet(host, snippet, options = {}) {
33
34
  variables: options.variables,
34
35
  selectionText,
35
36
  now: options.now,
37
+ locale: options.locale,
36
38
  });
37
39
  return host.insertText(rendered);
38
40
  }