@rohal12/spindle 0.51.3 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/pkg/format.js +1 -1
  2. package/dist/pkg/headless.js +4833 -1603
  3. package/dist/pkg/macro-registry.json +7 -7
  4. package/dist/pkg/story-variables.js +1658 -189
  5. package/package.json +5 -2
  6. package/src/automation/runner.ts +2 -1
  7. package/src/class-registry.ts +277 -90
  8. package/src/components/Passage.tsx +2 -2
  9. package/src/components/PassageDialog.tsx +2 -5
  10. package/src/components/StoryInterface.tsx +2 -4
  11. package/src/components/macros/Button.tsx +9 -32
  12. package/src/components/macros/Checkbox.tsx +10 -4
  13. package/src/components/macros/Computed.tsx +19 -13
  14. package/src/components/macros/Dialog.tsx +4 -1
  15. package/src/components/macros/For.tsx +33 -4
  16. package/src/components/macros/If.tsx +8 -0
  17. package/src/components/macros/Include.tsx +15 -24
  18. package/src/components/macros/MacroError.tsx +2 -1
  19. package/src/components/macros/MacroLink.tsx +14 -46
  20. package/src/components/macros/Meter.tsx +26 -40
  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 +27 -2
  25. package/src/components/macros/SaveManager.tsx +39 -14
  26. package/src/components/macros/Span.tsx +1 -0
  27. package/src/components/macros/StoryTitle.tsx +1 -0
  28. package/src/components/macros/Switch.tsx +13 -0
  29. package/src/components/macros/Unset.tsx +30 -10
  30. package/src/components/macros/VarDisplay.tsx +21 -4
  31. package/src/components/macros/Watch.tsx +88 -40
  32. package/src/components/macros/Widget.tsx +20 -1
  33. package/src/components/macros/WidgetInvocation.tsx +20 -158
  34. package/src/components/macros/arg-utils.ts +226 -0
  35. package/src/components/macros/detached-body.tsx +68 -0
  36. package/src/components/macros/option-utils.ts +10 -5
  37. package/src/define-macro.ts +44 -38
  38. package/src/execute-mutation.ts +499 -28
  39. package/src/expression.ts +88 -272
  40. package/src/hooks/use-action.ts +18 -3
  41. package/src/hooks/use-interpolate.ts +36 -5
  42. package/src/index.tsx +10 -1
  43. package/src/interpolation.ts +394 -96
  44. package/src/js-lexer.ts +1460 -0
  45. package/src/markup/code-attributes.ts +64 -0
  46. package/src/markup/markdown.ts +188 -9
  47. package/src/markup/render.tsx +552 -113
  48. package/src/markup/tokenizer.ts +601 -119
  49. package/src/prng.ts +8 -8
  50. package/src/registry.ts +35 -0
  51. package/src/saves/save-manager.ts +368 -153
  52. package/src/saves/storage.ts +24 -7
  53. package/src/saves/types.ts +20 -6
  54. package/src/store.ts +549 -137
  55. package/src/story-api.ts +46 -81
  56. package/src/story-init.ts +1 -1
  57. package/src/story-variables.ts +98 -102
  58. package/src/triggers.ts +6 -2
  59. package/src/utils/error-message.ts +12 -0
  60. package/src/utils/live-locals.ts +10 -3
  61. package/src/utils/namespace.ts +71 -0
  62. package/src/utils/object-path.ts +194 -0
  63. package/src/utils/stable-key.ts +82 -0
  64. package/src/widgets/widget-registry.ts +9 -0
  65. package/types/index.d.ts +43 -7
  66. package/types/tooling.d.ts +1 -0
