@rohal12/spindle 0.53.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.
Files changed (44) hide show
  1. package/README.md +3 -1
  2. package/dist/pkg/format.js +1 -1
  3. package/dist/pkg/headless.js +10058 -2075
  4. package/dist/pkg/macro-registry.json +4 -4
  5. package/dist/pkg/story-variables.js +10197 -1925
  6. package/dist/pkg/tooling.js +15 -1
  7. package/package.json +3 -1
  8. package/src/code-check.ts +314 -0
  9. package/src/components/Passage.tsx +3 -3
  10. package/src/components/PassageDialog.tsx +2 -4
  11. package/src/components/StoryInterface.tsx +2 -4
  12. package/src/components/macros/Do.tsx +1 -1
  13. package/src/components/macros/Goto.tsx +19 -8
  14. package/src/components/macros/Include.tsx +23 -11
  15. package/src/components/macros/Set.tsx +1 -1
  16. package/src/components/macros/VarDisplay.tsx +1 -1
  17. package/src/components/macros/Widget.tsx +5 -33
  18. package/src/components/macros/macro-args.ts +16 -7
  19. package/src/define-macro.ts +2 -0
  20. package/src/expression.ts +25 -20
  21. package/src/index.tsx +63 -37
  22. package/src/interpolation.ts +4 -5
  23. package/src/js-lexer.ts +981 -1342
  24. package/src/markup/ast.ts +11 -226
  25. package/src/markup/code-attributes.ts +3 -3
  26. package/src/markup/code-end.ts +121 -0
  27. package/src/markup/parse.ts +118 -0
  28. package/src/markup/render.tsx +1 -1
  29. package/src/markup/spindle.d.peggy.ts +24 -0
  30. package/src/markup/spindle.peggy +463 -0
  31. package/src/markup/tokens.ts +95 -0
  32. package/src/markup/validate.ts +281 -0
  33. package/src/parser.ts +19 -4
  34. package/src/registry.ts +49 -14
  35. package/src/runtime-errors.ts +10 -0
  36. package/src/store.ts +8 -2
  37. package/src/story-init.ts +3 -5
  38. package/src/story-variables.ts +49 -8
  39. package/src/tooling.ts +58 -0
  40. package/src/types-drift-check.ts +28 -1
  41. package/src/widgets/widget-def.ts +60 -0
  42. package/types/index.d.ts +7 -3
  43. package/types/tooling.d.ts +53 -3
  44. package/src/markup/tokenizer.ts +0 -1112
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Markup validation: parse every passage when the story starts and report
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).
7
+ */
8
+ import { lineColumn, MarkupError, parseMarkup, tokenizeMarkup } from './parse';
9
+ import type { Token } from './tokens';
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';
14
+
15
+ /** A passage to validate. */
16
+ export interface MarkupPassage {
17
+ name: string;
18
+ content: string;
19
+ tags?: string[];
20
+ /**
21
+ * Passage attributes; `data-source-file` and `data-source-line` (the
22
+ * line of its `::` header) place errors in the source file.
23
+ */
24
+ metadata?: Record<string, string>;
25
+ }
26
+
27
+ /** An error in a passage's markup. */
28
+ export interface MarkupDiagnostic {
29
+ passage: string;
30
+ /** 1-based line within the passage's content. */
31
+ line: number;
32
+ /** 1-based column (UTF-16 code units). */
33
+ column: number;
34
+ message: string;
35
+ /** The source file and line, when the passage says where it came from. */
36
+ file?: string;
37
+ fileLine?: number;
38
+ }
39
+
40
+ export interface MarkupValidationOptions {
41
+ /**
42
+ * Whether a macro of this name exists. Widgets that passages define with
43
+ * `{widget}` and branch macros (`{else}`, `{case}`, …) always do.
44
+ */
45
+ isKnownMacro(name: string): boolean;
46
+ /** Names to suggest for a misspelt macro. */
47
+ macroNames?: Iterable<string>;
48
+ /** Whether a macro takes a body (default: the registered block macros). */
49
+ isBlockMacro?(name: string): boolean;
50
+ /**
51
+ * Report on only the passages for which this holds (default: all). The
52
+ * others are still read for the widgets they define.
53
+ */
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;
62
+ }
63
+
64
+ /** Passages that hold no markup. */
65
+ const NOT_MARKUP = new Set(['StoryVariables', 'StoryTransients']);
66
+ const NOT_MARKUP_TAGS = ['script', 'stylesheet'];
67
+
68
+ /** Branch macros, which their parent macro renders. */
69
+ const BRANCH_MACROS = new Set(['elseif', 'else', 'case', 'default', 'next']);
70
+
71
+ /** A diagnostic as one line of text. */
72
+ export function formatDiagnostic(d: MarkupDiagnostic): string {
73
+ const source = d.file
74
+ ? ` (${d.file}${d.fileLine ? `:${d.fileLine}` : ''})`
75
+ : '';
76
+ return `Passage "${d.passage}", line ${d.line}, column ${d.column}${source}: ${d.message}`;
77
+ }
78
+
79
+ /** Edit distance, for "did you mean" suggestions. */
80
+ function distance(a: string, b: string): number {
81
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
82
+ for (let i = 1; i <= a.length; i++) {
83
+ const row = [i];
84
+ for (let j = 1; j <= b.length; j++) {
85
+ row[j] = Math.min(
86
+ prev[j]! + 1,
87
+ row[j - 1]! + 1,
88
+ prev[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
89
+ );
90
+ }
91
+ prev = row;
92
+ }
93
+ return prev[b.length]!;
94
+ }
95
+
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 {
105
+ const lower = name.toLowerCase();
106
+ let best = '';
107
+ let bestDistance = Math.max(2, Math.floor(lower.length / 3)) + 1;
108
+ for (const candidate of names) {
109
+ const d = distance(lower, candidate.toLowerCase());
110
+ if (d < bestDistance) {
111
+ best = candidate;
112
+ bestDistance = d;
113
+ }
114
+ }
115
+ return best ? ` Did you mean ${quote(best)}?` : '';
116
+ }
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
+
125
+ /** The name of the widget a `{widget}` with these arguments defines. */
126
+ function widgetName(rawArgs: string): string | undefined {
127
+ try {
128
+ return parseWidgetDef(rawArgs).name.toLowerCase() || undefined;
129
+ } catch {
130
+ return undefined; // the {widget} macro reports it when it renders
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Validate the markup of every passage. Passage names written out (links,
136
+ * quoted `passage` arguments) must name one of `passages`.
137
+ */
138
+ export function validateMarkup(
139
+ passages: Iterable<MarkupPassage>,
140
+ options: MarkupValidationOptions,
141
+ ): MarkupDiagnostic[] {
142
+ const diagnostics: MarkupDiagnostic[] = [];
143
+ const hooks = options.isBlockMacro
144
+ ? { isBlock: options.isBlockMacro }
145
+ : undefined;
146
+ const report = (passage: MarkupPassage, offset: number, message: string) => {
147
+ const { line, column } = lineColumn(passage.content, offset);
148
+ const d: MarkupDiagnostic = {
149
+ passage: passage.name,
150
+ line,
151
+ column,
152
+ message,
153
+ };
154
+ const file = passage.metadata?.['data-source-file'];
155
+ if (file) {
156
+ d.file = file;
157
+ const header = Number(passage.metadata?.['data-source-line']);
158
+ if (header) d.fileLine = header + line;
159
+ }
160
+ diagnostics.push(d);
161
+ };
162
+
163
+ // Parse every passage first: the widgets they define are known macros.
164
+ const parsed: [MarkupPassage, Token[]][] = [];
165
+ const widgets = new Set<string>();
166
+ const passageNames = new Set<string>();
167
+ for (const passage of passages) {
168
+ passageNames.add(passage.name);
169
+ if (NOT_MARKUP.has(passage.name)) continue;
170
+ if (passage.tags?.some((tag) => NOT_MARKUP_TAGS.includes(tag))) continue;
171
+ const reported = !options.only || options.only(passage);
172
+ try {
173
+ // The tokens first: they don't depend on which macros take a body
174
+ const tokens = tokenizeMarkup(passage.content);
175
+ for (const token of tokens) {
176
+ if (token.type !== 'macro' || token.isClose) continue;
177
+ if (token.name.toLowerCase() !== 'widget') continue;
178
+ const name = widgetName(token.rawArgs);
179
+ if (name) widgets.add(name);
180
+ }
181
+ if (!reported) continue;
182
+ parseMarkup(passage.content, { hooks });
183
+ parsed.push([passage, tokens]);
184
+ } catch (err) {
185
+ if (!(err instanceof MarkupError)) throw err;
186
+ if (reported) {
187
+ report(passage, err.offset, err.reason);
188
+ }
189
+ }
190
+ }
191
+ const names = [...(options.macroNames ?? []), ...widgets];
192
+
193
+ /** Report the unknown macros among `tokens`, which start at `base`. */
194
+ const checkMacros = (
195
+ passage: MarkupPassage,
196
+ tokens: Token[],
197
+ base: number,
198
+ where: string,
199
+ ) => {
200
+ for (const token of tokens) {
201
+ if (token.type !== 'macro' || token.isClose) continue;
202
+ const lower = token.name.toLowerCase();
203
+ if (
204
+ BRANCH_MACROS.has(lower) ||
205
+ widgets.has(lower) ||
206
+ options.isKnownMacro(lower)
207
+ ) {
208
+ continue;
209
+ }
210
+ report(
211
+ passage,
212
+ base + token.start,
213
+ `${where}Unknown macro {${token.name}}.${didYouMean(token.name, names, (n) => `{${n}}`)}`,
214
+ );
215
+ }
216
+ };
217
+
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(
238
+ passage,
239
+ at,
240
+ `${where}No passage named ${JSON.stringify(piece.name)} in ${piece.label}.${hint}`,
241
+ );
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);
250
+ report(
251
+ passage,
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}`,
261
+ );
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);
273
+ }
274
+ }
275
+ };
276
+
277
+ for (const [passage, tokens] of parsed) {
278
+ withParseCache(() => checkTokens(passage, passage.content, tokens, 0, ''));
279
+ }
280
+ return diagnostics;
281
+ }
package/src/parser.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { tokenize } from './markup/tokenizer';
1
+ import { MarkupError, tokenizeMarkup } from './markup/parse';
2
2
  import { isCodeAttribute, splitSigilTemplate } from './markup/code-attributes';
3
3
 
4
4
  export interface Passage {
@@ -88,7 +88,7 @@ function elementMarkup(el: Element): string {
88
88
  }
89
89
 
90
90
  /**
91
- * An attribute value quoted so that the passage tokenizer reads it back:
91
+ * An attribute value quoted so that the passage parser reads it back:
92
92
  * in double quotes, else single ones, else with the double quotes in its
93
93
  * text written as `&quot;`, which rendering decodes. Quotes inside markup in
94
94
  * the value (`{print "a"}`) are code, and don't end it.
@@ -97,7 +97,14 @@ function quoteAttribute(name: string, value: string): string {
97
97
  const code = isCodeAttribute(name);
98
98
  const probe = code ? 'on' : 'a';
99
99
  const readsBack = (quoted: string) => {
100
- const tokens = tokenize(`<i ${probe}=${quoted}>`);
100
+ let tokens;
101
+ try {
102
+ tokens = tokenizeMarkup(`<i ${probe}=${quoted}>`);
103
+ } catch (err) {
104
+ // Malformed markup in the value, such as an unclosed quote
105
+ if (err instanceof MarkupError) return false;
106
+ throw err;
107
+ }
101
108
  const tag = tokens[0];
102
109
  return (
103
110
  tokens.length === 1 &&
@@ -129,7 +136,15 @@ function escapeTextQuotes(value: string, code: boolean): string {
129
136
  )
130
137
  .join('');
131
138
  }
132
- return tokenize(value, { text: true })
139
+ let tokens;
140
+ try {
141
+ tokens = tokenizeMarkup(value, { text: true });
142
+ } catch (err) {
143
+ // Malformed markup: validation reports it when the story starts
144
+ if (err instanceof MarkupError) return escape(value);
145
+ throw err;
146
+ }
147
+ return tokens
133
148
  .map((token) => {
134
149
  const source = value.slice(token.start, token.end);
135
150
  return token.type === 'text' ? escape(source) : source;
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
 
package/src/story-init.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import { h, render } from 'preact';
2
2
  import { useStoryStore, recordStoryInitState } from './store';
3
- import { tokenize } from './markup/tokenizer';
4
- import { buildAST } from './markup/ast';
3
+ import { parseMarkup } from './markup/parse';
5
4
  import { renderNodes } from './markup/render';
6
5
  import { setSaveTitlePassage } from './saves/save-manager';
7
6
  import { emit } from './event-emitter';
@@ -22,7 +21,7 @@ function unmountStoryInit(): void {
22
21
  }
23
22
 
24
23
  /**
25
- * Execute the StoryInit passage: tokenize, parse, and render all macros
24
+ * Execute the StoryInit passage: parse it and render all macros
26
25
  * into a detached DOM node so their side effects fire through the normal
27
26
  * Preact pipeline. This is macro-agnostic — any macro works in StoryInit.
28
27
  * Re-executing (on restart) first unmounts the previous StoryInit tree.
@@ -35,8 +34,7 @@ export function executeStoryInit() {
35
34
 
36
35
  const storyInit = state.storyData.passages.get('StoryInit');
37
36
  if (storyInit) {
38
- const tokens = tokenize(storyInit.content);
39
- const ast = buildAST(tokens);
37
+ const ast = parseMarkup(storyInit.content);
40
38
 
41
39
  // Mount into a persistent hidden container. It stays mounted until the
42
40
  // next execution (restart) — this lets async effects (useEffect,
@@ -1,10 +1,30 @@
1
1
  import type { Passage } from './parser';
2
- import { tokenize, type Token } from './markup/tokenizer';
2
+ import type { Token } from './markup/tokens';
3
+ import { MarkupError, tokenizeMarkup } from './markup/parse';
3
4
  import { isCodeAttribute, splitSigilTemplate } from './markup/code-attributes';
4
5
  import { errorMessage } from './utils/error-message';
5
- 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';
6
13
  import { createNamespace, variableNameError } from './utils/namespace';
7
14
 
15
+ /**
16
+ * The tokens of markup, or none if it is malformed: validateMarkup reports
17
+ * that, with its position.
18
+ */
19
+ function tokensOf(text: string, textMode: boolean): Token[] {
20
+ try {
21
+ return tokenizeMarkup(text, { text: textMode });
22
+ } catch (err) {
23
+ if (err instanceof MarkupError) return [];
24
+ throw err;
25
+ }
26
+ }
27
+
8
28
  export type VarType = 'number' | 'string' | 'boolean' | 'array' | 'object';
9
29
 
10
30
  export interface FieldSchema {
@@ -217,7 +237,7 @@ function scanInterpolations(
217
237
  storeVarMacros: ReadonlySet<string> = NO_STORE_VAR_MACROS,
218
238
  ): void {
219
239
  if (!text.includes('{')) return;
220
- collectTokenRefs(text, tokenize(text, { text: true }), storeVarMacros, onRef);
240
+ collectTokenRefs(text, tokensOf(text, true), storeVarMacros, onRef);
221
241
  }
222
242
 
223
243
  /**
@@ -231,6 +251,25 @@ function scanCode(
231
251
  code: string,
232
252
  onRef: RefCallback,
233
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,
234
273
  ): void {
235
274
  /** Open template literals: their text so far, null in an interpolation. */
236
275
  const templates: { nesting: number; text: string | null }[] = [];
@@ -281,7 +320,9 @@ function collectPassageRefs(
281
320
  storeVarMacros: ReadonlySet<string>,
282
321
  onRef: RefCallback,
283
322
  ): void {
284
- collectTokenRefs(content, tokenize(content), storeVarMacros, onRef);
323
+ withParseCache(() =>
324
+ collectTokenRefs(content, tokensOf(content, false), storeVarMacros, onRef),
325
+ );
285
326
  }
286
327
 
287
328
  /** Report the `$var` references in the tokens of `content`. */
@@ -313,7 +354,7 @@ function collectTokenRefs(
313
354
 
314
355
  if (token.name === 'do') {
315
356
  // A {do} body is JavaScript: scan its source text as code, however
316
- // the markup tokenizer split it up.
357
+ // the markup tokens split it up.
317
358
  let close = t + 1;
318
359
  while (close < tokens.length) {
319
360
  const c = tokens[close]!;
@@ -331,8 +372,9 @@ function collectTokenRefs(
331
372
  }
332
373
 
333
374
  /**
334
- * Scan all passages for $var references, check against schema.
335
- * 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.)
336
378
  *
337
379
  * `storeVarMacros` lists the input macros whose first argument names a bound
338
380
  * variable; it defaults to the built-in ones.
@@ -352,7 +394,6 @@ export function validatePassages(
352
394
  if (name === 'StoryVariables' || name === 'StoryTransients') continue;
353
395
 
354
396
  const forLocals = extractForLocals(passage.content);
355
-
356
397
  collectPassageRefs(passage.content, storeVarSet, (ref) => {
357
398
  const error = validateRef(ref, schema, forLocals);
358
399
  if (error) {
package/src/tooling.ts ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * What the tooling entry point (`@rohal12/spindle/tooling`) bundles as
3
+ * dist/pkg/story-variables.js: the StoryVariables parser and the markup
4
+ * validator, with no Preact or DOM dependencies. pkg/tooling.js re-exports
5
+ * them, binding the validator to its macro metadata.
6
+ */
7
+ import {
8
+ validateMarkup,
9
+ type MarkupDiagnostic,
10
+ type MarkupPassage,
11
+ } from './markup/validate';
12
+ import { isBlockMacro } from './markup/ast';
13
+ import { blockWidgetNames } from './widgets/widget-def';
14
+ import type { ParameterDef } from './registry';
15
+
16
+ export { parseStoryVariables } from './story-variables';
17
+ export { checkParameterTypes } from './registry';
18
+ export { formatDiagnostic } from './markup/validate';
19
+ export type { MarkupDiagnostic, MarkupPassage } from './markup/validate';
20
+
21
+ /** What tooling knows about a macro (see MacroMetadata). */
22
+ export interface ToolingMacro {
23
+ name: string;
24
+ block: boolean;
25
+ subMacros: string[];
26
+ /** Its declared parameters: their types tell which arguments are code. */
27
+ parameters?: readonly ParameterDef[];
28
+ }
29
+
30
+ /**
31
+ * Validate the markup of a story's passages as Spindle does when the story
32
+ * starts, against the given macros (built-in and user-defined): malformed
33
+ * markup, unknown macros and syntax errors in code, with their passage, line
34
+ * and column.
35
+ */
36
+ export function validateStoryMarkup(
37
+ passages: Iterable<MarkupPassage>,
38
+ macros: Iterable<ToolingMacro>,
39
+ ): MarkupDiagnostic[] {
40
+ const list = [...passages];
41
+ const known = new Set<string>();
42
+ const parameters = new Map<string, readonly ParameterDef[] | undefined>();
43
+ const blocks = new Set(blockWidgetNames(list).map((n) => n.toLowerCase()));
44
+ for (const macro of macros) {
45
+ const name = macro.name.toLowerCase();
46
+ known.add(name);
47
+ parameters.set(name, macro.parameters);
48
+ for (const sub of macro.subMacros) known.add(sub.toLowerCase());
49
+ if (macro.block) blocks.add(name);
50
+ }
51
+ return validateMarkup(list, {
52
+ isKnownMacro: (name) => known.has(name),
53
+ macroNames: known,
54
+ parametersOf: (name) => parameters.get(name),
55
+ isBlockMacro: (name) =>
56
+ blocks.has(name.toLowerCase()) || isBlockMacro(name),
57
+ });
58
+ }