@rohal12/spindle 0.54.0 → 0.55.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,7 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
 
5
- import { validateStoryMarkup } from './story-variables.js';
5
+ import { checkParameterTypes, validateStoryMarkup } from './story-variables.js';
6
6
 
7
7
  export { parseStoryVariables, formatDiagnostic } from './story-variables.js';
8
8
 
@@ -24,8 +24,10 @@ for (const m of builtins) metadata.set(m.name.toLowerCase(), m);
24
24
  * Metadata-only defineMacro for tooling.
25
25
  * Captures macro metadata without creating Preact components.
26
26
  * LSP servers call this to register user-defined macros discovered in story scripts.
27
+ * Like Story.defineMacro(), it throws if a declared parameter has no type.
27
28
  */
28
29
  export function defineMacro(config) {
30
+ checkParameterTypes(config.name, config.parameters ?? []);
29
31
  const name = config.name.toLowerCase();
30
32
  metadata.set(name, {
31
33
  name: config.name,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.54.0",
3
+ "version": "0.55.0",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -67,6 +67,7 @@
67
67
  "prepare": "husky"
68
68
  },
69
69
  "dependencies": {
70
+ "acorn": "^8.19.0",
70
71
  "devalue": "^6",
71
72
  "immer": "^11.1.4",
72
73
  "micromark": "^4.0.2",
@@ -0,0 +1,314 @@
1
+ /**
2
+ * The code and the markup-holding text in a passage's tokens, for the
3
+ * story-start check (markup/validate.ts): every piece of code a passage
4
+ * runs is parsed when the story starts, and each syntax error is reported
5
+ * with its line and column in the passage.
6
+ *
7
+ * Code is found where passages run it:
8
+ * - `{$…}` expressions, and `{do}` bodies (statements);
9
+ * - the conditions of `{if}`, `{elseif}` and `{case}`;
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;
13
+ * - the `{$…}` references in attributes holding code (`onclick`).
14
+ *
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.
18
+ *
19
+ * Passage names written out are found in links (`[[Go->Hall]]`), in
20
+ * `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.
23
+ *
24
+ * The arguments of macros that declare no parameters may be anything:
25
+ * they are not checked.
26
+ */
27
+ import type { Token } from './markup/tokens';
28
+ import { isCodeAttribute, splitSigilTemplate } from './markup/code-attributes';
29
+ import { CodeSyntaxError, parseCode, type JsGoal } from './js-lexer';
30
+ import type { ParsedCode } from './js-lexer';
31
+ import type { ParameterDef } from './registry';
32
+ import { parseMacroArgs } from './components/macros/macro-args';
33
+ import { readWholeQuoted } from './components/macros/arg-utils';
34
+
35
+ /** While a pass runs: the code it parsed so far, by goal and source. */
36
+ let parses: Map<string, ParsedCode | CodeSyntaxError> | null = null;
37
+
38
+ /**
39
+ * Run `fn` with a cache of parsed code, so that a pass parses each piece of
40
+ * code once however often it meets it.
41
+ */
42
+ export function withParseCache<T>(fn: () => T): T {
43
+ const outer = parses;
44
+ parses = new Map();
45
+ try {
46
+ return fn();
47
+ } finally {
48
+ parses = outer;
49
+ }
50
+ }
51
+
52
+ /** `parseCode`, with the error it throws for code that is not well-formed. */
53
+ export function parseOrError(
54
+ code: string,
55
+ goal: JsGoal,
56
+ ): ParsedCode | CodeSyntaxError {
57
+ const key = goal[0] + code;
58
+ let result = parses?.get(key);
59
+ if (result) return result;
60
+ try {
61
+ result = parseCode(code, goal);
62
+ } catch (error) {
63
+ if (!(error instanceof CodeSyntaxError)) throw error;
64
+ result = error;
65
+ }
66
+ parses?.set(key, result);
67
+ return result;
68
+ }
69
+
70
+ /** A piece of code in markup, at `offset` in it. */
71
+ export interface CodePiece {
72
+ kind: 'code';
73
+ code: string;
74
+ offset: number;
75
+ goal: JsGoal;
76
+ /** The markup it is in, for the error: `{print $a +}`. */
77
+ label: string;
78
+ /** Whether it names a passage: a `passage` argument. */
79
+ passage?: boolean;
80
+ }
81
+
82
+ /** A passage name written out in markup, at `offset` in it. */
83
+ export interface PassagePiece {
84
+ kind: 'passage';
85
+ name: string;
86
+ offset: number;
87
+ /** The markup it is in, for the error: `[[Go->Hall]]`. */
88
+ label: string;
89
+ }
90
+
91
+ /** Text in markup that may hold markup of its own, at `offset` in it. */
92
+ export interface TextPiece {
93
+ kind: 'text';
94
+ text: string;
95
+ offset: number;
96
+ /** Where it is, for the error: `In the label of {button}: `. */
97
+ where: string;
98
+ }
99
+
100
+ /** Macros whose whole argument text is one expression: branch conditions. */
101
+ const CONDITION_MACROS = new Set(['if', 'elseif', 'case']);
102
+
103
+ /** 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
+ };
107
+
108
+ /** 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
+ };
113
+
114
+ /** Block macros whose body is the name of a passage. */
115
+ const PASSAGE_BODIES = new Set(['dialog']);
116
+
117
+ type Piece = CodePiece | TextPiece | PassagePiece;
118
+
119
+ /** The goal of the code an argument of this type holds, if it is code. */
120
+ function codeGoal(param: ParameterDef): JsGoal | undefined {
121
+ if (param.type === 'expression' || param.type === 'passage') {
122
+ return 'expression';
123
+ }
124
+ if (param.type === 'statements') return 'statements';
125
+ return undefined;
126
+ }
127
+
128
+ /**
129
+ * The code and the markup-holding text in `tokens`, the tokens of `src`.
130
+ * `parametersOf` gives the declared parameters of a macro, if it has any.
131
+ */
132
+ export function* codeAndText(
133
+ src: string,
134
+ tokens: readonly Token[],
135
+ parametersOf: (macro: string) => readonly ParameterDef[] | undefined,
136
+ ): Generator<Piece> {
137
+ /** The index of the closing tag of the `name` macro opened at `t`. */
138
+ const closeOf = (t: number, name: string) =>
139
+ tokens.findIndex(
140
+ (c, k) =>
141
+ k > t &&
142
+ c.type === 'macro' &&
143
+ c.isClose &&
144
+ c.name.toLowerCase() === name,
145
+ );
146
+ for (let t = 0; t < tokens.length; t++) {
147
+ const token = tokens[t]!;
148
+ if (token.type === 'link') {
149
+ yield {
150
+ kind: 'passage',
151
+ name: token.target,
152
+ offset: locate(src, token.target, token.start),
153
+ label: src.slice(token.start, token.end),
154
+ };
155
+ } else if (token.type === 'expression') {
156
+ yield {
157
+ kind: 'code',
158
+ code: token.expression,
159
+ offset: token.end - 1 - token.expression.length,
160
+ goal: 'expression',
161
+ label: `{${token.expression}}`,
162
+ };
163
+ } else if (token.type === 'html') {
164
+ for (const [name, value] of Object.entries(token.attributes)) {
165
+ // The value is the source text between its quotes
166
+ const at = locate(src, value, token.start);
167
+ if (!isCodeAttribute(name)) {
168
+ yield {
169
+ kind: 'text',
170
+ text: value,
171
+ offset: at,
172
+ where: `In the ${name} attribute of <${token.tag}>: `,
173
+ };
174
+ continue;
175
+ }
176
+ let from = 0;
177
+ for (const part of splitSigilTemplate(value)) {
178
+ if (!('expr' in part)) continue;
179
+ const k = locate(value, part.expr, from);
180
+ from = k + part.expr.length;
181
+ yield {
182
+ kind: 'code',
183
+ code: part.expr,
184
+ offset: at + k,
185
+ goal: 'expression',
186
+ label: `{${part.expr}} in the ${name} attribute of <${token.tag}>`,
187
+ };
188
+ }
189
+ }
190
+ } else if (token.type === 'macro' && !token.isClose) {
191
+ const name = token.name.toLowerCase();
192
+ if (name === 'do') {
193
+ const close = closeOf(t, name);
194
+ if (close < 0) continue;
195
+ const body = src.slice(token.end, tokens[close]!.start);
196
+ yield {
197
+ kind: 'code',
198
+ code: body,
199
+ offset: token.end,
200
+ goal: 'statements',
201
+ label: '{do}',
202
+ };
203
+ t = close;
204
+ continue;
205
+ }
206
+ const args = token.rawArgs;
207
+ if (PASSAGE_BODIES.has(name)) {
208
+ // The body names the passage when it is only text
209
+ const close = closeOf(t, name);
210
+ const body = tokens.slice(t + 1, close);
211
+ if (close > t + 1 && body.every((b) => b.type === 'text')) {
212
+ const text = src.slice(token.end, tokens[close]!.start);
213
+ const passage = text.trim().replace(/^["']|["']$/g, '');
214
+ yield {
215
+ kind: 'passage',
216
+ name: passage,
217
+ offset: locate(src, passage, token.end),
218
+ label: src.slice(token.start, token.end),
219
+ };
220
+ }
221
+ }
222
+ if (!args) continue;
223
+ const label = `{${token.name} ${args}}`;
224
+ const argsAt = locate(src, args, token.start + 1);
225
+ if (CONDITION_MACROS.has(name)) {
226
+ yield {
227
+ kind: 'code',
228
+ code: args,
229
+ offset: argsAt,
230
+ goal: 'expression',
231
+ label,
232
+ };
233
+ continue;
234
+ }
235
+ const params = parametersOf(name);
236
+ if (params) yield* argPieces(args, argsAt, params, token.name, label);
237
+ }
238
+ }
239
+ }
240
+
241
+ /** The code and text in the arguments `args` (at `offset`) of `macro`. */
242
+ function* argPieces(
243
+ args: string,
244
+ offset: number,
245
+ params: readonly ParameterDef[],
246
+ macro: string,
247
+ label: string,
248
+ ): 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()] ?? [];
252
+ let cursor = 0;
253
+ /** The pieces of the parameters `list`, with their values in `from`. */
254
+ function* visit(
255
+ list: readonly ParameterDef[],
256
+ from: Record<string, unknown>,
257
+ ): Generator<Piece> {
258
+ for (const param of list) {
259
+ const value = from[param.name];
260
+ if (param.type === 'options' && param.parameters) {
261
+ yield* visit(
262
+ param.parameters,
263
+ (value ?? {}) as Record<string, unknown>,
264
+ );
265
+ continue;
266
+ }
267
+ if (typeof value !== 'string' || !value.trim()) continue;
268
+ const at = locate(args, value, cursor);
269
+ if (at >= cursor) cursor = at + value.length;
270
+ const goal = codeGoal(param) ?? inStrings[param.name];
271
+ if (goal) {
272
+ const piece: CodePiece = {
273
+ kind: 'code',
274
+ code: value,
275
+ offset: offset + at,
276
+ goal,
277
+ label,
278
+ };
279
+ if (param.type === 'passage') piece.passage = true;
280
+ yield piece;
281
+ }
282
+ // A passage written out: a quoted `passage` argument, or a string
283
+ const name =
284
+ param.type === 'passage'
285
+ ? readWholeQuoted(value.trim())
286
+ : passageStrings.includes(param.name)
287
+ ? value
288
+ : null;
289
+ if (name !== null) {
290
+ yield { kind: 'passage', name, offset: offset + at, label };
291
+ }
292
+ if (!goal && (param.type === 'text' || param.type === 'string')) {
293
+ yield {
294
+ kind: 'text',
295
+ text: value,
296
+ offset: offset + at,
297
+ where: `In the ${param.name} of {${macro}}: `,
298
+ };
299
+ }
300
+ }
301
+ }
302
+ yield* visit(params, values);
303
+ }
304
+
305
+ /**
306
+ * Index of `part` in `text` from `from` on, else anywhere, else `from`: the
307
+ * place to report an error in `part` at.
308
+ */
309
+ function locate(text: string, part: string, from: number): number {
310
+ const at = text.indexOf(part, from);
311
+ if (at >= 0) return at;
312
+ const anywhere = text.indexOf(part);
313
+ return anywhere >= 0 ? anywhere : from;
314
+ }
@@ -1,13 +1,15 @@
1
1
  import { useStoryStore } from '../../store';
2
2
  import { defineMacro } from '../../define-macro';
3
3
  import { evaluatePassageName } from './macro-args';
4
- import { logMacroError } from './MacroError';
4
+ import { logMacroError, MacroError } from './MacroError';
5
5
 
6
6
  defineMacro({
7
7
  name: 'goto',
8
8
  merged: true,
9
- parameters: [{ name: 'passage', type: 'expression', required: true }],
9
+ parameters: [{ name: 'passage', type: 'passage', required: true }],
10
10
  render(_props, ctx) {
11
+ // Boxed: anything can be thrown, including null and other falsy values
12
+ const [failure, setFailure] = ctx.hooks.useState<{ error: unknown }>();
11
13
  ctx.hooks.useLayoutEffect(() => {
12
14
  // Reported like an error in {do}: thrown out of an effect, it would
13
15
  // abort the rest of the render's effects (e.g. the session could not
@@ -15,14 +17,23 @@ defineMacro({
15
17
  // this passage's, taken before navigating away from it
16
18
  const location = ctx.sourceLocation();
17
19
  try {
18
- useStoryStore
19
- .getState()
20
- .navigate(evaluatePassageName(ctx.args.passage, ctx.evaluate!));
21
- } catch (err) {
22
- logMacroError('goto', err, location);
20
+ const store = useStoryStore.getState();
21
+ store.navigate(
22
+ evaluatePassageName(ctx.args.passage, ctx.evaluate!, store),
23
+ );
24
+ } catch (error) {
25
+ logMacroError('goto', error, location);
26
+ // Shown in place, as other macros show their errors, unless it
27
+ // navigated away
28
+ setFailure({ error });
23
29
  }
24
30
  }, []);
25
31
 
26
- return null;
32
+ return failure ? (
33
+ <MacroError
34
+ macro="goto"
35
+ error={failure.error}
36
+ />
37
+ ) : null;
27
38
  },
28
39
  });
@@ -4,6 +4,7 @@ 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 { MacroError } from './MacroError';
7
8
 
8
9
  defineMacro({
9
10
  name: 'include',
@@ -13,13 +14,23 @@ defineMacro({
13
14
  // passage names and expressions containing the word stay intact (#201).
14
15
  parameters: [
15
16
  { name: 'inline', type: 'flag' },
16
- { name: 'passage', type: 'expression', required: true },
17
+ { name: 'passage', type: 'passage', required: true },
17
18
  ],
18
19
  render(_props, ctx) {
19
20
  const { storyData } = useStoryFields('storyData');
20
21
 
21
22
  const { inline } = ctx.args;
22
- const passageName = evaluatePassageName(ctx.args.passage, ctx.evaluate!);
23
+ let passageName = '';
24
+ let failure: { error: unknown } | undefined;
25
+ try {
26
+ passageName = evaluatePassageName(
27
+ ctx.args.passage,
28
+ ctx.evaluate!,
29
+ useStoryStore.getState(),
30
+ );
31
+ } catch (error) {
32
+ failure = { error };
33
+ }
23
34
 
24
35
  const passage = storyData?.passages.get(passageName);
25
36
  // Parse once per included passage: the renderer keys children by AST
@@ -32,15 +43,17 @@ defineMacro({
32
43
  );
33
44
 
34
45
  if (!storyData) return null;
35
-
36
- if (passage) {
37
- useStoryStore.getState().trackRender(passageName);
38
- }
39
- if (!passage || !ast) {
46
+ if (failure) {
40
47
  return (
41
- <span class="error">{`{include${ctx.sourceLocation()}: passage "${passageName}" not found}`}</span>
48
+ <MacroError
49
+ macro="include"
50
+ error={failure.error}
51
+ />
42
52
  );
43
53
  }
54
+ if (!passage || !ast) return null;
55
+
56
+ useStoryStore.getState().trackRender(passageName);
44
57
 
45
58
  const nobr = passage.tags.includes('nobr');
46
59
  const content = inline
@@ -3,7 +3,7 @@ import { useRunOnce } from './MacroError';
3
3
 
4
4
  defineMacro({
5
5
  name: 'set',
6
- parameters: [{ name: 'code', type: 'expression', required: true }],
6
+ parameters: [{ name: 'code', type: 'statements', required: true }],
7
7
  render({ rawArgs }, ctx) {
8
8
  return useRunOnce('set', rawArgs, () => ctx.mutate(rawArgs));
9
9
  },
@@ -16,6 +16,8 @@
16
16
  */
17
17
  import { parseDelay } from '../../utils/parse-delay';
18
18
  import type { MacroArgs, ParameterDef } from '../../registry';
19
+ import type { StoryState } from '../../store';
20
+ import { noPassageError } from '../../runtime-errors';
19
21
  import {
20
22
  endsWithOperator,
21
23
  isWhitespace,
@@ -177,8 +179,9 @@ function findSeparator(src: string, word: string): Span | null {
177
179
  }
178
180
 
179
181
  const takesRest = (param: ParameterDef) =>
180
- param.type === undefined ||
181
182
  param.type === 'expression' ||
183
+ param.type === 'statements' ||
184
+ param.type === 'passage' ||
182
185
  param.type === 'text';
183
186
 
184
187
  /** Read the parameters of one group from `src` (`null`: no text) into `args`. */
@@ -262,18 +265,24 @@ export function parseMacroArgs<const P extends readonly ParameterDef[]>(
262
265
  }
263
266
 
264
267
  /**
265
- * The passage a passage-name argument names: the value of its expression
266
- * or, when that can't be evaluated (`{goto Bob's room}`), its text.
268
+ * The passage a `passage` argument names (`{goto "Hall"}`, `{goto $room}`):
269
+ * the value of its expression. Throws what evaluating it throws, and if no
270
+ * passage of the story `state` holds has that name, an error naming it and
271
+ * the current passage.
267
272
  */
268
273
  export function evaluatePassageName(
269
274
  expr: string | undefined,
270
275
  evaluate: (expr: string) => unknown,
276
+ {
277
+ storyData,
278
+ currentPassage,
279
+ }: Pick<StoryState, 'storyData' | 'currentPassage'>,
271
280
  ): string {
272
- try {
273
- return String(evaluate(expr ?? ''));
274
- } catch {
275
- return readText(expr ?? '');
281
+ const name = String(evaluate(expr ?? ''));
282
+ if (storyData && !storyData.passages.has(name)) {
283
+ throw noPassageError(name, currentPassage);
276
284
  }
285
+ return name;
277
286
  }
278
287
 
279
288
  /**
@@ -34,6 +34,7 @@ import {
34
34
  readBoundVariable,
35
35
  } from './components/macros/macro-args';
36
36
  import {
37
+ checkParameterTypes,
37
38
  registerMacro,
38
39
  registerMacroText,
39
40
  registerSubMacro,
@@ -136,6 +137,7 @@ export function defineMacro<const P extends readonly ParameterDef[] = []>(
136
137
  source: 'builtin' | 'user' = 'builtin',
137
138
  ): void {
138
139
  const parameters: readonly ParameterDef[] = config.parameters ?? [];
140
+ checkParameterTypes(config.name, parameters);
139
141
 
140
142
  function Wrapper(props: MacroProps) {
141
143
  // className/id resolved first (interpolate may transform them)
package/src/expression.ts CHANGED
@@ -2,7 +2,7 @@ import type { StoryState } from './store';
2
2
  import { useStoryStore } from './store';
3
3
  import type { Passage } from './parser';
4
4
  import { random, randomInt } from './prng';
5
- import { lexJs, type JsGoal, type Sigil } from './js-lexer';
5
+ import { parseCode, type JsGoal, type Sigil } from './js-lexer';
6
6
  import {
7
7
  EMPTY_NAMESPACE,
8
8
  asNamespace,
@@ -63,29 +63,33 @@ const IDENT_END_RE = /[\p{ID_Continue}$\u200c\u200d]$/u;
63
63
  * Exported for tests.
64
64
  */
65
65
  export function transform(expr: string, goal: JsGoal = 'expression'): string {
66
+ const key = goal === 'statements' ? 's' + expr : 'e' + expr;
67
+ const cached = transformCache.get(key);
68
+ if (cached !== undefined) return cached;
69
+ const { refs } = parseCode(expr, goal);
66
70
  let result = '';
67
- lexJs(
68
- expr,
69
- {
70
- code(ch) {
71
- result += ch;
72
- },
73
- literal(text) {
74
- result += text;
75
- },
76
- variable(sigil, name) {
77
- const error = variableNameError(name, sigil + name);
78
- if (error) throw new SyntaxError(`spindle: ${error}`);
79
- // `typeof%x` needs a space once `%x` turns into an identifier.
80
- if (IDENT_END_RE.test(result.slice(-2))) result += ' ';
81
- result += `${NAMESPACES[sigil]}["${name}"]`;
82
- },
83
- },
84
- goal,
85
- );
71
+ let at = 0;
72
+ for (const ref of refs) {
73
+ const error = variableNameError(ref.name, ref.sigil + ref.name);
74
+ if (error) throw new SyntaxError(`spindle: ${error}`);
75
+ result += expr.slice(at, ref.start);
76
+ if (ref.shorthand) result += `${ref.name}: `;
77
+ // `typeof%x` needs a space once `%x` turns into an identifier.
78
+ else if (IDENT_END_RE.test(result.slice(-2))) result += ' ';
79
+ result += `${NAMESPACES[ref.sigil]}["${ref.name}"]`;
80
+ at = ref.end;
81
+ }
82
+ result += expr.slice(at);
83
+ if (transformCache.size >= FN_CACHE_MAX) {
84
+ transformCache.delete(transformCache.keys().next().value!);
85
+ }
86
+ transformCache.set(key, result);
86
87
  return result;
87
88
  }
88
89
 
90
+ /** Transformed code by goal and source. */
91
+ const transformCache = new Map<string, string>();
92
+
89
93
  const preamble =
90
94
  'const {currentPassage,previousPassage,visited,hasVisited,hasVisitedAny,hasVisitedAll,rendered,hasRendered,hasRenderedAny,hasRenderedAll,random,randomInt}=__fns;';
91
95
 
@@ -265,6 +269,7 @@ export function execute(code: string, ...namespaces: Namespaces): void {
265
269
  /** Clear the compiled expression cache. Useful for testing and HMR. */
266
270
  export function clearExpressionCache(): void {
267
271
  fnCache.clear();
272
+ transformCache.clear();
268
273
  cachedFns = null;
269
274
  cachedVisitCounts = null;
270
275
  cachedRenderCounts = null;
package/src/index.tsx CHANGED
@@ -62,17 +62,23 @@ function stopWithErrors(errors: string[]): never {
62
62
  }
63
63
 
64
64
  /**
65
- * The markup errors (malformed markup, unknown macros) of the passages for
66
- * which `only` holds, with the macros and widgets known now.
65
+ * The markup errors (malformed markup, unknown macros, syntax errors in
66
+ * code) of the passages for which `only` holds, with the macros and widgets
67
+ * known now.
67
68
  */
68
69
  function markupErrors(
69
70
  storyData: StoryData,
70
71
  only: (passage: MarkupPassage) => boolean,
71
72
  ): string[] {
73
+ const macros = getMacroRegistry();
74
+ const parameters = new Map(
75
+ macros.map((m) => [m.name.toLowerCase(), m.parameters]),
76
+ );
72
77
  return validateMarkup(storyData.passages.values(), {
73
78
  isKnownMacro: (name) =>
74
79
  !!getMacro(name) || isSubMacro(name) || !!getWidget(name),
75
- macroNames: getMacroRegistry().map((m) => m.name),
80
+ macroNames: macros.map((m) => m.name),
81
+ parametersOf: (name) => parameters.get(name),
76
82
  only,
77
83
  }).map(formatDiagnostic);
78
84
  }