@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.
- package/README.md +3 -1
- package/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +10058 -2075
- package/dist/pkg/macro-registry.json +4 -4
- package/dist/pkg/story-variables.js +10197 -1925
- package/dist/pkg/tooling.js +15 -1
- package/package.json +3 -1
- package/src/code-check.ts +314 -0
- package/src/components/Passage.tsx +3 -3
- package/src/components/PassageDialog.tsx +2 -4
- package/src/components/StoryInterface.tsx +2 -4
- package/src/components/macros/Do.tsx +1 -1
- package/src/components/macros/Goto.tsx +19 -8
- package/src/components/macros/Include.tsx +23 -11
- package/src/components/macros/Set.tsx +1 -1
- package/src/components/macros/VarDisplay.tsx +1 -1
- package/src/components/macros/Widget.tsx +5 -33
- package/src/components/macros/macro-args.ts +16 -7
- package/src/define-macro.ts +2 -0
- package/src/expression.ts +25 -20
- package/src/index.tsx +63 -37
- package/src/interpolation.ts +4 -5
- package/src/js-lexer.ts +981 -1342
- package/src/markup/ast.ts +11 -226
- package/src/markup/code-attributes.ts +3 -3
- package/src/markup/code-end.ts +121 -0
- package/src/markup/parse.ts +118 -0
- package/src/markup/render.tsx +1 -1
- package/src/markup/spindle.d.peggy.ts +24 -0
- package/src/markup/spindle.peggy +463 -0
- package/src/markup/tokens.ts +95 -0
- package/src/markup/validate.ts +281 -0
- package/src/parser.ts +19 -4
- package/src/registry.ts +49 -14
- package/src/runtime-errors.ts +10 -0
- package/src/store.ts +8 -2
- package/src/story-init.ts +3 -5
- package/src/story-variables.ts +49 -8
- package/src/tooling.ts +58 -0
- package/src/types-drift-check.ts +28 -1
- package/src/widgets/widget-def.ts +60 -0
- package/types/index.d.ts +7 -3
- package/types/tooling.d.ts +53 -3
- 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 {
|
|
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
|
|
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 `"`, 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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
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'
|
package/src/runtime-errors.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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:
|
|
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
|
|
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,
|
package/src/story-variables.ts
CHANGED
|
@@ -1,10 +1,30 @@
|
|
|
1
1
|
import type { Passage } from './parser';
|
|
2
|
-
import {
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
335
|
-
* Returns
|
|
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
|
+
}
|