@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.
Files changed (69) hide show
  1. package/dist/pkg/format.js +1 -1
  2. package/dist/pkg/headless.js +4512 -1702
  3. package/dist/pkg/macro-registry.json +7 -7
  4. package/dist/pkg/story-variables.js +1636 -177
  5. package/package.json +5 -2
  6. package/src/action-registry.ts +70 -18
  7. package/src/automation/runner.ts +2 -1
  8. package/src/class-registry.ts +214 -103
  9. package/src/components/Passage.tsx +2 -2
  10. package/src/components/PassageDialog.tsx +2 -5
  11. package/src/components/StoryInterface.tsx +2 -4
  12. package/src/components/macros/Button.tsx +5 -31
  13. package/src/components/macros/Checkbox.tsx +7 -4
  14. package/src/components/macros/Computed.tsx +28 -14
  15. package/src/components/macros/For.tsx +29 -3
  16. package/src/components/macros/If.tsx +8 -0
  17. package/src/components/macros/Include.tsx +7 -6
  18. package/src/components/macros/MacroError.tsx +2 -1
  19. package/src/components/macros/MacroLink.tsx +12 -45
  20. package/src/components/macros/Meter.tsx +11 -3
  21. package/src/components/macros/Nobr.tsx +1 -0
  22. package/src/components/macros/PassageDisplay.tsx +3 -0
  23. package/src/components/macros/Print.tsx +4 -0
  24. package/src/components/macros/Radiobutton.tsx +5 -2
  25. package/src/components/macros/SaveManager.tsx +25 -8
  26. package/src/components/macros/Set.tsx +5 -4
  27. package/src/components/macros/Span.tsx +1 -0
  28. package/src/components/macros/StoryTitle.tsx +1 -0
  29. package/src/components/macros/Switch.tsx +13 -0
  30. package/src/components/macros/Unset.tsx +31 -10
  31. package/src/components/macros/VarDisplay.tsx +21 -4
  32. package/src/components/macros/Widget.tsx +20 -1
  33. package/src/components/macros/WidgetInvocation.tsx +17 -75
  34. package/src/components/macros/arg-utils.ts +107 -1
  35. package/src/components/macros/detached-body.tsx +68 -0
  36. package/src/components/macros/option-utils.ts +3 -2
  37. package/src/define-macro.ts +32 -5
  38. package/src/execute-mutation.ts +271 -68
  39. package/src/expression.ts +91 -59
  40. package/src/hooks/use-action.ts +24 -3
  41. package/src/hooks/use-interpolate.ts +36 -5
  42. package/src/index.tsx +12 -2
  43. package/src/interpolation.ts +394 -96
  44. package/src/js-lexer.ts +1231 -97
  45. package/src/markup/ast.ts +7 -2
  46. package/src/markup/code-attributes.ts +64 -0
  47. package/src/markup/markdown.ts +188 -9
  48. package/src/markup/render.tsx +430 -49
  49. package/src/markup/tokenizer.ts +578 -110
  50. package/src/prng.ts +41 -8
  51. package/src/registry.ts +35 -0
  52. package/src/saves/save-manager.ts +346 -160
  53. package/src/saves/storage.ts +16 -7
  54. package/src/saves/types.ts +2 -1
  55. package/src/settings.ts +12 -9
  56. package/src/store.ts +640 -186
  57. package/src/story-api.ts +38 -73
  58. package/src/story-init.ts +1 -1
  59. package/src/story-variables.ts +98 -102
  60. package/src/triggers.ts +10 -12
  61. package/src/utils/counts.ts +45 -0
  62. package/src/utils/error-message.ts +12 -0
  63. package/src/utils/live-locals.ts +10 -3
  64. package/src/utils/namespace.ts +71 -0
  65. package/src/utils/object-path.ts +99 -14
  66. package/src/utils/stable-key.ts +14 -9
  67. package/src/widgets/widget-registry.ts +9 -0
  68. package/types/index.d.ts +43 -7
  69. package/types/tooling.d.ts +1 -0
