@rohal12/spindle 0.57.0 → 0.59.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.57.0",
3
+ "version": "0.59.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
@@ -8,18 +8,20 @@
8
8
  * - `{$…}` expressions, and `{do}` bodies (statements);
9
9
  * - the conditions of `{if}`, `{elseif}` and `{case}`;
10
10
  * - macro arguments whose declared parameter type is `expression`,
11
- * `statements` or `passage`, built-in and custom macros alike; the
12
- * condition and `run` action of `{watch}`, code in quoted strings;
11
+ * `statements` or `passage`, built-in and custom macros alike, and the
12
+ * `string` and `text` arguments that hold code (ParameterDef.holds: the
13
+ * condition and `run` action of `{watch}`);
13
14
  * - the `{$…}` references in attributes holding code (`onclick`).
14
15
  *
15
- * Text that may hold markup is found in quoted labels (arguments of type
16
- * `text` and `string`) and HTML attribute values; the check reads its
17
- * markup in turn.
16
+ * Text that may hold markup is found in the `string` and `text` arguments
17
+ * that hold markup (labels such as `{button}`'s) and HTML attribute values;
18
+ * the check reads its markup in turn.
18
19
  *
19
20
  * Passage names written out are found in links (`[[Go->Hall]]`), in
20
21
  * `passage` arguments that are one quoted string (`{goto "Hall"}`), in the
21
- * passage of `{link}`, the `goto` and `dialog` actions of `{watch}` and the
22
- * body of `{dialog}`; the check looks each one up.
22
+ * `string` and `text` arguments that hold a passage name (the `goto` and
23
+ * `dialog` actions of `{watch}`) and in the body of `{dialog}`; the check
24
+ * looks each one up.
23
25
  *
24
26
  * The arguments of macros that declare no parameters may be anything:
25
27
  * they are not checked.
@@ -28,12 +30,16 @@ import type { Token } from './markup/tokens';
28
30
  import { isCodeAttribute, splitSigilTemplate } from './markup/code-attributes';
29
31
  import { CodeSyntaxError, parseCode, type JsGoal } from './js-lexer';
30
32
  import type { ParsedCode } from './js-lexer';
31
- import type { ParameterDef } from './registry';
33
+ import type { ParameterDef, StringHolds } from './registry';
32
34
  import {
33
35
  MacroArgumentError,
34
36
  parseMacroArgs,
37
+ passageTarget,
38
+ type ArgSpans,
39
+ type PassageTarget,
35
40
  } from './components/macros/macro-args';
36
- import { readWholeQuoted } from './components/macros/arg-utils';
41
+ import { subMacroParameters } from './components/macros/option-utils';
42
+ import { NameMap } from './utils/macro-names';
37
43
 
38
44
  /** While a pass runs: the code it parsed so far, by goal and source. */
39
45
  let parses: Map<string, ParsedCode | CodeSyntaxError> | null = null;
@@ -78,8 +84,15 @@ export interface CodePiece {
78
84
  goal: JsGoal;
79
85
  /** The markup it is in, for the error: `{print $a +}`. */
80
86
  label: string;
81
- /** Whether it names a passage: a `passage` argument. */
87
+ /**
88
+ * Whether it names a passage: a `passage` argument that is an
89
+ * expression (one that is a string literal is a PassagePiece).
90
+ */
82
91
  passage?: boolean;
92
+ /** Whether it is the code in a quoted string, as in `{watch}`. */
93
+ inString?: boolean;
94
+ /** The macro it is the argument of, for a `passage` argument. */
95
+ macro?: string;
83
96
  }
84
97
 
85
98
  /** A passage name written out in markup, at `offset` in it. */
@@ -89,6 +102,10 @@ export interface PassagePiece {
89
102
  offset: number;
90
103
  /** The markup it is in, for the error: `[[Go->Hall]]`. */
91
104
  label: string;
105
+ /** How much of the markup is the name, as written (quotes included). */
106
+ length: number;
107
+ /** The macro it is the argument of; `link` for `[[…]]` links. */
108
+ macro: string;
92
109
  }
93
110
 
94
111
  /** Macro arguments that don't have their parameters' forms. */
@@ -112,21 +129,79 @@ export interface TextPiece {
112
129
  /** Macros whose whole argument text is one expression: branch conditions. */
113
130
  const CONDITION_MACROS = new Set(['if', 'elseif', 'case']);
114
131
 
115
- /** Code inside the quoted strings of a macro's arguments, by parameter. */
116
- const CODE_IN_STRINGS: Record<string, Record<string, JsGoal>> = {
117
- watch: { condition: 'expression', run: 'statements' },
118
- };
119
-
120
- /** The `string` parameters of a macro that name a passage. */
121
- const PASSAGE_STRINGS: Record<string, readonly string[]> = {
122
- watch: ['goto', 'dialog'],
123
- };
124
-
125
132
  /** Block macros whose body is the name of a passage. */
126
133
  const PASSAGE_BODIES = new Set(['dialog']);
127
134
 
128
135
  type Piece = CodePiece | TextPiece | PassagePiece | ArgumentErrorPiece;
129
136
 
137
+ /** The declared parameters of a macro, if it has any. */
138
+ export type ParametersOf = (
139
+ macro: string,
140
+ ) => readonly ParameterDef[] | undefined;
141
+
142
+ /** What the argument check needs to know of a macro (see MacroMetadata). */
143
+ export interface MacroParameters {
144
+ name: string;
145
+ parameters?: readonly ParameterDef[];
146
+ interpolate?: boolean;
147
+ }
148
+
149
+ /**
150
+ * `params` with each `string` and `text` parameter (options too) saying
151
+ * what it holds: what it declares, else `holds`.
152
+ */
153
+ function withHolds(
154
+ params: readonly ParameterDef[],
155
+ holds: StringHolds,
156
+ ): readonly ParameterDef[] {
157
+ return params.map((param) => {
158
+ if (param.parameters) {
159
+ return { ...param, parameters: withHolds(param.parameters, holds) };
160
+ }
161
+ const isString = param.type === 'string' || param.type === 'text';
162
+ return isString && !param.holds ? { ...param, holds } : param;
163
+ });
164
+ }
165
+
166
+ /**
167
+ * The declared parameters of `macros`, by name in any case, and of the
168
+ * built-in sub-macros (`{option}`): what the story-start check, reference
169
+ * collection and tooling read arguments by. Each `string` and `text`
170
+ * parameter says what it holds: what it declares, else `markup` for a macro
171
+ * with `interpolate` (which can resolve markup) and `text` for any other.
172
+ */
173
+ export function parameterLookup(
174
+ macros: Iterable<MacroParameters>,
175
+ ): ParametersOf {
176
+ const parameters = new NameMap<readonly ParameterDef[]>();
177
+ for (const { name, parameters: params, interpolate } of macros) {
178
+ if (params)
179
+ parameters.set(name, withHolds(params, holdsByDefault(interpolate)));
180
+ }
181
+ return (name) => {
182
+ let params = parameters.get(name);
183
+ if (!params) {
184
+ const sub = subMacroParameters(name);
185
+ if (sub) parameters.set(name, (params = withHolds(sub, 'text')));
186
+ }
187
+ return params;
188
+ };
189
+ }
190
+
191
+ /** What a `string` or `text` argument holds when its parameter doesn't say. */
192
+ const holdsByDefault = (interpolate: boolean | undefined): StringHolds =>
193
+ interpolate ? 'markup' : 'text';
194
+
195
+ /** Whether a parameter in `params` (options too) holds code in a string. */
196
+ export function holdsCode(params: readonly ParameterDef[]): boolean {
197
+ return params.some(
198
+ (p) =>
199
+ p.holds === 'expression' ||
200
+ p.holds === 'statements' ||
201
+ (!!p.parameters && holdsCode(p.parameters)),
202
+ );
203
+ }
204
+
130
205
  /** The goal of the code an argument of this type holds, if it is code. */
131
206
  function codeGoal(param: ParameterDef): JsGoal | undefined {
132
207
  if (param.type === 'expression' || param.type === 'passage') {
@@ -136,6 +211,15 @@ function codeGoal(param: ParameterDef): JsGoal | undefined {
136
211
  return undefined;
137
212
  }
138
213
 
214
+ /**
215
+ * What a `string` or `text` argument holds: what its parameter says (see
216
+ * parameterLookup), else markup, which is checked.
217
+ */
218
+ function stringHolds(param: ParameterDef): StringHolds | undefined {
219
+ if (param.type !== 'string' && param.type !== 'text') return undefined;
220
+ return param.holds ?? 'markup';
221
+ }
222
+
139
223
  /**
140
224
  * The code and the markup-holding text in `tokens`, the tokens of `src`.
141
225
  * `parametersOf` gives the declared parameters of a macro, if it has any.
@@ -143,7 +227,7 @@ function codeGoal(param: ParameterDef): JsGoal | undefined {
143
227
  export function* codeAndText(
144
228
  src: string,
145
229
  tokens: readonly Token[],
146
- parametersOf: (macro: string) => readonly ParameterDef[] | undefined,
230
+ parametersOf: ParametersOf,
147
231
  ): Generator<Piece> {
148
232
  /** The index of the closing tag of the `name` macro opened at `t`. */
149
233
  const closeOf = (t: number, name: string) =>
@@ -160,7 +244,9 @@ export function* codeAndText(
160
244
  yield {
161
245
  kind: 'passage',
162
246
  name: token.target,
163
- offset: locate(src, token.target, token.start),
247
+ offset: token.targetStart,
248
+ length: token.targetEnd - token.targetStart,
249
+ macro: 'link',
164
250
  label: src.slice(token.start, token.end),
165
251
  };
166
252
  } else if (token.type === 'expression') {
@@ -172,9 +258,11 @@ export function* codeAndText(
172
258
  label: `{${token.expression}}`,
173
259
  };
174
260
  } else if (token.type === 'html') {
261
+ // The values are the source text between their quotes, in order
262
+ let cursor = token.start;
175
263
  for (const [name, value] of Object.entries(token.attributes)) {
176
- // The value is the source text between its quotes
177
- const at = locate(src, value, token.start);
264
+ const at = locate(src, value, cursor);
265
+ cursor = at + value.length;
178
266
  if (!isCodeAttribute(name)) {
179
267
  yield {
180
268
  kind: 'text',
@@ -221,18 +309,22 @@ export function* codeAndText(
221
309
  const body = tokens.slice(t + 1, close);
222
310
  if (close > t + 1 && body.every((b) => b.type === 'text')) {
223
311
  const text = src.slice(token.end, tokens[close]!.start);
224
- const passage = text.trim().replace(/^["']|["']$/g, '');
312
+ const written = text.trim();
225
313
  yield {
226
314
  kind: 'passage',
227
- name: passage,
228
- offset: locate(src, passage, token.end),
315
+ name: written.replace(/^["']|["']$/g, ''),
316
+ offset: token.end + text.indexOf(written),
317
+ length: written.length,
318
+ macro: name,
229
319
  label: src.slice(token.start, token.end),
230
320
  };
231
321
  }
232
322
  }
233
323
  if (!args) continue;
234
324
  const label = `{${token.name} ${args}}`;
235
- const argsAt = locate(src, args, token.start + 1);
325
+ // The arguments end the tag, but for the whitespace before its }
326
+ const tag = src.slice(token.start, token.end - 1);
327
+ const argsAt = token.start + tag.trimEnd().length - args.length;
236
328
  if (CONDITION_MACROS.has(name)) {
237
329
  yield {
238
330
  kind: 'code',
@@ -250,7 +342,7 @@ export function* codeAndText(
250
342
  }
251
343
 
252
344
  /** The code and text in the arguments `args` (at `offset`) of `macro`. */
253
- function* argPieces(
345
+ export function* argPieces(
254
346
  args: string,
255
347
  offset: number,
256
348
  params: readonly ParameterDef[],
@@ -258,16 +350,15 @@ function* argPieces(
258
350
  label: string,
259
351
  ): Generator<Piece> {
260
352
  let values: Record<string, unknown>;
353
+ const spans: ArgSpans = new Map();
261
354
  try {
262
- values = parseMacroArgs(args, params) as Record<string, unknown>;
355
+ values = parseMacroArgs(args, params, spans) as Record<string, unknown>;
263
356
  } catch (error) {
264
357
  if (!(error instanceof MacroArgumentError)) throw error;
265
358
  yield { kind: 'argument-error', message: error.message, offset, label };
266
359
  return;
267
360
  }
268
- const inStrings = CODE_IN_STRINGS[macro.toLowerCase()] ?? {};
269
- const passageStrings = PASSAGE_STRINGS[macro.toLowerCase()] ?? [];
270
- let cursor = 0;
361
+ const name = macro.toLowerCase();
271
362
  /** The pieces of the parameters `list`, with their values in `from`. */
272
363
  function* visit(
273
364
  list: readonly ParameterDef[],
@@ -282,36 +373,63 @@ function* argPieces(
282
373
  );
283
374
  continue;
284
375
  }
285
- if (typeof value !== 'string' || !value.trim()) continue;
286
- const at = locate(args, value, cursor);
287
- if (at >= cursor) cursor = at + value.length;
288
- const goal = codeGoal(param) ?? inStrings[param.name];
376
+ const span = spans.get(param);
377
+ if (typeof value !== 'string' || !value.trim() || !span) continue;
378
+ const [start, end] = span;
379
+ const written = args.slice(start, end);
380
+ /** The name as written (quotes included), at the argument. */
381
+ const passage = (passageName: string): PassagePiece => ({
382
+ kind: 'passage',
383
+ name: passageName,
384
+ offset: offset + start,
385
+ length: end - start,
386
+ macro: name,
387
+ label,
388
+ });
389
+ const goal = codeGoal(param);
390
+ if (param.type === 'passage') {
391
+ // A name written out, else an expression naming one
392
+ const target = passageTarget(value);
393
+ if (target.kind === 'name') {
394
+ yield passage(target.name);
395
+ continue;
396
+ }
397
+ }
289
398
  if (goal) {
290
399
  const piece: CodePiece = {
291
400
  kind: 'code',
292
401
  code: value,
293
- offset: offset + at,
402
+ offset: offset + start,
294
403
  goal,
295
404
  label,
296
405
  };
297
- if (param.type === 'passage') piece.passage = true;
406
+ if (param.type === 'passage') {
407
+ piece.passage = true;
408
+ piece.macro = name;
409
+ }
298
410
  yield piece;
411
+ continue;
299
412
  }
300
- // A passage written out: a quoted `passage` argument, or a string
301
- const name =
302
- param.type === 'passage'
303
- ? readWholeQuoted(value.trim())
304
- : passageStrings.includes(param.name)
305
- ? value
306
- : null;
307
- if (name !== null) {
308
- yield { kind: 'passage', name, offset: offset + at, label };
309
- }
310
- if (!goal && (param.type === 'text' || param.type === 'string')) {
413
+ // The value of a string is inside its quotes
414
+ const at =
415
+ offset + start + (value !== written && /^["']/.test(written) ? 1 : 0);
416
+ const holds = stringHolds(param);
417
+ if (holds === 'expression' || holds === 'statements') {
418
+ yield {
419
+ kind: 'code',
420
+ code: value,
421
+ offset: at,
422
+ goal: holds,
423
+ label,
424
+ inString: true,
425
+ };
426
+ } else if (holds === 'passage') {
427
+ yield passage(value);
428
+ } else if (holds === 'markup') {
311
429
  yield {
312
430
  kind: 'text',
313
431
  text: value,
314
- offset: offset + at,
432
+ offset: at,
315
433
  where: `In the ${param.name} of {${macro}}: `,
316
434
  };
317
435
  }
@@ -330,3 +448,49 @@ function locate(text: string, part: string, from: number): number {
330
448
  const anywhere = text.indexOf(part);
331
449
  return anywhere >= 0 ? anywhere : from;
332
450
  }
451
+
452
+ /** A passage a piece of markup names, and where. */
453
+ export interface PassageReference {
454
+ /** The macro it is the argument of; `link` for `[[…]]` links. */
455
+ macro: string;
456
+ /** The name written out, or the expression whose value is the name. */
457
+ target: PassageTarget;
458
+ /** Where the name is written, as it is written (quotes included). */
459
+ start: number;
460
+ end: number;
461
+ }
462
+
463
+ /**
464
+ * The passages the markup `src` (with the `tokens` it tokenizes to) names:
465
+ * `[[…]]` links, the passage argument of `{goto}`, `{include}` and `{link}`
466
+ * (and of macros that declare one), the `goto` and `dialog` actions of
467
+ * `{watch}` (and the `string` and `text` arguments of macros that declare
468
+ * they hold one) and the body of `{dialog}`, in source order. A name written
469
+ * out is a literal; the others are expressions, which name a passage when
470
+ * they run.
471
+ */
472
+ export function collectPassageReferences(
473
+ src: string,
474
+ tokens: readonly Token[],
475
+ parametersOf: ParametersOf,
476
+ ): PassageReference[] {
477
+ const refs: PassageReference[] = [];
478
+ for (const piece of codeAndText(src, tokens, parametersOf)) {
479
+ if (piece.kind === 'passage') {
480
+ refs.push({
481
+ macro: piece.macro,
482
+ target: { kind: 'name', name: piece.name },
483
+ start: piece.offset,
484
+ end: piece.offset + piece.length,
485
+ });
486
+ } else if (piece.kind === 'code' && piece.passage) {
487
+ refs.push({
488
+ macro: piece.macro!,
489
+ target: { kind: 'expression', expression: piece.code },
490
+ start: piece.offset,
491
+ end: piece.offset + piece.code.length,
492
+ });
493
+ }
494
+ }
495
+ return refs;
496
+ }
@@ -5,7 +5,9 @@ defineMacro({
5
5
  name: 'button',
6
6
  block: true,
7
7
  interpolate: true,
8
- parameters: [{ name: 'label', type: 'text', required: true }],
8
+ parameters: [
9
+ { name: 'label', type: 'text', holds: 'markup', required: true },
10
+ ],
9
11
  render({ rawArgs, children = [] }, ctx) {
10
12
  const label = ctx.resolve?.(ctx.args.label ?? '') ?? rawArgs;
11
13
  const runBody = useDetachedBody();
@@ -4,7 +4,10 @@ import { VARIABLE_PARAMETER, useVariableAction } from './input-macro';
4
4
  defineMacro({
5
5
  name: 'checkbox',
6
6
  storeVar: true,
7
- parameters: [VARIABLE_PARAMETER, { name: 'label', type: 'text' }],
7
+ parameters: [
8
+ VARIABLE_PARAMETER,
9
+ { name: 'label', type: 'text', holds: 'text' },
10
+ ],
8
11
  render(_props, ctx) {
9
12
  const label = ctx.args.label ?? '';
10
13
 
@@ -6,7 +6,7 @@ defineMacro({
6
6
  block: true,
7
7
  interpolate: true,
8
8
  parameters: [
9
- { name: 'label', type: 'text', required: true },
9
+ { name: 'label', type: 'text', holds: 'markup', required: true },
10
10
  { name: 'noclose', type: 'flag' },
11
11
  ],
12
12
  render({ children = [] }, ctx) {
@@ -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
 
@@ -12,7 +12,7 @@ defineMacro({
12
12
  // {link "text" passage} or {link "text"}; the passage is a quoted name or
13
13
  // an expression, as in {goto}.
14
14
  parameters: [
15
- { name: 'text', type: 'string', required: true },
15
+ { name: 'text', type: 'string', holds: 'markup', required: true },
16
16
  { name: 'passage', type: 'passage' },
17
17
  ],
18
18
  render({ children = [] }, ctx) {
@@ -34,6 +34,8 @@ defineMacro({
34
34
  const runBody = useDetachedBody();
35
35
 
36
36
  const perform = () => {
37
+ // A link that failed to resolve is an error, not a control
38
+ if (failure) return;
37
39
  runBody(children);
38
40
  if (passage) {
39
41
  useStoryStore.getState().navigate(passage);
@@ -51,6 +53,7 @@ defineMacro({
51
53
  authorId: ctx.id,
52
54
  label: display,
53
55
  target: passage ?? undefined,
56
+ disabled: failure ? true : undefined,
54
57
  perform,
55
58
  });
56
59
 
@@ -40,7 +40,7 @@ defineMacro({
40
40
  parameters: [
41
41
  { name: 'current', type: 'expression', required: true },
42
42
  { name: 'max', type: 'expression', required: true },
43
- { name: 'label', type: 'string' },
43
+ { name: 'label', type: 'string', holds: 'markup' },
44
44
  ],
45
45
  render(_props, ctx) {
46
46
  try {
@@ -6,8 +6,8 @@ defineMacro({
6
6
  storeVar: true,
7
7
  parameters: [
8
8
  VARIABLE_PARAMETER,
9
- { name: 'value', type: 'text', required: true },
10
- { name: 'label', type: 'text' },
9
+ { name: 'value', type: 'text', holds: 'text', required: true },
10
+ { name: 'label', type: 'text', holds: 'text' },
11
11
  ],
12
12
  render(_props, ctx) {
13
13
  const radioValue = ctx.args.value ?? '';
@@ -5,15 +5,20 @@ defineMacro({
5
5
  name: 'watch',
6
6
  // {watch "condition" goto "X" dialog "X" run "X" name "X" priority N once}
7
7
  parameters: [
8
- { name: 'condition', type: 'string', required: true },
8
+ {
9
+ name: 'condition',
10
+ type: 'string',
11
+ holds: 'expression',
12
+ required: true,
13
+ },
9
14
  {
10
15
  name: 'options',
11
16
  type: 'options',
12
17
  parameters: [
13
- { name: 'goto', type: 'string' },
14
- { name: 'dialog', type: 'string' },
15
- { name: 'run', type: 'string' },
16
- { name: 'name', type: 'string' },
18
+ { name: 'goto', type: 'string', holds: 'passage' },
19
+ { name: 'dialog', type: 'string', holds: 'passage' },
20
+ { name: 'run', type: 'string', holds: 'statements' },
21
+ { name: 'name', type: 'string', holds: 'text' },
17
22
  { name: 'priority', type: 'number' },
18
23
  { name: 'once', type: 'flag' },
19
24
  ],
@@ -41,7 +46,7 @@ defineMacro({
41
46
 
42
47
  defineMacro({
43
48
  name: 'unwatch',
44
- parameters: [{ name: 'name', type: 'text', required: true }],
49
+ parameters: [{ name: 'name', type: 'text', holds: 'text', required: true }],
45
50
  render(_props, ctx) {
46
51
  const name = ctx.args.name ?? '';
47
52
 
@@ -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
+ }
@@ -46,7 +46,10 @@ export function defineInputMacro(
46
46
  defineMacro({
47
47
  name,
48
48
  storeVar: true,
49
- parameters: [VARIABLE_PARAMETER, { name: 'placeholder', type: 'string' }],
49
+ parameters: [
50
+ VARIABLE_PARAMETER,
51
+ { name: 'placeholder', type: 'string', holds: 'text' },
52
+ ],
50
53
  render(_props, ctx) {
51
54
  const placeholder = ctx.args.placeholder ?? '';
52
55