@rohal12/spindle 0.54.0 → 0.56.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/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +6295 -1088
- package/dist/pkg/macro-registry.json +4 -4
- package/dist/pkg/story-variables.js +6265 -1061
- package/dist/pkg/tooling.js +5 -3
- package/package.json +2 -1
- package/src/code-check.ts +314 -0
- package/src/components/macros/Goto.tsx +19 -8
- package/src/components/macros/Include.tsx +21 -8
- package/src/components/macros/Set.tsx +1 -1
- 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 +9 -3
- package/src/js-lexer.ts +981 -1342
- package/src/markup/validate.ts +98 -28
- package/src/registry.ts +49 -14
- package/src/runtime-errors.ts +10 -0
- package/src/store.ts +8 -2
- package/src/story-variables.ts +32 -5
- package/src/tooling.ts +21 -1
- package/src/types-drift-check.ts +2 -2
- package/src/widgets/widget-def.ts +1 -1
- package/types/index.d.ts +7 -3
- package/types/tooling.d.ts +25 -6
package/src/markup/validate.ts
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Markup validation: parse every passage when the story starts and report
|
|
3
|
-
* malformed markup
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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,18 @@ export interface MarkupValidationOptions {
|
|
|
49
52
|
* others are still read for the widgets they define.
|
|
50
53
|
*/
|
|
51
54
|
only?(passage: MarkupPassage): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Whether passage names written out must name one of the passages
|
|
57
|
+
* (default: true). Off when only part of a story is validated.
|
|
58
|
+
*/
|
|
59
|
+
checkPassageNames?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* The declared parameters of a macro, whose `expression` and `statements`
|
|
62
|
+
* arguments are checked as code and whose `text` and `string` arguments
|
|
63
|
+
* as markup (see code-check.ts). Without them only the code of `{$…}`,
|
|
64
|
+
* `{do}`, branch conditions and attributes is checked.
|
|
65
|
+
*/
|
|
66
|
+
parametersOf?(name: string): readonly ParameterDef[] | undefined;
|
|
52
67
|
}
|
|
53
68
|
|
|
54
69
|
/** Passages that hold no markup. */
|
|
@@ -83,8 +98,15 @@ function distance(a: string, b: string): number {
|
|
|
83
98
|
return prev[b.length]!;
|
|
84
99
|
}
|
|
85
100
|
|
|
86
|
-
/**
|
|
87
|
-
|
|
101
|
+
/**
|
|
102
|
+
* " Did you mean …?" with the known name closest to `name`, written by
|
|
103
|
+
* `quote`, if one is close.
|
|
104
|
+
*/
|
|
105
|
+
function didYouMean(
|
|
106
|
+
name: string,
|
|
107
|
+
names: Iterable<string>,
|
|
108
|
+
quote: (name: string) => string,
|
|
109
|
+
): string {
|
|
88
110
|
const lower = name.toLowerCase();
|
|
89
111
|
let best = '';
|
|
90
112
|
let bestDistance = Math.max(2, Math.floor(lower.length / 3)) + 1;
|
|
@@ -95,9 +117,16 @@ function suggestion(name: string, names: readonly string[]): string {
|
|
|
95
117
|
bestDistance = d;
|
|
96
118
|
}
|
|
97
119
|
}
|
|
98
|
-
return best ? ` Did you mean
|
|
120
|
+
return best ? ` Did you mean ${quote(best)}?` : '';
|
|
99
121
|
}
|
|
100
122
|
|
|
123
|
+
/** What a passage argument may be, after an error in one. */
|
|
124
|
+
const PASSAGE_ARGUMENT_HINT =
|
|
125
|
+
' (a passage name is a quoted string or an expression)';
|
|
126
|
+
|
|
127
|
+
/** A passage argument that is one bare word (`{goto Kitchen}`). */
|
|
128
|
+
const BARE_WORD_RE = /^[A-Za-z][\w$]*$/;
|
|
129
|
+
|
|
101
130
|
/** The name of the widget a `{widget}` with these arguments defines. */
|
|
102
131
|
function widgetName(rawArgs: string): string | undefined {
|
|
103
132
|
try {
|
|
@@ -107,7 +136,10 @@ function widgetName(rawArgs: string): string | undefined {
|
|
|
107
136
|
}
|
|
108
137
|
}
|
|
109
138
|
|
|
110
|
-
/**
|
|
139
|
+
/**
|
|
140
|
+
* Validate the markup of every passage. Passage names written out (links,
|
|
141
|
+
* quoted `passage` arguments) must name one of `passages`.
|
|
142
|
+
*/
|
|
111
143
|
export function validateMarkup(
|
|
112
144
|
passages: Iterable<MarkupPassage>,
|
|
113
145
|
options: MarkupValidationOptions,
|
|
@@ -136,7 +168,9 @@ export function validateMarkup(
|
|
|
136
168
|
// Parse every passage first: the widgets they define are known macros.
|
|
137
169
|
const parsed: [MarkupPassage, Token[]][] = [];
|
|
138
170
|
const widgets = new Set<string>();
|
|
171
|
+
const passageNames = new Set<string>();
|
|
139
172
|
for (const passage of passages) {
|
|
173
|
+
passageNames.add(passage.name);
|
|
140
174
|
if (NOT_MARKUP.has(passage.name)) continue;
|
|
141
175
|
if (passage.tags?.some((tag) => NOT_MARKUP_TAGS.includes(tag))) continue;
|
|
142
176
|
const reported = !options.only || options.only(passage);
|
|
@@ -181,39 +215,75 @@ export function validateMarkup(
|
|
|
181
215
|
report(
|
|
182
216
|
passage,
|
|
183
217
|
base + token.start,
|
|
184
|
-
`${where}Unknown macro {${token.name}}.${
|
|
218
|
+
`${where}Unknown macro {${token.name}}.${didYouMean(token.name, names, (n) => `{${n}}`)}`,
|
|
185
219
|
);
|
|
186
220
|
}
|
|
187
221
|
};
|
|
188
222
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
223
|
+
const parametersOf = options.parametersOf ?? (() => undefined);
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Check `tokens`, the tokens of `src`, which starts at `base` in the
|
|
227
|
+
* passage: their macros, the code in them and the markup in their text.
|
|
228
|
+
*/
|
|
229
|
+
const checkTokens = (
|
|
230
|
+
passage: MarkupPassage,
|
|
231
|
+
src: string,
|
|
232
|
+
tokens: Token[],
|
|
233
|
+
base: number,
|
|
234
|
+
where: string,
|
|
235
|
+
) => {
|
|
236
|
+
checkMacros(passage, tokens, base, where);
|
|
237
|
+
for (const piece of codeAndText(src, tokens, parametersOf)) {
|
|
238
|
+
const at = base + piece.offset;
|
|
239
|
+
if (piece.kind === 'passage') {
|
|
240
|
+
if (
|
|
241
|
+
options.checkPassageNames !== false &&
|
|
242
|
+
!passageNames.has(piece.name)
|
|
243
|
+
) {
|
|
244
|
+
const hint = didYouMean(piece.name, passageNames, JSON.stringify);
|
|
245
|
+
report(
|
|
202
246
|
passage,
|
|
203
|
-
tokenizeMarkup(value, { text: true }),
|
|
204
247
|
at,
|
|
205
|
-
where
|
|
248
|
+
`${where}No passage named ${JSON.stringify(piece.name)} in ${piece.label}.${hint}`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (piece.kind === 'code') {
|
|
254
|
+
const result = parseOrError(piece.code, piece.goal);
|
|
255
|
+
const code = piece.code.trim();
|
|
256
|
+
if (result instanceof CodeSyntaxError) {
|
|
257
|
+
const reason = result.reasonIn(passage.content, at);
|
|
258
|
+
report(
|
|
259
|
+
passage,
|
|
260
|
+
at + result.pos,
|
|
261
|
+
`${where}${reason} in ${piece.label}` +
|
|
262
|
+
(piece.passage ? PASSAGE_ARGUMENT_HINT : ''),
|
|
206
263
|
);
|
|
207
|
-
}
|
|
208
|
-
if (!(err instanceof MarkupError)) throw err;
|
|
264
|
+
} else if (piece.passage && BARE_WORD_RE.test(code)) {
|
|
209
265
|
report(
|
|
210
266
|
passage,
|
|
211
|
-
|
|
212
|
-
where
|
|
267
|
+
at,
|
|
268
|
+
`${where}Unquoted passage name in ${piece.label}: write ${JSON.stringify(code)}${PASSAGE_ARGUMENT_HINT}`,
|
|
213
269
|
);
|
|
214
270
|
}
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (!piece.text.includes('{')) continue;
|
|
274
|
+
try {
|
|
275
|
+
parseMarkup(piece.text, { text: true, hooks });
|
|
276
|
+
const inner = tokenizeMarkup(piece.text, { text: true });
|
|
277
|
+
checkTokens(passage, piece.text, inner, at, piece.where);
|
|
278
|
+
} catch (err) {
|
|
279
|
+
if (!(err instanceof MarkupError)) throw err;
|
|
280
|
+
report(passage, at + err.offset, piece.where + err.reason);
|
|
215
281
|
}
|
|
216
282
|
}
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
for (const [passage, tokens] of parsed) {
|
|
286
|
+
withParseCache(() => checkTokens(passage, passage.content, tokens, 0, ''));
|
|
217
287
|
}
|
|
218
288
|
return diagnostics;
|
|
219
289
|
}
|
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-variables.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
*
|
|
349
|
-
* 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.)
|
|
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,40 +11,60 @@ 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
|
|
|
19
21
|
/** What tooling knows about a macro (see MacroMetadata). */
|
|
22
|
+
/** Options for validating markup from tooling. */
|
|
23
|
+
export interface ValidateMarkupOptions {
|
|
24
|
+
/**
|
|
25
|
+
* Whether passage names written out (links, quoted `passage` arguments)
|
|
26
|
+
* must name one of the passages given (default: true). Turn it off to
|
|
27
|
+
* validate only part of a story.
|
|
28
|
+
*/
|
|
29
|
+
checkPassageNames?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
20
32
|
export interface ToolingMacro {
|
|
21
33
|
name: string;
|
|
22
34
|
block: boolean;
|
|
23
35
|
subMacros: string[];
|
|
36
|
+
/** Its declared parameters: their types tell which arguments are code. */
|
|
37
|
+
parameters?: readonly ParameterDef[];
|
|
24
38
|
}
|
|
25
39
|
|
|
26
40
|
/**
|
|
27
41
|
* Validate the markup of a story's passages as Spindle does when the story
|
|
28
42
|
* starts, against the given macros (built-in and user-defined): malformed
|
|
29
|
-
* markup and
|
|
43
|
+
* markup, unknown macros and syntax errors in code, with their passage, line
|
|
44
|
+
* and column.
|
|
30
45
|
*/
|
|
31
46
|
export function validateStoryMarkup(
|
|
32
47
|
passages: Iterable<MarkupPassage>,
|
|
33
48
|
macros: Iterable<ToolingMacro>,
|
|
49
|
+
options: ValidateMarkupOptions = {},
|
|
34
50
|
): MarkupDiagnostic[] {
|
|
35
51
|
const list = [...passages];
|
|
36
52
|
const known = new Set<string>();
|
|
53
|
+
const parameters = new Map<string, readonly ParameterDef[] | undefined>();
|
|
37
54
|
const blocks = new Set(blockWidgetNames(list).map((n) => n.toLowerCase()));
|
|
38
55
|
for (const macro of macros) {
|
|
39
56
|
const name = macro.name.toLowerCase();
|
|
40
57
|
known.add(name);
|
|
58
|
+
parameters.set(name, macro.parameters);
|
|
41
59
|
for (const sub of macro.subMacros) known.add(sub.toLowerCase());
|
|
42
60
|
if (macro.block) blocks.add(name);
|
|
43
61
|
}
|
|
44
62
|
return validateMarkup(list, {
|
|
45
63
|
isKnownMacro: (name) => known.has(name),
|
|
46
64
|
macroNames: known,
|
|
65
|
+
parametersOf: (name) => parameters.get(name),
|
|
47
66
|
isBlockMacro: (name) =>
|
|
48
67
|
blocks.has(name.toLowerCase()) || isBlockMacro(name),
|
|
68
|
+
checkPassageNames: options.checkPassageNames,
|
|
49
69
|
});
|
|
50
70
|
}
|
package/src/types-drift-check.ts
CHANGED
|
@@ -115,5 +115,5 @@ const _exampleMacro: PublishedMacroDefinition = {
|
|
|
115
115
|
// metadata.
|
|
116
116
|
declare const validateStoryMarkup: typeof SourceValidateStoryMarkup;
|
|
117
117
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
118
|
-
const _validateMarkup: typeof PublishedValidateMarkup = (passages) =>
|
|
119
|
-
validateStoryMarkup(passages, []);
|
|
118
|
+
const _validateMarkup: typeof PublishedValidateMarkup = (passages, options) =>
|
|
119
|
+
validateStoryMarkup(passages, [], options);
|
|
@@ -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: '
|
|
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
|
|
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
|
|
280
|
-
type
|
|
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
|
}
|
package/types/tooling.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
34
|
-
type
|
|
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,14 +132,29 @@ 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)
|
|
132
|
-
*
|
|
133
|
-
*
|
|
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>,
|
|
145
|
+
options?: ValidateMarkupOptions,
|
|
137
146
|
): MarkupDiagnostic[];
|
|
138
147
|
|
|
148
|
+
/** Options for {@link validateMarkup}. */
|
|
149
|
+
export interface ValidateMarkupOptions {
|
|
150
|
+
/**
|
|
151
|
+
* Whether passage names written out (links, quoted `passage` arguments)
|
|
152
|
+
* must name one of `passages` (default: true). Turn it off to validate
|
|
153
|
+
* only part of a story.
|
|
154
|
+
*/
|
|
155
|
+
checkPassageNames?: boolean;
|
|
156
|
+
}
|
|
157
|
+
|
|
139
158
|
/**
|
|
140
159
|
* A diagnostic as one line of text, as Spindle shows it:
|
|
141
160
|
* `Passage "Start", line 3, column 5 (story.twee:12): Unclosed {if}: …`.
|