@rohal12/spindle 0.52.4 → 0.52.6

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.
Files changed (47) hide show
  1. package/dist/pkg/format.js +1 -1
  2. package/dist/pkg/headless.js +1502 -1521
  3. package/dist/pkg/macro-registry.json +255 -0
  4. package/package.json +1 -1
  5. package/src/class-registry.ts +6 -182
  6. package/src/components/macros/Button.tsx +2 -4
  7. package/src/components/macros/Checkbox.tsx +6 -13
  8. package/src/components/macros/Computed.tsx +38 -55
  9. package/src/components/macros/Cycle.tsx +1 -0
  10. package/src/components/macros/Dialog.tsx +7 -8
  11. package/src/components/macros/Do.tsx +2 -1
  12. package/src/components/macros/For.tsx +27 -78
  13. package/src/components/macros/Goto.tsx +6 -9
  14. package/src/components/macros/Include.tsx +10 -57
  15. package/src/components/macros/Listbox.tsx +1 -0
  16. package/src/components/macros/MacroError.tsx +37 -0
  17. package/src/components/macros/MacroLink.tsx +9 -24
  18. package/src/components/macros/Meter.tsx +19 -37
  19. package/src/components/macros/Numberbox.tsx +5 -32
  20. package/src/components/macros/PassageDisplay.tsx +37 -47
  21. package/src/components/macros/Print.tsx +1 -0
  22. package/src/components/macros/Radiobutton.tsx +8 -38
  23. package/src/components/macros/Repeat.tsx +3 -3
  24. package/src/components/macros/Set.tsx +3 -29
  25. package/src/components/macros/Switch.tsx +36 -37
  26. package/src/components/macros/Textarea.tsx +2 -28
  27. package/src/components/macros/Textbox.tsx +2 -29
  28. package/src/components/macros/Type.tsx +3 -3
  29. package/src/components/macros/Unset.tsx +17 -40
  30. package/src/components/macros/Watch.tsx +23 -91
  31. package/src/components/macros/WidgetInvocation.tsx +9 -71
  32. package/src/components/macros/detached-body.tsx +2 -2
  33. package/src/components/macros/input-macro.ts +48 -0
  34. package/src/components/macros/locals-scope.tsx +60 -0
  35. package/src/components/macros/macro-args.ts +280 -0
  36. package/src/components/macros/option-utils.ts +7 -12
  37. package/src/define-macro.ts +47 -30
  38. package/src/execute-mutation.ts +17 -97
  39. package/src/hooks/use-render-options.ts +26 -0
  40. package/src/markup/render.tsx +2 -4
  41. package/src/registry.ts +48 -0
  42. package/src/saves/save-manager.ts +190 -259
  43. package/src/saves/storage.ts +229 -402
  44. package/src/store.ts +244 -310
  45. package/src/structural.ts +388 -0
  46. package/types/index.d.ts +69 -6
  47. package/types/tooling.d.ts +33 -1
@@ -1,28 +1,12 @@
1
- import {
2
- useContext,
3
- useState,
4
- useCallback,
5
- useRef,
6
- useMemo,
7
- } from 'preact/hooks';
8
- import {
9
- LocalsValuesContext,
10
- LocalsUpdateContext,
11
- NobrContext,
12
- InlineContext,
13
- WidgetChildrenContext,
14
- RawTextContext,
15
- renderNodes,
16
- } from '../../markup/render';
1
+ import { useMemo } from 'preact/hooks';
2
+ import { WidgetChildrenContext, renderNodes } from '../../markup/render';
17
3
  import { useMergedLocals } from '../../hooks/use-merged-locals';
18
4
  import { evaluate } from '../../expression';
19
5
  import type { ASTNode } from '../../markup/ast';
20
6
  import { splitArgs } from './arg-utils';
21
- import {
22
- checkVariableName,
23
- createNamespace,
24
- withEntry,
25
- } from '../../utils/namespace';
7
+ import { createNamespace } from '../../utils/namespace';
8
+ import { useRenderOptions } from '../../hooks/use-render-options';
9
+ import { LocalsScope } from './locals-scope';
26
10
 
27
11
  export { splitArgs };
28
12
 
@@ -33,60 +17,14 @@ interface WidgetInvocationProps {
33
17
  invocationChildren?: ASTNode[];
34
18
  }
35
19
 
