@rohal12/spindle 0.56.0 → 0.58.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.
@@ -2,9 +2,36 @@ import { readFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
 
5
- import { checkParameterTypes, validateStoryMarkup } from './story-variables.js';
5
+ import {
6
+ checkParameterTypes,
7
+ collectStoryPassageReferences,
8
+ validateStoryMarkup,
9
+ } from './story-variables.js';
6
10
 
7
- export { parseStoryVariables, formatDiagnostic } from './story-variables.js';
11
+ export {
12
+ parseStoryVariables,
13
+ formatDiagnostic,
14
+ findCodeEnd,
15
+ lexJs,
16
+ lexTemplate,
17
+ scanStringLiteral,
18
+ MarkupError,
19
+ parseSelectors,
20
+ tokenizeMarkup,
21
+ tokenizeMarkupTolerant,
22
+ transform,
23
+ passageTarget,
24
+ evaluatePassageName,
25
+ SIGIL_SCOPES,
26
+ isSigil,
27
+ endsWithOperator,
28
+ readQuoted,
29
+ splitArgs,
30
+ splitTopLevel,
31
+ stripLooseQuotes,
32
+ unescapeQuoted,
33
+ splitIncludeFlag,
34
+ } from './story-variables.js';
8
35
 
9
36
  const __dirname = dirname(fileURLToPath(import.meta.url));
10
37
 
@@ -58,3 +85,13 @@ export function getMacroRegistry() {
58
85
  export function validateMarkup(passages, options) {
59
86
  return validateStoryMarkup(passages, metadata.values(), options);
60
87
  }
88
+
89
+ /**
90
+ * The passages the markup of `source` names, with where each is written:
91
+ * `[[…]]` links and the passage of `{goto}`, `{include}`, `{link}`,
92
+ * `{watch}` and `{dialog}`, as the macros registered here declare them.
93
+ * Malformed tags are skipped, so half-typed markup reads.
94
+ */
95
+ export function collectPassageReferences(source) {
96
+ return collectStoryPassageReferences(source, metadata.values());
97
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.56.0",
3
+ "version": "0.58.0",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
package/src/code-check.ts CHANGED
@@ -29,8 +29,12 @@ import { isCodeAttribute, splitSigilTemplate } from './markup/code-attributes';
29
29
  import { CodeSyntaxError, parseCode, type JsGoal } from './js-lexer';
30
30
  import type { ParsedCode } from './js-lexer';
31
31
  import type { ParameterDef } from './registry';
32
- import { parseMacroArgs } from './components/macros/macro-args';
33
- import { readWholeQuoted } from './components/macros/arg-utils';
32
+ import {
33
+ MacroArgumentError,
34
+ parseMacroArgs,
35
+ passageTarget,
36
+ type PassageTarget,
37
+ } from './components/macros/macro-args';
34
38
 
35
39
  /** While a pass runs: the code it parsed so far, by goal and source. */
36
40
  let parses: Map<string, ParsedCode | CodeSyntaxError> | null = null;
@@ -77,6 +81,10 @@ export interface CodePiece {
77
81
  label: string;
78
82
  /** Whether it names a passage: a `passage` argument. */
79
83
  passage?: boolean;
84
+ /** Whether it is the code in a quoted string, as in `{watch}`. */
85
+ inString?: boolean;
86
+ /** The macro it is the argument of, for a `passage` argument. */
87
+ macro?: string;
80
88
  }
81
89
 
82
90
  /** A passage name written out in markup, at `offset` in it. */
@@ -86,6 +94,19 @@ export interface PassagePiece {
86
94
  offset: number;
87
95
  /** The markup it is in, for the error: `[[Go->Hall]]`. */
88
96
  label: string;
97
+ /** How much of the markup is the name, as written. */
98
+ length: number;
99
+ /** The macro it is the argument of; `link` for `[[…]]` links. */
100
+ macro: string;
101
+ }
102
+
103
+ /** Macro arguments that don't have their parameters' forms. */
104
+ export interface ArgumentErrorPiece {
105
+ kind: 'argument-error';
106
+ message: string;
107
+ offset: number;
108
+ /** The markup it is in, for the error: `{link Go}`. */
109
+ label: string;
89
110
  }
90
111
 
91
112
  /** Text in markup that may hold markup of its own, at `offset` in it. */
@@ -101,20 +122,29 @@ export interface TextPiece {
101
122
  const CONDITION_MACROS = new Set(['if', 'elseif', 'case']);
102
123
 
103
124
  /** Code inside the quoted strings of a macro's arguments, by parameter. */
104
- const CODE_IN_STRINGS: Record<string, Record<string, JsGoal>> = {
105
- watch: { condition: 'expression', run: 'statements' },
106
- };
125
+ const CODE_IN_STRINGS: ReadonlyMap<string, Record<string, JsGoal>> = new Map([
126
+ ['watch', { condition: 'expression', run: 'statements' }],
127
+ ]);
107
128
 
108
129
  /** The `string` parameters of a macro that name a passage. */
109
- const PASSAGE_STRINGS: Record<string, readonly string[]> = {
110
- link: ['passage'],
111
- watch: ['goto', 'dialog'],
112
- };
130
+ const PASSAGE_STRINGS: ReadonlyMap<string, readonly string[]> = new Map([
131
+ ['watch', ['goto', 'dialog']],
132
+ ]);
133
+
134
+ /**
135
+ * The `text` and `string` parameters of a macro that the runtime keeps as
136
+ * written, not as markup: an option's value, the name of a watcher.
137
+ */
138
+ const LITERAL_STRINGS: ReadonlyMap<string, readonly string[]> = new Map([
139
+ ['option', ['value']],
140
+ ['watch', ['name']],
141
+ ['unwatch', ['name']],
142
+ ]);
113
143
 
114
144
  /** Block macros whose body is the name of a passage. */
115
145
  const PASSAGE_BODIES = new Set(['dialog']);
116
146
 
117
- type Piece = CodePiece | TextPiece | PassagePiece;
147
+ type Piece = CodePiece | TextPiece | PassagePiece | ArgumentErrorPiece;
118
148
 
119
149
  /** The goal of the code an argument of this type holds, if it is code. */
120
150
  function codeGoal(param: ParameterDef): JsGoal | undefined {
@@ -150,6 +180,8 @@ export function* codeAndText(
150
180
  kind: 'passage',
151
181
  name: token.target,
152
182
  offset: locate(src, token.target, token.start),
183
+ length: token.target.length,
184
+ macro: 'link',
153
185
  label: src.slice(token.start, token.end),
154
186
  };
155
187
  } else if (token.type === 'expression') {
@@ -215,6 +247,8 @@ export function* codeAndText(
215
247
  kind: 'passage',
216
248
  name: passage,
217
249
  offset: locate(src, passage, token.end),
250
+ length: passage.length,
251
+ macro: name,
218
252
  label: src.slice(token.start, token.end),
219
253
  };
220
254
  }
@@ -239,16 +273,25 @@ export function* codeAndText(
239
273
  }
240
274
 
241
275
  /** The code and text in the arguments `args` (at `offset`) of `macro`. */
242
- function* argPieces(
276
+ export function* argPieces(
243
277
  args: string,
244
278
  offset: number,
245
279
  params: readonly ParameterDef[],
246
280
  macro: string,
247
281
  label: string,
248
282
  ): Generator<Piece> {
249
- const values = parseMacroArgs(args, params) as Record<string, unknown>;
250
- const inStrings = CODE_IN_STRINGS[macro.toLowerCase()] ?? {};
251
- const passageStrings = PASSAGE_STRINGS[macro.toLowerCase()] ?? [];
283
+ let values: Record<string, unknown>;
284
+ try {
285
+ values = parseMacroArgs(args, params) as Record<string, unknown>;
286
+ } catch (error) {
287
+ if (!(error instanceof MacroArgumentError)) throw error;
288
+ yield { kind: 'argument-error', message: error.message, offset, label };
289
+ return;
290
+ }
291
+ const key = macro.toLowerCase();
292
+ const inStrings = CODE_IN_STRINGS.get(key) ?? {};
293
+ const passageStrings = PASSAGE_STRINGS.get(key) ?? [];
294
+ const literalStrings = LITERAL_STRINGS.get(key) ?? [];
252
295
  let cursor = 0;
253
296
  /** The pieces of the parameters `list`, with their values in `from`. */
254
297
  function* visit(
@@ -276,20 +319,36 @@ function* argPieces(
276
319
  goal,
277
320
  label,
278
321
  };
279
- if (param.type === 'passage') piece.passage = true;
322
+ if (param.type === 'passage') {
323
+ piece.passage = true;
324
+ piece.macro = macro.toLowerCase();
325
+ }
326
+ if (!codeGoal(param)) piece.inString = true;
280
327
  yield piece;
281
328
  }
282
329
  // A passage written out: a quoted `passage` argument, or a string
283
- const name =
330
+ const written = param.type === 'passage' ? value.trim() : value;
331
+ const target =
284
332
  param.type === 'passage'
285
- ? readWholeQuoted(value.trim())
333
+ ? passageTarget(value)
286
334
  : passageStrings.includes(param.name)
287
- ? value
335
+ ? ({ kind: 'name', name: value } as const)
288
336
  : null;
289
- if (name !== null) {
290
- yield { kind: 'passage', name, offset: offset + at, label };
337
+ if (target?.kind === 'name') {
338
+ yield {
339
+ kind: 'passage',
340
+ name: target.name,
341
+ offset: offset + at + value.indexOf(written),
342
+ length: written.length,
343
+ macro: macro.toLowerCase(),
344
+ label,
345
+ };
291
346
  }
292
- if (!goal && (param.type === 'text' || param.type === 'string')) {
347
+ if (
348
+ !goal &&
349
+ (param.type === 'text' || param.type === 'string') &&
350
+ !literalStrings.includes(param.name)
351
+ ) {
293
352
  yield {
294
353
  kind: 'text',
295
354
  text: value,
@@ -312,3 +371,52 @@ function locate(text: string, part: string, from: number): number {
312
371
  const anywhere = text.indexOf(part);
313
372
  return anywhere >= 0 ? anywhere : from;
314
373
  }
374
+
375
+ /** A passage a piece of markup names, and where. */
376
+ export interface PassageReference {
377
+ /** The macro it is the argument of; `link` for `[[…]]` links. */
378
+ macro: string;
379
+ /** The name written out, or the expression whose value is the name. */
380
+ target: PassageTarget;
381
+ /** Where the name is written, as it is written (quotes included). */
382
+ start: number;
383
+ end: number;
384
+ }
385
+
386
+ /**
387
+ * The passages the markup `src` (with the `tokens` it tokenizes to) names:
388
+ * `[[…]]` links, the passage argument of `{goto}`, `{include}` and `{link}`
389
+ * (and of macros that declare one), the `goto` and `dialog` actions of
390
+ * `{watch}` and the body of `{dialog}`, in source order. A name written out
391
+ * is a literal; the others are expressions, which name a passage when they
392
+ * run.
393
+ */
394
+ export function collectPassageReferences(
395
+ src: string,
396
+ tokens: readonly Token[],
397
+ parametersOf: (macro: string) => readonly ParameterDef[] | undefined,
398
+ ): PassageReference[] {
399
+ const refs: PassageReference[] = [];
400
+ for (const piece of codeAndText(src, tokens, parametersOf)) {
401
+ if (piece.kind === 'passage') {
402
+ refs.push({
403
+ macro: piece.macro,
404
+ target: { kind: 'name', name: piece.name },
405
+ start: piece.offset,
406
+ end: piece.offset + piece.length,
407
+ });
408
+ } else if (piece.kind === 'code' && piece.passage) {
409
+ refs.push({
410
+ macro: piece.macro!,
411
+ target: passageTarget(piece.code),
412
+ start: piece.offset,
413
+ end: piece.offset + piece.code.length,
414
+ });
415
+ }
416
+ }
417
+ return refs.filter(
418
+ (ref, i) =>
419
+ refs.findIndex((r) => r.start === ref.start && r.macro === ref.macro) ===
420
+ i,
421
+ );
422
+ }
@@ -4,18 +4,14 @@ import { parseMarkup } from '../../markup/parse';
4
4
  import { NobrContext } from '../../markup/render';
5
5
  import { defineMacro } from '../../define-macro';
6
6
  import { evaluatePassageName } from './macro-args';
7
+ import { INCLUDE_PARAMETERS } from './include-args';
7
8
  import { MacroError } from './MacroError';
8
9
 
9
10
  defineMacro({
10
11
  name: 'include',
11
12
  interpolate: true,
12
13
  merged: true,
13
- // A standalone `inline` flag only counts outside quotes and brackets, so
14
- // passage names and expressions containing the word stay intact (#201).
15
- parameters: [
16
- { name: 'inline', type: 'flag' },
17
- { name: 'passage', type: 'passage', required: true },
18
- ],
14
+ parameters: INCLUDE_PARAMETERS,
19
15
  render(_props, ctx) {
20
16
  const { storyData } = useStoryFields('storyData');
21
17
 
@@ -1,24 +1,41 @@
1
1
  import { useStoryStore } from '../../store';
2
2
  import { defineMacro } from '../../define-macro';
3
3
  import { useDetachedBody } from './detached-body';
4
+ import { evaluatePassageName } from './macro-args';
5
+ import { MacroError } from './MacroError';
4
6
 
5
7
  defineMacro({
6
8
  name: 'link',
7
9
  block: true,
8
10
  interpolate: true,
9
- // {link "text" "Passage"} or {link "text"}; unquoted, the whole arguments
10
- // are the text.
11
+ merged: true,
12
+ // {link "text" passage} or {link "text"}; the passage is a quoted name or
13
+ // an expression, as in {goto}.
11
14
  parameters: [
12
15
  { name: 'text', type: 'string', required: true },
13
- { name: 'passage', type: 'string' },
16
+ { name: 'passage', type: 'passage' },
14
17
  ],
15
- render({ rawArgs, children = [] }, ctx) {
16
- const { text } = ctx.args;
17
- const display = text ?? rawArgs.trim();
18
- const passage = text === undefined ? null : (ctx.args.passage ?? null);
18
+ render({ children = [] }, ctx) {
19
+ const display = ctx.args.text ?? '';
20
+ let passage: string | null = null;
21
+ // Boxed: anything can be thrown, including null and other falsy values
22
+ let failure: { error: unknown } | undefined;
23
+ if (ctx.args.passage !== undefined) {
24
+ try {
25
+ passage = evaluatePassageName(
26
+ ctx.args.passage,
27
+ ctx.evaluate!,
28
+ useStoryStore.getState(),
29
+ );
30
+ } catch (error) {
31
+ failure = { error };
32
+ }
33
+ }
19
34
  const runBody = useDetachedBody();
20
35
 
21
36
  const perform = () => {
37
+ // A link that failed to resolve is an error, not a control
38
+ if (failure) return;
22
39
  runBody(children);
23
40
  if (passage) {
24
41
  useStoryStore.getState().navigate(passage);
@@ -36,9 +53,18 @@ defineMacro({
36
53
  authorId: ctx.id,
37
54
  label: display,
38
55
  target: passage ?? undefined,
56
+ disabled: failure ? true : undefined,
39
57
  perform,
40
58
  });
41
59
 
60
+ if (failure) {
61
+ return (
62
+ <MacroError
63
+ macro="link"
64
+ error={failure.error}
65
+ />
66
+ );
67
+ }
42
68
  return (
43
69
  <a
44
70
  id={ctx.id}
@@ -69,6 +69,43 @@ export function readWholeQuoted(src: string): string | null {
69
69
  return quoted && quoted.end === src.length ? quoted.value : null;
70
70
  }
71
71
 
72
+ const JS_SIMPLE_ESCAPES: Record<string, string> = {
73
+ n: '\n',
74
+ t: '\t',
75
+ r: '\r',
76
+ b: '\b',
77
+ f: '\f',
78
+ v: '\v',
79
+ '0': '\0',
80
+ };
81
+
82
+ /** Undo every escape of a JavaScript string literal's body. */
83
+ function decodeJsEscapes(body: string): string {
84
+ return body.replace(
85
+ /\\(?:u\{([0-9a-fA-F]+)\}|u([0-9a-fA-F]{4})|x([0-9a-fA-F]{2})|(\r\n|[\n\r\u2028\u2029])|([\s\S]))/g,
86
+ (_, braced, unicode, hex, lineBreak, other: string | undefined) => {
87
+ if (lineBreak !== undefined) return '';
88
+ const code = braced ?? unicode ?? hex;
89
+ if (code !== undefined) {
90
+ const point = parseInt(code, 16);
91
+ return point <= 0x10ffff ? String.fromCodePoint(point) : '';
92
+ }
93
+ return JS_SIMPLE_ESCAPES[other!] ?? other!;
94
+ },
95
+ );
96
+ }
97
+
98
+ /**
99
+ * The value of `src` when it is exactly one `"…"` or `'…'` string, read as
100
+ * the JavaScript literal it is (`"\u0048all"` is `Hall`), otherwise `null`.
101
+ * `readWholeQuoted` keeps other escapes as written, for labels and code.
102
+ */
103
+ export function readWholeJsString(src: string): string | null {
104
+ const quoted = readQuoted(src, 0);
105
+ if (!quoted || quoted.end !== src.length) return null;
106
+ return decodeJsEscapes(src.slice(1, -1));
107
+ }
108
+
72
109
  /**
73
110
  * Strip an optional quote from each end of an unquoted-or-loosely-quoted
74
111
  * argument (`"Red`, `Red'`), leaving at least one character. This is the
@@ -0,0 +1,23 @@
1
+ import { parseMacroArgs } from './macro-args';
2
+
3
+ /**
4
+ * The parameters of `{include}`. A standalone `inline` flag only counts
5
+ * outside quotes and brackets, so passage names and expressions containing
6
+ * the word stay intact (#201).
7
+ */
8
+ export const INCLUDE_PARAMETERS = [
9
+ { name: 'inline', type: 'flag' },
10
+ { name: 'passage', type: 'passage', required: true },
11
+ ] as const;
12
+
13
+ /**
14
+ * The arguments of `{include}`: whether it has the `inline` flag, as its
15
+ * first or last word, and the passage expression that is left.
16
+ */
17
+ export function splitIncludeFlag(rawArgs: string): {
18
+ inline: boolean;
19
+ passage: string | undefined;
20
+ } {
21
+ const { inline, passage } = parseMacroArgs(rawArgs, INCLUDE_PARAMETERS);
22
+ return { inline, passage };
23
+ }
@@ -22,6 +22,7 @@ import {
22
22
  endsWithOperator,
23
23
  isWhitespace,
24
24
  readQuoted,
25
+ readWholeJsString,
25
26
  readWholeQuoted,
26
27
  stripLooseQuotes,
27
28
  topLevelIndices,
@@ -50,10 +51,26 @@ export function readText(src: string): string {
50
51
  return readWholeQuoted(src) ?? stripLooseQuotes(src);
51
52
  }
52
53
 
54
+ /**
55
+ * An argument that doesn't have its parameter's form: a `string` parameter
56
+ * given anything but one quoted string.
57
+ */
58
+ export class MacroArgumentError extends Error {
59
+ constructor(param: ParameterDef, src: string) {
60
+ super(
61
+ `The ${param.name} must be a quoted string ("…" or '…'), ${src ? `not ${src}` : 'but it has no value'}`,
62
+ );
63
+ this.name = 'MacroArgumentError';
64
+ }
65
+ }
66
+
53
67
  function readValue(param: ParameterDef, src: string): unknown {
54
68
  switch (param.type) {
55
- case 'string':
56
- return readWholeQuoted(src) ?? undefined;
69
+ case 'string': {
70
+ const value = readWholeQuoted(src);
71
+ if (value === null) throw new MacroArgumentError(param, src);
72
+ return value;
73
+ }
57
74
  case 'text':
58
75
  return readText(src);
59
76
  case 'names':
@@ -76,6 +93,7 @@ function unset(param: ParameterDef): unknown {
76
93
 
77
94
  const WORD_RE = /\w+/y;
78
95
  const DIGITS_RE = /\d+/y;
96
+ const WORD_OR_PUNCT_RE = /\S+/y;
79
97
 
80
98
  /** Match the sticky regex `re` at `pos`, returning the matched text. */
81
99
  function matchAt(re: RegExp, src: string, pos: number): string | null {
@@ -86,7 +104,8 @@ function matchAt(re: RegExp, src: string, pos: number): string | null {
86
104
  /**
87
105
  * Read keyword options (`goto "X" priority 5 once`). A keyword takes the
88
106
  * quoted string or digit run after it as its value; keywords that aren't
89
- * declared, and their values, are skipped.
107
+ * declared, and their values, are skipped. A keyword of a `string` parameter
108
+ * must be followed by a quoted string.
90
109
  */
91
110
  function readOptions(
92
111
  params: readonly ParameterDef[],
@@ -105,10 +124,11 @@ function readOptions(
105
124
 
106
125
  // A value is a quoted string or a digit run, after whitespace.
107
126
  let val: string | undefined;
127
+ let quoted: ReturnType<typeof readQuoted> = null;
108
128
  let j = i;
109
129
  while (j < src.length && isWhitespace(src[j]!)) j++;
110
130
  if (j > i) {
111
- const quoted = readQuoted(src, j);
131
+ quoted = readQuoted(src, j);
112
132
  const digits = quoted ? null : matchAt(DIGITS_RE, src, j);
113
133
  if (quoted) {
114
134
  val = quoted.value;
@@ -120,6 +140,13 @@ function readOptions(
120
140
  }
121
141
 
122
142
  const param = params.find((p) => p.name === key);
143
+ // A keyword that takes a string was given anything else, or nothing
144
+ if (param?.type === 'string' && (!quoted || val === undefined)) {
145
+ throw new MacroArgumentError(
146
+ param,
147
+ matchAt(WORD_OR_PUNCT_RE, src, j) ?? '',
148
+ );
149
+ }
123
150
  if (param?.type === 'flag') options[key] = true;
124
151
  else if (param?.type === 'number') options[key] = Number(val ?? 0);
125
152
  else if (param) options[key] = val;
@@ -220,10 +247,20 @@ function readGroup(
220
247
  args[param.name] =
221
248
  from < to ? read(param, slice(text, spans[from++]!)) : unset(param);
222
249
  }
250
+ // Optional ones at the end: a term that doesn't have their form (a
251
+ // `string` that isn't quoted) belongs to the rest, as in {meter $hp 100}
252
+ const readTrailing = (param: ParameterDef, value: string) => {
253
+ try {
254
+ return read(param, value);
255
+ } catch (error) {
256
+ if (error instanceof MacroArgumentError) return undefined;
257
+ throw error;
258
+ }
259
+ };
223
260
  for (const param of positional.slice(restIndex + 1).reverse()) {
224
261
  const value =
225
262
  from < to && !endsWithOperator(text.slice(0, spans[to - 1]![0]))
226
- ? read(param, slice(text, spans[to - 1]!))
263
+ ? readTrailing(param, slice(text, spans[to - 1]!))
227
264
  : undefined;
228
265
  args[param.name] = value ?? unset(param);
229
266
  if (value !== undefined) to--;
@@ -264,6 +301,25 @@ export function parseMacroArgs<const P extends readonly ParameterDef[]>(
264
301
  return args as MacroArgs<P>;
265
302
  }
266
303
 
304
+ /** What a `passage` argument is: a quoted name, or an expression. */
305
+ export type PassageTarget =
306
+ | { kind: 'name'; name: string }
307
+ | { kind: 'expression'; expression: string };
308
+
309
+ /**
310
+ * Read a `passage` argument as written (`{goto "Hall"}`, `{goto $room}`): a
311
+ * quoted string is the name its JavaScript literal has (`"\u0048all"` is
312
+ * `Hall`), anything else an expression, whose value is the name when it
313
+ * runs (see evaluatePassageName).
314
+ */
315
+ export function passageTarget(arg: string): PassageTarget {
316
+ const expression = arg.trim();
317
+ const name = readWholeJsString(expression);
318
+ return name === null
319
+ ? { kind: 'expression', expression }
320
+ : { kind: 'name', name };
321
+ }
322
+
267
323
  /**
268
324
  * The passage a `passage` argument names (`{goto "Hall"}`, `{goto $room}`):
269
325
  * the value of its expression. Throws what evaluating it throws, and if no
@@ -1,5 +1,6 @@
1
1
  import type { ASTNode } from '../../markup/ast';
2
2
  import { parseMacroArgs } from './macro-args';
3
+ import type { ParameterDef } from '../../registry';
3
4
 
4
5
  /** `$var "placeholder"`, the arguments of the input macros. */
5
6
  export function parseVarArgs(rawArgs: string): {
@@ -14,7 +15,20 @@ export function parseVarArgs(rawArgs: string): {
14
15
  }
15
16
 
16
17
  /** The parameters of an `{option}` sub-macro: its value. */
17
- const OPTION_PARAMETERS = [{ name: 'value', type: 'string' }] as const;
18
+ const OPTION_PARAMETERS = [
19
+ { name: 'value', type: 'string', required: true },
20
+ ] as const;
21
+
22
+ /** The parameters of the built-in sub-macros that take arguments. */
23
+ const SUB_MACRO_PARAMETERS: ReadonlyMap<string, readonly ParameterDef[]> =
24
+ new Map([['option', OPTION_PARAMETERS]]);
25
+
26
+ /** The parameters of the built-in sub-macro `name`, if it takes any. */
27
+ export function subMacroParameters(
28
+ name: string,
29
+ ): readonly ParameterDef[] | undefined {
30
+ return SUB_MACRO_PARAMETERS.get(name.toLowerCase());
31
+ }
18
32
 
19
33
  /**
20
34
  * Walk AST children to find {option} macro nodes, returning their values.
@@ -23,12 +37,9 @@ export function extractOptions(children: ASTNode[]): string[] {
23
37
  const options: string[] = [];
24
38
  for (const node of children) {
25
39
  if (node.type === 'macro' && node.name === 'option') {
26
- // {option "Long Sword"} gives `Long Sword`. A value that isn't one
27
- // quoted string loses a matching pair of quotes around it, if any.
40
+ // {option "Long Sword"} gives `Long Sword`
28
41
  const { value } = parseMacroArgs(node.rawArgs, OPTION_PARAMETERS);
29
- options.push(
30
- value ?? node.rawArgs.trim().replace(/^(["'])(.+)\1$/s, '$2'),
31
- );
42
+ options.push(value ?? '');
32
43
  }
33
44
  }
34
45
  return options;
@@ -30,9 +30,11 @@ import { currentSourceLocation } from './utils/source-location';
30
30
  import { parseVarArgs, extractOptions } from './components/macros/option-utils';
31
31
  import { wrapContent } from './components/macros/display';
32
32
  import {
33
+ MacroArgumentError,
33
34
  parseMacroArgs,
34
35
  readBoundVariable,
35
36
  } from './components/macros/macro-args';
37
+ import { MacroError } from './components/macros/MacroError';
36
38
  import {
37
39
  checkParameterTypes,
38
40
  registerMacro,
@@ -162,8 +164,18 @@ export function defineMacro<const P extends readonly ParameterDef[] = []>(
162
164
  raw?: boolean;
163
165
  },
164
166
  ) => _renderNodes(nodes, { ...renderOptions, ...options });
167
+ let args: MacroArgs<P>;
168
+ try {
169
+ args = parseMacroArgs(props.rawArgs, parameters) as MacroArgs<P>;
170
+ } catch (error) {
171
+ // Shown in place, as other macro errors are
172
+ if (error instanceof MacroArgumentError) {
173
+ return h(MacroError, { macro: config.name, error });
174
+ }
175
+ throw error;
176
+ }
165
177
  const ctx: MacroContext<MacroArgs<P>> = {
166
- args: parseMacroArgs(props.rawArgs, parameters) as MacroArgs<P>,
178
+ args,
167
179
  collectText,
168
180
  sourceLocation: currentSourceLocation,
169
181
  parseVarArgs,
@@ -224,7 +236,15 @@ export function defineMacro<const P extends readonly ParameterDef[] = []>(
224
236
  };
225
237
  }
226
238
 
227
- return config.render(props, ctx);
239
+ try {
240
+ return config.render(props, ctx);
241
+ } catch (error) {
242
+ // An argument read while rendering, e.g. a sub-macro's ({option})
243
+ if (error instanceof MacroArgumentError) {
244
+ return h(MacroError, { macro: config.name, error });
245
+ }
246
+ throw error;
247
+ }
228
248
  }
229
249
 
230
250
  registerMacro(config.name, Wrapper);