@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.
@@ -1,13 +1,16 @@
1
1
  /**
2
2
  * Markup validation: parse every passage when the story starts and report
3
- * malformed markup and unknown macros with the passage, line and column
4
- * they are at, instead of failing when the passage renders. Tooling runs it
5
- * too (see src/tooling.ts).
3
+ * malformed markup, unknown macros, syntax errors in the code it runs and
4
+ * passage names that name no passage (see code-check.ts) with the passage,
5
+ * line and column they are at, instead of failing when the passage renders.
6
+ * Tooling runs it too (see src/tooling.ts).
6
7
  */
7
8
  import { lineColumn, MarkupError, parseMarkup, tokenizeMarkup } from './parse';
8
9
  import type { Token } from './tokens';
9
- import { isCodeAttribute } from './code-attributes';
10
10
  import { parseWidgetDef } from '../widgets/widget-def';
11
+ import { codeAndText, parseOrError, withParseCache } from '../code-check';
12
+ import { CodeSyntaxError } from '../js-lexer';
13
+ import type { ParameterDef } from '../registry';
11
14
 
12
15
  /** A passage to validate. */
13
16
  export interface MarkupPassage {
@@ -49,6 +52,13 @@ export interface MarkupValidationOptions {
49
52
  * others are still read for the widgets they define.
50
53
  */
51
54
  only?(passage: MarkupPassage): boolean;
55
+ /**
56
+ * The declared parameters of a macro, whose `expression` and `statements`
57
+ * arguments are checked as code and whose `text` and `string` arguments
58
+ * as markup (see code-check.ts). Without them only the code of `{$…}`,
59
+ * `{do}`, branch conditions and attributes is checked.
60
+ */
61
+ parametersOf?(name: string): readonly ParameterDef[] | undefined;
52
62
  }
53
63
 
54
64
  /** Passages that hold no markup. */
@@ -83,8 +93,15 @@ function distance(a: string, b: string): number {
83
93
  return prev[b.length]!;
84
94
  }
85
95
 
86
- /** " Did you mean {name}?" for the closest known name, if one is close. */
87
- function suggestion(name: string, names: readonly string[]): string {
96
+ /**
97
+ * " Did you mean …?" with the known name closest to `name`, written by
98
+ * `quote`, if one is close.
99
+ */
100
+ function didYouMean(
101
+ name: string,
102
+ names: Iterable<string>,
103
+ quote: (name: string) => string,
104
+ ): string {
88
105
  const lower = name.toLowerCase();
89
106
  let best = '';
90
107
  let bestDistance = Math.max(2, Math.floor(lower.length / 3)) + 1;
@@ -95,9 +112,16 @@ function suggestion(name: string, names: readonly string[]): string {
95
112
  bestDistance = d;
96
113
  }
97
114
  }
98
- return best ? ` Did you mean {${best}}?` : '';
115
+ return best ? ` Did you mean ${quote(best)}?` : '';
99
116
  }
100
117
 
118
+ /** What a passage argument may be, after an error in one. */
119
+ const PASSAGE_ARGUMENT_HINT =
120
+ ' (a passage name is a quoted string or an expression)';
121
+
122
+ /** A passage argument that is one bare word (`{goto Kitchen}`). */
123
+ const BARE_WORD_RE = /^[A-Za-z][\w$]*$/;
124
+
101
125
  /** The name of the widget a `{widget}` with these arguments defines. */
102
126
  function widgetName(rawArgs: string): string | undefined {
103
127
  try {
@@ -107,7 +131,10 @@ function widgetName(rawArgs: string): string | undefined {
107
131
  }
108
132
  }
109
133
 
110
- /** Validate the markup of every passage. */
134
+ /**
135
+ * Validate the markup of every passage. Passage names written out (links,
136
+ * quoted `passage` arguments) must name one of `passages`.
137
+ */
111
138
  export function validateMarkup(
112
139
  passages: Iterable<MarkupPassage>,
113
140
  options: MarkupValidationOptions,
@@ -136,7 +163,9 @@ export function validateMarkup(
136
163
  // Parse every passage first: the widgets they define are known macros.
137
164
  const parsed: [MarkupPassage, Token[]][] = [];
138
165
  const widgets = new Set<string>();
166
+ const passageNames = new Set<string>();
139
167
  for (const passage of passages) {
168
+ passageNames.add(passage.name);
140
169
  if (NOT_MARKUP.has(passage.name)) continue;
141
170
  if (passage.tags?.some((tag) => NOT_MARKUP_TAGS.includes(tag))) continue;
142
171
  const reported = !options.only || options.only(passage);
@@ -181,39 +210,72 @@ export function validateMarkup(
181
210
  report(
182
211
  passage,
183
212
  base + token.start,
184
- `${where}Unknown macro {${token.name}}.${suggestion(token.name, names)}`,
213
+ `${where}Unknown macro {${token.name}}.${didYouMean(token.name, names, (n) => `{${n}}`)}`,
185
214
  );
186
215
  }
187
216
  };
188
217
 
189
- for (const [passage, tokens] of parsed) {
190
- checkMacros(passage, tokens, 0, '');
191
- for (const token of tokens) {
192
- if (token.type !== 'html') continue;
193
- for (const [attr, value] of Object.entries(token.attributes)) {
194
- if (isCodeAttribute(attr) || !value.includes('{')) continue;
195
- // The value is the source text between its quotes
196
- const found = passage.content.indexOf(value, token.start);
197
- const at = found === -1 ? token.start : found;
198
- const where = `In the ${attr} attribute of <${token.tag}>: `;
199
- try {
200
- parseMarkup(value, { text: true, hooks });
201
- checkMacros(
218
+ const parametersOf = options.parametersOf ?? (() => undefined);
219
+
220
+ /**
221
+ * Check `tokens`, the tokens of `src`, which starts at `base` in the
222
+ * passage: their macros, the code in them and the markup in their text.
223
+ */
224
+ const checkTokens = (
225
+ passage: MarkupPassage,
226
+ src: string,
227
+ tokens: Token[],
228
+ base: number,
229
+ where: string,
230
+ ) => {
231
+ checkMacros(passage, tokens, base, where);
232
+ for (const piece of codeAndText(src, tokens, parametersOf)) {
233
+ const at = base + piece.offset;
234
+ if (piece.kind === 'passage') {
235
+ if (!passageNames.has(piece.name)) {
236
+ const hint = didYouMean(piece.name, passageNames, JSON.stringify);
237
+ report(
202
238
  passage,
203
- tokenizeMarkup(value, { text: true }),
204
239
  at,
205
- where,
240
+ `${where}No passage named ${JSON.stringify(piece.name)} in ${piece.label}.${hint}`,
206
241
  );
207
- } catch (err) {
208
- if (!(err instanceof MarkupError)) throw err;
242
+ }
243
+ continue;
244
+ }
245
+ if (piece.kind === 'code') {
246
+ const result = parseOrError(piece.code, piece.goal);
247
+ const code = piece.code.trim();
248
+ if (result instanceof CodeSyntaxError) {
249
+ const reason = result.reasonIn(passage.content, at);
209
250
  report(
210
251
  passage,
211
- found === -1 ? at : at + err.offset,
212
- where + err.reason,
252
+ at + result.pos,
253
+ `${where}${reason} in ${piece.label}` +
254
+ (piece.passage ? PASSAGE_ARGUMENT_HINT : ''),
255
+ );
256
+ } else if (piece.passage && BARE_WORD_RE.test(code)) {
257
+ report(
258
+ passage,
259
+ at,
260
+ `${where}Unquoted passage name in ${piece.label}: write ${JSON.stringify(code)}${PASSAGE_ARGUMENT_HINT}`,
213
261
  );
214
262
  }
263
+ continue;
264
+ }
265
+ if (!piece.text.includes('{')) continue;
266
+ try {
267
+ parseMarkup(piece.text, { text: true, hooks });
268
+ const inner = tokenizeMarkup(piece.text, { text: true });
269
+ checkTokens(passage, piece.text, inner, at, piece.where);
270
+ } catch (err) {
271
+ if (!(err instanceof MarkupError)) throw err;
272
+ report(passage, at + err.offset, piece.where + err.reason);
215
273
  }
216
274
  }
275
+ };
276
+
277
+ for (const [passage, tokens] of parsed) {
278
+ withParseCache(() => checkTokens(passage, passage.content, tokens, 0, ''));
217
279
  }
218
280
  return diagnostics;
219
281
  }
package/src/registry.ts CHANGED
@@ -69,9 +69,12 @@ export function isSubMacro(name: string): boolean {
69
69
  }
70
70
 
71
71
  /**
72
- * How a macro argument is read (see components/macros/macro-args.ts).
72
+ * How a macro argument is read (see components/macros/macro-args.ts). Every
73
+ * declared parameter names one: there is no default.
73
74
  * Quoted strings accept `\"`, `\'` and `\\` escapes.
74
- * - `expression`: code, as written (the default).
75
+ * - `expression`: code, as written.
76
+ * - `statements`: code run as statements (`{set}`), as written.
77
+ * - `passage`: a passage name: a quoted string or an expression, as written.
75
78
  * - `variable`: a variable reference such as `$name` or `"$name"`, as written.
76
79
  * - `string`: one quoted string; anything else leaves the argument unset.
77
80
  * - `text`: one quoted string, or text with any loose quotes stripped.
@@ -84,27 +87,59 @@ export function isSubMacro(name: string): boolean {
84
87
  * - `options`: keywords, the names of its `parameters`, each followed by a
85
88
  * quoted string or a number unless it is a flag.
86
89
  */
87
- export type ParameterType =
88
- | 'expression'
89
- | 'variable'
90
- | 'string'
91
- | 'text'
92
- | 'names'
93
- | 'delay'
94
- | 'number'
95
- | 'flag'
96
- | 'separator'
97
- | 'options';
90
+ export const PARAMETER_TYPES = [
91
+ 'expression',
92
+ 'statements',
93
+ 'passage',
94
+ 'variable',
95
+ 'string',
96
+ 'text',
97
+ 'names',
98
+ 'delay',
99
+ 'number',
100
+ 'flag',
101
+ 'separator',
102
+ 'options',
103
+ ] as const;
104
+
105
+ export type ParameterType = (typeof PARAMETER_TYPES)[number];
98
106
 
99
107
  export interface ParameterDef {
100
108
  name: string;
101
109
  required?: boolean;
102
110
  description?: string;
103
- type?: ParameterType;
111
+ type: ParameterType;
104
112
  /** The options of an `options` parameter. */
105
113
  parameters?: readonly ParameterDef[];
106
114
  }
107
115
 
116
+ /**
117
+ * Throw if a parameter `macro` declares (or an option of one) has no type,
118
+ * or one that isn't a ParameterType: arguments are read by their type, and
119
+ * there is no default.
120
+ */
121
+ export function checkParameterTypes(
122
+ macro: string,
123
+ parameters: readonly ParameterDef[],
124
+ ): void {
125
+ for (const param of parameters) {
126
+ const type: unknown = param.type;
127
+ if (!PARAMETER_TYPES.includes(type as ParameterType)) {
128
+ const problem =
129
+ type === undefined
130
+ ? 'has no type'
131
+ : `has the unknown type ${JSON.stringify(type)}`;
132
+ throw new Error(
133
+ `spindle: The parameter "${param.name}" of the macro {${macro}} ${problem}. ` +
134
+ `Give it one of the types ${PARAMETER_TYPES.join(', ')} ` +
135
+ '(see docs/custom-macros.md#parameter-types), ' +
136
+ 'or declare no parameters and read props.rawArgs.',
137
+ );
138
+ }
139
+ if (param.parameters) checkParameterTypes(macro, param.parameters);
140
+ }
141
+ }
142
+
108
143
  type ArgValue<T, D> = T extends 'flag' | 'separator'
109
144
  ? boolean
110
145
  : T extends 'names'
@@ -41,6 +41,16 @@ export function showRuntimeError(context: string, error: unknown): void {
41
41
  );
42
42
  }
43
43
 
44
+ /**
45
+ * The error for `name`, a passage name that names no passage, used in the
46
+ * passage `from`.
47
+ */
48
+ export function noPassageError(name: string, from: string): Error {
49
+ return new Error(
50
+ `No passage named ${JSON.stringify(name)} (in passage ${JSON.stringify(from)})`,
51
+ );
52
+ }
53
+
44
54
  /** Remove the shown error `id`. */
45
55
  export function dismissRuntimeError(id: number): void {
46
56
  update(errors.filter((e) => e.id !== id));
package/src/store.ts CHANGED
@@ -45,7 +45,7 @@ import {
45
45
 
46
46
  import { deepClone, mergeKeys, mergesWith, shareEqual } from './structural';
47
47
  import { shallowCopy } from './utils/object-path';
48
- import { showRuntimeError } from './runtime-errors';
48
+ import { noPassageError, showRuntimeError } from './runtime-errors';
49
49
  import {
50
50
  snapshotPRNG,
51
51
  restorePRNG,
@@ -278,6 +278,9 @@ function persistSession(get: () => StoryState): void {
278
278
  const SESSION_ERROR_CONTEXT =
279
279
  'The game could not be saved for a page reload; a reload goes back to the last passage it could save:';
280
280
 
281
+ /** What the page shows before an error naming a missing passage. */
282
+ const NAVIGATION_ERROR_CONTEXT = 'The story could not go to another passage:';
283
+
281
284
  /**
282
285
  * Write the session, then run `after` (the rest of the operation: its
283
286
  * events and queued navigations), then throw the session's error if writing
@@ -1145,7 +1148,10 @@ export const useStoryStore = create<StoryState>()(
1145
1148
  }
1146
1149
 
1147
1150
  if (!storyData.passages.has(passageName)) {
1148
- console.error(`spindle: Passage "${passageName}" not found.`);
1151
+ // Shown on the page too: code (Story.goto()) can name any passage
1152
+ const error = noPassageError(passageName, get().currentPassage);
1153
+ console.error(`spindle: ${error.message}`);
1154
+ showRuntimeError(NAVIGATION_ERROR_CONTEXT, error);
1149
1155
  return;
1150
1156
  }
1151
1157
 
@@ -3,7 +3,13 @@ import type { Token } from './markup/tokens';
3
3
  import { MarkupError, tokenizeMarkup } from './markup/parse';
4
4
  import { isCodeAttribute, splitSigilTemplate } from './markup/code-attributes';
5
5
  import { errorMessage } from './utils/error-message';
6
- import { lexJs, scanStringLiteral, type JsGoal } from './js-lexer';
6
+ import {
7
+ CodeSyntaxError,
8
+ lexJs,
9
+ scanStringLiteral,
10
+ type JsGoal,
11
+ } from './js-lexer';
12
+ import { parseOrError, withParseCache } from './code-check';
7
13
  import { createNamespace, variableNameError } from './utils/namespace';
8
14
 
9
15
  /**
@@ -245,6 +251,25 @@ function scanCode(
245
251
  code: string,
246
252
  onRef: RefCallback,
247
253
  goal: JsGoal = 'expression',
254
+ ): void {
255
+ const parsed = parseOrError(code, goal);
256
+ if (parsed instanceof CodeSyntaxError) {
257
+ scanCodeLeniently(code, onRef, goal);
258
+ return;
259
+ }
260
+ for (const ref of parsed.refs) {
261
+ if (ref.sigil !== '$') continue;
262
+ VAR_PATH_RE.lastIndex = ref.start;
263
+ onRef(VAR_PATH_RE.exec(code)![1]!);
264
+ }
265
+ for (const text of parsed.strings) scanInterpolations(text, onRef);
266
+ }
267
+
268
+ /** `scanCode` for code acorn can't parse: lexed leniently. */
269
+ function scanCodeLeniently(
270
+ code: string,
271
+ onRef: RefCallback,
272
+ goal: JsGoal,
248
273
  ): void {
249
274
  /** Open template literals: their text so far, null in an interpolation. */
250
275
  const templates: { nesting: number; text: string | null }[] = [];
@@ -295,7 +320,9 @@ function collectPassageRefs(
295
320
  storeVarMacros: ReadonlySet<string>,
296
321
  onRef: RefCallback,
297
322
  ): void {
298
- collectTokenRefs(content, tokensOf(content, false), storeVarMacros, onRef);
323
+ withParseCache(() =>
324
+ collectTokenRefs(content, tokensOf(content, false), storeVarMacros, onRef),
325
+ );
299
326
  }
300
327
 
301
328
  /** Report the `$var` references in the tokens of `content`. */
@@ -345,8 +372,9 @@ function collectTokenRefs(
345
372
  }
346
373
 
347
374
  /**
348
- * Scan all passages for $var references, check against schema.
349
- * Returns list of error messages (empty = valid).
375
+ * Check the `$var` references of all passages against the schema, at story
376
+ * start. Returns the error messages (empty = valid). (The syntax of their
377
+ * code is checked with their markup: see markup/validate.ts.)
350
378
  *
351
379
  * `storeVarMacros` lists the input macros whose first argument names a bound
352
380
  * variable; it defaults to the built-in ones.
@@ -366,7 +394,6 @@ export function validatePassages(
366
394
  if (name === 'StoryVariables' || name === 'StoryTransients') continue;
367
395
 
368
396
  const forLocals = extractForLocals(passage.content);
369
-
370
397
  collectPassageRefs(passage.content, storeVarSet, (ref) => {
371
398
  const error = validateRef(ref, schema, forLocals);
372
399
  if (error) {
package/src/tooling.ts CHANGED
@@ -11,8 +11,10 @@ import {
11
11
  } from './markup/validate';
12
12
  import { isBlockMacro } from './markup/ast';
13
13
  import { blockWidgetNames } from './widgets/widget-def';
14
+ import type { ParameterDef } from './registry';
14
15
 
15
16
  export { parseStoryVariables } from './story-variables';
17
+ export { checkParameterTypes } from './registry';
16
18
  export { formatDiagnostic } from './markup/validate';
17
19
  export type { MarkupDiagnostic, MarkupPassage } from './markup/validate';
18
20
 
@@ -21,12 +23,15 @@ export interface ToolingMacro {
21
23
  name: string;
22
24
  block: boolean;
23
25
  subMacros: string[];
26
+ /** Its declared parameters: their types tell which arguments are code. */
27
+ parameters?: readonly ParameterDef[];
24
28
  }
25
29
 
26
30
  /**
27
31
  * Validate the markup of a story's passages as Spindle does when the story
28
32
  * starts, against the given macros (built-in and user-defined): malformed
29
- * markup and unknown macros, with their passage, line and column.
33
+ * markup, unknown macros and syntax errors in code, with their passage, line
34
+ * and column.
30
35
  */
31
36
  export function validateStoryMarkup(
32
37
  passages: Iterable<MarkupPassage>,
@@ -34,16 +39,19 @@ export function validateStoryMarkup(
34
39
  ): MarkupDiagnostic[] {
35
40
  const list = [...passages];
36
41
  const known = new Set<string>();
42
+ const parameters = new Map<string, readonly ParameterDef[] | undefined>();
37
43
  const blocks = new Set(blockWidgetNames(list).map((n) => n.toLowerCase()));
38
44
  for (const macro of macros) {
39
45
  const name = macro.name.toLowerCase();
40
46
  known.add(name);
47
+ parameters.set(name, macro.parameters);
41
48
  for (const sub of macro.subMacros) known.add(sub.toLowerCase());
42
49
  if (macro.block) blocks.add(name);
43
50
  }
44
51
  return validateMarkup(list, {
45
52
  isKnownMacro: (name) => known.has(name),
46
53
  macroNames: known,
54
+ parametersOf: (name) => parameters.get(name),
47
55
  isBlockMacro: (name) =>
48
56
  blocks.has(name.toLowerCase()) || isBlockMacro(name),
49
57
  });
@@ -8,7 +8,7 @@ import type { MacroArgs } from '../registry';
8
8
  /** A {widget} definition's name, then its `@` parameters. */
9
9
  export const WIDGET_PARAMETERS = [
10
10
  { name: 'name', type: 'text', required: true },
11
- { name: 'parameters', type: 'expression' },
11
+ { name: 'parameters', type: 'text' },
12
12
  ] as const;
13
13
 
14
14
  export interface WidgetDef {
package/types/index.d.ts CHANGED
@@ -242,7 +242,9 @@ export interface StorageQuota {
242
242
  /**
243
243
  * How a macro argument is read into `ctx.args`. Quoted strings accept `\"`,
244
244
  * `\'` and `\\` escapes.
245
- * - `expression`: code, as written (the default).
245
+ * - `expression`: code, as written.
246
+ * - `statements`: code run as statements (`{set}`), as written.
247
+ * - `passage`: a passage name: a quoted string or an expression, as written.
246
248
  * - `variable`: a variable reference such as `$name` or `"$name"`, as written.
247
249
  * - `string`: one quoted string; anything else leaves the argument unset.
248
250
  * - `text`: one quoted string, or text with any loose quotes stripped.
@@ -258,6 +260,8 @@ export interface StorageQuota {
258
260
  */
259
261
  export type ParameterType =
260
262
  | 'expression'
263
+ | 'statements'
264
+ | 'passage'
261
265
  | 'variable'
262
266
  | 'string'
263
267
  | 'text'
@@ -276,8 +280,8 @@ export interface ParameterDef {
276
280
  name: string;
277
281
  required?: boolean;
278
282
  description?: string;
279
- /** How the argument is read (default `expression`). */
280
- type?: ParameterType;
283
+ /** How the argument is read: required, there is no default. */
284
+ type: ParameterType;
281
285
  /** The options of an `options` parameter. */
282
286
  parameters?: readonly ParameterDef[];
283
287
  }
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * How a macro argument is read into `ctx.args`. Quoted strings accept `\"`,
3
3
  * `\'` and `\\` escapes.
4
- * - `expression`: code, as written (the default).
4
+ * - `expression`: code, as written.
5
+ * - `statements`: code run as statements (`{set}`), as written.
6
+ * - `passage`: a passage name: a quoted string or an expression, as written.
5
7
  * - `variable`: a variable reference such as `$name` or `"$name"`, as written.
6
8
  * - `string`: one quoted string; anything else leaves the argument unset.
7
9
  * - `text`: one quoted string, or text with any loose quotes stripped.
@@ -16,6 +18,8 @@
16
18
  */
17
19
  export type ParameterType =
18
20
  | 'expression'
21
+ | 'statements'
22
+ | 'passage'
19
23
  | 'variable'
20
24
  | 'string'
21
25
  | 'text'
@@ -30,8 +34,8 @@ export interface ParameterDef {
30
34
  name: string;
31
35
  required?: boolean;
32
36
  description?: string;
33
- /** How the argument is read (default `expression`). */
34
- type?: ParameterType;
37
+ /** How the argument is read: required, there is no default. */
38
+ type: ParameterType;
35
39
  /** The options of an `options` parameter. */
36
40
  parameters?: readonly ParameterDef[];
37
41
  }
@@ -128,9 +132,13 @@ export interface MarkupDiagnostic {
128
132
  /**
129
133
  * Validate the markup of a story's passages as Spindle does when the story
130
134
  * starts: malformed markup (unclosed or mismatched macros, tags, links,
131
- * braces and attribute values) and unknown macros, checked against the
132
- * built-in macros, those registered with `defineMacro` and the widgets the
133
- * passages define. Spindle refuses to start a story with any of these.
135
+ * braces and attribute values), unknown macros and syntax errors in the
136
+ * code passages run (`{$…}` expressions, `{do}` bodies, conditions and the
137
+ * `expression`/`statements`/`passage` arguments of macros), checked against
138
+ * the built-in macros, those registered with `defineMacro` and the widgets
139
+ * the passages define, and passage names that name none of `passages`
140
+ * (links, quoted `passage` arguments, `{link}`, `{dialog}`, `{watch}`).
141
+ * Spindle refuses to start a story with any of these.
134
142
  */
135
143
  export declare function validateMarkup(
136
144
  passages: Iterable<MarkupPassage>,