@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
package/src/store.ts
CHANGED
|
@@ -120,7 +120,10 @@ let lastNavigationVars: Namespace = createNamespace();
|
|
|
120
120
|
* object) or deleted. A recorded value is a deep copy, so that changing a
|
|
121
121
|
* live value in place (a class instance is not frozen) cannot change
|
|
122
122
|
* history; instances of unregistered classes are kept, as the store keeps
|
|
123
|
-
* them (a save then refuses them).
|
|
123
|
+
* them (a save then refuses them). The copies are made together, so what
|
|
124
|
+
* variables share (an object, a cycle) stays shared in them, and a variable
|
|
125
|
+
* left as it is that shares with a replaced one is recorded again, to share
|
|
126
|
+
* with its copy.
|
|
124
127
|
*/
|
|
125
128
|
function computeVarPatches(
|
|
126
129
|
prev: Record<string, unknown>,
|
|
@@ -132,13 +135,32 @@ function computeVarPatches(
|
|
|
132
135
|
// whose inherited `constructor` is no variable
|
|
133
136
|
if (!hasOwn(curr, key)) forward.push({ key, deleted: true });
|
|
134
137
|
}
|
|
138
|
+
const seen = new Map<object, object>();
|
|
139
|
+
const record = (key: string) =>
|
|
140
|
+
forward.push({
|
|
141
|
+
key,
|
|
142
|
+
deleted: false,
|
|
143
|
+
value: deepClone(curr[key], { keepUnregistered: true, seen }),
|
|
144
|
+
});
|
|
145
|
+
const unchanged: string[] = [];
|
|
135
146
|
for (const key of Object.keys(curr)) {
|
|
136
|
-
if (!hasOwn(prev, key) || !Object.is(prev[key], curr[key]))
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
147
|
+
if (!hasOwn(prev, key) || !Object.is(prev[key], curr[key])) record(key);
|
|
148
|
+
else if (typeof curr[key] === 'object' && curr[key] !== null) {
|
|
149
|
+
unchanged.push(key);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Probe an unchanged variable with a copy of its own: it shares with a
|
|
153
|
+
// recorded one if the copy meets an object already copied
|
|
154
|
+
for (let found = seen.size > 0; found; ) {
|
|
155
|
+
found = false;
|
|
156
|
+
for (const key of unchanged) {
|
|
157
|
+
const probe = new Map<object, object>();
|
|
158
|
+
deepClone(curr[key], { keepUnregistered: true, seen: probe });
|
|
159
|
+
if (![...probe.keys()].some((object) => seen.has(object))) continue;
|
|
160
|
+
record(key);
|
|
161
|
+
unchanged.splice(unchanged.indexOf(key), 1);
|
|
162
|
+
found = true;
|
|
163
|
+
break;
|
|
142
164
|
}
|
|
143
165
|
}
|
|
144
166
|
return { forward };
|
package/src/story-variables.ts
CHANGED
|
@@ -9,7 +9,12 @@ import {
|
|
|
9
9
|
scanStringLiteral,
|
|
10
10
|
type JsGoal,
|
|
11
11
|
} from './js-lexer';
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
argPieces,
|
|
14
|
+
parseOrError,
|
|
15
|
+
withParseCache,
|
|
16
|
+
type ParametersOf,
|
|
17
|
+
} from './code-check';
|
|
13
18
|
import { getMacroParameters, type ParameterDef } from './registry';
|
|
14
19
|
import { NOT_MARKUP } from './markup/validate';
|
|
15
20
|
import { createNamespace, variableNameError } from './utils/namespace';
|
|
@@ -47,7 +52,6 @@ function declarationRegex(sigil: string): RegExp {
|
|
|
47
52
|
const VAR_PATH_RE = /\$(\w+(?:\.\w+)*)/y;
|
|
48
53
|
/** Quoted first argument of an input macro naming a story variable. */
|
|
49
54
|
const QUOTED_VAR_ARG_RE = /^["']\$(\w+(?:\.\w+)*)["']?$/;
|
|
50
|
-
const FOR_LOCAL_RE = /\{for\s+@(\w+)(?:\s*,\s*@(\w+))?\s+of\b/g;
|
|
51
55
|
|
|
52
56
|
const VALID_VAR_TYPES = new Set<string>(['number', 'string', 'boolean']);
|
|
53
57
|
|
|
@@ -125,22 +129,6 @@ export function parseStoryVariables(
|
|
|
125
129
|
return schema;
|
|
126
130
|
}
|
|
127
131
|
|
|
128
|
-
/**
|
|
129
|
-
* Extract for-loop local variable names from passage content.
|
|
130
|
-
* `{for @item of ...}` → "item"
|
|
131
|
-
* `{for @index, @item of ...}` → "index", "item"
|
|
132
|
-
*/
|
|
133
|
-
function extractForLocals(content: string): Set<string> {
|
|
134
|
-
const locals = new Set<string>();
|
|
135
|
-
let match: RegExpExecArray | null;
|
|
136
|
-
FOR_LOCAL_RE.lastIndex = 0;
|
|
137
|
-
while ((match = FOR_LOCAL_RE.exec(content)) !== null) {
|
|
138
|
-
locals.add(match[1]!);
|
|
139
|
-
if (match[2]) locals.add(match[2]!);
|
|
140
|
-
}
|
|
141
|
-
return locals;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
132
|
/**
|
|
145
133
|
* Validate a single variable reference path (e.g. "player.health") against
|
|
146
134
|
* the schema. Returns an error message or null if valid.
|
|
@@ -148,7 +136,6 @@ function extractForLocals(content: string): Set<string> {
|
|
|
148
136
|
function validateRef(
|
|
149
137
|
ref: string,
|
|
150
138
|
schema: Map<string, VariableSchema>,
|
|
151
|
-
forLocals: Set<string>,
|
|
152
139
|
): string | null {
|
|
153
140
|
const parts = ref.split('.');
|
|
154
141
|
const rootName = parts[0]!;
|
|
@@ -156,9 +143,6 @@ function validateRef(
|
|
|
156
143
|
const nameError = variableNameError(rootName, '$' + rootName);
|
|
157
144
|
if (nameError) return nameError;
|
|
158
145
|
|
|
159
|
-
// Skip for-loop locals
|
|
160
|
-
if (forLocals.has(rootName)) return null;
|
|
161
|
-
|
|
162
146
|
const rootSchema = schema.get(rootName);
|
|
163
147
|
if (!rootSchema) {
|
|
164
148
|
return `Undeclared variable: $${ref}`;
|
|
@@ -246,8 +230,9 @@ function scanInterpolations(
|
|
|
246
230
|
* Report `$var` references in JavaScript code, lexed as the expression
|
|
247
231
|
* engine lexes it (`lexJs`): references in code, but none inside string or
|
|
248
232
|
* regex literals or comments, nor property names (`a.$b`, `{ $b: 1 }`).
|
|
249
|
-
* The text of string and template literals is scanned
|
|
250
|
-
*
|
|
233
|
+
* The text of string and template literals is not scanned: a string in code
|
|
234
|
+
* is literal text unless a macro argument that holds markup is read as such
|
|
235
|
+
* (see scanArgs); the `${…}` parts of template literals are code.
|
|
251
236
|
*/
|
|
252
237
|
function scanCode(
|
|
253
238
|
code: string,
|
|
@@ -264,7 +249,6 @@ function scanCode(
|
|
|
264
249
|
VAR_PATH_RE.lastIndex = ref.start;
|
|
265
250
|
onRef(VAR_PATH_RE.exec(code)![1]!);
|
|
266
251
|
}
|
|
267
|
-
for (const text of parsed.strings) scanInterpolations(text, onRef);
|
|
268
252
|
}
|
|
269
253
|
|
|
270
254
|
/** `scanCode` for code acorn can't parse: lexed leniently. */
|
|
@@ -273,8 +257,6 @@ function scanCodeLeniently(
|
|
|
273
257
|
onRef: RefCallback,
|
|
274
258
|
goal: JsGoal,
|
|
275
259
|
): void {
|
|
276
|
-
/** Open template literals: their text so far, null in an interpolation. */
|
|
277
|
-
const templates: { nesting: number; text: string | null }[] = [];
|
|
278
260
|
lexJs(
|
|
279
261
|
code,
|
|
280
262
|
{
|
|
@@ -283,32 +265,11 @@ function scanCodeLeniently(
|
|
|
283
265
|
VAR_PATH_RE.lastIndex = index;
|
|
284
266
|
onRef(VAR_PATH_RE.exec(code)![1]!);
|
|
285
267
|
},
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
if (template?.nesting === nesting) {
|
|
289
|
-
if (template.text === null) {
|
|
290
|
-
// The `}` ending an interpolation: back to the template's text
|
|
291
|
-
template.text = '';
|
|
292
|
-
} else if (text === '`' || text === '${') {
|
|
293
|
-
scanInterpolations(template.text, onRef);
|
|
294
|
-
if (text === '`') templates.pop();
|
|
295
|
-
else template.text = null;
|
|
296
|
-
} else {
|
|
297
|
-
template.text += text;
|
|
298
|
-
}
|
|
299
|
-
} else if (text === '`') {
|
|
300
|
-
templates.push({ nesting, text: '' });
|
|
301
|
-
} else if (text[0] === '"' || text[0] === "'") {
|
|
302
|
-
const { closed } = scanStringLiteral(text, 0);
|
|
303
|
-
scanInterpolations(text.slice(1, closed ? -1 : undefined), onRef);
|
|
304
|
-
}
|
|
305
|
-
// Regex literals and comments hold no references
|
|
306
|
-
},
|
|
268
|
+
// Strings, regex literals and comments hold no references
|
|
269
|
+
literal() {},
|
|
307
270
|
},
|
|
308
271
|
goal,
|
|
309
272
|
);
|
|
310
|
-
// An unterminated template literal's text
|
|
311
|
-
for (const t of templates) if (t.text) scanInterpolations(t.text, onRef);
|
|
312
273
|
}
|
|
313
274
|
|
|
314
275
|
/**
|
|
@@ -334,25 +295,30 @@ function collectPassageRefs(
|
|
|
334
295
|
);
|
|
335
296
|
}
|
|
336
297
|
|
|
337
|
-
/** The declared parameters of a macro, if it has any. */
|
|
338
|
-
type ParametersOf = (macro: string) => readonly ParameterDef[] | undefined;
|
|
339
|
-
|
|
340
298
|
/**
|
|
341
|
-
* Report the references in the
|
|
342
|
-
*
|
|
343
|
-
*
|
|
299
|
+
* Report the references in the arguments of a macro by what its parameters
|
|
300
|
+
* say each holds (see ParameterDef.holds): code is scanned as code and
|
|
301
|
+
* markup as markup, but literal text (a placeholder, a passage name) holds
|
|
302
|
+
* none. A macro without declared parameters, or arguments that do not
|
|
303
|
+
* parse, are scanned as code.
|
|
344
304
|
*/
|
|
345
|
-
function
|
|
305
|
+
function scanArgs(
|
|
346
306
|
token: Extract<Token, { type: 'macro' }>,
|
|
347
307
|
params: readonly ParameterDef[] | undefined,
|
|
348
308
|
onRef: RefCallback,
|
|
349
309
|
): void {
|
|
350
|
-
if (!
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
310
|
+
if (!token.rawArgs) return;
|
|
311
|
+
if (params) {
|
|
312
|
+
const pieces = [...argPieces(token.rawArgs, 0, params, token.name, '')];
|
|
313
|
+
if (!pieces.some((piece) => piece.kind === 'argument-error')) {
|
|
314
|
+
for (const piece of pieces) {
|
|
315
|
+
if (piece.kind === 'code') scanCode(piece.code, onRef, piece.goal);
|
|
316
|
+
else if (piece.kind === 'text') scanInterpolations(piece.text, onRef);
|
|
317
|
+
}
|
|
318
|
+
return;
|
|
354
319
|
}
|
|
355
320
|
}
|
|
321
|
+
scanCode(token.rawArgs, onRef);
|
|
356
322
|
}
|
|
357
323
|
|
|
358
324
|
/** Report the `$var` references in the tokens of `content`. */
|
|
@@ -375,13 +341,14 @@ function collectTokenRefs(
|
|
|
375
341
|
else scanInterpolations(value, onRef, storeVarMacros);
|
|
376
342
|
}
|
|
377
343
|
} else if (token.type === 'macro' && !token.isClose) {
|
|
378
|
-
|
|
379
|
-
scanStringCode(token, parametersOf(token.name), onRef);
|
|
344
|
+
scanArgs(token, parametersOf(token.name), onRef);
|
|
380
345
|
|
|
381
346
|
if (storeVarMacros.has(token.name.toLowerCase())) {
|
|
382
347
|
const first = token.rawArgs.trim().split(/\s+/)[0] ?? '';
|
|
383
348
|
const quoted = QUOTED_VAR_ARG_RE.exec(first);
|
|
384
349
|
if (quoted) onRef(quoted[1]!);
|
|
350
|
+
// Unquoted, the variable is an argument of no declared role
|
|
351
|
+
else if (!/^["'`]/.test(first)) scanCode(first, onRef);
|
|
385
352
|
}
|
|
386
353
|
|
|
387
354
|
if (token.name === 'do') {
|
|
@@ -426,12 +393,11 @@ export function validatePassages(
|
|
|
426
393
|
// Don't validate the declarations or the SaveTitle code themselves
|
|
427
394
|
if (NOT_MARKUP.has(name)) continue;
|
|
428
395
|
|
|
429
|
-
const forLocals = extractForLocals(passage.content);
|
|
430
396
|
collectPassageRefs(
|
|
431
397
|
passage.content,
|
|
432
398
|
storeVarSet,
|
|
433
399
|
(ref) => {
|
|
434
|
-
const error = validateRef(ref, schema
|
|
400
|
+
const error = validateRef(ref, schema);
|
|
435
401
|
if (error) {
|
|
436
402
|
errors.push(`Passage "${name}": ${error}`);
|
|
437
403
|
}
|
package/src/structural.ts
CHANGED
|
@@ -36,6 +36,12 @@ export interface DeepCloneOptions {
|
|
|
36
36
|
* plain object, which would lose their prototype and identity.
|
|
37
37
|
*/
|
|
38
38
|
keepUnregistered?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* The copies made so far, by original. Values copied with one map share
|
|
41
|
+
* their references: an object met again, in the same value or another,
|
|
42
|
+
* is the copy already made. The map is filled as values are copied.
|
|
43
|
+
*/
|
|
44
|
+
seen?: Map<object, object>;
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
type TypedArrayCtor = new (
|
|
@@ -45,7 +51,7 @@ type TypedArrayCtor = new (
|
|
|
45
51
|
) => ArrayBufferView;
|
|
46
52
|
|
|
47
53
|
export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
|
|
48
|
-
const seen = new Map<object, object>();
|
|
54
|
+
const seen = options.seen ?? new Map<object, object>();
|
|
49
55
|
|
|
50
56
|
/** Record `copy` as the copy of `obj` before its contents are copied. */
|
|
51
57
|
const keep = <C extends object>(obj: object, copy: C): C => {
|
|
@@ -53,11 +59,20 @@ export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
|
|
|
53
59
|
return copy;
|
|
54
60
|
};
|
|
55
61
|
|
|
56
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* Copy the own (enumerable) keys of `obj` into `copy`, symbol keys too: a
|
|
64
|
+
* save refuses them, which a copy must not hide.
|
|
65
|
+
*/
|
|
57
66
|
function copyKeys(obj: object, copy: object): object {
|
|
58
67
|
for (const key of Object.keys(obj)) {
|
|
59
68
|
setOwn(copy, key, clone((obj as Record<string, unknown>)[key]));
|
|
60
69
|
}
|
|
70
|
+
for (const sym of Object.getOwnPropertySymbols(obj)) {
|
|
71
|
+
if (!Object.prototype.propertyIsEnumerable.call(obj, sym)) continue;
|
|
72
|
+
(copy as Record<symbol, unknown>)[sym] = clone(
|
|
73
|
+
(obj as Record<symbol, unknown>)[sym],
|
|
74
|
+
);
|
|
75
|
+
}
|
|
61
76
|
return copy;
|
|
62
77
|
}
|
|
63
78
|
|
|
@@ -195,20 +210,25 @@ function loosePairs(): Pairs {
|
|
|
195
210
|
/**
|
|
196
211
|
* Pairs that also require the same reference structure: each object of one
|
|
197
212
|
* value pairs with exactly one of the other, so shared references and
|
|
198
|
-
* cycles are shared at the same places.
|
|
213
|
+
* cycles are shared at the same places. `ab` and `ba` hold the pairs (and
|
|
214
|
+
* may hold some already); those made are listed in `added`.
|
|
199
215
|
*/
|
|
200
|
-
function strictPairs(
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
216
|
+
function strictPairs(
|
|
217
|
+
ab = new Map<object, object>(),
|
|
218
|
+
ba = new Map<object, object>(),
|
|
219
|
+
): Pairs & { added: [object, object][] } {
|
|
220
|
+
const added: [object, object][] = [];
|
|
221
|
+
const pairs = ((a, b) => {
|
|
204
222
|
const known = ab.get(a);
|
|
205
223
|
if (known !== undefined) return known === b;
|
|
206
224
|
if (ba.has(b)) return false;
|
|
207
225
|
ab.set(a, b);
|
|
208
226
|
ba.set(b, a);
|
|
227
|
+
added.push([a, b]);
|
|
209
228
|
return undefined;
|
|
210
|
-
};
|
|
229
|
+
}) as Pairs & { added: [object, object][] };
|
|
211
230
|
pairs.strict = true;
|
|
231
|
+
pairs.added = added;
|
|
212
232
|
return pairs;
|
|
213
233
|
}
|
|
214
234
|
|
|
@@ -225,28 +245,55 @@ export function deepEqualStrict(a: unknown, b: unknown): boolean {
|
|
|
225
245
|
* `prev` at the same place replaced by `prev`'s: the history moments of a
|
|
226
246
|
* save then share what did not change between them, which a save stores
|
|
227
247
|
* once. Containers on the way to a change are copied; neither value is
|
|
228
|
-
* changed.
|
|
248
|
+
* changed. The result has the reference structure of `curr`: what `curr`
|
|
249
|
+
* shares or cycles through stays so, and what it does not share stays
|
|
250
|
+
* apart, whichever parts are reused.
|
|
229
251
|
*/
|
|
230
252
|
export function shareEqual<T>(prev: unknown, curr: T): T {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
253
|
+
if (!isObjectValue(prev) || !isObjectValue(curr)) return curr;
|
|
254
|
+
/** The object of the result standing for each object of `curr` met. */
|
|
255
|
+
const done = new Map<object, object>();
|
|
256
|
+
/** The object of `curr` each object of `prev` in the result stands for. */
|
|
257
|
+
const used = new Map<object, object>();
|
|
258
|
+
|
|
259
|
+
/** `value` for the result: copied, as far as it is not met already. */
|
|
260
|
+
const fresh = <V>(value: V): V =>
|
|
261
|
+
isObjectValue(value)
|
|
262
|
+
? deepClone(value, { keepUnregistered: true, seen: done })
|
|
263
|
+
: value;
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Whether `p` and `c` are equal and so can stand for each other, which
|
|
267
|
+
* needs each object in them to stand for one object only, in the whole
|
|
268
|
+
* result. Then every pair of objects they hold is placed in the result.
|
|
269
|
+
*/
|
|
270
|
+
function reuse(p: object, c: object): boolean {
|
|
271
|
+
const pairs = strictPairs(used, done);
|
|
272
|
+
if (equal(p, c, pairs)) return true;
|
|
273
|
+
for (const [a, b] of pairs.added) {
|
|
274
|
+
used.delete(a);
|
|
275
|
+
done.delete(b);
|
|
238
276
|
}
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function share(p: unknown, c: unknown): unknown {
|
|
281
|
+
if (!isObjectValue(c)) return c;
|
|
282
|
+
const placed = done.get(c);
|
|
283
|
+
if (placed !== undefined) return placed;
|
|
284
|
+
if (!isObjectValue(p)) return fresh(c);
|
|
285
|
+
if (reuse(p, c)) return p;
|
|
239
286
|
if (c instanceof Map && p instanceof Map) {
|
|
240
287
|
const out = new Map();
|
|
241
288
|
done.set(c, out);
|
|
242
|
-
for (const [k, v] of c) out.set(
|
|
289
|
+
for (const [k, v] of c) out.set(fresh(k), share(p.get(k), v));
|
|
243
290
|
return out;
|
|
244
291
|
}
|
|
245
292
|
if (Array.isArray(c) && Array.isArray(p)) {
|
|
246
293
|
const out = new Array(c.length) as unknown[];
|
|
247
294
|
done.set(c, out);
|
|
248
295
|
for (let i = 0; i < c.length; i++) {
|
|
249
|
-
if (i in c) out[i] =
|
|
296
|
+
if (i in c) out[i] = share(p[i], c[i]);
|
|
250
297
|
}
|
|
251
298
|
return out;
|
|
252
299
|
}
|
|
@@ -255,12 +302,11 @@ export function shareEqual<T>(prev: unknown, curr: T): T {
|
|
|
255
302
|
done.set(c, out);
|
|
256
303
|
const cr = c as Record<string, unknown>;
|
|
257
304
|
for (const key of Object.keys(cr)) {
|
|
258
|
-
setOwn(out, key, hasOwn(p, key) ?
|
|
305
|
+
setOwn(out, key, share(hasOwn(p, key) ? p[key] : undefined, cr[key]));
|
|
259
306
|
}
|
|
260
307
|
return out;
|
|
261
308
|
}
|
|
262
|
-
|
|
263
|
-
return c;
|
|
309
|
+
return fresh(c);
|
|
264
310
|
}
|
|
265
311
|
return share(prev, curr) as T;
|
|
266
312
|
}
|
package/src/tooling.ts
CHANGED
|
@@ -11,8 +11,11 @@ import {
|
|
|
11
11
|
} from './markup/validate';
|
|
12
12
|
import { isBlockMacro } from './markup/ast';
|
|
13
13
|
import { tokenizeMarkupTolerant } from './markup/parse';
|
|
14
|
-
import {
|
|
15
|
-
|
|
14
|
+
import {
|
|
15
|
+
collectPassageReferences,
|
|
16
|
+
parameterLookup,
|
|
17
|
+
type PassageReference,
|
|
18
|
+
} from './code-check';
|
|
16
19
|
import { blockWidgetNames } from './widgets/widget-def';
|
|
17
20
|
import type { ParameterDef } from './registry';
|
|
18
21
|
|
|
@@ -71,8 +74,13 @@ export interface ToolingMacro {
|
|
|
71
74
|
name: string;
|
|
72
75
|
block: boolean;
|
|
73
76
|
subMacros: string[];
|
|
74
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* Its declared parameters: their types tell which arguments are code, and
|
|
79
|
+
* what a `string` or `text` argument holds (see ParameterDef.holds).
|
|
80
|
+
*/
|
|
75
81
|
parameters?: readonly ParameterDef[];
|
|
82
|
+
/** Whether it resolves markup: its strings hold markup by default. */
|
|
83
|
+
interpolate?: boolean;
|
|
76
84
|
}
|
|
77
85
|
|
|
78
86
|
/**
|
|
@@ -87,20 +95,19 @@ export function validateStoryMarkup(
|
|
|
87
95
|
options: ValidateMarkupOptions = {},
|
|
88
96
|
): MarkupDiagnostic[] {
|
|
89
97
|
const list = [...passages];
|
|
98
|
+
const all = [...macros];
|
|
90
99
|
const known = new Set<string>();
|
|
91
|
-
const parameters = new Map<string, readonly ParameterDef[] | undefined>();
|
|
92
100
|
const blocks = new Set(blockWidgetNames(list).map((n) => n.toLowerCase()));
|
|
93
|
-
for (const macro of
|
|
101
|
+
for (const macro of all) {
|
|
94
102
|
const name = macro.name.toLowerCase();
|
|
95
103
|
known.add(name);
|
|
96
|
-
parameters.set(name, macro.parameters);
|
|
97
104
|
for (const sub of macro.subMacros) known.add(sub.toLowerCase());
|
|
98
105
|
if (macro.block) blocks.add(name);
|
|
99
106
|
}
|
|
100
107
|
return validateMarkup(list, {
|
|
101
108
|
isKnownMacro: (name) => known.has(name),
|
|
102
109
|
macroNames: known,
|
|
103
|
-
parametersOf: (
|
|
110
|
+
parametersOf: parameterLookup(all),
|
|
104
111
|
isBlockMacro: (name) =>
|
|
105
112
|
blocks.has(name.toLowerCase()) || isBlockMacro(name),
|
|
106
113
|
checkPassageNames: options.checkPassageNames,
|
|
@@ -116,14 +123,6 @@ export function collectStoryPassageReferences(
|
|
|
116
123
|
source: string,
|
|
117
124
|
macros: Iterable<ToolingMacro>,
|
|
118
125
|
): PassageReference[] {
|
|
119
|
-
const parameters = new Map<string, readonly ParameterDef[] | undefined>();
|
|
120
|
-
for (const macro of macros) {
|
|
121
|
-
parameters.set(macro.name.toLowerCase(), macro.parameters);
|
|
122
|
-
}
|
|
123
126
|
const { tokens } = tokenizeMarkupTolerant(source);
|
|
124
|
-
return collectPassageReferences(
|
|
125
|
-
source,
|
|
126
|
-
tokens,
|
|
127
|
-
(name) => parameters.get(name.toLowerCase()) ?? subMacroParameters(name),
|
|
128
|
-
);
|
|
127
|
+
return collectPassageReferences(source, tokens, parameterLookup(macros));
|
|
129
128
|
}
|
|
@@ -7,8 +7,8 @@ import type { MacroArgs } from '../registry';
|
|
|
7
7
|
|
|
8
8
|
/** A {widget} definition's name, then its `@` parameters. */
|
|
9
9
|
export const WIDGET_PARAMETERS = [
|
|
10
|
-
{ name: 'name', type: 'text', required: true },
|
|
11
|
-
{ name: 'parameters', type: 'text' },
|
|
10
|
+
{ name: 'name', type: 'text', holds: 'text', required: true },
|
|
11
|
+
{ name: 'parameters', type: 'text', holds: 'text' },
|
|
12
12
|
] as const;
|
|
13
13
|
|
|
14
14
|
export interface WidgetDef {
|
package/types/index.d.ts
CHANGED
|
@@ -272,6 +272,27 @@ export type ParameterType =
|
|
|
272
272
|
| 'separator'
|
|
273
273
|
| 'options';
|
|
274
274
|
|
|
275
|
+
/**
|
|
276
|
+
* What the value of a `string` or `text` argument holds, for the check at
|
|
277
|
+
* story start and for tooling:
|
|
278
|
+
* - `markup`: markup the macro renders (`{button}`'s label): its markup
|
|
279
|
+
* is checked.
|
|
280
|
+
* - `text`: plain text the macro uses as written (`{checkbox}`'s label).
|
|
281
|
+
* - `passage`: a passage name (`{watch}`'s `goto`): the passage must exist.
|
|
282
|
+
* - `expression`, `statements`: code (`{watch}`'s condition and `run`): it
|
|
283
|
+
* is checked as code, and its variable references against the schema.
|
|
284
|
+
*
|
|
285
|
+
* Without it, the argument of a macro with `interpolate` holds `markup`,
|
|
286
|
+
* any other `text`.
|
|
287
|
+
* @see {@link ../../src/registry.ts} for the implementation.
|
|
288
|
+
*/
|
|
289
|
+
export type StringHolds =
|
|
290
|
+
| 'markup'
|
|
291
|
+
| 'text'
|
|
292
|
+
| 'passage'
|
|
293
|
+
| 'expression'
|
|
294
|
+
| 'statements';
|
|
295
|
+
|
|
275
296
|
/**
|
|
276
297
|
* Parameter metadata for a macro definition.
|
|
277
298
|
* @see {@link ../../src/registry.ts} for the implementation.
|
|
@@ -282,6 +303,8 @@ export interface ParameterDef {
|
|
|
282
303
|
description?: string;
|
|
283
304
|
/** How the argument is read: required, there is no default. */
|
|
284
305
|
type: ParameterType;
|
|
306
|
+
/** What a `string` or `text` argument holds (see StringHolds). */
|
|
307
|
+
holds?: StringHolds;
|
|
285
308
|
/** The options of an `options` parameter. */
|
|
286
309
|
parameters?: readonly ParameterDef[];
|
|
287
310
|
}
|
package/types/tooling.d.ts
CHANGED
|
@@ -30,12 +30,34 @@ export type ParameterType =
|
|
|
30
30
|
| 'separator'
|
|
31
31
|
| 'options';
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* What the value of a `string` or `text` argument holds, for the check at
|
|
35
|
+
* story start and for tooling:
|
|
36
|
+
* - `markup`: markup the macro renders (`{button}`'s label): its markup
|
|
37
|
+
* is checked.
|
|
38
|
+
* - `text`: plain text the macro uses as written (`{checkbox}`'s label).
|
|
39
|
+
* - `passage`: a passage name (`{watch}`'s `goto`): the passage must exist.
|
|
40
|
+
* - `expression`, `statements`: code (`{watch}`'s condition and `run`): it
|
|
41
|
+
* is checked as code, and its variable references against the schema.
|
|
42
|
+
*
|
|
43
|
+
* Without it, the argument of a macro with `interpolate` holds `markup`,
|
|
44
|
+
* any other `text`.
|
|
45
|
+
*/
|
|
46
|
+
export type StringHolds =
|
|
47
|
+
| 'markup'
|
|
48
|
+
| 'text'
|
|
49
|
+
| 'passage'
|
|
50
|
+
| 'expression'
|
|
51
|
+
| 'statements';
|
|
52
|
+
|
|
33
53
|
export interface ParameterDef {
|
|
34
54
|
name: string;
|
|
35
55
|
required?: boolean;
|
|
36
56
|
description?: string;
|
|
37
57
|
/** How the argument is read: required, there is no default. */
|
|
38
58
|
type: ParameterType;
|
|
59
|
+
/** What a `string` or `text` argument holds (see StringHolds). */
|
|
60
|
+
holds?: StringHolds;
|
|
39
61
|
/** The options of an `options` parameter. */
|
|
40
62
|
parameters?: readonly ParameterDef[];
|
|
41
63
|
}
|
|
@@ -285,6 +307,9 @@ export interface LinkToken extends TokenSpan, Selectors {
|
|
|
285
307
|
type: 'link';
|
|
286
308
|
display: string;
|
|
287
309
|
target: string;
|
|
310
|
+
/** Where the target is written, from `targetStart` to `targetEnd`. */
|
|
311
|
+
targetStart: number;
|
|
312
|
+
targetEnd: number;
|
|
288
313
|
}
|
|
289
314
|
|
|
290
315
|
export interface MacroToken extends TokenSpan, Selectors {
|
|
@@ -465,8 +490,9 @@ export interface PassageReference {
|
|
|
465
490
|
* The passages the markup of `source` names, in source order: `[[…]]` links,
|
|
466
491
|
* the passage of `{goto}`, `{include}` and `{link}` (and of macros that
|
|
467
492
|
* declare a `passage` argument), the `goto` and `dialog` actions of
|
|
468
|
-
* `{watch}` and the
|
|
469
|
-
*
|
|
493
|
+
* `{watch}` (and the `string` and `text` arguments of macros that declare
|
|
494
|
+
* they hold a passage name) and the body of `{dialog}`. Malformed tags are
|
|
495
|
+
* skipped, so half-typed markup reads.
|
|
470
496
|
*/
|
|
471
497
|
export declare function collectPassageReferences(
|
|
472
498
|
source: string,
|