@rohal12/spindle 0.51.4 → 0.52.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 +4512 -1702
- package/dist/pkg/macro-registry.json +7 -7
- package/dist/pkg/story-variables.js +1636 -177
- package/package.json +5 -2
- package/src/action-registry.ts +70 -18
- package/src/automation/runner.ts +2 -1
- package/src/class-registry.ts +214 -103
- package/src/components/Passage.tsx +2 -2
- package/src/components/PassageDialog.tsx +2 -5
- package/src/components/StoryInterface.tsx +2 -4
- package/src/components/macros/Button.tsx +5 -31
- package/src/components/macros/Checkbox.tsx +7 -4
- package/src/components/macros/Computed.tsx +28 -14
- package/src/components/macros/For.tsx +29 -3
- package/src/components/macros/If.tsx +8 -0
- package/src/components/macros/Include.tsx +7 -6
- package/src/components/macros/MacroError.tsx +2 -1
- package/src/components/macros/MacroLink.tsx +12 -45
- package/src/components/macros/Meter.tsx +11 -3
- package/src/components/macros/Nobr.tsx +1 -0
- package/src/components/macros/PassageDisplay.tsx +3 -0
- package/src/components/macros/Print.tsx +4 -0
- package/src/components/macros/Radiobutton.tsx +5 -2
- package/src/components/macros/SaveManager.tsx +25 -8
- package/src/components/macros/Set.tsx +5 -4
- package/src/components/macros/Span.tsx +1 -0
- package/src/components/macros/StoryTitle.tsx +1 -0
- package/src/components/macros/Switch.tsx +13 -0
- package/src/components/macros/Unset.tsx +31 -10
- package/src/components/macros/VarDisplay.tsx +21 -4
- package/src/components/macros/Widget.tsx +20 -1
- package/src/components/macros/WidgetInvocation.tsx +17 -75
- package/src/components/macros/arg-utils.ts +107 -1
- package/src/components/macros/detached-body.tsx +68 -0
- package/src/components/macros/option-utils.ts +3 -2
- package/src/define-macro.ts +32 -5
- package/src/execute-mutation.ts +271 -68
- package/src/expression.ts +91 -59
- package/src/hooks/use-action.ts +24 -3
- package/src/hooks/use-interpolate.ts +36 -5
- package/src/index.tsx +12 -2
- package/src/interpolation.ts +394 -96
- package/src/js-lexer.ts +1231 -97
- package/src/markup/ast.ts +7 -2
- package/src/markup/code-attributes.ts +64 -0
- package/src/markup/markdown.ts +188 -9
- package/src/markup/render.tsx +430 -49
- package/src/markup/tokenizer.ts +578 -110
- package/src/prng.ts +41 -8
- package/src/registry.ts +35 -0
- package/src/saves/save-manager.ts +346 -160
- package/src/saves/storage.ts +16 -7
- package/src/saves/types.ts +2 -1
- package/src/settings.ts +12 -9
- package/src/store.ts +640 -186
- package/src/story-api.ts +38 -73
- package/src/story-init.ts +1 -1
- package/src/story-variables.ts +98 -102
- package/src/triggers.ts +10 -12
- package/src/utils/counts.ts +45 -0
- package/src/utils/error-message.ts +12 -0
- package/src/utils/live-locals.ts +10 -3
- package/src/utils/namespace.ts +71 -0
- package/src/utils/object-path.ts +99 -14
- package/src/utils/stable-key.ts +14 -9
- package/src/widgets/widget-registry.ts +9 -0
- package/types/index.d.ts +43 -7
- package/types/tooling.d.ts +1 -0
package/src/execute-mutation.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Immer,
|
|
3
|
+
current as currentDraft,
|
|
4
|
+
freeze,
|
|
5
|
+
isDraft,
|
|
6
|
+
type Draft,
|
|
7
|
+
type Patch,
|
|
8
|
+
} from 'immer';
|
|
2
9
|
import { useStoryStore } from './store';
|
|
3
|
-
import type { VariableNamespaces } from './store';
|
|
10
|
+
import type { StoryState, VariableNamespaces } from './store';
|
|
4
11
|
import { execute } from './expression';
|
|
5
12
|
import { deepClone, deepEqual } from './class-registry';
|
|
6
13
|
import { deleteByPath, getByPath, setByPath } from './utils/object-path';
|
|
14
|
+
import { asNamespace } from './utils/namespace';
|
|
7
15
|
|
|
8
16
|
type NamespaceName = keyof VariableNamespaces;
|
|
9
17
|
|
|
@@ -21,10 +29,10 @@ type PathChange =
|
|
|
21
29
|
/**
|
|
22
30
|
* The state of one executing mutation. The code reads and writes `work`.
|
|
23
31
|
* `base` is what `work` started as, with every write made elsewhere during
|
|
24
|
-
* execution (
|
|
25
|
-
* so diffing `work` against `base` yields exactly the
|
|
26
|
-
* and a write made elsewhere after the code's own write
|
|
27
|
-
* leaves no difference there: the later write wins.
|
|
32
|
+
* execution (any other store update, the commits of nested mutations)
|
|
33
|
+
* applied to both, so diffing `work` against `base` yields exactly the
|
|
34
|
+
* code's own changes, and a write made elsewhere after the code's own write
|
|
35
|
+
* to the same path leaves no difference there: the later write wins.
|
|
28
36
|
*/
|
|
29
37
|
interface MutationScope {
|
|
30
38
|
work: VariableNamespaces;
|
|
@@ -43,6 +51,19 @@ export function getActiveMutationScope(): VariableNamespaces | undefined {
|
|
|
43
51
|
return activeScopes[activeScopes.length - 1]?.work;
|
|
44
52
|
}
|
|
45
53
|
|
|
54
|
+
/**
|
|
55
|
+
* The story state in program order: the store's, with the variable
|
|
56
|
+
* namespaces of the innermost running mutation, which hold its pending
|
|
57
|
+
* writes, while mutation code runs. Readers that can run in the middle of
|
|
58
|
+
* mutation code (watcher conditions, {computed}, input bindings) read this,
|
|
59
|
+
* so they never see the store without the code's writes so far.
|
|
60
|
+
*/
|
|
61
|
+
export function readState(): StoryState {
|
|
62
|
+
const state = useStoryStore.getState();
|
|
63
|
+
const scope = getActiveMutationScope();
|
|
64
|
+
return scope ? { ...state, ...scope } : state;
|
|
65
|
+
}
|
|
66
|
+
|
|
46
67
|
const cloneValue = <T>(value: T): T =>
|
|
47
68
|
deepClone(value, { keepUnregistered: true });
|
|
48
69
|
|
|
@@ -61,7 +82,7 @@ export function frozenCopy<T>(value: T): T {
|
|
|
61
82
|
* replaced as a whole, since their elements have no stable identity to
|
|
62
83
|
* merge by (a shift moves every index).
|
|
63
84
|
*/
|
|
64
|
-
function isMergeable(value: unknown): value is Record<string, unknown> {
|
|
85
|
+
export function isMergeable(value: unknown): value is Record<string, unknown> {
|
|
65
86
|
return (
|
|
66
87
|
typeof value === 'object' &&
|
|
67
88
|
value !== null &&
|
|
@@ -92,6 +113,9 @@ function diff(
|
|
|
92
113
|
const w = work[key];
|
|
93
114
|
if (!hasOwn(base, key)) {
|
|
94
115
|
changes.push({ path: [...path, key], deleted: false, value: w });
|
|
116
|
+
} else if (Object.is(b, w)) {
|
|
117
|
+
// The same value (an untouched subtree of an Immer update)
|
|
118
|
+
continue;
|
|
95
119
|
} else if (
|
|
96
120
|
isMergeable(b) &&
|
|
97
121
|
isMergeable(w) &&
|
|
@@ -165,79 +189,258 @@ function mirror(
|
|
|
165
189
|
}
|
|
166
190
|
}
|
|
167
191
|
|
|
192
|
+
const NAMESPACE_KEYS: ReadonlySet<string> = new Set(NAMESPACES);
|
|
193
|
+
|
|
168
194
|
/**
|
|
169
|
-
*
|
|
170
|
-
*
|
|
195
|
+
* Runs store updates on the running code's working copies. They stay the
|
|
196
|
+
* code's own, so nothing may freeze them.
|
|
171
197
|
*/
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
198
|
+
const scratch = new Immer({ autoFreeze: false });
|
|
199
|
+
|
|
200
|
+
/** Set while commitScopes() hands its commit to the store. */
|
|
201
|
+
let committing = false;
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The path below a namespace that an Immer patch at `segments` changed, as
|
|
205
|
+
* the merge sees values: arrays, Map, Set, Date and RegExp change as a
|
|
206
|
+
* whole (see isMergeable), so a patch inside one is a change of it.
|
|
207
|
+
*/
|
|
208
|
+
function changedPath(
|
|
209
|
+
ns: Record<string, unknown>,
|
|
210
|
+
segments: readonly (string | number)[],
|
|
211
|
+
): string[] {
|
|
212
|
+
const path: string[] = [];
|
|
213
|
+
let node: unknown = ns;
|
|
214
|
+
for (const segment of segments) {
|
|
215
|
+
if (!isMergeable(node)) break;
|
|
216
|
+
const key = String(segment);
|
|
217
|
+
path.push(key);
|
|
218
|
+
node = hasOwn(node, key) ? node[key] : undefined;
|
|
219
|
+
}
|
|
220
|
+
return path;
|
|
180
221
|
}
|
|
181
222
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
223
|
+
/** The change that brings `path` to what it holds in `ns`. */
|
|
224
|
+
function changeTo(ns: Record<string, unknown>, path: string[]): PathChange {
|
|
225
|
+
const parent = getByPath(ns, path.slice(0, -1));
|
|
226
|
+
const key = path[path.length - 1]!;
|
|
227
|
+
return isMergeable(parent) && hasOwn(parent, key)
|
|
228
|
+
? { path, deleted: false, value: parent[key] }
|
|
229
|
+
: { path, deleted: true };
|
|
230
|
+
}
|
|
187
231
|
|
|
188
232
|
/**
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
* its working copy must stay its own (the store freezes what it is given),
|
|
194
|
-
* and its base is moved up to the working copy so nothing commits twice.
|
|
233
|
+
* The property paths of a namespace that an update wrote (Immer `patches`
|
|
234
|
+
* from `before` to `after`), each with what it holds afterwards: where the
|
|
235
|
+
* update assigned an object, the whole object is the change, so a write
|
|
236
|
+
* replaces exactly what it replaced in program order.
|
|
195
237
|
*/
|
|
196
|
-
function
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
):
|
|
238
|
+
function writtenPaths(
|
|
239
|
+
before: Record<string, unknown>,
|
|
240
|
+
after: Record<string, unknown>,
|
|
241
|
+
patches: readonly Patch[],
|
|
242
|
+
): PathChange[] {
|
|
243
|
+
if (patches.some((p) => p.path.length === 1)) {
|
|
244
|
+
// The namespace itself was replaced: each root that differs
|
|
245
|
+
const roots = new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
246
|
+
return [...roots]
|
|
247
|
+
.filter((root) => !hasOwn(before, root) || before[root] !== after[root])
|
|
248
|
+
.map((root) => changeTo(after, [root]));
|
|
249
|
+
}
|
|
250
|
+
const seen = new Set<string>();
|
|
251
|
+
const changes: PathChange[] = [];
|
|
252
|
+
for (const patch of patches) {
|
|
253
|
+
const path = changedPath(after, patch.path.slice(1));
|
|
254
|
+
const key = JSON.stringify(path);
|
|
255
|
+
if (seen.has(key)) continue;
|
|
256
|
+
seen.add(key);
|
|
257
|
+
changes.push(changeTo(after, path));
|
|
258
|
+
}
|
|
259
|
+
return changes;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Route a store update made while mutation code runs into program order.
|
|
264
|
+
* The store calls this for every update (see storyStateGuard in store.ts);
|
|
265
|
+
* it returns undefined, leaving the update as it is, when no mutation code
|
|
266
|
+
* runs or the update is a commit of mutation code. Otherwise the update is
|
|
267
|
+
* one made by something the code called: Story.set, an input binding or a
|
|
268
|
+
* {computed}/{unset} the code set off, a direct store action, a watcher.
|
|
269
|
+
*
|
|
270
|
+
* In program order such an update happens after the code's writes so far,
|
|
271
|
+
* so those are committed first, in one store update: subscribers (watchers,
|
|
272
|
+
* variableChanged handlers) see the program-order state before this write,
|
|
273
|
+
* and an error the code throws later does not undo them. The update then
|
|
274
|
+
* happens to the running code's state: its recipe runs on the innermost
|
|
275
|
+
* mutation's working copies (so it reads the code's state, and a path the
|
|
276
|
+
* code's state cannot take throws before this write is made, as an
|
|
277
|
+
* assignment would). The property paths it wrote there are written to the
|
|
278
|
+
* code's working copy at once, so the code reads them next, and are
|
|
279
|
+
* returned as the recipe for the store: each write reaches the store at
|
|
280
|
+
* once (should the store's state not take the path, the code's state of
|
|
281
|
+
* the whole root is written instead). Each write is also mirrored into
|
|
282
|
+
* every executing mutation (see mirror()), so their commits neither drop
|
|
283
|
+
* nor revert it. Changes to the rest of the store state are applied as
|
|
284
|
+
* they are.
|
|
285
|
+
*/
|
|
286
|
+
export function routeStoreUpdate<S extends VariableNamespaces>(
|
|
287
|
+
recipe: (draft: Draft<S>) => void,
|
|
288
|
+
): ((draft: Draft<S>) => void) | undefined {
|
|
289
|
+
if (committing) {
|
|
290
|
+
committing = false;
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
if (activeScopes.length === 0) return undefined;
|
|
294
|
+
|
|
295
|
+
// The store first takes the code's pending writes, which came before this
|
|
296
|
+
// one, so it goes from one program-order state to the next (watchers that
|
|
297
|
+
// commit fires run before this write, as they would in program order)
|
|
298
|
+
commitScopes(running());
|
|
299
|
+
const inner = activeScopes[activeScopes.length - 1]!;
|
|
300
|
+
const view = {
|
|
301
|
+
...(useStoryStore.getState() as unknown as S),
|
|
302
|
+
variables: inner.work.variables,
|
|
303
|
+
temporary: inner.work.temporary,
|
|
304
|
+
transient: inner.work.transient,
|
|
305
|
+
};
|
|
306
|
+
const [next, patches] = scratch.produceWithPatches(
|
|
307
|
+
view,
|
|
308
|
+
recipe as (draft: Draft<S>) => void,
|
|
309
|
+
);
|
|
201
310
|
const changes = NAMESPACES.map((ns) => {
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
311
|
+
const own = patches.filter((p) => p.path[0] === ns);
|
|
312
|
+
return [
|
|
313
|
+
ns,
|
|
314
|
+
own.length ? writtenPaths(view[ns], next[ns], own) : [],
|
|
315
|
+
] as const;
|
|
205
316
|
});
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
317
|
+
const owned = (change: PathChange): PathChange =>
|
|
318
|
+
change.deleted ? change : { ...change, value: cloneValue(change.value) };
|
|
319
|
+
for (const [ns, list] of changes) {
|
|
320
|
+
for (const change of list) applyChange(inner.work[ns], owned(change));
|
|
321
|
+
}
|
|
211
322
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
323
|
+
return (draft) => {
|
|
324
|
+
const target = draft as unknown as Record<string, unknown>;
|
|
325
|
+
for (const key of Object.keys(next)) {
|
|
326
|
+
const value = (next as unknown as Record<string, unknown>)[key];
|
|
327
|
+
if (
|
|
328
|
+
!NAMESPACE_KEYS.has(key) &&
|
|
329
|
+
value !== (view as unknown as Record<string, unknown>)[key]
|
|
330
|
+
) {
|
|
331
|
+
target[key] = value;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const namespaces = draft as unknown as VariableNamespaces;
|
|
216
335
|
for (const [ns, list] of changes) {
|
|
217
|
-
const replaced = new Set<string>();
|
|
218
336
|
for (const change of list) {
|
|
219
337
|
const root = change.path[0]!;
|
|
220
|
-
// A changed path already holding the value keeps its reference
|
|
221
|
-
if (replaced.has(root) || isApplied(current[ns], change)) continue;
|
|
222
338
|
try {
|
|
223
|
-
applyChange(
|
|
224
|
-
draft[ns],
|
|
225
|
-
change.deleted ? change : { ...change, value: own(change.value) },
|
|
226
|
-
);
|
|
339
|
+
applyChange(namespaces[ns], owned(change));
|
|
227
340
|
} catch {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
341
|
+
if (hasOwn(inner.work[ns], root)) {
|
|
342
|
+
namespaces[ns][root] = cloneValue(inner.work[ns][root]);
|
|
343
|
+
} else {
|
|
344
|
+
delete namespaces[ns][root];
|
|
345
|
+
}
|
|
232
346
|
}
|
|
347
|
+
mirror(namespaces, ns, change);
|
|
233
348
|
}
|
|
234
|
-
// Hand the changes to the mutations this one runs inside, before
|
|
235
|
-
// watchers fired by this update run.
|
|
236
|
-
for (const change of list) mirror(draft, ns, change, enclosing);
|
|
237
349
|
}
|
|
238
|
-
}
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const cloneNamespaces = (from: VariableNamespaces): VariableNamespaces => ({
|
|
354
|
+
variables: deepClone(from.variables),
|
|
355
|
+
temporary: deepClone(from.temporary),
|
|
356
|
+
transient: deepClone(from.transient),
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
/** A mutation to commit, and whether its code goes on running after. */
|
|
360
|
+
interface Commit {
|
|
361
|
+
scope: MutationScope;
|
|
362
|
+
keepRunning: boolean;
|
|
239
363
|
}
|
|
240
364
|
|
|
365
|
+
/**
|
|
366
|
+
* Commit the property paths each mutation's code changed (work vs base) on
|
|
367
|
+
* top of the current store state, so writes made elsewhere during execution
|
|
368
|
+
* to other paths of the same objects survive. `commits` lists running
|
|
369
|
+
* mutations from the outermost in: each one's changes are applied after
|
|
370
|
+
* those of the mutations it runs inside (which it started from, so they
|
|
371
|
+
* came first in program order) and handed to them. All of it is one store
|
|
372
|
+
* update, so the store goes from one program-order state to another:
|
|
373
|
+
* watchers and other subscribers never see some pending writes without
|
|
374
|
+
* the ones made before them.
|
|
375
|
+
*
|
|
376
|
+
* A mutation whose code goes on running (`keepRunning`) keeps its working
|
|
377
|
+
* copy its own (the store freezes what it is given), and its base is moved
|
|
378
|
+
* up to the working copy so nothing commits twice.
|
|
379
|
+
*/
|
|
380
|
+
function commitScopes(commits: readonly Commit[]): void {
|
|
381
|
+
const all = commits.map(({ scope, keepRunning }) => ({
|
|
382
|
+
scope,
|
|
383
|
+
keepRunning,
|
|
384
|
+
changes: NAMESPACES.map((ns) => {
|
|
385
|
+
const list: PathChange[] = [];
|
|
386
|
+
diff(scope.base[ns], scope.work[ns], [], list, new Set());
|
|
387
|
+
return [ns, list] as const;
|
|
388
|
+
}),
|
|
389
|
+
}));
|
|
390
|
+
const changed = all.filter(({ changes }) =>
|
|
391
|
+
changes.some(([, list]) => list.length > 0),
|
|
392
|
+
);
|
|
393
|
+
if (changed.length === 0) return;
|
|
394
|
+
// Before the store update: watchers it fires may commit again (a goto)
|
|
395
|
+
for (const { scope, keepRunning } of changed) {
|
|
396
|
+
if (keepRunning) scope.base = cloneNamespaces(scope.work);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// One store update for everything: watchers must see the whole state,
|
|
400
|
+
// and a watcher's run action must not be overwritten by paths this commit
|
|
401
|
+
// writes after it fired. It is the commit itself, not an update made by
|
|
402
|
+
// running code (see routeStoreUpdate).
|
|
403
|
+
committing = true;
|
|
404
|
+
try {
|
|
405
|
+
useStoryStore.getState().updateVariables(commitRecipe);
|
|
406
|
+
} finally {
|
|
407
|
+
committing = false;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function commitRecipe(draft: VariableNamespaces): void {
|
|
411
|
+
all.forEach(({ scope, keepRunning, changes }, i) => {
|
|
412
|
+
const own = <T>(value: T): T => (keepRunning ? cloneValue(value) : value);
|
|
413
|
+
for (const [ns, list] of changes) {
|
|
414
|
+
const replaced = new Set<string>();
|
|
415
|
+
for (const change of list) {
|
|
416
|
+
const root = change.path[0]!;
|
|
417
|
+
// A changed path already holding the value keeps its reference
|
|
418
|
+
if (replaced.has(root) || isApplied(draft[ns], change)) continue;
|
|
419
|
+
try {
|
|
420
|
+
applyChange(
|
|
421
|
+
draft[ns],
|
|
422
|
+
change.deleted ? change : { ...change, value: own(change.value) },
|
|
423
|
+
);
|
|
424
|
+
} catch {
|
|
425
|
+
// An intermediate object the code wrote into is gone from the
|
|
426
|
+
// store: the code's view of the whole root wins.
|
|
427
|
+
draft[ns][root] = own(scope.work[ns][root]);
|
|
428
|
+
replaced.add(root);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
// Hand the changes to the mutations this one runs inside, before
|
|
432
|
+
// watchers fired by this update run.
|
|
433
|
+
const enclosing = all.slice(0, i).map((c) => c.scope);
|
|
434
|
+
for (const change of list) mirror(draft, ns, change, enclosing);
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Every running mutation, to commit with its code going on. */
|
|
441
|
+
const running = (): Commit[] =>
|
|
442
|
+
activeScopes.map((scope) => ({ scope, keepRunning: true }));
|
|
443
|
+
|
|
241
444
|
/**
|
|
242
445
|
* Bring a suspended mutation's copies up to the store after an action
|
|
243
446
|
* replaced or changed state under it (navigation clears temporaries, back
|
|
@@ -270,11 +473,7 @@ function resync(scope: MutationScope): void {
|
|
|
270
473
|
*/
|
|
271
474
|
export function runWithCommittedMutations<T>(action: () => T): T {
|
|
272
475
|
if (activeScopes.length === 0) return action();
|
|
273
|
-
|
|
274
|
-
// Innermost first: each commit hands its changes to the enclosing ones
|
|
275
|
-
for (let i = scopes.length - 1; i >= 0; i--) {
|
|
276
|
-
commitScope(scopes[i]!, scopes.slice(0, i), true);
|
|
277
|
-
}
|
|
476
|
+
commitScopes(running());
|
|
278
477
|
const suspended = activeScopes.splice(0);
|
|
279
478
|
try {
|
|
280
479
|
return action();
|
|
@@ -302,7 +501,9 @@ export function executeMutation(
|
|
|
302
501
|
// mutated in place would keep its reference, so a nested assignment
|
|
303
502
|
// (`@item.name = "x"`) would be lost or never reach the scope updater.
|
|
304
503
|
// Unregistered class instances (DOM nodes etc.) stay shared by reference.
|
|
305
|
-
const localsClone =
|
|
504
|
+
const localsClone = asNamespace(
|
|
505
|
+
deepClone(mergedLocals, { keepUnregistered: true }),
|
|
506
|
+
);
|
|
306
507
|
|
|
307
508
|
activeScopes.push(scope);
|
|
308
509
|
try {
|
|
@@ -317,7 +518,9 @@ export function executeMutation(
|
|
|
317
518
|
activeScopes.pop();
|
|
318
519
|
}
|
|
319
520
|
|
|
320
|
-
|
|
521
|
+
// With the pending writes of the mutations this one runs inside, which
|
|
522
|
+
// came before its own
|
|
523
|
+
commitScopes([...running(), { scope, keepRunning: false }]);
|
|
321
524
|
|
|
322
525
|
for (const key of Object.keys(localsClone)) {
|
|
323
526
|
if (!deepEqual(localsClone[key], mergedLocals[key])) {
|
|
@@ -327,7 +530,7 @@ export function executeMutation(
|
|
|
327
530
|
|
|
328
531
|
// Detect deleted locals
|
|
329
532
|
for (const key of Object.keys(mergedLocals)) {
|
|
330
|
-
if (!(key
|
|
533
|
+
if (!hasOwn(localsClone, key)) {
|
|
331
534
|
scopeUpdate(key, undefined);
|
|
332
535
|
}
|
|
333
536
|
}
|
package/src/expression.ts
CHANGED
|
@@ -2,7 +2,15 @@ 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 { lexJs } from './js-lexer';
|
|
5
|
+
import { lexJs, type JsGoal, type Sigil } from './js-lexer';
|
|
6
|
+
import {
|
|
7
|
+
EMPTY_NAMESPACE,
|
|
8
|
+
RESERVED_NAME,
|
|
9
|
+
asNamespace,
|
|
10
|
+
isNamespace,
|
|
11
|
+
type Namespace,
|
|
12
|
+
} from './utils/namespace';
|
|
13
|
+
import { countOf, type Counts } from './utils/counts';
|
|
6
14
|
|
|
7
15
|
interface ExpressionFns {
|
|
8
16
|
currentPassage: () => Passage | undefined;
|
|
@@ -30,55 +38,53 @@ type CompiledExpression = (
|
|
|
30
38
|
const FN_CACHE_MAX = 500;
|
|
31
39
|
const fnCache = new Map<string, CompiledExpression>();
|
|
32
40
|
|
|
41
|
+
const NAMESPACES: Record<Sigil, string> = {
|
|
42
|
+
$: 'variables',
|
|
43
|
+
_: 'temporary',
|
|
44
|
+
'@': 'locals',
|
|
45
|
+
'%': 'transient',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Ends with an identifier character. */
|
|
49
|
+
const IDENT_END_RE = /[\p{ID_Continue}$\u200c\u200d]$/u;
|
|
50
|
+
|
|
33
51
|
/**
|
|
34
52
|
* Transform expression: $var → variables["var"], _var → temporary["var"],
|
|
35
53
|
* @var → locals["var"], %var → transient["var"].
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
function transformSegment(segment: string): string {
|
|
46
|
-
return segment
|
|
47
|
-
.replace(VAR_RE, 'variables["$1"]')
|
|
48
|
-
.replace(TEMP_RE, 'temporary["$1"]')
|
|
49
|
-
.replace(LOCAL_RE, 'locals["$1"]');
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Rewrite sigil references in `expr`. The lexer passes literal text and
|
|
54
|
-
* comments through untouched; each run of code between them (including the
|
|
55
|
-
* code of template-literal `${…}` interpolations) is transformed as a whole.
|
|
54
|
+
* The lexer (`lexJs`) finds the references: only in code (string, template
|
|
55
|
+
* and regex literal text and comments are left untouched), only where an
|
|
56
|
+
* identifier starts (not in `a$b`, nor as the property name in `obj._x`),
|
|
57
|
+
* and `%var` only where an operand is expected, since `%` is also the
|
|
58
|
+
* modulo operator. `goal` tells whether `expr` is an expression or a list of
|
|
59
|
+
* statements, as that decides whether a leading `{` opens an object literal
|
|
60
|
+
* or a block. A reference to a variable named `__proto__` throws a
|
|
61
|
+
* SyntaxError: no namespace can hold one (see utils/namespace.ts).
|
|
62
|
+
* Exported for tests.
|
|
56
63
|
*/
|
|
57
|
-
function transform(expr: string): string {
|
|
64
|
+
export function transform(expr: string, goal: JsGoal = 'expression'): string {
|
|
58
65
|
let result = '';
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
66
|
+
lexJs(
|
|
67
|
+
expr,
|
|
68
|
+
{
|
|
69
|
+
code(ch) {
|
|
70
|
+
result += ch;
|
|
71
|
+
},
|
|
72
|
+
literal(text) {
|
|
73
|
+
result += text;
|
|
74
|
+
},
|
|
75
|
+
variable(sigil, name) {
|
|
76
|
+
if (name === RESERVED_NAME) {
|
|
77
|
+
throw new SyntaxError(
|
|
78
|
+
`spindle: "${sigil}${name}" cannot be used as a variable name (${RESERVED_NAME} is reserved)`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
// `typeof%x` needs a space once `%x` turns into an identifier.
|
|
82
|
+
if (IDENT_END_RE.test(result.slice(-2))) result += ' ';
|
|
83
|
+
result += `${NAMESPACES[sigil]}["${name}"]`;
|
|
84
|
+
},
|
|
75
85
|
},
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
result += `transient["${name}"]`;
|
|
79
|
-
},
|
|
80
|
-
});
|
|
81
|
-
flushCode();
|
|
86
|
+
goal,
|
|
87
|
+
);
|
|
82
88
|
return result;
|
|
83
89
|
}
|
|
84
90
|
|
|
@@ -111,8 +117,8 @@ function getOrCompile(key: string, body: string): CompiledExpression {
|
|
|
111
117
|
}
|
|
112
118
|
|
|
113
119
|
let cachedFns: ExpressionFns | null = null;
|
|
114
|
-
let cachedVisitCounts:
|
|
115
|
-
let cachedRenderCounts:
|
|
120
|
+
let cachedVisitCounts: Counts | null = null;
|
|
121
|
+
let cachedRenderCounts: Counts | null = null;
|
|
116
122
|
|
|
117
123
|
export function buildExpressionFns() {
|
|
118
124
|
const state = useStoryStore.getState();
|
|
@@ -127,7 +133,7 @@ export function buildExpressionFns() {
|
|
|
127
133
|
}
|
|
128
134
|
|
|
129
135
|
const visited = (name?: string): number =>
|
|
130
|
-
visitCounts
|
|
136
|
+
countOf(visitCounts, name ?? useStoryStore.getState().currentPassage);
|
|
131
137
|
const hasVisited = (name?: string): boolean => visited(name) > 0;
|
|
132
138
|
const hasVisitedAny = (...names: string[]): boolean =>
|
|
133
139
|
names.some((n) => visited(n) > 0);
|
|
@@ -135,7 +141,7 @@ export function buildExpressionFns() {
|
|
|
135
141
|
names.every((n) => visited(n) > 0);
|
|
136
142
|
|
|
137
143
|
const rendered = (name?: string): number =>
|
|
138
|
-
renderCounts
|
|
144
|
+
countOf(renderCounts, name ?? useStoryStore.getState().currentPassage);
|
|
139
145
|
const hasRendered = (name?: string): boolean => rendered(name) > 0;
|
|
140
146
|
const hasRenderedAny = (...names: string[]): boolean =>
|
|
141
147
|
names.some((n) => rendered(n) > 0);
|
|
@@ -173,22 +179,42 @@ export function buildExpressionFns() {
|
|
|
173
179
|
return cachedFns;
|
|
174
180
|
}
|
|
175
181
|
|
|
182
|
+
/**
|
|
183
|
+
* The namespaces compiled code reads and writes are records without a
|
|
184
|
+
* prototype (story state keeps them that way, see utils/namespace.ts), so
|
|
185
|
+
* `$toString` is the variable, not the method. One that has a prototype
|
|
186
|
+
* loses it, in place so that writes still reach it; one that cannot change
|
|
187
|
+
* is passed as a copy (writes to it are lost either way).
|
|
188
|
+
*/
|
|
189
|
+
function namespaceArg(ns: Namespace): Namespace {
|
|
190
|
+
if (isNamespace(ns)) return ns;
|
|
191
|
+
if (!Object.isExtensible(ns)) return asNamespace(ns);
|
|
192
|
+
Object.setPrototypeOf(ns, null);
|
|
193
|
+
return ns;
|
|
194
|
+
}
|
|
195
|
+
|
|
176
196
|
/**
|
|
177
197
|
* Evaluate an expression and return its value.
|
|
178
198
|
* e.g. evaluate("$health + 10", variables, temporary) → number
|
|
179
199
|
*/
|
|
180
200
|
export function evaluate(
|
|
181
201
|
expr: string,
|
|
182
|
-
variables:
|
|
183
|
-
temporary:
|
|
184
|
-
locals:
|
|
185
|
-
transient:
|
|
202
|
+
variables: Namespace,
|
|
203
|
+
temporary: Namespace,
|
|
204
|
+
locals: Namespace = EMPTY_NAMESPACE,
|
|
205
|
+
transient: Namespace = EMPTY_NAMESPACE,
|
|
186
206
|
): unknown {
|
|
187
207
|
const transformed = transform(expr);
|
|
188
208
|
// The line break keeps a trailing `// comment` from swallowing the `)`.
|
|
189
209
|
const body = `return (${transformed}\n);`;
|
|
190
210
|
const fn = getOrCompile(body, body);
|
|
191
|
-
return fn(
|
|
211
|
+
return fn(
|
|
212
|
+
namespaceArg(variables),
|
|
213
|
+
namespaceArg(temporary),
|
|
214
|
+
namespaceArg(locals),
|
|
215
|
+
buildExpressionFns(),
|
|
216
|
+
namespaceArg(transient),
|
|
217
|
+
);
|
|
192
218
|
}
|
|
193
219
|
|
|
194
220
|
/**
|
|
@@ -197,14 +223,20 @@ export function evaluate(
|
|
|
197
223
|
*/
|
|
198
224
|
export function execute(
|
|
199
225
|
code: string,
|
|
200
|
-
variables:
|
|
201
|
-
temporary:
|
|
202
|
-
locals:
|
|
203
|
-
transient:
|
|
226
|
+
variables: Namespace,
|
|
227
|
+
temporary: Namespace,
|
|
228
|
+
locals: Namespace = EMPTY_NAMESPACE,
|
|
229
|
+
transient: Namespace = EMPTY_NAMESPACE,
|
|
204
230
|
): void {
|
|
205
|
-
const transformed = transform(code);
|
|
231
|
+
const transformed = transform(code, 'statements');
|
|
206
232
|
const fn = getOrCompile('exec:' + transformed, transformed);
|
|
207
|
-
fn(
|
|
233
|
+
fn(
|
|
234
|
+
namespaceArg(variables),
|
|
235
|
+
namespaceArg(temporary),
|
|
236
|
+
namespaceArg(locals),
|
|
237
|
+
buildExpressionFns(),
|
|
238
|
+
namespaceArg(transient),
|
|
239
|
+
);
|
|
208
240
|
}
|
|
209
241
|
|
|
210
242
|
/**
|