@rohal12/spindle 0.57.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 +584 -426
- package/dist/pkg/story-variables.js +567 -154
- package/dist/pkg/tooling.js +39 -2
- package/package.json +1 -1
- package/src/code-check.ts +107 -17
- package/src/components/macros/Include.tsx +2 -6
- package/src/components/macros/MacroLink.tsx +3 -0
- 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 +35 -3
- package/src/components/macros/option-utils.ts +9 -3
- 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 +11 -4
- 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
|
@@ -32,8 +32,9 @@ import type { ParameterDef } from './registry';
|
|
|
32
32
|
import {
|
|
33
33
|
MacroArgumentError,
|
|
34
34
|
parseMacroArgs,
|
|
35
|
+
passageTarget,
|
|
36
|
+
type PassageTarget,
|
|
35
37
|
} from './components/macros/macro-args';
|
|
36
|
-
import { readWholeQuoted } from './components/macros/arg-utils';
|
|
37
38
|
|
|
38
39
|
/** While a pass runs: the code it parsed so far, by goal and source. */
|
|
39
40
|
let parses: Map<string, ParsedCode | CodeSyntaxError> | null = null;
|
|
@@ -80,6 +81,10 @@ export interface CodePiece {
|
|
|
80
81
|
label: string;
|
|
81
82
|
/** Whether it names a passage: a `passage` argument. */
|
|
82
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;
|
|
83
88
|
}
|
|
84
89
|
|
|
85
90
|
/** A passage name written out in markup, at `offset` in it. */
|
|
@@ -89,6 +94,10 @@ export interface PassagePiece {
|
|
|
89
94
|
offset: number;
|
|
90
95
|
/** The markup it is in, for the error: `[[Go->Hall]]`. */
|
|
91
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;
|
|
92
101
|
}
|
|
93
102
|
|
|
94
103
|
/** Macro arguments that don't have their parameters' forms. */
|
|
@@ -113,14 +122,24 @@ export interface TextPiece {
|
|
|
113
122
|
const CONDITION_MACROS = new Set(['if', 'elseif', 'case']);
|
|
114
123
|
|
|
115
124
|
/** Code inside the quoted strings of a macro's arguments, by parameter. */
|
|
116
|
-
const CODE_IN_STRINGS:
|
|
117
|
-
watch
|
|
118
|
-
|
|
125
|
+
const CODE_IN_STRINGS: ReadonlyMap<string, Record<string, JsGoal>> = new Map([
|
|
126
|
+
['watch', { condition: 'expression', run: 'statements' }],
|
|
127
|
+
]);
|
|
119
128
|
|
|
120
129
|
/** The `string` parameters of a macro that name a passage. */
|
|
121
|
-
const PASSAGE_STRINGS:
|
|
122
|
-
watch
|
|
123
|
-
|
|
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
|
+
]);
|
|
124
143
|
|
|
125
144
|
/** Block macros whose body is the name of a passage. */
|
|
126
145
|
const PASSAGE_BODIES = new Set(['dialog']);
|
|
@@ -161,6 +180,8 @@ export function* codeAndText(
|
|
|
161
180
|
kind: 'passage',
|
|
162
181
|
name: token.target,
|
|
163
182
|
offset: locate(src, token.target, token.start),
|
|
183
|
+
length: token.target.length,
|
|
184
|
+
macro: 'link',
|
|
164
185
|
label: src.slice(token.start, token.end),
|
|
165
186
|
};
|
|
166
187
|
} else if (token.type === 'expression') {
|
|
@@ -226,6 +247,8 @@ export function* codeAndText(
|
|
|
226
247
|
kind: 'passage',
|
|
227
248
|
name: passage,
|
|
228
249
|
offset: locate(src, passage, token.end),
|
|
250
|
+
length: passage.length,
|
|
251
|
+
macro: name,
|
|
229
252
|
label: src.slice(token.start, token.end),
|
|
230
253
|
};
|
|
231
254
|
}
|
|
@@ -250,7 +273,7 @@ export function* codeAndText(
|
|
|
250
273
|
}
|
|
251
274
|
|
|
252
275
|
/** The code and text in the arguments `args` (at `offset`) of `macro`. */
|
|
253
|
-
function* argPieces(
|
|
276
|
+
export function* argPieces(
|
|
254
277
|
args: string,
|
|
255
278
|
offset: number,
|
|
256
279
|
params: readonly ParameterDef[],
|
|
@@ -265,8 +288,10 @@ function* argPieces(
|
|
|
265
288
|
yield { kind: 'argument-error', message: error.message, offset, label };
|
|
266
289
|
return;
|
|
267
290
|
}
|
|
268
|
-
const
|
|
269
|
-
const
|
|
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) ?? [];
|
|
270
295
|
let cursor = 0;
|
|
271
296
|
/** The pieces of the parameters `list`, with their values in `from`. */
|
|
272
297
|
function* visit(
|
|
@@ -294,20 +319,36 @@ function* argPieces(
|
|
|
294
319
|
goal,
|
|
295
320
|
label,
|
|
296
321
|
};
|
|
297
|
-
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;
|
|
298
327
|
yield piece;
|
|
299
328
|
}
|
|
300
329
|
// A passage written out: a quoted `passage` argument, or a string
|
|
301
|
-
const
|
|
330
|
+
const written = param.type === 'passage' ? value.trim() : value;
|
|
331
|
+
const target =
|
|
302
332
|
param.type === 'passage'
|
|
303
|
-
?
|
|
333
|
+
? passageTarget(value)
|
|
304
334
|
: passageStrings.includes(param.name)
|
|
305
|
-
? value
|
|
335
|
+
? ({ kind: 'name', name: value } as const)
|
|
306
336
|
: null;
|
|
307
|
-
if (
|
|
308
|
-
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
|
+
};
|
|
309
346
|
}
|
|
310
|
-
if (
|
|
347
|
+
if (
|
|
348
|
+
!goal &&
|
|
349
|
+
(param.type === 'text' || param.type === 'string') &&
|
|
350
|
+
!literalStrings.includes(param.name)
|
|
351
|
+
) {
|
|
311
352
|
yield {
|
|
312
353
|
kind: 'text',
|
|
313
354
|
text: value,
|
|
@@ -330,3 +371,52 @@ function locate(text: string, part: string, from: number): number {
|
|
|
330
371
|
const anywhere = text.indexOf(part);
|
|
331
372
|
return anywhere >= 0 ? anywhere : from;
|
|
332
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
|
|
|
@@ -34,6 +34,8 @@ defineMacro({
|
|
|
34
34
|
const runBody = useDetachedBody();
|
|
35
35
|
|
|
36
36
|
const perform = () => {
|
|
37
|
+
// A link that failed to resolve is an error, not a control
|
|
38
|
+
if (failure) return;
|
|
37
39
|
runBody(children);
|
|
38
40
|
if (passage) {
|
|
39
41
|
useStoryStore.getState().navigate(passage);
|
|
@@ -51,6 +53,7 @@ defineMacro({
|
|
|
51
53
|
authorId: ctx.id,
|
|
52
54
|
label: display,
|
|
53
55
|
target: passage ?? undefined,
|
|
56
|
+
disabled: failure ? true : undefined,
|
|
54
57
|
perform,
|
|
55
58
|
});
|
|
56
59
|
|
|
@@ -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,
|
|
@@ -56,7 +57,9 @@ export function readText(src: string): string {
|
|
|
56
57
|
*/
|
|
57
58
|
export class MacroArgumentError extends Error {
|
|
58
59
|
constructor(param: ParameterDef, src: string) {
|
|
59
|
-
super(
|
|
60
|
+
super(
|
|
61
|
+
`The ${param.name} must be a quoted string ("…" or '…'), ${src ? `not ${src}` : 'but it has no value'}`,
|
|
62
|
+
);
|
|
60
63
|
this.name = 'MacroArgumentError';
|
|
61
64
|
}
|
|
62
65
|
}
|
|
@@ -90,6 +93,7 @@ function unset(param: ParameterDef): unknown {
|
|
|
90
93
|
|
|
91
94
|
const WORD_RE = /\w+/y;
|
|
92
95
|
const DIGITS_RE = /\d+/y;
|
|
96
|
+
const WORD_OR_PUNCT_RE = /\S+/y;
|
|
93
97
|
|
|
94
98
|
/** Match the sticky regex `re` at `pos`, returning the matched text. */
|
|
95
99
|
function matchAt(re: RegExp, src: string, pos: number): string | null {
|
|
@@ -100,7 +104,8 @@ function matchAt(re: RegExp, src: string, pos: number): string | null {
|
|
|
100
104
|
/**
|
|
101
105
|
* Read keyword options (`goto "X" priority 5 once`). A keyword takes the
|
|
102
106
|
* quoted string or digit run after it as its value; keywords that aren't
|
|
103
|
-
* 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.
|
|
104
109
|
*/
|
|
105
110
|
function readOptions(
|
|
106
111
|
params: readonly ParameterDef[],
|
|
@@ -119,10 +124,11 @@ function readOptions(
|
|
|
119
124
|
|
|
120
125
|
// A value is a quoted string or a digit run, after whitespace.
|
|
121
126
|
let val: string | undefined;
|
|
127
|
+
let quoted: ReturnType<typeof readQuoted> = null;
|
|
122
128
|
let j = i;
|
|
123
129
|
while (j < src.length && isWhitespace(src[j]!)) j++;
|
|
124
130
|
if (j > i) {
|
|
125
|
-
|
|
131
|
+
quoted = readQuoted(src, j);
|
|
126
132
|
const digits = quoted ? null : matchAt(DIGITS_RE, src, j);
|
|
127
133
|
if (quoted) {
|
|
128
134
|
val = quoted.value;
|
|
@@ -134,6 +140,13 @@ function readOptions(
|
|
|
134
140
|
}
|
|
135
141
|
|
|
136
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
|
+
}
|
|
137
150
|
if (param?.type === 'flag') options[key] = true;
|
|
138
151
|
else if (param?.type === 'number') options[key] = Number(val ?? 0);
|
|
139
152
|
else if (param) options[key] = val;
|
|
@@ -288,6 +301,25 @@ export function parseMacroArgs<const P extends readonly ParameterDef[]>(
|
|
|
288
301
|
return args as MacroArgs<P>;
|
|
289
302
|
}
|
|
290
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
|
+
|
|
291
323
|
/**
|
|
292
324
|
* The passage a `passage` argument names (`{goto "Hall"}`, `{goto $room}`):
|
|
293
325
|
* the value of its expression. Throws what evaluating it throws, and if no
|
|
@@ -20,9 +20,15 @@ const OPTION_PARAMETERS = [
|
|
|
20
20
|
] as const;
|
|
21
21
|
|
|
22
22
|
/** The parameters of the built-in sub-macros that take arguments. */
|
|
23
|
-
|
|
24
|
-
option
|
|
25
|
-
|
|
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
|
+
}
|
|
26
32
|
|
|
27
33
|
/**
|
|
28
34
|
* Walk AST children to find {option} macro nodes, returning their values.
|
package/src/expression.ts
CHANGED
|
@@ -2,13 +2,12 @@ import type { StoryState } from './store';
|
|
|
2
2
|
import { useStoryStore } from './store';
|
|
3
3
|
import type { Passage } from './parser';
|
|
4
4
|
import { random, randomInt } from './prng';
|
|
5
|
-
import {
|
|
5
|
+
import { clearTransformCache, transform } from './transform';
|
|
6
6
|
import {
|
|
7
7
|
EMPTY_NAMESPACE,
|
|
8
8
|
asNamespace,
|
|
9
9
|
countOf,
|
|
10
10
|
isNamespace,
|
|
11
|
-
variableNameError,
|
|
12
11
|
type Counts,
|
|
13
12
|
type Namespace,
|
|
14
13
|
} from './utils/namespace';
|
|
@@ -36,60 +35,11 @@ type CompiledExpression = (
|
|
|
36
35
|
transient: Record<string, unknown>,
|
|
37
36
|
) => unknown;
|
|
38
37
|
|
|
38
|
+
export { transform };
|
|
39
|
+
|
|
39
40
|
const FN_CACHE_MAX = 500;
|
|
40
41
|
const fnCache = new Map<string, CompiledExpression>();
|
|
41
42
|
|
|
42
|
-
const NAMESPACES: Record<Sigil, string> = {
|
|
43
|
-
$: 'variables',
|
|
44
|
-
_: 'temporary',
|
|
45
|
-
'@': 'locals',
|
|
46
|
-
'%': 'transient',
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
/** Ends with an identifier character. */
|
|
50
|
-
const IDENT_END_RE = /[\p{ID_Continue}$\u200c\u200d]$/u;
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Transform expression: $var → variables["var"], _var → temporary["var"],
|
|
54
|
-
* @var → locals["var"], %var → transient["var"].
|
|
55
|
-
* The lexer (`lexJs`) finds the references: only in code (string, template
|
|
56
|
-
* and regex literal text and comments are left untouched), only where an
|
|
57
|
-
* identifier starts (not in `a$b`, nor as the property name in `obj._x`),
|
|
58
|
-
* and `%var` only where an operand is expected, since `%` is also the
|
|
59
|
-
* modulo operator. `goal` tells whether `expr` is an expression or a list of
|
|
60
|
-
* statements, as that decides whether a leading `{` opens an object literal
|
|
61
|
-
* or a block. A reference to a variable named `__proto__` throws a
|
|
62
|
-
* SyntaxError: no namespace can hold one (see utils/namespace.ts).
|
|
63
|
-
* Exported for tests.
|
|
64
|
-
*/
|
|
65
|
-
export function transform(expr: string, goal: JsGoal = 'expression'): string {
|
|
66
|
-
const key = goal === 'statements' ? 's' + expr : 'e' + expr;
|
|
67
|
-
const cached = transformCache.get(key);
|
|
68
|
-
if (cached !== undefined) return cached;
|
|
69
|
-
const { refs } = parseCode(expr, goal);
|
|
70
|
-
let result = '';
|
|
71
|
-
let at = 0;
|
|
72
|
-
for (const ref of refs) {
|
|
73
|
-
const error = variableNameError(ref.name, ref.sigil + ref.name);
|
|
74
|
-
if (error) throw new SyntaxError(`spindle: ${error}`);
|
|
75
|
-
result += expr.slice(at, ref.start);
|
|
76
|
-
if (ref.shorthand) result += `${ref.name}: `;
|
|
77
|
-
// `typeof%x` needs a space once `%x` turns into an identifier.
|
|
78
|
-
else if (IDENT_END_RE.test(result.slice(-2))) result += ' ';
|
|
79
|
-
result += `${NAMESPACES[ref.sigil]}["${ref.name}"]`;
|
|
80
|
-
at = ref.end;
|
|
81
|
-
}
|
|
82
|
-
result += expr.slice(at);
|
|
83
|
-
if (transformCache.size >= FN_CACHE_MAX) {
|
|
84
|
-
transformCache.delete(transformCache.keys().next().value!);
|
|
85
|
-
}
|
|
86
|
-
transformCache.set(key, result);
|
|
87
|
-
return result;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** Transformed code by goal and source. */
|
|
91
|
-
const transformCache = new Map<string, string>();
|
|
92
|
-
|
|
93
43
|
const preamble =
|
|
94
44
|
'const {currentPassage,previousPassage,visited,hasVisited,hasVisitedAny,hasVisitedAll,rendered,hasRendered,hasRenderedAny,hasRenderedAll,random,randomInt}=__fns;';
|
|
95
45
|
|
|
@@ -269,7 +219,7 @@ export function execute(code: string, ...namespaces: Namespaces): void {
|
|
|
269
219
|
/** Clear the compiled expression cache. Useful for testing and HMR. */
|
|
270
220
|
export function clearExpressionCache(): void {
|
|
271
221
|
fnCache.clear();
|
|
272
|
-
|
|
222
|
+
clearTransformCache();
|
|
273
223
|
cachedFns = null;
|
|
274
224
|
cachedVisitCounts = null;
|
|
275
225
|
cachedRenderCounts = null;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the grammar (spindle.peggy) imports from the rest of Spindle, so that
|
|
3
|
+
* it holds no second copy: the generated parser imports this module's
|
|
4
|
+
* default export as `shared` (see scripts/peggy.ts).
|
|
5
|
+
*/
|
|
6
|
+
import { SIGIL_SCOPES, withSelectors } from './tokens';
|
|
7
|
+
|
|
8
|
+
export default { SIGIL_SCOPES, withSelectors };
|
package/src/markup/parse.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { parse as pegParse } from './spindle.peggy';
|
|
7
7
|
import { isBlockMacro, type ASTNode } from './ast';
|
|
8
|
-
import type { Token } from './tokens';
|
|
8
|
+
import type { Selectors, Token } from './tokens';
|
|
9
9
|
import { isCodeAttribute } from './code-attributes';
|
|
10
10
|
import { defaultCodeEnd, type CodeEnd } from './code-end';
|
|
11
11
|
|
|
@@ -74,7 +74,7 @@ export function lineColumn(
|
|
|
74
74
|
|
|
75
75
|
function run(
|
|
76
76
|
source: string,
|
|
77
|
-
startRule: 'Markup' | 'Tokens',
|
|
77
|
+
startRule: 'Markup' | 'Tokens' | 'SelectorsPrefix',
|
|
78
78
|
options: ParseMarkupOptions,
|
|
79
79
|
): unknown {
|
|
80
80
|
const hooks = options.hooks
|
|
@@ -116,3 +116,90 @@ export function tokenizeMarkup(
|
|
|
116
116
|
): Token[] {
|
|
117
117
|
return run(source, 'Tokens', options) as Token[];
|
|
118
118
|
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The `.class#id` selectors that start `source` at `at`, with the index just
|
|
122
|
+
* past them and the one space that may follow (`end`; `at` if there are
|
|
123
|
+
* none). A selector name may hold `{$name}` interpolations, as in markup.
|
|
124
|
+
*/
|
|
125
|
+
export function parseSelectors(
|
|
126
|
+
source: string,
|
|
127
|
+
at = 0,
|
|
128
|
+
): Selectors & { end: number } {
|
|
129
|
+
const found = run(source.slice(at), 'SelectorsPrefix', {}) as Selectors & {
|
|
130
|
+
end: number;
|
|
131
|
+
};
|
|
132
|
+
return { ...found, end: at + found.end };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The tokens of markup that may be malformed, and its errors. */
|
|
136
|
+
export interface TolerantTokens {
|
|
137
|
+
tokens: Token[];
|
|
138
|
+
/** One for each malformed tag, in source order, with offsets in the source. */
|
|
139
|
+
errors: MarkupError[];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* `tokenizeMarkup` for half-typed markup: the tokens it can read, and every
|
|
144
|
+
* error, instead of throwing at the first. At a malformed tag, the tokens
|
|
145
|
+
* before it are kept, its first character is read as text and tokenizing
|
|
146
|
+
* resumes after it, so what follows is read as if the tag had not been
|
|
147
|
+
* started. Tokens and errors have offsets in `source`; for well-formed
|
|
148
|
+
* markup the tokens are those of `tokenizeMarkup` and there are no errors.
|
|
149
|
+
*/
|
|
150
|
+
export function tokenizeMarkupTolerant(
|
|
151
|
+
source: string,
|
|
152
|
+
options: ParseMarkupOptions = {},
|
|
153
|
+
): TolerantTokens {
|
|
154
|
+
const tokens: Token[] = [];
|
|
155
|
+
const errors: MarkupError[] = [];
|
|
156
|
+
/** `text` shifted by `base`, tokenized: the tokens or the error. */
|
|
157
|
+
const attempt = (text: string, base: number): Token[] | MarkupError => {
|
|
158
|
+
try {
|
|
159
|
+
return tokenizeMarkup(text, options).map((token) => shift(token, base));
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (!(error instanceof MarkupError)) throw error;
|
|
162
|
+
return error;
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
let base = 0;
|
|
166
|
+
while (base <= source.length) {
|
|
167
|
+
const rest = source.slice(base);
|
|
168
|
+
const result = attempt(rest, base);
|
|
169
|
+
if (Array.isArray(result)) {
|
|
170
|
+
tokens.push(...result);
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
const at = base + result.offset;
|
|
174
|
+
const { line, column } = lineColumn(source, at);
|
|
175
|
+
errors.push(new MarkupError(result.reason, at, line, column));
|
|
176
|
+
// The longest prefix that tokenizes: the error may be inside a tag
|
|
177
|
+
// (an attribute value) of which the prefix is itself malformed
|
|
178
|
+
let cut = result.offset;
|
|
179
|
+
let prefix = attempt(rest.slice(0, cut), base);
|
|
180
|
+
while (!Array.isArray(prefix)) {
|
|
181
|
+
cut = prefix.offset;
|
|
182
|
+
prefix = attempt(rest.slice(0, cut), base);
|
|
183
|
+
}
|
|
184
|
+
tokens.push(...prefix);
|
|
185
|
+
// The character that started the damage is text
|
|
186
|
+
if (cut < rest.length) {
|
|
187
|
+
const start = base + cut;
|
|
188
|
+
tokens.push({
|
|
189
|
+
type: 'text',
|
|
190
|
+
value: rest[cut]!,
|
|
191
|
+
start,
|
|
192
|
+
end: start + 1,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
base += cut + 1;
|
|
196
|
+
}
|
|
197
|
+
return { tokens, errors };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** `token` with its offsets moved `by` on. */
|
|
201
|
+
function shift(token: Token, by: number): Token {
|
|
202
|
+
return by === 0
|
|
203
|
+
? token
|
|
204
|
+
: { ...token, start: token.start + by, end: token.end + by };
|
|
205
|
+
}
|