@rohal12/spindle 0.56.0 → 0.58.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 +638 -414
- package/dist/pkg/macro-registry.json +2 -1
- package/dist/pkg/story-variables.js +611 -157
- package/dist/pkg/tooling.js +39 -2
- package/package.json +1 -1
- package/src/code-check.ts +129 -21
- package/src/components/macros/Include.tsx +2 -6
- package/src/components/macros/MacroLink.tsx +33 -7
- package/src/components/macros/arg-utils.ts +37 -0
- package/src/components/macros/include-args.ts +23 -0
- package/src/components/macros/macro-args.ts +61 -5
- package/src/components/macros/option-utils.ts +17 -6
- package/src/define-macro.ts +22 -2
- package/src/expression.ts +4 -54
- package/src/markup/grammar-shared.ts +8 -0
- package/src/markup/parse.ts +89 -2
- package/src/markup/spindle.d.peggy.ts +5 -2
- package/src/markup/spindle.peggy +6 -7
- package/src/markup/validate.ts +16 -3
- package/src/registry.ts +7 -0
- package/src/story-variables.ts +48 -10
- package/src/tooling.ts +59 -0
- package/src/transform.ts +65 -0
- package/types/tooling.d.ts +311 -0
package/dist/pkg/tooling.js
CHANGED
|
@@ -2,9 +2,36 @@ import { readFileSync } from 'node:fs';
|
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
checkParameterTypes,
|
|
7
|
+
collectStoryPassageReferences,
|
|
8
|
+
validateStoryMarkup,
|
|
9
|
+
} from './story-variables.js';
|
|
6
10
|
|
|
7
|
-
export {
|
|
11
|
+
export {
|
|
12
|
+
parseStoryVariables,
|
|
13
|
+
formatDiagnostic,
|
|
14
|
+
findCodeEnd,
|
|
15
|
+
lexJs,
|
|
16
|
+
lexTemplate,
|
|
17
|
+
scanStringLiteral,
|
|
18
|
+
MarkupError,
|
|
19
|
+
parseSelectors,
|
|
20
|
+
tokenizeMarkup,
|
|
21
|
+
tokenizeMarkupTolerant,
|
|
22
|
+
transform,
|
|
23
|
+
passageTarget,
|
|
24
|
+
evaluatePassageName,
|
|
25
|
+
SIGIL_SCOPES,
|
|
26
|
+
isSigil,
|
|
27
|
+
endsWithOperator,
|
|
28
|
+
readQuoted,
|
|
29
|
+
splitArgs,
|
|
30
|
+
splitTopLevel,
|
|
31
|
+
stripLooseQuotes,
|
|
32
|
+
unescapeQuoted,
|
|
33
|
+
splitIncludeFlag,
|
|
34
|
+
} from './story-variables.js';
|
|
8
35
|
|
|
9
36
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
37
|
|
|
@@ -58,3 +85,13 @@ export function getMacroRegistry() {
|
|
|
58
85
|
export function validateMarkup(passages, options) {
|
|
59
86
|
return validateStoryMarkup(passages, metadata.values(), options);
|
|
60
87
|
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The passages the markup of `source` names, with where each is written:
|
|
91
|
+
* `[[…]]` links and the passage of `{goto}`, `{include}`, `{link}`,
|
|
92
|
+
* `{watch}` and `{dialog}`, as the macros registered here declare them.
|
|
93
|
+
* Malformed tags are skipped, so half-typed markup reads.
|
|
94
|
+
*/
|
|
95
|
+
export function collectPassageReferences(source) {
|
|
96
|
+
return collectStoryPassageReferences(source, metadata.values());
|
|
97
|
+
}
|
package/package.json
CHANGED
package/src/code-check.ts
CHANGED
|
@@ -29,8 +29,12 @@ import { isCodeAttribute, splitSigilTemplate } from './markup/code-attributes';
|
|
|
29
29
|
import { CodeSyntaxError, parseCode, type JsGoal } from './js-lexer';
|
|
30
30
|
import type { ParsedCode } from './js-lexer';
|
|
31
31
|
import type { ParameterDef } from './registry';
|
|
32
|
-
import {
|
|
33
|
-
|
|
32
|
+
import {
|
|
33
|
+
MacroArgumentError,
|
|
34
|
+
parseMacroArgs,
|
|
35
|
+
passageTarget,
|
|
36
|
+
type PassageTarget,
|
|
37
|
+
} from './components/macros/macro-args';
|
|
34
38
|
|
|
35
39
|
/** While a pass runs: the code it parsed so far, by goal and source. */
|
|
36
40
|
let parses: Map<string, ParsedCode | CodeSyntaxError> | null = null;
|
|
@@ -77,6 +81,10 @@ export interface CodePiece {
|
|
|
77
81
|
label: string;
|
|
78
82
|
/** Whether it names a passage: a `passage` argument. */
|
|
79
83
|
passage?: boolean;
|
|
84
|
+
/** Whether it is the code in a quoted string, as in `{watch}`. */
|
|
85
|
+
inString?: boolean;
|
|
86
|
+
/** The macro it is the argument of, for a `passage` argument. */
|
|
87
|
+
macro?: string;
|
|
80
88
|
}
|
|
81
89
|
|
|
82
90
|
/** A passage name written out in markup, at `offset` in it. */
|
|
@@ -86,6 +94,19 @@ export interface PassagePiece {
|
|
|
86
94
|
offset: number;
|
|
87
95
|
/** The markup it is in, for the error: `[[Go->Hall]]`. */
|
|
88
96
|
label: string;
|
|
97
|
+
/** How much of the markup is the name, as written. */
|
|
98
|
+
length: number;
|
|
99
|
+
/** The macro it is the argument of; `link` for `[[…]]` links. */
|
|
100
|
+
macro: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Macro arguments that don't have their parameters' forms. */
|
|
104
|
+
export interface ArgumentErrorPiece {
|
|
105
|
+
kind: 'argument-error';
|
|
106
|
+
message: string;
|
|
107
|
+
offset: number;
|
|
108
|
+
/** The markup it is in, for the error: `{link Go}`. */
|
|
109
|
+
label: string;
|
|
89
110
|
}
|
|
90
111
|
|
|
91
112
|
/** Text in markup that may hold markup of its own, at `offset` in it. */
|
|
@@ -101,20 +122,29 @@ export interface TextPiece {
|
|
|
101
122
|
const CONDITION_MACROS = new Set(['if', 'elseif', 'case']);
|
|
102
123
|
|
|
103
124
|
/** Code inside the quoted strings of a macro's arguments, by parameter. */
|
|
104
|
-
const CODE_IN_STRINGS:
|
|
105
|
-
watch
|
|
106
|
-
|
|
125
|
+
const CODE_IN_STRINGS: ReadonlyMap<string, Record<string, JsGoal>> = new Map([
|
|
126
|
+
['watch', { condition: 'expression', run: 'statements' }],
|
|
127
|
+
]);
|
|
107
128
|
|
|
108
129
|
/** The `string` parameters of a macro that name a passage. */
|
|
109
|
-
const PASSAGE_STRINGS:
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
130
|
+
const PASSAGE_STRINGS: ReadonlyMap<string, readonly string[]> = new Map([
|
|
131
|
+
['watch', ['goto', 'dialog']],
|
|
132
|
+
]);
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The `text` and `string` parameters of a macro that the runtime keeps as
|
|
136
|
+
* written, not as markup: an option's value, the name of a watcher.
|
|
137
|
+
*/
|
|
138
|
+
const LITERAL_STRINGS: ReadonlyMap<string, readonly string[]> = new Map([
|
|
139
|
+
['option', ['value']],
|
|
140
|
+
['watch', ['name']],
|
|
141
|
+
['unwatch', ['name']],
|
|
142
|
+
]);
|
|
113
143
|
|
|
114
144
|
/** Block macros whose body is the name of a passage. */
|
|
115
145
|
const PASSAGE_BODIES = new Set(['dialog']);
|
|
116
146
|
|
|
117
|
-
type Piece = CodePiece | TextPiece | PassagePiece;
|
|
147
|
+
type Piece = CodePiece | TextPiece | PassagePiece | ArgumentErrorPiece;
|
|
118
148
|
|
|
119
149
|
/** The goal of the code an argument of this type holds, if it is code. */
|
|
120
150
|
function codeGoal(param: ParameterDef): JsGoal | undefined {
|
|
@@ -150,6 +180,8 @@ export function* codeAndText(
|
|
|
150
180
|
kind: 'passage',
|
|
151
181
|
name: token.target,
|
|
152
182
|
offset: locate(src, token.target, token.start),
|
|
183
|
+
length: token.target.length,
|
|
184
|
+
macro: 'link',
|
|
153
185
|
label: src.slice(token.start, token.end),
|
|
154
186
|
};
|
|
155
187
|
} else if (token.type === 'expression') {
|
|
@@ -215,6 +247,8 @@ export function* codeAndText(
|
|
|
215
247
|
kind: 'passage',
|
|
216
248
|
name: passage,
|
|
217
249
|
offset: locate(src, passage, token.end),
|
|
250
|
+
length: passage.length,
|
|
251
|
+
macro: name,
|
|
218
252
|
label: src.slice(token.start, token.end),
|
|
219
253
|
};
|
|
220
254
|
}
|
|
@@ -239,16 +273,25 @@ export function* codeAndText(
|
|
|
239
273
|
}
|
|
240
274
|
|
|
241
275
|
/** The code and text in the arguments `args` (at `offset`) of `macro`. */
|
|
242
|
-
function* argPieces(
|
|
276
|
+
export function* argPieces(
|
|
243
277
|
args: string,
|
|
244
278
|
offset: number,
|
|
245
279
|
params: readonly ParameterDef[],
|
|
246
280
|
macro: string,
|
|
247
281
|
label: string,
|
|
248
282
|
): Generator<Piece> {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
283
|
+
let values: Record<string, unknown>;
|
|
284
|
+
try {
|
|
285
|
+
values = parseMacroArgs(args, params) as Record<string, unknown>;
|
|
286
|
+
} catch (error) {
|
|
287
|
+
if (!(error instanceof MacroArgumentError)) throw error;
|
|
288
|
+
yield { kind: 'argument-error', message: error.message, offset, label };
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const key = macro.toLowerCase();
|
|
292
|
+
const inStrings = CODE_IN_STRINGS.get(key) ?? {};
|
|
293
|
+
const passageStrings = PASSAGE_STRINGS.get(key) ?? [];
|
|
294
|
+
const literalStrings = LITERAL_STRINGS.get(key) ?? [];
|
|
252
295
|
let cursor = 0;
|
|
253
296
|
/** The pieces of the parameters `list`, with their values in `from`. */
|
|
254
297
|
function* visit(
|
|
@@ -276,20 +319,36 @@ function* argPieces(
|
|
|
276
319
|
goal,
|
|
277
320
|
label,
|
|
278
321
|
};
|
|
279
|
-
if (param.type === 'passage')
|
|
322
|
+
if (param.type === 'passage') {
|
|
323
|
+
piece.passage = true;
|
|
324
|
+
piece.macro = macro.toLowerCase();
|
|
325
|
+
}
|
|
326
|
+
if (!codeGoal(param)) piece.inString = true;
|
|
280
327
|
yield piece;
|
|
281
328
|
}
|
|
282
329
|
// A passage written out: a quoted `passage` argument, or a string
|
|
283
|
-
const
|
|
330
|
+
const written = param.type === 'passage' ? value.trim() : value;
|
|
331
|
+
const target =
|
|
284
332
|
param.type === 'passage'
|
|
285
|
-
?
|
|
333
|
+
? passageTarget(value)
|
|
286
334
|
: passageStrings.includes(param.name)
|
|
287
|
-
? value
|
|
335
|
+
? ({ kind: 'name', name: value } as const)
|
|
288
336
|
: null;
|
|
289
|
-
if (
|
|
290
|
-
yield {
|
|
337
|
+
if (target?.kind === 'name') {
|
|
338
|
+
yield {
|
|
339
|
+
kind: 'passage',
|
|
340
|
+
name: target.name,
|
|
341
|
+
offset: offset + at + value.indexOf(written),
|
|
342
|
+
length: written.length,
|
|
343
|
+
macro: macro.toLowerCase(),
|
|
344
|
+
label,
|
|
345
|
+
};
|
|
291
346
|
}
|
|
292
|
-
if (
|
|
347
|
+
if (
|
|
348
|
+
!goal &&
|
|
349
|
+
(param.type === 'text' || param.type === 'string') &&
|
|
350
|
+
!literalStrings.includes(param.name)
|
|
351
|
+
) {
|
|
293
352
|
yield {
|
|
294
353
|
kind: 'text',
|
|
295
354
|
text: value,
|
|
@@ -312,3 +371,52 @@ function locate(text: string, part: string, from: number): number {
|
|
|
312
371
|
const anywhere = text.indexOf(part);
|
|
313
372
|
return anywhere >= 0 ? anywhere : from;
|
|
314
373
|
}
|
|
374
|
+
|
|
375
|
+
/** A passage a piece of markup names, and where. */
|
|
376
|
+
export interface PassageReference {
|
|
377
|
+
/** The macro it is the argument of; `link` for `[[…]]` links. */
|
|
378
|
+
macro: string;
|
|
379
|
+
/** The name written out, or the expression whose value is the name. */
|
|
380
|
+
target: PassageTarget;
|
|
381
|
+
/** Where the name is written, as it is written (quotes included). */
|
|
382
|
+
start: number;
|
|
383
|
+
end: number;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* The passages the markup `src` (with the `tokens` it tokenizes to) names:
|
|
388
|
+
* `[[…]]` links, the passage argument of `{goto}`, `{include}` and `{link}`
|
|
389
|
+
* (and of macros that declare one), the `goto` and `dialog` actions of
|
|
390
|
+
* `{watch}` and the body of `{dialog}`, in source order. A name written out
|
|
391
|
+
* is a literal; the others are expressions, which name a passage when they
|
|
392
|
+
* run.
|
|
393
|
+
*/
|
|
394
|
+
export function collectPassageReferences(
|
|
395
|
+
src: string,
|
|
396
|
+
tokens: readonly Token[],
|
|
397
|
+
parametersOf: (macro: string) => readonly ParameterDef[] | undefined,
|
|
398
|
+
): PassageReference[] {
|
|
399
|
+
const refs: PassageReference[] = [];
|
|
400
|
+
for (const piece of codeAndText(src, tokens, parametersOf)) {
|
|
401
|
+
if (piece.kind === 'passage') {
|
|
402
|
+
refs.push({
|
|
403
|
+
macro: piece.macro,
|
|
404
|
+
target: { kind: 'name', name: piece.name },
|
|
405
|
+
start: piece.offset,
|
|
406
|
+
end: piece.offset + piece.length,
|
|
407
|
+
});
|
|
408
|
+
} else if (piece.kind === 'code' && piece.passage) {
|
|
409
|
+
refs.push({
|
|
410
|
+
macro: piece.macro!,
|
|
411
|
+
target: passageTarget(piece.code),
|
|
412
|
+
start: piece.offset,
|
|
413
|
+
end: piece.offset + piece.code.length,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return refs.filter(
|
|
418
|
+
(ref, i) =>
|
|
419
|
+
refs.findIndex((r) => r.start === ref.start && r.macro === ref.macro) ===
|
|
420
|
+
i,
|
|
421
|
+
);
|
|
422
|
+
}
|
|
@@ -4,18 +4,14 @@ import { parseMarkup } from '../../markup/parse';
|
|
|
4
4
|
import { NobrContext } from '../../markup/render';
|
|
5
5
|
import { defineMacro } from '../../define-macro';
|
|
6
6
|
import { evaluatePassageName } from './macro-args';
|
|
7
|
+
import { INCLUDE_PARAMETERS } from './include-args';
|
|
7
8
|
import { MacroError } from './MacroError';
|
|
8
9
|
|
|
9
10
|
defineMacro({
|
|
10
11
|
name: 'include',
|
|
11
12
|
interpolate: true,
|
|
12
13
|
merged: true,
|
|
13
|
-
|
|
14
|
-
// passage names and expressions containing the word stay intact (#201).
|
|
15
|
-
parameters: [
|
|
16
|
-
{ name: 'inline', type: 'flag' },
|
|
17
|
-
{ name: 'passage', type: 'passage', required: true },
|
|
18
|
-
],
|
|
14
|
+
parameters: INCLUDE_PARAMETERS,
|
|
19
15
|
render(_props, ctx) {
|
|
20
16
|
const { storyData } = useStoryFields('storyData');
|
|
21
17
|
|
|
@@ -1,24 +1,41 @@
|
|
|
1
1
|
import { useStoryStore } from '../../store';
|
|
2
2
|
import { defineMacro } from '../../define-macro';
|
|
3
3
|
import { useDetachedBody } from './detached-body';
|
|
4
|
+
import { evaluatePassageName } from './macro-args';
|
|
5
|
+
import { MacroError } from './MacroError';
|
|
4
6
|
|
|
5
7
|
defineMacro({
|
|
6
8
|
name: 'link',
|
|
7
9
|
block: true,
|
|
8
10
|
interpolate: true,
|
|
9
|
-
|
|
10
|
-
//
|
|
11
|
+
merged: true,
|
|
12
|
+
// {link "text" passage} or {link "text"}; the passage is a quoted name or
|
|
13
|
+
// an expression, as in {goto}.
|
|
11
14
|
parameters: [
|
|
12
15
|
{ name: 'text', type: 'string', required: true },
|
|
13
|
-
{ name: 'passage', type: '
|
|
16
|
+
{ name: 'passage', type: 'passage' },
|
|
14
17
|
],
|
|
15
|
-
render({
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
render({ children = [] }, ctx) {
|
|
19
|
+
const display = ctx.args.text ?? '';
|
|
20
|
+
let passage: string | null = null;
|
|
21
|
+
// Boxed: anything can be thrown, including null and other falsy values
|
|
22
|
+
let failure: { error: unknown } | undefined;
|
|
23
|
+
if (ctx.args.passage !== undefined) {
|
|
24
|
+
try {
|
|
25
|
+
passage = evaluatePassageName(
|
|
26
|
+
ctx.args.passage,
|
|
27
|
+
ctx.evaluate!,
|
|
28
|
+
useStoryStore.getState(),
|
|
29
|
+
);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
failure = { error };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
19
34
|
const runBody = useDetachedBody();
|
|
20
35
|
|
|
21
36
|
const perform = () => {
|
|
37
|
+
// A link that failed to resolve is an error, not a control
|
|
38
|
+
if (failure) return;
|
|
22
39
|
runBody(children);
|
|
23
40
|
if (passage) {
|
|
24
41
|
useStoryStore.getState().navigate(passage);
|
|
@@ -36,9 +53,18 @@ defineMacro({
|
|
|
36
53
|
authorId: ctx.id,
|
|
37
54
|
label: display,
|
|
38
55
|
target: passage ?? undefined,
|
|
56
|
+
disabled: failure ? true : undefined,
|
|
39
57
|
perform,
|
|
40
58
|
});
|
|
41
59
|
|
|
60
|
+
if (failure) {
|
|
61
|
+
return (
|
|
62
|
+
<MacroError
|
|
63
|
+
macro="link"
|
|
64
|
+
error={failure.error}
|
|
65
|
+
/>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
42
68
|
return (
|
|
43
69
|
<a
|
|
44
70
|
id={ctx.id}
|
|
@@ -69,6 +69,43 @@ export function readWholeQuoted(src: string): string | null {
|
|
|
69
69
|
return quoted && quoted.end === src.length ? quoted.value : null;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
const JS_SIMPLE_ESCAPES: Record<string, string> = {
|
|
73
|
+
n: '\n',
|
|
74
|
+
t: '\t',
|
|
75
|
+
r: '\r',
|
|
76
|
+
b: '\b',
|
|
77
|
+
f: '\f',
|
|
78
|
+
v: '\v',
|
|
79
|
+
'0': '\0',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Undo every escape of a JavaScript string literal's body. */
|
|
83
|
+
function decodeJsEscapes(body: string): string {
|
|
84
|
+
return body.replace(
|
|
85
|
+
/\\(?:u\{([0-9a-fA-F]+)\}|u([0-9a-fA-F]{4})|x([0-9a-fA-F]{2})|(\r\n|[\n\r\u2028\u2029])|([\s\S]))/g,
|
|
86
|
+
(_, braced, unicode, hex, lineBreak, other: string | undefined) => {
|
|
87
|
+
if (lineBreak !== undefined) return '';
|
|
88
|
+
const code = braced ?? unicode ?? hex;
|
|
89
|
+
if (code !== undefined) {
|
|
90
|
+
const point = parseInt(code, 16);
|
|
91
|
+
return point <= 0x10ffff ? String.fromCodePoint(point) : '';
|
|
92
|
+
}
|
|
93
|
+
return JS_SIMPLE_ESCAPES[other!] ?? other!;
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The value of `src` when it is exactly one `"…"` or `'…'` string, read as
|
|
100
|
+
* the JavaScript literal it is (`"\u0048all"` is `Hall`), otherwise `null`.
|
|
101
|
+
* `readWholeQuoted` keeps other escapes as written, for labels and code.
|
|
102
|
+
*/
|
|
103
|
+
export function readWholeJsString(src: string): string | null {
|
|
104
|
+
const quoted = readQuoted(src, 0);
|
|
105
|
+
if (!quoted || quoted.end !== src.length) return null;
|
|
106
|
+
return decodeJsEscapes(src.slice(1, -1));
|
|
107
|
+
}
|
|
108
|
+
|
|
72
109
|
/**
|
|
73
110
|
* Strip an optional quote from each end of an unquoted-or-loosely-quoted
|
|
74
111
|
* argument (`"Red`, `Red'`), leaving at least one character. This is the
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { parseMacroArgs } from './macro-args';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The parameters of `{include}`. A standalone `inline` flag only counts
|
|
5
|
+
* outside quotes and brackets, so passage names and expressions containing
|
|
6
|
+
* the word stay intact (#201).
|
|
7
|
+
*/
|
|
8
|
+
export const INCLUDE_PARAMETERS = [
|
|
9
|
+
{ name: 'inline', type: 'flag' },
|
|
10
|
+
{ name: 'passage', type: 'passage', required: true },
|
|
11
|
+
] as const;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The arguments of `{include}`: whether it has the `inline` flag, as its
|
|
15
|
+
* first or last word, and the passage expression that is left.
|
|
16
|
+
*/
|
|
17
|
+
export function splitIncludeFlag(rawArgs: string): {
|
|
18
|
+
inline: boolean;
|
|
19
|
+
passage: string | undefined;
|
|
20
|
+
} {
|
|
21
|
+
const { inline, passage } = parseMacroArgs(rawArgs, INCLUDE_PARAMETERS);
|
|
22
|
+
return { inline, passage };
|
|
23
|
+
}
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
endsWithOperator,
|
|
23
23
|
isWhitespace,
|
|
24
24
|
readQuoted,
|
|
25
|
+
readWholeJsString,
|
|
25
26
|
readWholeQuoted,
|
|
26
27
|
stripLooseQuotes,
|
|
27
28
|
topLevelIndices,
|
|
@@ -50,10 +51,26 @@ export function readText(src: string): string {
|
|
|
50
51
|
return readWholeQuoted(src) ?? stripLooseQuotes(src);
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
/**
|
|
55
|
+
* An argument that doesn't have its parameter's form: a `string` parameter
|
|
56
|
+
* given anything but one quoted string.
|
|
57
|
+
*/
|
|
58
|
+
export class MacroArgumentError extends Error {
|
|
59
|
+
constructor(param: ParameterDef, src: string) {
|
|
60
|
+
super(
|
|
61
|
+
`The ${param.name} must be a quoted string ("…" or '…'), ${src ? `not ${src}` : 'but it has no value'}`,
|
|
62
|
+
);
|
|
63
|
+
this.name = 'MacroArgumentError';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
53
67
|
function readValue(param: ParameterDef, src: string): unknown {
|
|
54
68
|
switch (param.type) {
|
|
55
|
-
case 'string':
|
|
56
|
-
|
|
69
|
+
case 'string': {
|
|
70
|
+
const value = readWholeQuoted(src);
|
|
71
|
+
if (value === null) throw new MacroArgumentError(param, src);
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
57
74
|
case 'text':
|
|
58
75
|
return readText(src);
|
|
59
76
|
case 'names':
|
|
@@ -76,6 +93,7 @@ function unset(param: ParameterDef): unknown {
|
|
|
76
93
|
|
|
77
94
|
const WORD_RE = /\w+/y;
|
|
78
95
|
const DIGITS_RE = /\d+/y;
|
|
96
|
+
const WORD_OR_PUNCT_RE = /\S+/y;
|
|
79
97
|
|
|
80
98
|
/** Match the sticky regex `re` at `pos`, returning the matched text. */
|
|
81
99
|
function matchAt(re: RegExp, src: string, pos: number): string | null {
|
|
@@ -86,7 +104,8 @@ function matchAt(re: RegExp, src: string, pos: number): string | null {
|
|
|
86
104
|
/**
|
|
87
105
|
* Read keyword options (`goto "X" priority 5 once`). A keyword takes the
|
|
88
106
|
* quoted string or digit run after it as its value; keywords that aren't
|
|
89
|
-
* declared, and their values, are skipped.
|
|
107
|
+
* declared, and their values, are skipped. A keyword of a `string` parameter
|
|
108
|
+
* must be followed by a quoted string.
|
|
90
109
|
*/
|
|
91
110
|
function readOptions(
|
|
92
111
|
params: readonly ParameterDef[],
|
|
@@ -105,10 +124,11 @@ function readOptions(
|
|
|
105
124
|
|
|
106
125
|
// A value is a quoted string or a digit run, after whitespace.
|
|
107
126
|
let val: string | undefined;
|
|
127
|
+
let quoted: ReturnType<typeof readQuoted> = null;
|
|
108
128
|
let j = i;
|
|
109
129
|
while (j < src.length && isWhitespace(src[j]!)) j++;
|
|
110
130
|
if (j > i) {
|
|
111
|
-
|
|
131
|
+
quoted = readQuoted(src, j);
|
|
112
132
|
const digits = quoted ? null : matchAt(DIGITS_RE, src, j);
|
|
113
133
|
if (quoted) {
|
|
114
134
|
val = quoted.value;
|
|
@@ -120,6 +140,13 @@ function readOptions(
|
|
|
120
140
|
}
|
|
121
141
|
|
|
122
142
|
const param = params.find((p) => p.name === key);
|
|
143
|
+
// A keyword that takes a string was given anything else, or nothing
|
|
144
|
+
if (param?.type === 'string' && (!quoted || val === undefined)) {
|
|
145
|
+
throw new MacroArgumentError(
|
|
146
|
+
param,
|
|
147
|
+
matchAt(WORD_OR_PUNCT_RE, src, j) ?? '',
|
|
148
|
+
);
|
|
149
|
+
}
|
|
123
150
|
if (param?.type === 'flag') options[key] = true;
|
|
124
151
|
else if (param?.type === 'number') options[key] = Number(val ?? 0);
|
|
125
152
|
else if (param) options[key] = val;
|
|
@@ -220,10 +247,20 @@ function readGroup(
|
|
|
220
247
|
args[param.name] =
|
|
221
248
|
from < to ? read(param, slice(text, spans[from++]!)) : unset(param);
|
|
222
249
|
}
|
|
250
|
+
// Optional ones at the end: a term that doesn't have their form (a
|
|
251
|
+
// `string` that isn't quoted) belongs to the rest, as in {meter $hp 100}
|
|
252
|
+
const readTrailing = (param: ParameterDef, value: string) => {
|
|
253
|
+
try {
|
|
254
|
+
return read(param, value);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
if (error instanceof MacroArgumentError) return undefined;
|
|
257
|
+
throw error;
|
|
258
|
+
}
|
|
259
|
+
};
|
|
223
260
|
for (const param of positional.slice(restIndex + 1).reverse()) {
|
|
224
261
|
const value =
|
|
225
262
|
from < to && !endsWithOperator(text.slice(0, spans[to - 1]![0]))
|
|
226
|
-
?
|
|
263
|
+
? readTrailing(param, slice(text, spans[to - 1]!))
|
|
227
264
|
: undefined;
|
|
228
265
|
args[param.name] = value ?? unset(param);
|
|
229
266
|
if (value !== undefined) to--;
|
|
@@ -264,6 +301,25 @@ export function parseMacroArgs<const P extends readonly ParameterDef[]>(
|
|
|
264
301
|
return args as MacroArgs<P>;
|
|
265
302
|
}
|
|
266
303
|
|
|
304
|
+
/** What a `passage` argument is: a quoted name, or an expression. */
|
|
305
|
+
export type PassageTarget =
|
|
306
|
+
| { kind: 'name'; name: string }
|
|
307
|
+
| { kind: 'expression'; expression: string };
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Read a `passage` argument as written (`{goto "Hall"}`, `{goto $room}`): a
|
|
311
|
+
* quoted string is the name its JavaScript literal has (`"\u0048all"` is
|
|
312
|
+
* `Hall`), anything else an expression, whose value is the name when it
|
|
313
|
+
* runs (see evaluatePassageName).
|
|
314
|
+
*/
|
|
315
|
+
export function passageTarget(arg: string): PassageTarget {
|
|
316
|
+
const expression = arg.trim();
|
|
317
|
+
const name = readWholeJsString(expression);
|
|
318
|
+
return name === null
|
|
319
|
+
? { kind: 'expression', expression }
|
|
320
|
+
: { kind: 'name', name };
|
|
321
|
+
}
|
|
322
|
+
|
|
267
323
|
/**
|
|
268
324
|
* The passage a `passage` argument names (`{goto "Hall"}`, `{goto $room}`):
|
|
269
325
|
* the value of its expression. Throws what evaluating it throws, and if no
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ASTNode } from '../../markup/ast';
|
|
2
2
|
import { parseMacroArgs } from './macro-args';
|
|
3
|
+
import type { ParameterDef } from '../../registry';
|
|
3
4
|
|
|
4
5
|
/** `$var "placeholder"`, the arguments of the input macros. */
|
|
5
6
|
export function parseVarArgs(rawArgs: string): {
|
|
@@ -14,7 +15,20 @@ export function parseVarArgs(rawArgs: string): {
|
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
/** The parameters of an `{option}` sub-macro: its value. */
|
|
17
|
-
const OPTION_PARAMETERS = [
|
|
18
|
+
const OPTION_PARAMETERS = [
|
|
19
|
+
{ name: 'value', type: 'string', required: true },
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
/** The parameters of the built-in sub-macros that take arguments. */
|
|
23
|
+
const SUB_MACRO_PARAMETERS: ReadonlyMap<string, readonly ParameterDef[]> =
|
|
24
|
+
new Map([['option', OPTION_PARAMETERS]]);
|
|
25
|
+
|
|
26
|
+
/** The parameters of the built-in sub-macro `name`, if it takes any. */
|
|
27
|
+
export function subMacroParameters(
|
|
28
|
+
name: string,
|
|
29
|
+
): readonly ParameterDef[] | undefined {
|
|
30
|
+
return SUB_MACRO_PARAMETERS.get(name.toLowerCase());
|
|
31
|
+
}
|
|
18
32
|
|
|
19
33
|
/**
|
|
20
34
|
* Walk AST children to find {option} macro nodes, returning their values.
|
|
@@ -23,12 +37,9 @@ export function extractOptions(children: ASTNode[]): string[] {
|
|
|
23
37
|
const options: string[] = [];
|
|
24
38
|
for (const node of children) {
|
|
25
39
|
if (node.type === 'macro' && node.name === 'option') {
|
|
26
|
-
// {option "Long Sword"} gives `Long Sword
|
|
27
|
-
// quoted string loses a matching pair of quotes around it, if any.
|
|
40
|
+
// {option "Long Sword"} gives `Long Sword`
|
|
28
41
|
const { value } = parseMacroArgs(node.rawArgs, OPTION_PARAMETERS);
|
|
29
|
-
options.push(
|
|
30
|
-
value ?? node.rawArgs.trim().replace(/^(["'])(.+)\1$/s, '$2'),
|
|
31
|
-
);
|
|
42
|
+
options.push(value ?? '');
|
|
32
43
|
}
|
|
33
44
|
}
|
|
34
45
|
return options;
|
package/src/define-macro.ts
CHANGED
|
@@ -30,9 +30,11 @@ import { currentSourceLocation } from './utils/source-location';
|
|
|
30
30
|
import { parseVarArgs, extractOptions } from './components/macros/option-utils';
|
|
31
31
|
import { wrapContent } from './components/macros/display';
|
|
32
32
|
import {
|
|
33
|
+
MacroArgumentError,
|
|
33
34
|
parseMacroArgs,
|
|
34
35
|
readBoundVariable,
|
|
35
36
|
} from './components/macros/macro-args';
|
|
37
|
+
import { MacroError } from './components/macros/MacroError';
|
|
36
38
|
import {
|
|
37
39
|
checkParameterTypes,
|
|
38
40
|
registerMacro,
|
|
@@ -162,8 +164,18 @@ export function defineMacro<const P extends readonly ParameterDef[] = []>(
|
|
|
162
164
|
raw?: boolean;
|
|
163
165
|
},
|
|
164
166
|
) => _renderNodes(nodes, { ...renderOptions, ...options });
|
|
167
|
+
let args: MacroArgs<P>;
|
|
168
|
+
try {
|
|
169
|
+
args = parseMacroArgs(props.rawArgs, parameters) as MacroArgs<P>;
|
|
170
|
+
} catch (error) {
|
|
171
|
+
// Shown in place, as other macro errors are
|
|
172
|
+
if (error instanceof MacroArgumentError) {
|
|
173
|
+
return h(MacroError, { macro: config.name, error });
|
|
174
|
+
}
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
165
177
|
const ctx: MacroContext<MacroArgs<P>> = {
|
|
166
|
-
args
|
|
178
|
+
args,
|
|
167
179
|
collectText,
|
|
168
180
|
sourceLocation: currentSourceLocation,
|
|
169
181
|
parseVarArgs,
|
|
@@ -224,7 +236,15 @@ export function defineMacro<const P extends readonly ParameterDef[] = []>(
|
|
|
224
236
|
};
|
|
225
237
|
}
|
|
226
238
|
|
|
227
|
-
|
|
239
|
+
try {
|
|
240
|
+
return config.render(props, ctx);
|
|
241
|
+
} catch (error) {
|
|
242
|
+
// An argument read while rendering, e.g. a sub-macro's ({option})
|
|
243
|
+
if (error instanceof MacroArgumentError) {
|
|
244
|
+
return h(MacroError, { macro: config.name, error });
|
|
245
|
+
}
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
228
248
|
}
|
|
229
249
|
|
|
230
250
|
registerMacro(config.name, Wrapper);
|