package/src/story-api.ts CHANGED
@@ -18,13 +18,12 @@ import type {
18
18
  import {
19
19
  setTitleGenerator,
20
20
  getStorageInfo as _getStorageInfo,
21
- clearGameData as _clearGameData,
22
- clearAllData as _clearAllData,
23
- deletePlaythroughData,
24
- populateKnownSaves,
25
21
  } from './saves/save-manager';
26
22
  import { getBackendType } from './saves/storage';
27
23
  import { registerClass } from './class-registry';
24
+ import { frozenCopy, getActiveMutationScope } from './execute-mutation';
25
+ import { getByPath, setByPath } from './utils/object-path';
26
+ import { checkVariableName } from './utils/namespace';
28
27
  import { defineMacro } from './define-macro';
29
28
  import type { MacroDefinition } from './define-macro';
30
29
  import { getMacroRegistry as _getMacroRegistry } from './registry';
@@ -78,8 +77,9 @@ let variableChangedSubActive = false;
78
77
  function ensureVariableChangedSubscription(): void {
79
78
  if (variableChangedSubActive) return;
80
79
  variableChangedSubActive = true;
81
- let prevVars = { ...useStoryStore.getState().variables };
82
- let prevTrans = { ...useStoryStore.getState().transient };
80
+ // Store namespaces are immutable snapshots: keep the references
81
+ let prevVars = useStoryStore.getState().variables;
82
+ let prevTrans = useStoryStore.getState().transient;
83
83
  useStoryStore.subscribe((state) => {
84
84
  const changed: Record<string, { from: unknown; to: unknown }> = {};
85
85
  let hasChanges = false;
@@ -108,45 +108,14 @@ function ensureVariableChangedSubscription(): void {
108
108
  }
109
109
  }
110
110
 
111
- prevVars = { ...state.variables };
112
- prevTrans = { ...state.transient };
111
+ prevVars = state.variables;
112
+ prevTrans = state.transient;
113
113
  if (hasChanges) {
114
114
  emit('variableChanged', changed);
115
115
  }
116
116
  });
117
117
  }
118
118
 