36
- function WidgetBody({
37
- body,
38
- parentValues,
39
- ownKeys,
40
- }: {
41
- body: ASTNode[];
42
- parentValues: Record<string, unknown>;
43
- ownKeys: Record<string, unknown>;
44
- }) {
45
- const nobr = useContext(NobrContext);
46
- const inline = useContext(InlineContext);
47
- const raw = useContext(RawTextContext);
48
- const [localMutations, setLocalMutations] = useState<Record<string, unknown>>(
49
- {},
50
- );
51
-
52
- const localState = useMemo(
53
- () => createNamespace(parentValues, ownKeys, localMutations),
54
- [parentValues, ownKeys, localMutations],
55
- );
56
-
57
- const valuesRef = useRef(localState);
58
- valuesRef.current = localState;
59
-
60
- const getValues = useCallback(() => valuesRef.current, []);
61
- const update = useCallback((key: string, value: unknown) => {
62
- // Apply synchronously so later macros in the same render pass (e.g. a
63
- // second {set}) read the new value via getValues(); the state update
64
- // then re-renders consumers of LocalsValuesContext.
65
- checkVariableName(key, `@${key}`);
66
- valuesRef.current = withEntry(valuesRef.current, key, value);
67
- setLocalMutations((prev) => ({ ...prev, [key]: value }));
68
- }, []);
69
- const updater = useMemo(() => ({ update, getValues }), [update, getValues]);
70
-
71
- return (
72
- <LocalsUpdateContext.Provider value={updater}>
73
- <LocalsValuesContext.Provider value={localState}>
74
- {renderNodes(body, { nobr, inline, raw, locals: localState })}
75
- </LocalsValuesContext.Provider>
76
- </LocalsUpdateContext.Provider>
77
- );
78
- }
79
-
80
20
  export function WidgetInvocation({
81
21
  body,
82
22
  params,
83
23
  rawArgs,
84
24
  invocationChildren,
85
25
  }: WidgetInvocationProps) {
86
- const parentValues = useContext(LocalsValuesContext);
87
- const nobr = useContext(NobrContext);
88
- const inline = useContext(InlineContext);
89
- const raw = useContext(RawTextContext);
26
+ const renderOptions = useRenderOptions();
27
+ const parentValues = renderOptions.locals;
90
28
  const [mergedVars, mergedTemps, mergedLocals, mergedTrans] =
91
29
  useMergedLocals();
92
30
 
@@ -97,7 +35,7 @@ export function WidgetInvocation({
97
35
  if (params.length === 0) {
98
36
  return (
99
37
  <WidgetChildrenContext.Provider value={childrenValue}>
100
- {renderNodes(body, { nobr, inline, raw, locals: parentValues })}
38
+ {renderNodes(body, renderOptions)}
101
39
  </WidgetChildrenContext.Provider>
102
40
  );
103
41
  }
@@ -137,7 +75,7 @@ export function WidgetInvocation({
137
75
 
138
76
  return (
139
77
  <WidgetChildrenContext.Provider value={childrenValue}>
140
- <WidgetBody
78
+ <LocalsScope
141
79
  body={body}
142
80
  parentValues={parentValues}
143
81
  ownKeys={ownKeys}
@@ -9,6 +9,7 @@ import {
9
9
  WidgetChildrenContext,
10
10
  } from '../../markup/render';
11
11
  import type { ASTNode } from '../../markup/ast';
12
+ import { useRenderOptions } from '../../hooks/use-render-options';
12
13
  import { liveLocalsView } from '../../utils/live-locals';
13
14
  import { runWithCommittedMutations } from '../../execute-mutation';
14
15
  import { RepeatContext } from './Repeat';
@@ -35,8 +36,7 @@ import { DialogCloseContext } from '../PassageDialog';
35
36
  */
36
37
  export function useDetachedBody(): (children: ASTNode[]) => void {
37
38
  const updater = useContext(LocalsUpdateContext);
38
- const nobr = useContext(NobrContext);
39
- const inline = useContext(InlineContext);
39
+ const { nobr, inline } = useRenderOptions();
40
40
  const widgetChildren = useContext(WidgetChildrenContext);
41
41
  const repeat = useContext(RepeatContext);
42
42
  const closeDialog = useContext(DialogCloseContext);
@@ -0,0 +1,48 @@
1
+ import { h } from 'preact';
2
+ import { defineMacro } from '../../define-macro';
3
+
4
+ /**
5
+ * Define a text input macro, `{name $var "placeholder"}`: an `<input>` of
6
+ * `type` (or, without one, a `<textarea>`) bound to the story variable.
7
+ * `toValue` turns what the reader types, or an automation action's value
8
+ * (`undefined` when it has none), into the variable's value: by default,
9
+ * the text.
10
+ */
11
+ export function defineInputMacro(
12
+ name: 'textbox' | 'numberbox' | 'textarea',
13
+ type?: 'text' | 'number',
14
+ toValue = (input: unknown): unknown =>
15
+ input !== undefined ? String(input) : '',
16
+ ): void {
17
+ defineMacro({
18
+ name,
19
+ storeVar: true,
20
+ parameters: [
21
+ { name: 'variable', type: 'variable', required: true },
22
+ { name: 'placeholder', type: 'string' },
23
+ ],
24
+ render(_props, ctx) {
25
+ const placeholder = ctx.args.placeholder ?? '';
26
+
27
+ ctx.useAction({
28
+ type: name,
29
+ key: `$${ctx.varName}`,
30
+ authorId: ctx.id,
31
+ label: placeholder || ctx.varName!,
32
+ variable: ctx.varName,
33
+ value: ctx.value,
34
+ perform: (v) => ctx.setValue!(toValue(v)),
35
+ });
36
+
37
+ return h(type ? 'input' : 'textarea', {
38
+ type,
39
+ id: ctx.id,
40
+ class: ctx.cls,
41
+ value: ctx.value == null ? '' : String(ctx.value),
42
+ placeholder,
43
+ onInput: (e: Event) =>
44
+ ctx.setValue!(toValue((e.target as HTMLInputElement).value)),
45
+ });
46
+ },
47
+ });
48
+ }
@@ -0,0 +1,60 @@
1
+ import { useState, useCallback, useRef, useMemo } from 'preact/hooks';
2
+ import {
3
+ LocalsValuesContext,
4
+ LocalsUpdateContext,
5
+ renderNodes,
6
+ } from '../../markup/render';
7
+ import type { ASTNode } from '../../markup/ast';
8
+ import { useRenderOptions } from '../../hooks/use-render-options';
9
+ import {
10
+ checkVariableName,
11
+ createNamespace,
12
+ withEntry,
13
+ } from '../../utils/namespace';
14
+
15
+ /**
16
+ * Render `body` in a new locals scope: the locals of `parentValues`, with
17
+ * `ownKeys` (an iteration's loop variables, a widget's parameters) on top.
18
+ * Locals the body assigns are kept in this scope.
19
+ */
20
+ export function LocalsScope({
21
+ body,
22
+ parentValues,
23
+ ownKeys,
24
+ }: {
25
+ body: ASTNode[];
26
+ parentValues: Record<string, unknown>;
27
+ ownKeys: Record<string, unknown>;
28
+ }) {
29
+ const options = useRenderOptions();
30
+ const [localMutations, setLocalMutations] = useState<Record<string, unknown>>(
31
+ {},
32
+ );
33
+
34
+ const localState = useMemo(
35
+ () => createNamespace(parentValues, ownKeys, localMutations),
36
+ [parentValues, ownKeys, localMutations],
37
+ );
38
+
39
+ const valuesRef = useRef(localState);
40
+ valuesRef.current = localState;
41
+
42
+ const getValues = useCallback(() => valuesRef.current, []);
43
+ const update = useCallback((key: string, value: unknown) => {
44
+ // Apply synchronously so later macros in the same render pass (e.g. a
45
+ // second {set}) read the new value via getValues(); the state update
46
+ // then re-renders consumers of LocalsValuesContext.
47
+ checkVariableName(key, `@${key}`);
48
+ valuesRef.current = withEntry(valuesRef.current, key, value);
49
+ setLocalMutations((prev) => ({ ...prev, [key]: value }));
50
+ }, []);
51
+ const updater = useMemo(() => ({ update, getValues }), [update, getValues]);
52
+
53
+ return (
54
+ <LocalsUpdateContext.Provider value={updater}>
55
+ <LocalsValuesContext.Provider value={localState}>
56
+ {renderNodes(body, { ...options, locals: localState })}
57
+ </LocalsValuesContext.Provider>
58
+ </LocalsUpdateContext.Provider>
59
+ );
60
+ }
@@ -0,0 +1,280 @@
1
+ /**
2
+ * The macro argument layer: reads a macro's raw argument text into the
3
+ * parameters its definition declares (see ParameterDef), so every macro
4
+ * splits and unquotes its arguments the same way. defineMacro() passes the
5
+ * result to the macro as `ctx.args`.
6
+ *
7
+ * Arguments are read in groups, split at `separator` parameters. A group's
8
+ * `flag` parameters are taken off its start or end first. Its last
9
+ * `expression` or `text` parameter (without one, its last parameter) reads
10
+ * the text the others leave; each parameter before it reads one term from
11
+ * the start, and each after it one term from the end. Terms are separated by
12
+ * whitespace outside literals and brackets (see arg-utils.ts), and a term
13
+ * that follows an operator belongs to the expression before it. A parameter
14
+ * with nothing to read is unset (a flag or separator is false, options are
15
+ * empty).
16
+ */
17
+ import { parseDelay } from '../../utils/parse-delay';
18
+ import type { MacroArgs, ParameterDef } from '../../registry';
19
+ import {
20
+ endsWithOperator,
21
+ isWhitespace,
22
+ readQuoted,
23
+ readWholeQuoted,
24
+ stripLooseQuotes,
25
+ topLevelIndices,
26
+ } from './arg-utils';
27
+
28
+ type Span = [start: number, end: number];
29
+
30
+ /** The terms of `src`: the runs of text between its depth-0 whitespace. */
31
+ function terms(src: string): Span[] {
32
+ const spans: Span[] = [];
33
+ let start = 0;
34
+ for (const i of [...topLevelIndices(src, isWhitespace), src.length]) {
35
+ if (i > start) spans.push([start, i]);
36
+ start = i + 1;
37
+ }
38
+ return spans;
39
+ }
40
+
41
+ const slice = (src: string, [start, end]: Span) => src.slice(start, end);
42
+
43
+ /**
44
+ * The value of a `text` argument: one quoted string, or the text with any
45
+ * loose quotes stripped (`"Red` → `Red`).
46
+ */
47
+ export function readText(src: string): string {
48
+ return readWholeQuoted(src) ?? stripLooseQuotes(src);
49
+ }
50
+
51
+ function readValue(param: ParameterDef, src: string): unknown {
52
+ switch (param.type) {
53
+ case 'string':
54
+ return readWholeQuoted(src) ?? undefined;
55
+ case 'text':
56
+ return readText(src);
57
+ case 'names':
58
+ return src.split(',').map((name) => name.trim());
59
+ case 'delay':
60
+ return parseDelay(src);
61
+ case 'number':
62
+ return Number(src);
63
+ case 'options':
64
+ return readOptions(param.parameters ?? [], src);
65
+ default:
66
+ return src;
67
+ }
68
+ }
69
+
70
+ function unset(param: ParameterDef): unknown {
71
+ if (param.type === 'flag' || param.type === 'separator') return false;
72
+ return param.type === 'options' ? {} : undefined;
73
+ }
74
+
75
+ const WORD_RE = /\w+/y;
76
+ const DIGITS_RE = /\d+/y;
77
+
78
+ /** Match the sticky regex `re` at `pos`, returning the matched text. */
79
+ function matchAt(re: RegExp, src: string, pos: number): string | null {
80
+ re.lastIndex = pos;
81
+ return re.exec(src)?.[0] ?? null;
82
+ }
83
+
84
+ /**
85
+ * Read keyword options (`goto "X" priority 5 once`). A keyword takes the
86
+ * quoted string or digit run after it as its value; keywords that aren't
87
+ * declared, and their values, are skipped.
88
+ */
89
+ function readOptions(
90
+ params: readonly ParameterDef[],
91
+ src: string,
92
+ ): Record<string, unknown> {
93
+ const options: Record<string, unknown> = {};
94
+ let i = 0;
95
+ while (i < src.length) {
96
+ const key = matchAt(WORD_RE, src, i);
97
+ if (!key) {
98
+ // Skip whitespace and stray characters, or a stray quoted string.
99
+ i = readQuoted(src, i)?.end ?? i + 1;
100
+ continue;
101
+ }
102
+ i += key.length;
103
+
104
+ // A value is a quoted string or a digit run, after whitespace.
105
+ let val: string | undefined;
106
+ let j = i;
107
+ while (j < src.length && isWhitespace(src[j]!)) j++;
108
+ if (j > i) {
109
+ const quoted = readQuoted(src, j);
110
+ const digits = quoted ? null : matchAt(DIGITS_RE, src, j);
111
+ if (quoted) {
112
+ val = quoted.value;
113
+ i = quoted.end;
114
+ } else if (digits) {
115
+ val = digits;
116
+ i = j + digits.length;
117
+ }
118
+ }
119
+
120
+ const param = params.find((p) => p.name === key);
121
+ if (param?.type === 'flag') options[key] = true;
122
+ else if (param?.type === 'number') options[key] = Number(val ?? 0);
123
+ else if (param) options[key] = val;
124
+ }
125
+ return options;
126
+ }
127
+
128
+ /** `inline %name` reads a transient variable; `inline % 2` is modulo. */
129
+ const LEADING_OPERATOR_RE = /^(?:[-+*/&|^=<>?:,.]|%(?![A-Za-z_]))/;
130
+
131
+ /**
132
+ * The rest of `src` without the flag `word` as its last or first term, or
133
+ * `null` if it has no such flag. Next to a binary operator the word is an
134
+ * operand (`"a" + inline`), not a flag; the closing `/` of a regex literal
135
+ * is not one.
136
+ */
137
+ function takeFlag(src: string, word: string): string | null {
138
+ const spans = terms(src);
139
+ if (spans.length < 2) return null;
140
+ if (slice(src, spans[spans.length - 1]!) === word) {
141
+ const rest = src.slice(0, spans[spans.length - 2]![1]);
142
+ if (!endsWithOperator(rest)) return rest;
143
+ }
144
+ if (slice(src, spans[0]!) === word) {
145
+ const rest = src.slice(spans[1]![0]);
146
+ if (!LEADING_OPERATOR_RE.test(rest)) return rest;
147
+ }
148
+ return null;
149
+ }
150
+
151
+ /**
152
+ * Where the separator `word` splits `src`, as the end of the text before it
153
+ * and the start of the text after it, or `null` if it doesn't. A word is a
154
+ * term with terms on both sides; `=` is an assignment at depth 0, not part
155
+ * of `==` or `!=`.
156
+ */
157
+ function findSeparator(src: string, word: string): Span | null {
158
+ if (word === '=') {
159
+ const indices = topLevelIndices(src, (ch) => ch === '=');
160
+ for (let k = 0; k < indices.length; k++) {
161
+ const i = indices[k]!;
162
+ if (src[i + 1] === '=') {
163
+ k++;
164
+ continue;
165
+ }
166
+ if (src[i - 1] !== '!') return [i, i + 1];
167
+ }
168
+ return null;
169
+ }
170
+ const spans = terms(src);
171
+ for (let k = 1; k < spans.length - 1; k++) {
172
+ if (slice(src, spans[k]!) === word) {
173
+ return [spans[k - 1]![1], spans[k + 1]![0]];
174
+ }
175
+ }
176
+ return null;
177
+ }
178
+
179
+ const takesRest = (param: ParameterDef) =>
180
+ param.type === undefined ||
181
+ param.type === 'expression' ||
182
+ param.type === 'text';
183
+
184
+ /** Read the parameters of one group from `src` (`null`: no text) into `args`. */
185
+ function readGroup(
186
+ params: readonly ParameterDef[],
187
+ src: string | null,
188
+ args: Record<string, unknown>,
189
+ ): void {
190
+ let text = src?.trim() ?? '';
191
+ for (const flag of params.filter((p) => p.type === 'flag')) {
192
+ const rest = src === null ? null : takeFlag(text, flag.name);
193
+ args[flag.name] = rest !== null;
194
+ text = rest ?? text;
195
+ }
196
+ const positional = params.filter((p) => p.type !== 'flag');
197
+ if (positional.length === 0) return;
198
+
199
+ // The last expression or text parameter, else the last one, reads the rest.
200
+ const lastText = positional.findIndex(
201
+ (p, i) => takesRest(p) && !positional.slice(i + 1).some(takesRest),
202
+ );
203
+ const restIndex = lastText < 0 ? positional.length - 1 : lastText;
204
+ const read = (param: ParameterDef, value: string) =>
205
+ value === '' ? unset(param) : readValue(param, value);
206
+
207
+ // One parameter reads the whole text: no need to split it.
208
+ if (positional.length === 1) {
209
+ args[positional[0]!.name] = read(positional[0]!, text);
210
+ return;
211
+ }
212
+
213
+ const spans = terms(text);
214
+ let from = 0;
215
+ let to = spans.length;
216
+ for (const param of positional.slice(0, restIndex)) {
217
+ args[param.name] =
218
+ from < to ? read(param, slice(text, spans[from++]!)) : unset(param);
219
+ }
220
+ for (const param of positional.slice(restIndex + 1).reverse()) {
221
+ const value =
222
+ from < to && !endsWithOperator(text.slice(0, spans[to - 1]![0]))
223
+ ? read(param, slice(text, spans[to - 1]!))
224
+ : undefined;
225
+ args[param.name] = value ?? unset(param);
226
+ if (value !== undefined) to--;
227
+ }
228
+ const rest = positional[restIndex]!;
229
+ args[rest.name] =
230
+ from < to
231
+ ? read(rest, text.slice(spans[from]![0], spans[to - 1]![1]))
232
+ : unset(rest);
233
+ }
234
+
235
+ /** Read `rawArgs` into the declared `parameters` (see above). */
236
+ export function parseMacroArgs<const P extends readonly ParameterDef[]>(
237
+ rawArgs: string,
238
+ parameters: P,
239
+ ): MacroArgs<P> {
240
+ const args: Record<string, unknown> = {};
241
+ // The text after the last separator read, null once one is missing
242
+ let rest: string | null = rawArgs.trim();
243
+ let groupStart = 0;
244
+ parameters.forEach((separator, i) => {
245
+ if (separator.type !== 'separator') return;
246
+ const at = rest === null ? null : findSeparator(rest, separator.name);
247
+ args[separator.name] = at !== null;
248
+ const before = at ? rest!.slice(0, at[0]) : rest;
249
+ readGroup(parameters.slice(groupStart, i), before, args);
250
+ rest = at ? rest!.slice(at[1]) : null;
251
+ groupStart = i + 1;
252
+ });
253
+ readGroup(parameters.slice(groupStart), rest, args);
254
+ return args as MacroArgs<P>;
255
+ }
256
+
257
+ /**
258
+ * The passage a passage-name argument names: the value of its expression
259
+ * or, when that can't be evaluated (`{goto Bob's room}`), its text.
260
+ */
261
+ export function evaluatePassageName(
262
+ expr: string | undefined,
263
+ evaluate: (expr: string) => unknown,
264
+ ): string {
265
+ try {
266
+ return String(evaluate(expr ?? ''));
267
+ } catch {
268
+ return readText(expr ?? '');
269
+ }
270
+ }
271
+
272
+ /**
273
+ * The variable a `storeVar` macro binds: its first term, as written. Custom
274
+ * macros may bind one without declaring parameters.
275
+ */
276
+ export function readBoundVariable(rawArgs: string): string {
277
+ const src = rawArgs.trim();
278
+ const first = terms(src)[0];
279
+ return first ? slice(src, first) : '';
280
+ }
@@ -1,22 +1,17 @@
1
1
  import type { ASTNode } from '../../markup/ast';
2
2
  import { readWholeQuoted } from './arg-utils';
3
+ import { parseMacroArgs } from './macro-args';
3
4
 
5
+ /** `$var "placeholder"`, the arguments of the input macros. */
4
6
  export function parseVarArgs(rawArgs: string): {
5
7
  varName: string;
6
8
  placeholder: string;
7
9
  } {
8
- // `s`: a quoted placeholder may span lines.
9
- const match = rawArgs.match(/^\s*(["']?\$[\w.]+["']?)\s*(["'].*["'])?\s*$/s);
10
- if (!match) {
11
- return { varName: rawArgs.trim(), placeholder: '' };
12
- }
13
- const varName = match[1]!.replace(/["']/g, '');
14
- // A quoted placeholder accepts \" \' and \\ escapes.
15
- const quoted = match[2];
16
- const placeholder = quoted
17
- ? (readWholeQuoted(quoted) ?? quoted.slice(1, -1))
18
- : '';
19
- return { varName, placeholder };
10
+ const { variable = '', placeholder = '' } = parseMacroArgs(rawArgs, [
11
+ { name: 'variable', type: 'variable' },
12
+ { name: 'placeholder', type: 'string' },
13
+ ]);
14
+ return { varName: variable.replace(/["']/g, ''), placeholder };
20
15
  }
21
16
 
22
17
  /**