@rohal12/spindle 0.58.0 → 0.59.1
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 +428 -277
- package/dist/pkg/macro-registry.json +29 -11
- package/dist/pkg/story-variables.js +330 -207
- package/package.json +1 -1
- package/src/automation/runner.ts +2 -1
- package/src/code-check.ts +158 -83
- 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/MacroLink.tsx +3 -2
- package/src/components/macros/Meter.tsx +6 -2
- package/src/components/macros/Radiobutton.tsx +2 -2
- package/src/components/macros/Watch.tsx +11 -6
- package/src/components/macros/arg-utils.ts +0 -37
- package/src/components/macros/input-macro.ts +4 -1
- package/src/components/macros/macro-args.ts +93 -52
- package/src/components/macros/option-utils.ts +1 -1
- package/src/index.tsx +2 -4
- package/src/js-lexer.ts +19 -0
- package/src/markup/parse.ts +22 -4
- package/src/markup/spindle.peggy +21 -9
- package/src/markup/tokens.ts +3 -0
- package/src/markup/validate.ts +13 -10
- package/src/registry.ts +63 -6
- package/src/saves/storage.ts +14 -1
- package/src/store.ts +29 -7
- package/src/story-variables.ts +31 -65
- package/src/structural.ts +67 -21
- package/src/tooling.ts +15 -16
- package/src/widgets/widget-def.ts +2 -2
- package/types/index.d.ts +23 -0
- package/types/tooling.d.ts +28 -2
|
@@ -18,11 +18,11 @@ 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,
|
|
24
25
|
readQuoted,
|
|
25
|
-
readWholeJsString,
|
|
26
26
|
readWholeQuoted,
|
|
27
27
|
stripLooseQuotes,
|
|
28
28
|
topLevelIndices,
|
|
@@ -30,6 +30,20 @@ import {
|
|
|
30
30
|
|
|
31
31
|
type Span = [start: number, end: number];
|
|
32
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
|
+
|
|
33
47
|
/** The terms of `src`: the runs of text between its depth-0 whitespace. */
|
|
34
48
|
function terms(src: string): Span[] {
|
|
35
49
|
const spans: Span[] = [];
|
|
@@ -64,7 +78,9 @@ export class MacroArgumentError extends Error {
|
|
|
64
78
|
}
|
|
65
79
|
}
|
|
66
80
|
|
|
67
|
-
|
|
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]);
|
|
68
84
|
switch (param.type) {
|
|
69
85
|
case 'string': {
|
|
70
86
|
const value = readWholeQuoted(src);
|
|
@@ -80,7 +96,7 @@ function readValue(param: ParameterDef, src: string): unknown {
|
|
|
80
96
|
case 'number':
|
|
81
97
|
return Number(src);
|
|
82
98
|
case 'options':
|
|
83
|
-
return readOptions(param.parameters ?? [], src);
|
|
99
|
+
return readOptions(param.parameters ?? [], src, place);
|
|
84
100
|
default:
|
|
85
101
|
return src;
|
|
86
102
|
}
|
|
@@ -103,13 +119,15 @@ function matchAt(re: RegExp, src: string, pos: number): string | null {
|
|
|
103
119
|
|
|
104
120
|
/**
|
|
105
121
|
* Read keyword options (`goto "X" priority 5 once`). A keyword takes the
|
|
106
|
-
* quoted string or
|
|
107
|
-
* declared, and their values, are
|
|
108
|
-
* must be followed by a quoted
|
|
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.
|
|
109
126
|
*/
|
|
110
127
|
function readOptions(
|
|
111
128
|
params: readonly ParameterDef[],
|
|
112
129
|
src: string,
|
|
130
|
+
{ at, spans }: Place,
|
|
113
131
|
): Record<string, unknown> {
|
|
114
132
|
const options: Record<string, unknown> = {};
|
|
115
133
|
let i = 0;
|
|
@@ -122,26 +140,28 @@ function readOptions(
|
|
|
122
140
|
}
|
|
123
141
|
i += key.length;
|
|
124
142
|
|
|
125
|
-
// 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.
|
|
126
146
|
let val: string | undefined;
|
|
127
|
-
let quoted: ReturnType<typeof readQuoted> = null;
|
|
128
147
|
let j = i;
|
|
129
148
|
while (j < src.length && isWhitespace(src[j]!)) j++;
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
i = j + digits.length;
|
|
139
|
-
}
|
|
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;
|
|
140
157
|
}
|
|
141
158
|
|
|
142
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
|
+
}
|
|
143
163
|
// A keyword that takes a string was given anything else, or nothing
|
|
144
|
-
if (param?.type === 'string' &&
|
|
164
|
+
if (param?.type === 'string' && !quoted) {
|
|
145
165
|
throw new MacroArgumentError(
|
|
146
166
|
param,
|
|
147
167
|
matchAt(WORD_OR_PUNCT_RE, src, j) ?? '',
|
|
@@ -158,21 +178,21 @@ function readOptions(
|
|
|
158
178
|
const LEADING_OPERATOR_RE = /^(?:[-+*/&|^=<>?:,.]|%(?![A-Za-z_]))/;
|
|
159
179
|
|
|
160
180
|
/**
|
|
161
|
-
*
|
|
162
|
-
* `null` if it has no such flag. Next to a binary operator the
|
|
163
|
-
* operand (`"a" + inline`), not a flag; the closing `/` of a
|
|
164
|
-
* 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.
|
|
165
185
|
*/
|
|
166
|
-
function takeFlag(src: string, word: string):
|
|
186
|
+
function takeFlag(src: string, word: string): Span | null {
|
|
167
187
|
const spans = terms(src);
|
|
168
188
|
if (spans.length < 2) return null;
|
|
169
189
|
if (slice(src, spans[spans.length - 1]!) === word) {
|
|
170
|
-
const rest =
|
|
171
|
-
if (!endsWithOperator(rest)) return rest;
|
|
190
|
+
const rest: Span = [0, spans[spans.length - 2]![1]];
|
|
191
|
+
if (!endsWithOperator(slice(src, rest))) return rest;
|
|
172
192
|
}
|
|
173
193
|
if (slice(src, spans[0]!) === word) {
|
|
174
|
-
const rest =
|
|
175
|
-
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;
|
|
176
196
|
}
|
|
177
197
|
return null;
|
|
178
198
|
}
|
|
@@ -211,17 +231,27 @@ const takesRest = (param: ParameterDef) =>
|
|
|
211
231
|
param.type === 'passage' ||
|
|
212
232
|
param.type === 'text';
|
|
213
233
|
|
|
214
|
-
/**
|
|
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
|
+
*/
|
|
215
244
|
function readGroup(
|
|
216
245
|
params: readonly ParameterDef[],
|
|
217
246
|
src: string | null,
|
|
218
247
|
args: Record<string, unknown>,
|
|
248
|
+
{ at: srcAt, spans }: Place,
|
|
219
249
|
): void {
|
|
220
|
-
let text = src
|
|
250
|
+
let [text, at] = trimmed(src ?? '', srcAt);
|
|
221
251
|
for (const flag of params.filter((p) => p.type === 'flag')) {
|
|
222
252
|
const rest = src === null ? null : takeFlag(text, flag.name);
|
|
223
253
|
args[flag.name] = rest !== null;
|
|
224
|
-
text = rest
|
|
254
|
+
if (rest) [text, at] = trimmed(slice(text, rest), at + rest[0]);
|
|
225
255
|
}
|
|
226
256
|
const positional = params.filter((p) => p.type !== 'flag');
|
|
227
257
|
if (positional.length === 0) return;
|
|
@@ -231,27 +261,29 @@ function readGroup(
|
|
|
231
261
|
(p, i) => takesRest(p) && !positional.slice(i + 1).some(takesRest),
|
|
232
262
|
);
|
|
233
263
|
const restIndex = lastText < 0 ? positional.length - 1 : lastText;
|
|
234
|
-
|
|
235
|
-
|
|
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 });
|
|
236
269
|
|
|
237
270
|
// One parameter reads the whole text: no need to split it.
|
|
238
271
|
if (positional.length === 1) {
|
|
239
|
-
args[positional[0]!.name] = read(positional[0]!, text);
|
|
272
|
+
args[positional[0]!.name] = read(positional[0]!, [0, text.length]);
|
|
240
273
|
return;
|
|
241
274
|
}
|
|
242
275
|
|
|
243
|
-
const
|
|
276
|
+
const parts = terms(text);
|
|
244
277
|
let from = 0;
|
|
245
|
-
let to =
|
|
278
|
+
let to = parts.length;
|
|
246
279
|
for (const param of positional.slice(0, restIndex)) {
|
|
247
|
-
args[param.name] =
|
|
248
|
-
from < to ? read(param, slice(text, spans[from++]!)) : unset(param);
|
|
280
|
+
args[param.name] = from < to ? read(param, parts[from++]!) : unset(param);
|
|
249
281
|
}
|
|
250
282
|
// Optional ones at the end: a term that doesn't have their form (a
|
|
251
283
|
// `string` that isn't quoted) belongs to the rest, as in {meter $hp 100}
|
|
252
|
-
const readTrailing = (param: ParameterDef,
|
|
284
|
+
const readTrailing = (param: ParameterDef, span: Span) => {
|
|
253
285
|
try {
|
|
254
|
-
return read(param,
|
|
286
|
+
return read(param, span);
|
|
255
287
|
} catch (error) {
|
|
256
288
|
if (error instanceof MacroArgumentError) return undefined;
|
|
257
289
|
throw error;
|
|
@@ -259,17 +291,17 @@ function readGroup(
|
|
|
259
291
|
};
|
|
260
292
|
for (const param of positional.slice(restIndex + 1).reverse()) {
|
|
261
293
|
const value =
|
|
262
|
-
from < to && !endsWithOperator(text.slice(0,
|
|
263
|
-
? readTrailing(param,
|
|
294
|
+
from < to && !endsWithOperator(text.slice(0, parts[to - 1]![0]))
|
|
295
|
+
? readTrailing(param, parts[to - 1]!)
|
|
264
296
|
: undefined;
|
|
265
297
|
args[param.name] = value ?? unset(param);
|
|
266
298
|
if (value !== undefined) to--;
|
|
299
|
+
// A term it didn't take
|
|
300
|
+
else spans?.delete(param);
|
|
267
301
|
}
|
|
268
302
|
const rest = positional[restIndex]!;
|
|
269
303
|
args[rest.name] =
|
|
270
|
-
from < to
|
|
271
|
-
? read(rest, text.slice(spans[from]![0], spans[to - 1]![1]))
|
|
272
|
-
: unset(rest);
|
|
304
|
+
from < to ? read(rest, [parts[from]![0], parts[to - 1]![1]]) : unset(rest);
|
|
273
305
|
}
|
|
274
306
|
|
|
275
307
|
/** The duration a timing macro ({repeat}, {type}, {timed}/{next}) waits. */
|
|
@@ -279,25 +311,33 @@ export const DELAY_PARAMETER = {
|
|
|
279
311
|
required: true,
|
|
280
312
|
} as const;
|
|
281
313
|
|
|
282
|
-
/**
|
|
314
|
+
/**
|
|
315
|
+
* Read `rawArgs` into the declared `parameters` (see above). With `spans`,
|
|
316
|
+
* note there where each argument set was read.
|
|
317
|
+
*/
|
|
283
318
|
export function parseMacroArgs<const P extends readonly ParameterDef[]>(
|
|
284
319
|
rawArgs: string,
|
|
285
320
|
parameters: P,
|
|
321
|
+
spans?: ArgSpans,
|
|
286
322
|
): MacroArgs<P> {
|
|
287
323
|
const args: Record<string, unknown> = {};
|
|
288
324
|
// The text after the last separator read, null once one is missing
|
|
289
|
-
let rest: string | null = rawArgs
|
|
325
|
+
let [rest, restAt]: [string | null, number] = trimmed(rawArgs, 0);
|
|
290
326
|
let groupStart = 0;
|
|
291
327
|
parameters.forEach((separator, i) => {
|
|
292
328
|
if (separator.type !== 'separator') return;
|
|
293
329
|
const at = rest === null ? null : findSeparator(rest, separator.name);
|
|
294
330
|
args[separator.name] = at !== null;
|
|
295
331
|
const before = at ? rest!.slice(0, at[0]) : rest;
|
|
296
|
-
readGroup(parameters.slice(groupStart, i), before, args
|
|
332
|
+
readGroup(parameters.slice(groupStart, i), before, args, {
|
|
333
|
+
at: restAt,
|
|
334
|
+
spans,
|
|
335
|
+
});
|
|
297
336
|
rest = at ? rest!.slice(at[1]) : null;
|
|
337
|
+
if (at) restAt += at[1];
|
|
298
338
|
groupStart = i + 1;
|
|
299
339
|
});
|
|
300
|
-
readGroup(parameters.slice(groupStart), rest, args);
|
|
340
|
+
readGroup(parameters.slice(groupStart), rest, args, { at: restAt, spans });
|
|
301
341
|
return args as MacroArgs<P>;
|
|
302
342
|
}
|
|
303
343
|
|
|
@@ -308,13 +348,14 @@ export type PassageTarget =
|
|
|
308
348
|
|
|
309
349
|
/**
|
|
310
350
|
* Read a `passage` argument as written (`{goto "Hall"}`, `{goto $room}`): a
|
|
311
|
-
*
|
|
312
|
-
* `Hall`), anything else
|
|
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
|
|
313
354
|
* runs (see evaluatePassageName).
|
|
314
355
|
*/
|
|
315
356
|
export function passageTarget(arg: string): PassageTarget {
|
|
316
357
|
const expression = arg.trim();
|
|
317
|
-
const name =
|
|
358
|
+
const name = stringLiteralValue(expression);
|
|
318
359
|
return name === null
|
|
319
360
|
? { kind: 'expression', expression }
|
|
320
361
|
: { kind: 'name', name };
|
|
@@ -16,7 +16,7 @@ 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. */
|
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
|
package/src/markup/parse.ts
CHANGED
|
@@ -153,10 +153,24 @@ export function tokenizeMarkupTolerant(
|
|
|
153
153
|
): TolerantTokens {
|
|
154
154
|
const tokens: Token[] = [];
|
|
155
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 };
|
|
156
161
|
/** `text` shifted by `base`, tokenized: the tokens or the error. */
|
|
157
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
|
+
};
|
|
158
170
|
try {
|
|
159
|
-
return tokenizeMarkup(text, options).map((token) =>
|
|
171
|
+
return tokenizeMarkup(text, { ...options, hooks }).map((token) =>
|
|
172
|
+
shift(token, base),
|
|
173
|
+
);
|
|
160
174
|
} catch (error) {
|
|
161
175
|
if (!(error instanceof MarkupError)) throw error;
|
|
162
176
|
return error;
|
|
@@ -199,7 +213,11 @@ export function tokenizeMarkupTolerant(
|
|
|
199
213
|
|
|
200
214
|
/** `token` with its offsets moved `by` on. */
|
|
201
215
|
function shift(token: Token, by: number): Token {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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;
|
|
205
223
|
}
|
package/src/markup/spindle.peggy
CHANGED
|
@@ -32,16 +32,28 @@ const BRANCHING = new Set(['if', 'switch', 'timed']);
|
|
|
32
32
|
const LINK_SEPARATORS = [['|', false], ['->', false], ['<-', true]];
|
|
33
33
|
const NO_SELECTORS = {};
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
/**
|
|
36
|
+
* A link's display text and target, from its text `inner` (at `at` in the
|
|
37
|
+
* input), with where the target is written.
|
|
38
|
+
*/
|
|
39
|
+
function parseLink(inner, at) {
|
|
40
|
+
/** The trimmed text from `start` to `end` of inner, and where it is. */
|
|
41
|
+
const part = (start, end) => {
|
|
42
|
+
const raw = inner.slice(start, end);
|
|
43
|
+
const value = raw.trim();
|
|
44
|
+
const from = at + start + (value ? raw.indexOf(value) : 0);
|
|
45
|
+
return { value, start: from, end: from + value.length };
|
|
46
|
+
};
|
|
36
47
|
for (const [separator, targetFirst] of LINK_SEPARATORS) {
|
|
37
48
|
const idx = inner.indexOf(separator);
|
|
38
49
|
if (idx === -1) continue;
|
|
39
|
-
const before =
|
|
40
|
-
const after =
|
|
41
|
-
|
|
50
|
+
const before = part(0, idx);
|
|
51
|
+
const after = part(idx + separator.length, inner.length);
|
|
52
|
+
const [display, target] = targetFirst ? [after, before] : [before, after];
|
|
53
|
+
return { display: display.value, target: target.value, targetStart: target.start, targetEnd: target.end };
|
|
42
54
|
}
|
|
43
|
-
const
|
|
44
|
-
return { display:
|
|
55
|
+
const whole = part(0, inner.length);
|
|
56
|
+
return { display: whole.value, target: whole.value, targetStart: whole.start, targetEnd: whole.end };
|
|
45
57
|
}
|
|
46
58
|
|
|
47
59
|
function quoteArg(value) {
|
|
@@ -106,7 +118,7 @@ function tokenOf(x) {
|
|
|
106
118
|
case 'text':
|
|
107
119
|
return { type: 'text', value: x.value, ...span };
|
|
108
120
|
case 'link':
|
|
109
|
-
return withSelectors({ type: 'link', display: x.display, target: x.target, ...span }, x);
|
|
121
|
+
return withSelectors({ type: 'link', display: x.display, target: x.target, targetStart: x.targetStart, targetEnd: x.targetEnd, ...span }, x);
|
|
110
122
|
case 'variable':
|
|
111
123
|
return withSelectors({ type: 'variable', name: x.name, scope: x.scope, ...span }, x);
|
|
112
124
|
case 'expression':
|
|
@@ -336,8 +348,8 @@ Literal
|
|
|
336
348
|
/ "[" { return lexeme('text', location(), { value: '[' }); }
|
|
337
349
|
|
|
338
350
|
LinkLexeme
|
|
339
|
-
= &{ return !textMode; } "[[" s:Selectors inner:$LinkText "]]" {
|
|
340
|
-
return withSelectors(lexeme('link', location(), parseLink(inner)), s);
|
|
351
|
+
= &{ return !textMode; } "[[" s:Selectors at:Here inner:$LinkText "]]" {
|
|
352
|
+
return withSelectors(lexeme('link', location(), parseLink(inner, at)), s);
|
|
341
353
|
}
|
|
342
354
|
/ &{ return !textMode; } "[[" { fail('Unclosed link: [[ without ]]', location()); }
|
|
343
355
|
|
package/src/markup/tokens.ts
CHANGED
|
@@ -58,6 +58,9 @@ export interface LinkToken extends Span, Selectors {
|
|
|
58
58
|
type: 'link';
|
|
59
59
|
display: string;
|
|
60
60
|
target: string;
|
|
61
|
+
/** Where the target is written, from `targetStart` to `targetEnd`. */
|
|
62
|
+
targetStart: number;
|
|
63
|
+
targetEnd: number;
|
|
61
64
|
}
|
|
62
65
|
|
|
63
66
|
export interface MacroToken extends Span, Selectors {
|
package/src/markup/validate.ts
CHANGED
|
@@ -8,10 +8,13 @@
|
|
|
8
8
|
import { lineColumn, MarkupError, parseMarkup, tokenizeMarkup } from './parse';
|
|
9
9
|
import type { Token } from './tokens';
|
|
10
10
|
import { parseWidgetDef } from '../widgets/widget-def';
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
codeAndText,
|
|
13
|
+
parseOrError,
|
|
14
|
+
withParseCache,
|
|
15
|
+
type ParametersOf,
|
|
16
|
+
} from '../code-check';
|
|
12
17
|
import { CodeSyntaxError } from '../js-lexer';
|
|
13
|
-
import type { ParameterDef } from '../registry';
|
|
14
|
-
import { subMacroParameters } from '../components/macros/option-utils';
|
|
15
18
|
|
|
16
19
|
/** A passage to validate. */
|
|
17
20
|
export interface MarkupPassage {
|
|
@@ -59,12 +62,13 @@ export interface MarkupValidationOptions {
|
|
|
59
62
|
*/
|
|
60
63
|
checkPassageNames?: boolean;
|
|
61
64
|
/**
|
|
62
|
-
* The declared parameters of a macro
|
|
63
|
-
*
|
|
64
|
-
* as
|
|
65
|
-
* `{do}`, branch conditions
|
|
65
|
+
* The declared parameters of a macro (see parameterLookup in
|
|
66
|
+
* code-check.ts), whose `expression` and `statements` arguments are
|
|
67
|
+
* checked as code and whose `text` and `string` arguments as what they
|
|
68
|
+
* hold. Without them only the code of `{$…}`, `{do}`, branch conditions
|
|
69
|
+
* and attributes is checked.
|
|
66
70
|
*/
|
|
67
|
-
parametersOf
|
|
71
|
+
parametersOf?: ParametersOf;
|
|
68
72
|
}
|
|
69
73
|
|
|
70
74
|
/**
|
|
@@ -228,8 +232,7 @@ export function validateMarkup(
|
|
|
228
232
|
}
|
|
229
233
|
};
|
|
230
234
|
|
|
231
|
-
const parametersOf = (
|
|
232
|
-
options.parametersOf?.(name) ?? subMacroParameters(name);
|
|
235
|
+
const parametersOf: ParametersOf = options.parametersOf ?? (() => undefined);
|
|
233
236
|
|
|
234
237
|
/**
|
|
235
238
|
* Check `tokens`, the tokens of `src`, which starts at `base` in the
|
package/src/registry.ts
CHANGED
|
@@ -104,11 +104,36 @@ export const PARAMETER_TYPES = [
|
|
|
104
104
|
|
|
105
105
|
export type ParameterType = (typeof PARAMETER_TYPES)[number];
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* What the value of a `string` or `text` argument holds, for the check at
|
|
109
|
+
* story start and for tooling (see code-check.ts):
|
|
110
|
+
* - `markup`: markup the macro renders (`{button}`'s label): its markup
|
|
111
|
+
* is checked.
|
|
112
|
+
* - `text`: plain text the macro uses as written (`{checkbox}`'s label).
|
|
113
|
+
* - `passage`: a passage name (`{watch}`'s `goto`): the passage must exist.
|
|
114
|
+
* - `expression`, `statements`: code (`{watch}`'s condition and `run`): it
|
|
115
|
+
* is checked as code, and its variable references against the schema.
|
|
116
|
+
*
|
|
117
|
+
* Without it, the argument of a macro with `interpolate` holds `markup`,
|
|
118
|
+
* any other `text`.
|
|
119
|
+
*/
|
|
120
|
+
export const STRING_HOLDS = [
|
|
121
|
+
'markup',
|
|
122
|
+
'text',
|
|
123
|
+
'passage',
|
|
124
|
+
'expression',
|
|
125
|
+
'statements',
|
|
126
|
+
] as const;
|
|
127
|
+
|
|
128
|
+
export type StringHolds = (typeof STRING_HOLDS)[number];
|
|
129
|
+
|
|
107
130
|
export interface ParameterDef {
|
|
108
131
|
name: string;
|
|
109
132
|
required?: boolean;
|
|
110
133
|
description?: string;
|
|
111
134
|
type: ParameterType;
|
|
135
|
+
/** What a `string` or `text` argument holds (see StringHolds). */
|
|
136
|
+
holds?: StringHolds;
|
|
112
137
|
/** The options of an `options` parameter. */
|
|
113
138
|
parameters?: readonly ParameterDef[];
|
|
114
139
|
}
|
|
@@ -116,30 +141,62 @@ export interface ParameterDef {
|
|
|
116
141
|
/**
|
|
117
142
|
* Throw if a parameter `macro` declares (or an option of one) has no type,
|
|
118
143
|
* or one that isn't a ParameterType: arguments are read by their type, and
|
|
119
|
-
* there is no default.
|
|
144
|
+
* there is no default. Throw too if it declares what it `holds` wrongly.
|
|
120
145
|
*/
|
|
121
146
|
export function checkParameterTypes(
|
|
122
147
|
macro: string,
|
|
123
148
|
parameters: readonly ParameterDef[],
|
|
124
149
|
): void {
|
|
150
|
+
const holdsFix =
|
|
151
|
+
`Give a \`string\` or \`text\` parameter one of ${STRING_HOLDS.join(', ')} ` +
|
|
152
|
+
'to hold (see docs/custom-macros.md#what-a-string-holds).';
|
|
125
153
|
for (const param of parameters) {
|
|
126
154
|
const type: unknown = param.type;
|
|
127
155
|
if (!PARAMETER_TYPES.includes(type as ParameterType)) {
|
|
128
|
-
|
|
156
|
+
throw parameterError(
|
|
157
|
+
macro,
|
|
158
|
+
param,
|
|
129
159
|
type === undefined
|
|
130
160
|
? 'has no type'
|
|
131
|
-
: `has the unknown type ${JSON.stringify(type)}
|
|
132
|
-
|
|
133
|
-
`spindle: The parameter "${param.name}" of the macro {${macro}} ${problem}. ` +
|
|
134
|
-
`Give it one of the types ${PARAMETER_TYPES.join(', ')} ` +
|
|
161
|
+
: `has the unknown type ${JSON.stringify(type)}`,
|
|
162
|
+
`Give it one of the types ${PARAMETER_TYPES.join(', ')} ` +
|
|
135
163
|
'(see docs/custom-macros.md#parameter-types), ' +
|
|
136
164
|
'or declare no parameters and read props.rawArgs.',
|
|
137
165
|
);
|
|
138
166
|
}
|
|
167
|
+
const holds: unknown = param.holds;
|
|
168
|
+
if (holds !== undefined && type !== 'string' && type !== 'text') {
|
|
169
|
+
throw parameterError(
|
|
170
|
+
macro,
|
|
171
|
+
param,
|
|
172
|
+
`is of the type ${type}, which holds what it is`,
|
|
173
|
+
holdsFix,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
if (holds !== undefined && !STRING_HOLDS.includes(holds as StringHolds)) {
|
|
177
|
+
throw parameterError(
|
|
178
|
+
macro,
|
|
179
|
+
param,
|
|
180
|
+
`holds the unknown ${JSON.stringify(holds)}`,
|
|
181
|
+
holdsFix,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
139
184
|
if (param.parameters) checkParameterTypes(macro, param.parameters);
|
|
140
185
|
}
|
|
141
186
|
}
|
|
142
187
|
|
|
188
|
+
/** The error for the parameter `param` of `macro`: what is wrong, and the fix. */
|
|
189
|
+
function parameterError(
|
|
190
|
+
macro: string,
|
|
191
|
+
param: ParameterDef,
|
|
192
|
+
problem: string,
|
|
193
|
+
fix: string,
|
|
194
|
+
): Error {
|
|
195
|
+
return new Error(
|
|
196
|
+
`spindle: The parameter "${param.name}" of the macro {${macro}} ${problem}. ${fix}`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
143
200
|
type ArgValue<T, D> = T extends 'flag' | 'separator'
|
|
144
201
|
? boolean
|
|
145
202
|
: T extends 'names'
|
package/src/saves/storage.ts
CHANGED
|
@@ -315,7 +315,20 @@ function createIDBBackend(): StorageBackend {
|
|
|
315
315
|
make: (store: IDBObjectStore) => IDBRequest<R>,
|
|
316
316
|
): Promise<R> => {
|
|
317
317
|
const db = await openDB();
|
|
318
|
-
|
|
318
|
+
const tx = db.transaction(name, mode);
|
|
319
|
+
const result = idbReq(make(tx.objectStore(name)));
|
|
320
|
+
// A request can succeed and its transaction still roll back, so a
|
|
321
|
+
// write is done only when the transaction completes.
|
|
322
|
+
if (mode === 'readonly') return result;
|
|
323
|
+
const committed = new Promise<void>((resolve, reject) => {
|
|
324
|
+
tx.oncomplete = () => resolve();
|
|
325
|
+
tx.onabort = () =>
|
|
326
|
+
reject(
|
|
327
|
+
tx.error ?? new DOMException('Transaction aborted', 'AbortError'),
|
|
328
|
+
);
|
|
329
|
+
});
|
|
330
|
+
const [value] = await Promise.all([result, committed]);
|
|
331
|
+
return value;
|
|
319
332
|
};
|
|
320
333
|
|
|
321
334
|
return {
|