119
- /** Traverse a dot-delimited path on an object and return the value. */
120
- function getByPath(obj: Record<string, unknown>, path: string): unknown {
121
- const segments = path.split('.');
122
- let current: unknown = obj[segments[0]!];
123
- for (let i = 1; i < segments.length; i++) {
124
- if (current == null) return undefined;
125
- current = (current as Record<string, unknown>)[segments[i]!];
126
- }
127
- return current;
128
- }
129
-
130
- /** Set a value at a dot-delimited path on an object (must be an Immer draft for mutation). */
131
- function setByPath(
132
- obj: Record<string, unknown>,
133
- path: string,
134
- value: unknown,
135
- ): void {
136
- const segments = path.split('.');
137
- let current: Record<string, unknown> = obj;
138
- for (let i = 0; i < segments.length - 1; i++) {
139
- const next = current[segments[i]!];
140
- if (next == null || typeof next !== 'object') {
141
- throw new TypeError(
142
- `spindle: Cannot set property "${segments[i + 1]}" on ${typeof next} (at "${segments.slice(0, i + 1).join('.')}")`,
143
- );
144
- }
145
- current = next as Record<string, unknown>;
146
- }
147
- current[segments[segments.length - 1]!] = value;
148
- }
149
-
150
119
  export interface StoryAPI {
151
120
  get(name: string): unknown;
152
121
  set(name: string, value: unknown): void;
@@ -261,15 +230,17 @@ export function _resetDeclaredVariables(): void {
261
230
  /**
262
231
  * Split an API variable name into namespace and key. Accepts the bare name
263
232
  * (`hp`), the `$` sigil authors use in passages (`$hp`), and `%` for
264
- * transients (`%npcs`). Dot-paths are kept in the key.
233
+ * transients (`%npcs`). Dot-paths are kept in the key. A variable named
234
+ * `__proto__` throws a TypeError (see utils/namespace.ts).
265
235
  */
266
236
  function parseName(name: string): {
267
237
  isTransient: boolean;
268
238
  key: string;
269
239
  } {
270
- if (name.startsWith('%')) return { isTransient: true, key: name.slice(1) };
271
- if (name.startsWith('$')) return { isTransient: false, key: name.slice(1) };
272
- return { isTransient: false, key: name };
240
+ const isTransient = name.startsWith('%');
241
+ const key = isTransient || name.startsWith('$') ? name.slice(1) : name;
242
+ checkVariableName(key.split('.')[0]!, name);
243
+ return { isTransient, key };
273
244
  }
274
245
 
275
246
  function warnIfUndeclared(isTransient: boolean, key: string): void {
@@ -292,7 +263,7 @@ function setOne(draft: VariableNamespaces, name: string, value: unknown): void {
292
263
  const namespace = isTransient ? draft.transient : draft.variables;
293
264
 
294
265
  if (key.includes('.')) {
295
- setByPath(namespace, key, value);
266
+ setByPath(namespace, key.split('.'), value);
296
267
  } else {
297
268
  namespace[key] = value;
298
269
  }
@@ -302,31 +273,41 @@ function createStoryAPI(): StoryAPI {
302
273
  return {
303
274
  get(name: string): unknown {
304
275
  const { isTransient, key } = parseName(name);
305
- const store = isTransient
306
- ? useStoryStore.getState().transient
307
- : useStoryStore.getState().variables;
308
- return key.includes('.') ? getByPath(store, key) : store[key];
276
+ // Mutation code running now ({do}, ctx.mutate, watcher run actions)
277
+ // has pending writes in its working copy: read that, so the code sees
278
+ // its own changes. The value is a frozen copy, like the frozen store
279
+ // values returned otherwise, so writing to it cannot change the
280
+ // pending state outside the code's own assignments.
281
+ const scope = getActiveMutationScope();
282
+ const source = scope ?? useStoryStore.getState();
283
+ const namespace = isTransient ? source.transient : source.variables;
284
+ const value = key.includes('.')
285
+ ? getByPath(namespace, key.split('.'))
286
+ : namespace[key];
287
+ return scope ? frozenCopy(value) : value;
309
288
  },
310
289
 
311
290
  set(nameOrVars: string | Record<string, unknown>, value?: unknown): void {
312
- const names =
313
- typeof nameOrVars === 'string' ? [nameOrVars] : Object.keys(nameOrVars);
314
- for (const name of names) {
291
+ const entries: [string, unknown][] =
292
+ typeof nameOrVars === 'string'
293
+ ? [[nameOrVars, value]]
294
+ : Object.entries(nameOrVars);
295
+ for (const [name] of entries) {
315
296
  const { isTransient, key } = parseName(name);
316
297
  warnIfUndeclared(isTransient, key);
317
298
  }
318
- // One store update for all keys, so watchers see them together
299
+ // One store update for all keys, so watchers see them together. Made
300
+ // while mutation code runs ({do}, ctx.mutate, watcher run actions), it
301
+ // follows the code's own pending writes (program order, #215): see
302
+ // routeStoreUpdate.
319
303
  useStoryStore.getState().updateVariables((draft) => {
320
- if (typeof nameOrVars === 'string') {
321
- setOne(draft, nameOrVars, value);
322
- } else {
323
- for (const [k, v] of Object.entries(nameOrVars)) {
324
- setOne(draft, k, v);
325
- }
326
- }
304
+ for (const [k, v] of entries) setOne(draft, k, v);
327
305
  });
328
306
  },
329
307
 
308
+ // Called from running mutation code, these store actions commit the
309
+ // code's writes so far before they record, replace or save state, and
310
+ // the code goes on from the state they leave (see storyStateGuard).
330
311
  goto(passageName: string): void {
331
312
  useStoryStore.getState().navigate(passageName);
332
313
  },
@@ -482,32 +463,16 @@ function createStoryAPI(): StoryAPI {
482
463
  return { usage: 0, quota: 0, estimateSupported: false };
483
464
  },
484
465
 
485
- async clearGameData(): Promise<void> {
486
- const ifid = useStoryStore.getState().storyData?.ifid;
487
- if (!ifid) return;
488
- await _clearGameData(ifid);
489
- useStoryStore.setState((state) => {
490
- state.knownSaves = {};
491
- });
492
- useStoryStore.getState().restart();
466
+ clearGameData(): Promise<void> {
467
+ return useStoryStore.getState().clearGameData();
493
468
  },
494
469
 
495
- async clearAllData(): Promise<void> {
496
- await _clearAllData();
497
- useStoryStore.setState((state) => {
498
- state.knownSaves = {};
499
- });
500
- useStoryStore.getState().restart();
470
+ clearAllData(): Promise<void> {
471
+ return useStoryStore.getState().clearAllData();
501
472
  },
502
473
 
503
- async deletePlaythrough(playthroughId: string): Promise<void> {
504
- const { storyData } = useStoryStore.getState();
505
- if (!storyData) return;
506
- await deletePlaythroughData(storyData.ifid, playthroughId);
507
- const known = await populateKnownSaves(storyData.ifid);
508
- useStoryStore.setState((state) => {
509
- state.knownSaves = known;
510
- });
474
+ deletePlaythrough(playthroughId: string): Promise<void> {
475
+ return useStoryStore.getState().deletePlaythrough(playthroughId);
511
476
  },
512
477
 
513
478
  get backend() {
package/src/story-init.ts CHANGED
@@ -47,7 +47,7 @@ export function executeStoryInit() {
47
47
  document.body.appendChild(container);
48
48
  storyInitContainer = container;
49
49
  render(
50
- h(() => renderNodes(ast) as any, null),
50
+ h(() => renderNodes(ast), null),
51
51
  container,
52
52
  );
53
53
 
@@ -1,5 +1,9 @@
1
1
  import type { Passage } from './parser';
2
- import { tokenize } from './markup/tokenizer';
2
+ import { tokenize, type Token } from './markup/tokenizer';
3
+ import { isCodeAttribute, splitSigilTemplate } from './markup/code-attributes';
4
+ import { errorMessage } from './utils/error-message';
5
+ import { lexJs, scanStringLiteral, type JsGoal } from './js-lexer';
6
+ import { createNamespace, RESERVED_NAME } from './utils/namespace';
3
7
 
4
8
  export type VarType = 'number' | 'string' | 'boolean' | 'array' | 'object';
5
9
 
@@ -17,9 +21,8 @@ function declarationRegex(sigil: string): RegExp {
17
21
  const escaped = sigil.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
18
22
  return new RegExp(`^${escaped}(\\w+)\\s*=\\s*(.+)$`);
19
23
  }
20
- const VAR_REF_RE = /\$(\w+(?:\.\w+)*)/g;
21
- /** `{` followed by a sigil starts an interpolation block inside literal text. */
22
- const INTERP_START_RE = /^[$_@%]\w/;
24
+ /** A `$name` reference with its dot path, at a `$` the lexer found. */
25
+ const VAR_PATH_RE = /\$(\w+(?:\.\w+)*)/y;
23
26
  /** Quoted first argument of an input macro naming a story variable. */
24
27
  const QUOTED_VAR_ARG_RE = /^["']\$(\w+(?:\.\w+)*)["']?$/;
25
28
  const FOR_LOCAL_RE = /\{for\s+@(\w+)(?:\s*,\s*@(\w+))?\s+of\b/g;
@@ -77,12 +80,17 @@ export function parseStoryVariables(
77
80
  }
78
81
 
79
82
  const [, name, expr] = match as [string, string, string];
83
+ if (name === RESERVED_NAME) {
84
+ throw new Error(
85
+ `${passageName}: "${sigil}${name}" cannot be used as a variable name (${RESERVED_NAME} is reserved)`,
86
+ );
87
+ }
80
88
  let value: unknown;
81
89
  try {
82
90
  value = new Function('return (' + expr + ')')();
83
91
  } catch (err) {
84
92
  throw new Error(
85
- `${passageName}: Failed to evaluate "${sigil}${name} = ${expr}": ${err instanceof Error ? err.message : err}`,
93
+ `${passageName}: Failed to evaluate "${sigil}${name} = ${expr}": ${errorMessage(err)}`,
86
94
  );
87
95
  }
88
96
 
@@ -90,9 +98,7 @@ export function parseStoryVariables(
90
98
  try {
91
99
  fieldSchema = inferSchema(value);
92
100
  } catch (err) {
93
- throw new Error(
94
- `${passageName}: ${err instanceof Error ? err.message : err}`,
95
- );
101
+ throw new Error(`${passageName}: ${errorMessage(err)}`);
96
102
  }
97
103
  schema.set(name, { ...fieldSchema, name, default: value });
98
104
  }
@@ -128,6 +134,10 @@ function validateRef(
128
134
  const parts = ref.split('.');
129
135
  const rootName = parts[0]!;
130
136
 
137
+ if (rootName === RESERVED_NAME) {
138
+ return `"$${rootName}" cannot be used as a variable name (${RESERVED_NAME} is reserved)`;
139
+ }
140
+
131
141
  // Skip for-loop locals
132
142
  if (forLocals.has(rootName)) return null;
133
143
 
@@ -187,107 +197,81 @@ const BUILTIN_STORE_VAR_MACROS: readonly string[] = [
187
197
 
188
198
  type RefCallback = (ref: string) => void;
189
199
 
190
- /** Report every `$var.path` in a code segment free of strings/comments. */
191
- function scanRefs(segment: string, onRef: RefCallback): void {
192
- for (const match of segment.matchAll(VAR_REF_RE)) onRef(match[1]!);
193
- }
194
-
195
- /** Index of the quote closing the string opened at `start` (or the end). */
196
- function findClosingQuote(code: string, start: number): number {
197
- const quote = code[start];
198
- let i = start + 1;
199
- while (i < code.length && code[i] !== quote) {
200
- i += code[i] === '\\' ? 2 : 1;
201
- }
202
- return Math.min(i, code.length);
203
- }
200
+ const NO_STORE_VAR_MACROS: ReadonlySet<string> = new Set();
204
201
 
205
202
  /**
206
- * Scan literal text (string contents, HTML attribute values) for `{$…}`
207
- * interpolation blocks, which interpolating macros and HTML attributes
208
- * resolve at runtime. A bare `$word` in literal text is not a reference.
203
+ * Scan the value of a code attribute (`onclick`, see isCodeAttribute) for
204
+ * the `{$…}` references resolved in it; its other braces are code.
209
205
  */
210
- function scanInterpolations(text: string, onRef: RefCallback): void {
211
- let i = text.indexOf('{');
212
- while (i !== -1) {
213
- const next = INTERP_START_RE.test(text.slice(i + 1, i + 3))
214
- ? scanCode(text, i + 1, onRef, true)
215
- : i + 1;
216
- i = text.indexOf('{', next);
206
+ function scanSigilReferences(value: string, onRef: RefCallback): void {
207
+ for (const part of splitSigilTemplate(value)) {
208
+ if ('expr' in part) scanCode(part.expr, onRef);
217
209
  }
218
210
  }
219
211
 
220
212
  /**
221
- * Scan a template literal starting just after its opening backtick: literal
222
- * parts are text, `${…}` parts are code. Returns the index just past the
223
- * closing backtick.
213
+ * Scan literal text (string contents, HTML attribute values) for the markup
214
+ * that labels and HTML attributes evaluate at runtime: `{$…}` displays,
215
+ * expressions and macros, read as in passage text. A bare `$word` in
216
+ * literal text is not a reference.
224
217
  */
225
- function scanTemplate(code: string, start: number, onRef: RefCallback): number {
226
- let i = start;
227
- let textStart = start;
228
- while (i < code.length) {
229
- const ch = code[i];
230
- if (ch === '\\') {
231
- i += 2;
232
- } else if (ch === '`') {
233
- scanInterpolations(code.slice(textStart, i), onRef);
234
- return i + 1;
235
- } else if (ch === '$' && code[i + 1] === '{') {
236
- scanInterpolations(code.slice(textStart, i), onRef);
237
- i = textStart = scanCode(code, i + 2, onRef, true);
238
- } else {
239
- i++;
240
- }
241
- }
242
- scanInterpolations(code.slice(textStart), onRef);
243
- return code.length;
218
+ function scanInterpolations(
219
+ text: string,
220
+ onRef: RefCallback,
221
+ storeVarMacros: ReadonlySet<string> = NO_STORE_VAR_MACROS,
222
+ ): void {
223
+ if (!text.includes('{')) return;
224
+ collectTokenRefs(text, tokenize(text, { text: true }), storeVarMacros, onRef);
244
225
  }
245
226
 
246
227
  /**
247
- * Report `$var` references in JavaScript code. Like the string-aware
248
- * expression transformer, sigils inside string literals are left alone while
249
- * template-literal `${…}` parts are code; comments are skipped too. When
250
- * `nested`, stops at the `}` closing the enclosing block and returns the index
251
- * just past it.
228
+ * Report `$var` references in JavaScript code, lexed as the expression
229
+ * engine lexes it (`lexJs`): references in code, but none inside string or
230
+ * regex literals or comments, nor property names (`a.$b`, `{ $b: 1 }`).
231
+ * The text of string and template literals is scanned for the markup that
232
+ * labels evaluate (`{$…}`); the `${…}` parts of template literals are code.
252
233
  */
253
234
  function scanCode(
254
235
  code: string,
255
- start: number,
256
236
  onRef: RefCallback,
257
- nested = false,
258
- ): number {
259
- let i = start;
260
- let segStart = start;
261
- let depth = 0;
262
- while (i < code.length) {
263
- const ch = code[i];
264
- const next = code[i + 1];
265
- const isComment = ch === '/' && (next === '/' || next === '*');
266
- if (ch === '"' || ch === "'" || ch === '`' || isComment) {
267
- scanRefs(code.slice(segStart, i), onRef);
268
- if (ch === '`') {
269
- i = scanTemplate(code, i + 1, onRef);
270
- } else if (isComment) {
271
- const close = code.indexOf(next === '/' ? '\n' : '*/', i + 2);
272
- i = close === -1 ? code.length : next === '/' ? close : close + 2;
273
- } else {
274
- const close = findClosingQuote(code, i);
275
- scanInterpolations(code.slice(i + 1, close), onRef);
276
- i = Math.min(close + 1, code.length);
277
- }
278
- segStart = i;
279
- continue;
280
- }
281
- if (nested && ch === '{') {
282
- depth++;
283
- } else if (nested && ch === '}' && depth-- === 0) {
284
- scanRefs(code.slice(segStart, i), onRef);
285
- return i + 1;
286
- }
287
- i++;
288
- }
289
- scanRefs(code.slice(segStart), onRef);
290
- return code.length;
237
+ goal: JsGoal = 'expression',
238
+ ): void {
239
+ /** Open template literals: their text so far, null in an interpolation. */
240
+ const templates: { nesting: number; text: string | null }[] = [];
241
+ lexJs(
242
+ code,
243
+ {
244
+ variable(sigil, _name, index) {
245
+ if (sigil !== '$') return;
246
+ VAR_PATH_RE.lastIndex = index;
247
+ onRef(VAR_PATH_RE.exec(code)![1]!);
248
+ },
249
+ literal(text, _index, nesting) {
250
+ const template = templates[templates.length - 1];
251
+ if (template?.nesting === nesting) {
252
+ if (template.text === null) {
253
+ // The `}` ending an interpolation: back to the template's text
254
+ template.text = '';
255
+ } else if (text === '`' || text === '${') {
256
+ scanInterpolations(template.text, onRef);
257
+ if (text === '`') templates.pop();
258
+ else template.text = null;
259
+ } else {
260
+ template.text += text;
261
+ }
262
+ } else if (text === '`') {
263
+ templates.push({ nesting, text: '' });
264
+ } else if (text[0] === '"' || text[0] === "'") {
265
+ const { closed } = scanStringLiteral(text, 0);
266
+ scanInterpolations(text.slice(1, closed ? -1 : undefined), onRef);
267
+ }
268
+ // Regex literals and comments hold no references
269
+ },
270
+ },
271
+ goal,
272
+ );
273
+ // An unterminated template literal's text
274
+ for (const t of templates) if (t.text) scanInterpolations(t.text, onRef);
291
275
  }
292
276
 
293
277
  /**
@@ -301,19 +285,29 @@ function collectPassageRefs(
301
285
  storeVarMacros: ReadonlySet<string>,
302
286
  onRef: RefCallback,
303
287
  ): void {
304
- const tokens = tokenize(content);
288
+ collectTokenRefs(content, tokenize(content), storeVarMacros, onRef);
289
+ }
290
+
291
+ /** Report the `$var` references in the tokens of `content`. */
292
+ function collectTokenRefs(
293
+ content: string,
294
+ tokens: Token[],
295
+ storeVarMacros: ReadonlySet<string>,
296
+ onRef: RefCallback,
297
+ ): void {
305
298
  for (let t = 0; t < tokens.length; t++) {
306
299
  const token = tokens[t]!;
307
300
  if (token.type === 'variable') {
308
301
  if (token.scope === 'variable' && token.name) onRef(token.name);
309
302
  } else if (token.type === 'expression') {
310
- scanCode(token.expression, 0, onRef);
303
+ scanCode(token.expression, onRef);
311
304
  } else if (token.type === 'html') {
312
- for (const value of Object.values(token.attributes)) {
313
- scanInterpolations(value, onRef);
305
+ for (const [name, value] of Object.entries(token.attributes)) {
306
+ if (isCodeAttribute(name)) scanSigilReferences(value, onRef);
307
+ else scanInterpolations(value, onRef, storeVarMacros);
314
308
  }
315
309
  } else if (token.type === 'macro' && !token.isClose) {
316
- scanCode(token.rawArgs, 0, onRef);
310
+ scanCode(token.rawArgs, onRef);
317
311
 
318
312
  if (storeVarMacros.has(token.name.toLowerCase())) {
319
313
  const first = token.rawArgs.trim().split(/\s+/)[0] ?? '';
@@ -331,7 +325,8 @@ function collectPassageRefs(
331
325
  close++;
332
326
  }
333
327
  if (close < tokens.length) {
334
- scanCode(content.slice(token.end, tokens[close]!.start), 0, onRef);
328
+ const body = content.slice(token.end, tokens[close]!.start);
329
+ scanCode(body, onRef, 'statements');
335
330
  t = close;
336
331
  }
337
332
  }
@@ -374,12 +369,13 @@ export function validatePassages(
374
369
  }
375
370
 
376
371
  /**
377
- * Extract default values from the schema as a plain object.
372
+ * Extract default values from the schema as a record without a prototype,
373
+ * like the namespaces it initializes (see utils/namespace.ts).
378
374
  */
379
375
  export function extractDefaults(
380
376
  schema: Map<string, VariableSchema>,
381
377
  ): Record<string, unknown> {
382
- const defaults: Record<string, unknown> = {};
378
+ const defaults = createNamespace();
383
379
  for (const [name, varSchema] of schema) {
384
380
  defaults[name] = varSchema.default;
385
381
  }
package/src/triggers.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { evaluate } from './expression';
2
- import { executeMutation } from './execute-mutation';
2
+ import { executeMutation, readState } from './execute-mutation';
3
3
  import { useStoryStore } from './store';
4
4
 
5
5
  export interface WatchOptions {
@@ -46,8 +46,9 @@ interface DialogHostCallbacks {
46
46
  }
47
47
  let dialogHostCallbacks: DialogHostCallbacks | null = null;
48
48
 
49
+ /** A condition's value in program order (see readState). */
49
50
  function evalCondition(condition: string): boolean {
50
- const state = useStoryStore.getState();
51
+ const state = readState();
51
52
  try {
52
53
  return !!evaluate(
53
54
  condition,
@@ -149,6 +150,9 @@ function fireTrigger(trigger: Trigger): void {
149
150
  }
150
151
 
151
152
  if (options.goto) {
153
+ // A watcher fired by a Story.set in running code navigates after that
154
+ // code's writes so far, so they are part of the moment it leaves (the
155
+ // store commits them first).
152
156
  useStoryStore.getState().navigate(options.goto);
153
157
  }
154
158
  }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The message to show for a caught value. Anything can be thrown, not only
3
+ * Errors: an Error gives its message, a string is its own message, and any
4
+ * other value gives `fallback` when one is passed, else its string form
5
+ * (never `undefined`). An empty message also gives `fallback`, or for an
6
+ * Error its string form (e.g. "Error").
7
+ */
8
+ export function errorMessage(err: unknown, fallback?: string): string {
9
+ if (err instanceof Error) return err.message || fallback || String(err);
10
+ if (typeof err === 'string' && err !== '') return err;
11
+ return fallback ?? String(err);
12
+ }
@@ -1,3 +1,5 @@
1
+ import { createNamespace, ownValue } from './namespace';
2
+
1
3
  /**
2
4
  * Read-only view of a locals scope that always reflects its current values.
3
5
  *
@@ -9,9 +11,14 @@
9
11
  export function liveLocalsView(
10
12
  getValues: () => Record<string, unknown>,
11
13
  ): Record<string, unknown> {
12
- return new Proxy({} as Record<string, unknown>, {
13
- get: (_, key) => (typeof key === 'string' ? getValues()[key] : undefined),
14
- has: (_, key) => key in getValues(),
14
+ // No prototype and own entries only, like every namespace (see
15
+ // utils/namespace.ts)
16
+ return new Proxy(createNamespace(), {
17
+ get: (_, key) =>
18
+ typeof key === 'string' ? ownValue(getValues(), key) : undefined,
19
+ has: (_, key) =>
20
+ typeof key === 'string' &&
21
+ Object.prototype.hasOwnProperty.call(getValues(), key),
15
22
  ownKeys: () => Reflect.ownKeys(getValues()),
16
23
  getOwnPropertyDescriptor: (_, key) => {
17
24
  const desc = Object.getOwnPropertyDescriptor(getValues(), key);
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Variable namespaces: the records holding $story variables, _temporary
3
+ * variables, %transient variables and @locals, keyed by variable name.
4
+ *
5
+ * They have no prototype, so every name is plain storage: a variable may be
6
+ * called `constructor`, `toString` or `hasOwnProperty`, and reading one
7
+ * that was never set gives undefined rather than an Object.prototype
8
+ * member. The one name they cannot hold is `__proto__`: story state (and so
9
+ * saves) never holds a property by that name, and on an ordinary object
10
+ * writing it would replace the prototype instead of storing a value. It is
11
+ * refused wherever a variable name enters (checkVariableName).
12
+ */
13
+
14
+ export type Namespace = Record<string, unknown>;
15
+
16
+ /** The variable name no namespace can hold. */
17
+ export const RESERVED_NAME = '__proto__';
18
+
19
+ /**
20
+ * Throw a TypeError if `name` (without its sigil) cannot be a variable
21
+ * name. `label` is the name as the author wrote it (e.g. `$__proto__`).
22
+ */
23
+ export function checkVariableName(name: string, label = name): void {
24
+ if (name === RESERVED_NAME) {
25
+ throw new TypeError(
26
+ `spindle: "${label}" cannot be used as a variable name (${RESERVED_NAME} is reserved)`,
27
+ );
28
+ }
29
+ }
30
+
31
+ /** A namespace with no prototype holding the own entries of `sources`. */
32
+ export function createNamespace(
33
+ ...sources: readonly (object | null | undefined)[]
34
+ ): Namespace {
35
+ const ns = Object.create(null) as Namespace;
36
+ for (const source of sources) {
37
+ if (!source) continue;
38
+ for (const key of Object.keys(source)) {
39
+ ns[key] = (source as Namespace)[key];
40
+ }
41
+ }
42
+ return ns;
43
+ }
44
+
45
+ /** Whether `value` is an object with no prototype. */
46
+ export const isNamespace = (value: object): boolean =>
47
+ Object.getPrototypeOf(value) === null;
48
+
49
+ /** `ns` itself if it has no prototype, else a namespace copy of it. */
50
+ export const asNamespace = (ns: Namespace): Namespace =>
51
+ isNamespace(ns) ? ns : createNamespace(ns);
52
+
53
+ /** A namespace copy of `ns` with `key` set to `value`. */
54
+ export function withEntry(
55
+ ns: Namespace,
56
+ key: string,
57
+ value: unknown,
58
+ ): Namespace {
59
+ const next = createNamespace(ns);
60
+ next[key] = value;
61
+ return next;
62
+ }
63
+
64
+ /** An empty namespace that cannot change. */
65
+ export const EMPTY_NAMESPACE: Namespace = Object.freeze(createNamespace());
66
+
67
+ /** `ns[key]` if `ns` holds it as an own property, else undefined. */
68
+ export const ownValue = (ns: object, key: string): unknown =>
69
+ Object.prototype.hasOwnProperty.call(ns, key)
70
+ ? (ns as Namespace)[key]
71
+ : undefined;