@@ -1,9 +1,17 @@
1
- import { current as currentDraft, freeze, isDraft } from 'immer';
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 (Story.set, the commits of nested mutations) applied to both,
25
- * so diffing `work` against `base` yields exactly the code's own changes,
26
- * and a write made elsewhere after the code's own write to the same path
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
- * Mirror a Story.set write into every executing mutation (see mirror()).
170
- * Call it inside the store update that writes `value` at `path` of `ns`.
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
- export function mirrorWriteToActiveScopes(
173
- draft: VariableNamespaces,
174
- ns: 'variables' | 'transient',
175
- path: string[],
176
- value: unknown,
177
- ): void {
178
- if (activeScopes.length === 0) return;
179
- mirror(draft, ns, { path, deleted: false, value });
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
- const cloneNamespaces = (from: VariableNamespaces): VariableNamespaces => ({
183
- variables: deepClone(from.variables),
184
- temporary: deepClone(from.temporary),
185
- transient: deepClone(from.transient),
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
- * Commit the property paths `scope`'s code changed (work vs base) on top of
190
- * the current store state, so writes made elsewhere during execution to
191
- * other paths of the same objects survive, and hand them to the `enclosing`
192
- * mutations. With `keepRunning`, the code goes on running after the commit:
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 commitScope(
197
- scope: MutationScope,
198
- enclosing: readonly MutationScope[],
199
- keepRunning: boolean,
200
- ): void {
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 list: PathChange[] = [];
203
- diff(scope.base[ns], scope.work[ns], [], list, new Set());
204
- return [ns, list] as const;
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
- if (changes.every(([, list]) => list.length === 0)) return;
207
- // Before the store update: watchers it fires may commit again (a goto)
208
- if (keepRunning) scope.base = cloneNamespaces(scope.work);
209
- const own = <T>(value: T): T => (keepRunning ? cloneValue(value) : value);
210
- const current = useStoryStore.getState();
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
- // One store update for everything: watchers must see the whole mutation,
213
- // and a watcher's run action must not be overwritten by paths this commit
214
- // writes after it fired.
215
- current.updateVariables((draft) => {
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
- // An intermediate object the code wrote into is gone from the
229
- // store: the code's view of the whole root wins.
230
- draft[ns][root] = own(scope.work[ns][root]);
231
- replaced.add(root);
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
- const scopes = [...activeScopes];
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 = deepClone(mergedLocals, { keepUnregistered: true });
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
- commitScope(scope, activeScopes, false);
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 in localsClone)) {
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
- * Only transforms when sigils appear as a word boundary, and only in code:
37
- * string, template and regex literal text and comments are left untouched.
38
- * `%var` is recognised by the lexer (`lexJs`), because `%` is also the modulo
39
- * operator and only an operand position makes it a sigil.
40
- */
41
- const VAR_RE = /\$(\w+)/g;
42
- const TEMP_RE = /(?<![.\w])_(\w+)/g;
43
- const LOCAL_RE = /@(\w+)/g;
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
- let code = ''; // accumulates code characters to be transformed
60
-
61
- function flushCode() {
62
- if (code) {
63
- result += transformSegment(code);
64
- code = '';
65
- }
66
- }
67
-
68
- lexJs(expr, {
69
- code(ch) {
70
- code += ch;
71
- },
72
- literal(text) {
73
- flushCode();
74
- result += text;
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
- transient(name) {
77
- flushCode();
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: Record<string, number> | null = null;
115
- let cachedRenderCounts: Record<string, number> | null = null;
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[name ?? useStoryStore.getState().currentPassage] ?? 0;
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[name ?? useStoryStore.getState().currentPassage] ?? 0;
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: Record<string, unknown>,
183
- temporary: Record<string, unknown>,
184
- locals: Record<string, unknown> = {},
185
- transient: Record<string, unknown> = {},
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(variables, temporary, locals, buildExpressionFns(), transient);
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: Record<string, unknown>,
201
- temporary: Record<string, unknown>,
202
- locals: Record<string, unknown> = {},
203
- transient: Record<string, unknown> = {},
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(variables, temporary, locals, buildExpressionFns(), transient);
233
+ fn(
234
+ namespaceArg(variables),
235
+ namespaceArg(temporary),
236
+ namespaceArg(locals),
237
+ buildExpressionFns(),
238
+ namespaceArg(transient),
239
+ );
208
240
  }
209
241
 
210
242
  /**