@rohal12/spindle 0.57.0 → 0.59.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 +773 -493
- package/dist/pkg/macro-registry.json +29 -11
- package/dist/pkg/story-variables.js +804 -268
- package/dist/pkg/tooling.js +39 -2
- package/package.json +1 -1
- package/src/code-check.ts +215 -51
- package/src/components/macros/Button.tsx +3 -1
- package/src/components/macros/Checkbox.tsx +4 -1
- package/src/components/macros/Dialog.tsx +1 -1
- package/src/components/macros/Include.tsx +2 -6
- package/src/components/macros/MacroLink.tsx +4 -1
- package/src/components/macros/Meter.tsx +1 -1
- package/src/components/macros/Radiobutton.tsx +2 -2
- package/src/components/macros/Watch.tsx +11 -6
- package/src/components/macros/include-args.ts +23 -0
- package/src/components/macros/input-macro.ts +4 -1
- package/src/components/macros/macro-args.ts +119 -46
- package/src/components/macros/option-utils.ts +10 -4
- package/src/expression.ts +4 -54
- package/src/index.tsx +2 -4
- package/src/js-lexer.ts +19 -0
- package/src/markup/grammar-shared.ts +8 -0
- package/src/markup/parse.ts +107 -2
- package/src/markup/spindle.d.peggy.ts +5 -2
- package/src/markup/spindle.peggy +27 -16
- package/src/markup/tokens.ts +3 -0
- package/src/markup/validate.ts +22 -12
- package/src/registry.ts +70 -6
- package/src/story-variables.ts +52 -10
- package/src/tooling.ts +63 -5
- package/src/transform.ts +65 -0
- package/src/widgets/widget-def.ts +2 -2
- package/types/index.d.ts +23 -0
- package/types/tooling.d.ts +337 -0
|
@@ -18,6 +18,7 @@ import { parseDelay } from '../../utils/parse-delay';
|
|
|
18
18
|
import type { MacroArgs, ParameterDef } from '../../registry';
|
|
19
19
|
import type { StoryState } from '../../store';
|
|
20
20
|
import { noPassageError } from '../../runtime-errors';
|
|
21
|
+
import { stringLiteralValue } from '../../js-lexer';
|
|
21
22
|
import {
|
|
22
23
|
endsWithOperator,
|
|
23
24
|
isWhitespace,
|
|
@@ -29,6 +30,20 @@ import {
|
|
|
29
30
|
|
|
30
31
|
type Span = [start: number, end: number];
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Where parseMacroArgs read each argument it set from text (not flags or
|
|
35
|
+
* separators), by parameter, options too: the start and end of its text in
|
|
36
|
+
* the raw arguments, quotes included.
|
|
37
|
+
*/
|
|
38
|
+
export type ArgSpans = Map<ParameterDef, Span>;
|
|
39
|
+
|
|
40
|
+
/** Where a group's text is in the raw arguments, and the spans read so far. */
|
|
41
|
+
interface Place {
|
|
42
|
+
/** The index in the raw arguments of the text being read. */
|
|
43
|
+
at: number;
|
|
44
|
+
spans: ArgSpans | undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
32
47
|
/** The terms of `src`: the runs of text between its depth-0 whitespace. */
|
|
33
48
|
function terms(src: string): Span[] {
|
|
34
49
|
const spans: Span[] = [];
|
|
@@ -56,12 +71,16 @@ export function readText(src: string): string {
|
|
|
56
71
|
*/
|
|
57
72
|
export class MacroArgumentError extends Error {
|
|
58
73
|
constructor(param: ParameterDef, src: string) {
|
|
59
|
-
super(
|
|
74
|
+
super(
|
|
75
|
+
`The ${param.name} must be a quoted string ("…" or '…'), ${src ? `not ${src}` : 'but it has no value'}`,
|
|
76
|
+
);
|
|
60
77
|
this.name = 'MacroArgumentError';
|
|
61
78
|
}
|
|
62
79
|
}
|
|
63
80
|
|
|
64
|
-
|
|
81
|
+
/** Read `src`, at `place`, as `param` says. */
|
|
82
|
+
function readValue(param: ParameterDef, src: string, place: Place): unknown {
|
|
83
|
+
place.spans?.set(param, [place.at, place.at + src.length]);
|
|
65
84
|
switch (param.type) {
|
|
66
85
|
case 'string': {
|
|
67
86
|
const value = readWholeQuoted(src);
|
|
@@ -77,7 +96,7 @@ function readValue(param: ParameterDef, src: string): unknown {
|
|
|
77
96
|
case 'number':
|
|
78
97
|
return Number(src);
|
|
79
98
|
case 'options':
|
|
80
|
-
return readOptions(param.parameters ?? [], src);
|
|
99
|
+
return readOptions(param.parameters ?? [], src, place);
|
|
81
100
|
default:
|
|
82
101
|
return src;
|
|
83
102
|
}
|
|
@@ -90,6 +109,7 @@ function unset(param: ParameterDef): unknown {
|
|
|
90
109
|
|
|
91
110
|
const WORD_RE = /\w+/y;
|
|
92
111
|
const DIGITS_RE = /\d+/y;
|
|
112
|
+
const WORD_OR_PUNCT_RE = /\S+/y;
|
|
93
113
|
|
|
94
114
|
/** Match the sticky regex `re` at `pos`, returning the matched text. */
|
|
95
115
|
function matchAt(re: RegExp, src: string, pos: number): string | null {
|
|
@@ -99,12 +119,15 @@ function matchAt(re: RegExp, src: string, pos: number): string | null {
|
|
|
99
119
|
|
|
100
120
|
/**
|
|
101
121
|
* Read keyword options (`goto "X" priority 5 once`). A keyword takes the
|
|
102
|
-
* quoted string or
|
|
103
|
-
* declared, and their values, are
|
|
122
|
+
* quoted string (with or without whitespace before it) or digit run after
|
|
123
|
+
* it as its value; keywords that aren't declared, and their values, are
|
|
124
|
+
* skipped. A keyword of a `string` parameter must be followed by a quoted
|
|
125
|
+
* string.
|
|
104
126
|
*/
|
|
105
127
|
function readOptions(
|
|
106
128
|
params: readonly ParameterDef[],
|
|
107
129
|
src: string,
|
|
130
|
+
{ at, spans }: Place,
|
|
108
131
|
): Record<string, unknown> {
|
|
109
132
|
const options: Record<string, unknown> = {};
|
|
110
133
|
let i = 0;
|
|
@@ -117,23 +140,33 @@ function readOptions(
|
|
|
117
140
|
}
|
|
118
141
|
i += key.length;
|
|
119
142
|
|
|
120
|
-
// A value is a quoted string
|
|
143
|
+
// A value is a quoted string, which may follow the keyword directly
|
|
144
|
+
// (`goto"Hall"`: a quote can't be part of one), or a digit run after
|
|
145
|
+
// whitespace.
|
|
121
146
|
let val: string | undefined;
|
|
122
147
|
let j = i;
|
|
123
148
|
while (j < src.length && isWhitespace(src[j]!)) j++;
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
i = j + digits.length;
|
|
133
|
-
}
|
|
149
|
+
const quoted = readQuoted(src, j);
|
|
150
|
+
const digits = quoted || j === i ? null : matchAt(DIGITS_RE, src, j);
|
|
151
|
+
if (quoted) {
|
|
152
|
+
val = quoted.value;
|
|
153
|
+
i = quoted.end;
|
|
154
|
+
} else if (digits) {
|
|
155
|
+
val = digits;
|
|
156
|
+
i = j + digits.length;
|
|
134
157
|
}
|
|
135
158
|
|
|
136
159
|
const param = params.find((p) => p.name === key);
|
|
160
|
+
if (param && param.type !== 'flag' && val !== undefined) {
|
|
161
|
+
spans?.set(param, [at + j, at + i]);
|
|
162
|
+
}
|
|
163
|
+
// A keyword that takes a string was given anything else, or nothing
|
|
164
|
+
if (param?.type === 'string' && !quoted) {
|
|
165
|
+
throw new MacroArgumentError(
|
|
166
|
+
param,
|
|
167
|
+
matchAt(WORD_OR_PUNCT_RE, src, j) ?? '',
|
|
168
|
+
);
|
|
169
|
+
}
|
|
137
170
|
if (param?.type === 'flag') options[key] = true;
|
|
138
171
|
else if (param?.type === 'number') options[key] = Number(val ?? 0);
|
|
139
172
|
else if (param) options[key] = val;
|
|
@@ -145,21 +178,21 @@ function readOptions(
|
|
|
145
178
|
const LEADING_OPERATOR_RE = /^(?:[-+*/&|^=<>?:,.]|%(?![A-Za-z_]))/;
|
|
146
179
|
|
|
147
180
|
/**
|
|
148
|
-
*
|
|
149
|
-
* `null` if it has no such flag. Next to a binary operator the
|
|
150
|
-
* operand (`"a" + inline`), not a flag; the closing `/` of a
|
|
151
|
-
* is not one.
|
|
181
|
+
* Where the rest of `src` is without the flag `word` as its last or first
|
|
182
|
+
* term, or `null` if it has no such flag. Next to a binary operator the
|
|
183
|
+
* word is an operand (`"a" + inline`), not a flag; the closing `/` of a
|
|
184
|
+
* regex literal is not one.
|
|
152
185
|
*/
|
|
153
|
-
function takeFlag(src: string, word: string):
|
|
186
|
+
function takeFlag(src: string, word: string): Span | null {
|
|
154
187
|
const spans = terms(src);
|
|
155
188
|
if (spans.length < 2) return null;
|
|
156
189
|
if (slice(src, spans[spans.length - 1]!) === word) {
|
|
157
|
-
const rest =
|
|
158
|
-
if (!endsWithOperator(rest)) return rest;
|
|
190
|
+
const rest: Span = [0, spans[spans.length - 2]![1]];
|
|
191
|
+
if (!endsWithOperator(slice(src, rest))) return rest;
|
|
159
192
|
}
|
|
160
193
|
if (slice(src, spans[0]!) === word) {
|
|
161
|
-
const rest =
|
|
162
|
-
if (!LEADING_OPERATOR_RE.test(rest)) return rest;
|
|
194
|
+
const rest: Span = [spans[1]![0], src.length];
|
|
195
|
+
if (!LEADING_OPERATOR_RE.test(slice(src, rest))) return rest;
|
|
163
196
|
}
|
|
164
197
|
return null;
|
|
165
198
|
}
|
|
@@ -198,17 +231,27 @@ const takesRest = (param: ParameterDef) =>
|
|
|
198
231
|
param.type === 'passage' ||
|
|
199
232
|
param.type === 'text';
|
|
200
233
|
|
|
201
|
-
/**
|
|
234
|
+
/** The text of `src` without the whitespace around it, and where it is. */
|
|
235
|
+
function trimmed(src: string, at: number): [text: string, at: number] {
|
|
236
|
+
const text = src.trim();
|
|
237
|
+
return [text, text ? at + src.indexOf(text) : at];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Read the parameters of one group from `src` (`null`: no text), which is
|
|
242
|
+
* at `at` in the raw arguments, into `args`.
|
|
243
|
+
*/
|
|
202
244
|
function readGroup(
|
|
203
245
|
params: readonly ParameterDef[],
|
|
204
246
|
src: string | null,
|
|
205
247
|
args: Record<string, unknown>,
|
|
248
|
+
{ at: srcAt, spans }: Place,
|
|
206
249
|
): void {
|
|
207
|
-
let text = src
|
|
250
|
+
let [text, at] = trimmed(src ?? '', srcAt);
|
|
208
251
|
for (const flag of params.filter((p) => p.type === 'flag')) {
|
|
209
252
|
const rest = src === null ? null : takeFlag(text, flag.name);
|
|
210
253
|
args[flag.name] = rest !== null;
|
|
211
|
-
text = rest
|
|
254
|
+
if (rest) [text, at] = trimmed(slice(text, rest), at + rest[0]);
|
|
212
255
|
}
|
|
213
256
|
const positional = params.filter((p) => p.type !== 'flag');
|
|
214
257
|
if (positional.length === 0) return;
|
|
@@ -218,27 +261,29 @@ function readGroup(
|
|
|
218
261
|
(p, i) => takesRest(p) && !positional.slice(i + 1).some(takesRest),
|
|
219
262
|
);
|
|
220
263
|
const restIndex = lastText < 0 ? positional.length - 1 : lastText;
|
|
221
|
-
|
|
222
|
-
|
|
264
|
+
/** Read `param` from the text from `start` to `end`. */
|
|
265
|
+
const read = (param: ParameterDef, [start, end]: Span) =>
|
|
266
|
+
start === end
|
|
267
|
+
? unset(param)
|
|
268
|
+
: readValue(param, text.slice(start, end), { at: at + start, spans });
|
|
223
269
|
|
|
224
270
|
// One parameter reads the whole text: no need to split it.
|
|
225
271
|
if (positional.length === 1) {
|
|
226
|
-
args[positional[0]!.name] = read(positional[0]!, text);
|
|
272
|
+
args[positional[0]!.name] = read(positional[0]!, [0, text.length]);
|
|
227
273
|
return;
|
|
228
274
|
}
|
|
229
275
|
|
|
230
|
-
const
|
|
276
|
+
const parts = terms(text);
|
|
231
277
|
let from = 0;
|
|
232
|
-
let to =
|
|
278
|
+
let to = parts.length;
|
|
233
279
|
for (const param of positional.slice(0, restIndex)) {
|
|
234
|
-
args[param.name] =
|
|
235
|
-
from < to ? read(param, slice(text, spans[from++]!)) : unset(param);
|
|
280
|
+
args[param.name] = from < to ? read(param, parts[from++]!) : unset(param);
|
|
236
281
|
}
|
|
237
282
|
// Optional ones at the end: a term that doesn't have their form (a
|
|
238
283
|
// `string` that isn't quoted) belongs to the rest, as in {meter $hp 100}
|
|
239
|
-
const readTrailing = (param: ParameterDef,
|
|
284
|
+
const readTrailing = (param: ParameterDef, span: Span) => {
|
|
240
285
|
try {
|
|
241
|
-
return read(param,
|
|
286
|
+
return read(param, span);
|
|
242
287
|
} catch (error) {
|
|
243
288
|
if (error instanceof MacroArgumentError) return undefined;
|
|
244
289
|
throw error;
|
|
@@ -246,17 +291,17 @@ function readGroup(
|
|
|
246
291
|
};
|
|
247
292
|
for (const param of positional.slice(restIndex + 1).reverse()) {
|
|
248
293
|
const value =
|
|
249
|
-
from < to && !endsWithOperator(text.slice(0,
|
|
250
|
-
? readTrailing(param,
|
|
294
|
+
from < to && !endsWithOperator(text.slice(0, parts[to - 1]![0]))
|
|
295
|
+
? readTrailing(param, parts[to - 1]!)
|
|
251
296
|
: undefined;
|
|
252
297
|
args[param.name] = value ?? unset(param);
|
|
253
298
|
if (value !== undefined) to--;
|
|
299
|
+
// A term it didn't take
|
|
300
|
+
else spans?.delete(param);
|
|
254
301
|
}
|
|
255
302
|
const rest = positional[restIndex]!;
|
|
256
303
|
args[rest.name] =
|
|
257
|
-
from < to
|
|
258
|
-
? read(rest, text.slice(spans[from]![0], spans[to - 1]![1]))
|
|
259
|
-
: unset(rest);
|
|
304
|
+
from < to ? read(rest, [parts[from]![0], parts[to - 1]![1]]) : unset(rest);
|
|
260
305
|
}
|
|
261
306
|
|
|
262
307
|
/** The duration a timing macro ({repeat}, {type}, {timed}/{next}) waits. */
|
|
@@ -266,28 +311,56 @@ export const DELAY_PARAMETER = {
|
|
|
266
311
|
required: true,
|
|
267
312
|
} as const;
|
|
268
313
|
|
|
269
|
-
/**
|
|
314
|
+
/**
|
|
315
|
+
* Read `rawArgs` into the declared `parameters` (see above). With `spans`,
|
|
316
|
+
* note there where each argument set was read.
|
|
317
|
+
*/
|
|
270
318
|
export function parseMacroArgs<const P extends readonly ParameterDef[]>(
|
|
271
319
|
rawArgs: string,
|
|
272
320
|
parameters: P,
|
|
321
|
+
spans?: ArgSpans,
|
|
273
322
|
): MacroArgs<P> {
|
|
274
323
|
const args: Record<string, unknown> = {};
|
|
275
324
|
// The text after the last separator read, null once one is missing
|
|
276
|
-
let rest: string | null = rawArgs
|
|
325
|
+
let [rest, restAt]: [string | null, number] = trimmed(rawArgs, 0);
|
|
277
326
|
let groupStart = 0;
|
|
278
327
|
parameters.forEach((separator, i) => {
|
|
279
328
|
if (separator.type !== 'separator') return;
|
|
280
329
|
const at = rest === null ? null : findSeparator(rest, separator.name);
|
|
281
330
|
args[separator.name] = at !== null;
|
|
282
331
|
const before = at ? rest!.slice(0, at[0]) : rest;
|
|
283
|
-
readGroup(parameters.slice(groupStart, i), before, args
|
|
332
|
+
readGroup(parameters.slice(groupStart, i), before, args, {
|
|
333
|
+
at: restAt,
|
|
334
|
+
spans,
|
|
335
|
+
});
|
|
284
336
|
rest = at ? rest!.slice(at[1]) : null;
|
|
337
|
+
if (at) restAt += at[1];
|
|
285
338
|
groupStart = i + 1;
|
|
286
339
|
});
|
|
287
|
-
readGroup(parameters.slice(groupStart), rest, args);
|
|
340
|
+
readGroup(parameters.slice(groupStart), rest, args, { at: restAt, spans });
|
|
288
341
|
return args as MacroArgs<P>;
|
|
289
342
|
}
|
|
290
343
|
|
|
344
|
+
/** What a `passage` argument is: a quoted name, or an expression. */
|
|
345
|
+
export type PassageTarget =
|
|
346
|
+
| { kind: 'name'; name: string }
|
|
347
|
+
| { kind: 'expression'; expression: string };
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Read a `passage` argument as written (`{goto "Hall"}`, `{goto $room}`): a
|
|
351
|
+
* string literal is the name JavaScript reads from it (`"\u0048all"` is
|
|
352
|
+
* `Hall`), anything else (a literal that is not well-formed too) an
|
|
353
|
+
* expression, whose value is the name when it
|
|
354
|
+
* runs (see evaluatePassageName).
|
|
355
|
+
*/
|
|
356
|
+
export function passageTarget(arg: string): PassageTarget {
|
|
357
|
+
const expression = arg.trim();
|
|
358
|
+
const name = stringLiteralValue(expression);
|
|
359
|
+
return name === null
|
|
360
|
+
? { kind: 'expression', expression }
|
|
361
|
+
: { kind: 'name', name };
|
|
362
|
+
}
|
|
363
|
+
|
|
291
364
|
/**
|
|
292
365
|
* The passage a `passage` argument names (`{goto "Hall"}`, `{goto $room}`):
|
|
293
366
|
* the value of its expression. Throws what evaluating it throws, and if no
|
|
@@ -16,13 +16,19 @@ export function parseVarArgs(rawArgs: string): {
|
|
|
16
16
|
|
|
17
17
|
/** The parameters of an `{option}` sub-macro: its value. */
|
|
18
18
|
const OPTION_PARAMETERS = [
|
|
19
|
-
{ name: 'value', type: 'string', required: true },
|
|
19
|
+
{ name: 'value', type: 'string', holds: 'text', required: true },
|
|
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;
|
package/src/index.tsx
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
extractDefaults,
|
|
18
18
|
} from './story-variables';
|
|
19
19
|
import { getMacro, getMacroRegistry, isSubMacro } from './registry';
|
|
20
|
+
import { parameterLookup } from './code-check';
|
|
20
21
|
import { getWidget } from './widgets/widget-registry';
|
|
21
22
|
import {
|
|
22
23
|
formatDiagnostic,
|
|
@@ -71,14 +72,11 @@ function markupErrors(
|
|
|
71
72
|
only: (passage: MarkupPassage) => boolean,
|
|
72
73
|
): string[] {
|
|
73
74
|
const macros = getMacroRegistry();
|
|
74
|
-
const parameters = new Map(
|
|
75
|
-
macros.map((m) => [m.name.toLowerCase(), m.parameters]),
|
|
76
|
-
);
|
|
77
75
|
return validateMarkup(storyData.passages.values(), {
|
|
78
76
|
isKnownMacro: (name) =>
|
|
79
77
|
!!getMacro(name) || isSubMacro(name) || !!getWidget(name),
|
|
80
78
|
macroNames: macros.map((m) => m.name),
|
|
81
|
-
parametersOf: (
|
|
79
|
+
parametersOf: parameterLookup(macros),
|
|
82
80
|
only,
|
|
83
81
|
}).map(formatDiagnostic);
|
|
84
82
|
}
|
package/src/js-lexer.ts
CHANGED
|
@@ -1122,6 +1122,25 @@ function regexEnd(src: string, start: number): number {
|
|
|
1122
1122
|
return -1;
|
|
1123
1123
|
}
|
|
1124
1124
|
|
|
1125
|
+
/**
|
|
1126
|
+
* The value of `src` when it is exactly one `"…"` or `'…'` string literal,
|
|
1127
|
+
* as JavaScript reads it (`"\u0048all"` is `Hall`, `"\1"` is U+0001 in
|
|
1128
|
+
* the non-strict code passages run), otherwise `null`: other code, or a
|
|
1129
|
+
* literal that is not well-formed (`"\u{110000}"`).
|
|
1130
|
+
*/
|
|
1131
|
+
export function stringLiteralValue(src: string): string | null {
|
|
1132
|
+
if (src[0] !== '"' && src[0] !== "'") return null;
|
|
1133
|
+
try {
|
|
1134
|
+
const p = tokenizerAt(src, 0, 'expression');
|
|
1135
|
+
p.nextToken();
|
|
1136
|
+
return p.type === tt.string && p.end === src.length
|
|
1137
|
+
? (p.value as string)
|
|
1138
|
+
: null;
|
|
1139
|
+
} catch {
|
|
1140
|
+
return null;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1125
1144
|
/**
|
|
1126
1145
|
* Scan the `"…"` or `'…'` string literal opening at `start`. `end` is the
|
|
1127
1146
|
* index just past its closing quote, or `src.length` when it is unterminated
|
|
@@ -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,108 @@ 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
|
+
// The code of a tag ends at a }: with none after it, the tag is unclosed
|
|
157
|
+
// without reading its code to the end of the source, which every
|
|
158
|
+
// unclosed tag would do again (#265).
|
|
159
|
+
const lastBrace = source.lastIndexOf('}');
|
|
160
|
+
const { closeBrace } = { ...defaultHooks, ...options.hooks };
|
|
161
|
+
/** `text` shifted by `base`, tokenized: the tokens or the error. */
|
|
162
|
+
const attempt = (text: string, base: number): Token[] | MarkupError => {
|
|
163
|
+
const hooks: Partial<MarkupHooks> = {
|
|
164
|
+
...options.hooks,
|
|
165
|
+
closeBrace: (input, codeStart, lenientStart) =>
|
|
166
|
+
base + Math.min(codeStart, lenientStart) > lastBrace
|
|
167
|
+
? -1
|
|
168
|
+
: closeBrace(input, codeStart, lenientStart),
|
|
169
|
+
};
|
|
170
|
+
try {
|
|
171
|
+
return tokenizeMarkup(text, { ...options, hooks }).map((token) =>
|
|
172
|
+
shift(token, base),
|
|
173
|
+
);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (!(error instanceof MarkupError)) throw error;
|
|
176
|
+
return error;
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
let base = 0;
|
|
180
|
+
while (base <= source.length) {
|
|
181
|
+
const rest = source.slice(base);
|
|
182
|
+
const result = attempt(rest, base);
|
|
183
|
+
if (Array.isArray(result)) {
|
|
184
|
+
tokens.push(...result);
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
const at = base + result.offset;
|
|
188
|
+
const { line, column } = lineColumn(source, at);
|
|
189
|
+
errors.push(new MarkupError(result.reason, at, line, column));
|
|
190
|
+
// The longest prefix that tokenizes: the error may be inside a tag
|
|
191
|
+
// (an attribute value) of which the prefix is itself malformed
|
|
192
|
+
let cut = result.offset;
|
|
193
|
+
let prefix = attempt(rest.slice(0, cut), base);
|
|
194
|
+
while (!Array.isArray(prefix)) {
|
|
195
|
+
cut = prefix.offset;
|
|
196
|
+
prefix = attempt(rest.slice(0, cut), base);
|
|
197
|
+
}
|
|
198
|
+
tokens.push(...prefix);
|
|
199
|
+
// The character that started the damage is text
|
|
200
|
+
if (cut < rest.length) {
|
|
201
|
+
const start = base + cut;
|
|
202
|
+
tokens.push({
|
|
203
|
+
type: 'text',
|
|
204
|
+
value: rest[cut]!,
|
|
205
|
+
start,
|
|
206
|
+
end: start + 1,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
base += cut + 1;
|
|
210
|
+
}
|
|
211
|
+
return { tokens, errors };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** `token` with its offsets moved `by` on. */
|
|
215
|
+
function shift(token: Token, by: number): Token {
|
|
216
|
+
if (by === 0) return token;
|
|
217
|
+
const moved = { ...token, start: token.start + by, end: token.end + by };
|
|
218
|
+
if (moved.type === 'link') {
|
|
219
|
+
moved.targetStart += by;
|
|
220
|
+
moved.targetEnd += by;
|
|
221
|
+
}
|
|
222
|
+
return moved;
|
|
223
|
+
}
|
|
@@ -13,12 +13,15 @@ export declare class SyntaxError extends globalThis.SyntaxError {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export interface ParseOptions {
|
|
16
|
-
startRule: 'Markup' | 'Tokens';
|
|
16
|
+
startRule: 'Markup' | 'Tokens' | 'SelectorsPrefix';
|
|
17
17
|
/** Text mode (attribute values, labels). */
|
|
18
18
|
text: boolean;
|
|
19
19
|
/** What the grammar leaves to code (see parse.ts MarkupHooks). */
|
|
20
20
|
hooks: unknown;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* Parse markup: the AST for `Markup`, the tokens for `Tokens`, the selectors
|
|
25
|
+
* and where they end for `SelectorsPrefix`.
|
|
26
|
+
*/
|
|
24
27
|
export declare function parse(input: string, options: ParseOptions): unknown;
|