@rohal12/spindle 0.51.4 → 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 (64) hide show
  1. package/dist/pkg/format.js +1 -1
  2. package/dist/pkg/headless.js +4313 -1640
  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/automation/runner.ts +2 -1
  7. package/src/class-registry.ts +214 -103
  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 +5 -31
  12. package/src/components/macros/Checkbox.tsx +7 -4
  13. package/src/components/macros/Computed.tsx +19 -13
  14. package/src/components/macros/For.tsx +29 -3
  15. package/src/components/macros/If.tsx +8 -0
  16. package/src/components/macros/Include.tsx +7 -6
  17. package/src/components/macros/MacroError.tsx +2 -1
  18. package/src/components/macros/MacroLink.tsx +12 -45
  19. package/src/components/macros/Meter.tsx +11 -3
  20. package/src/components/macros/Nobr.tsx +1 -0
  21. package/src/components/macros/PassageDisplay.tsx +3 -0
  22. package/src/components/macros/Print.tsx +4 -0
  23. package/src/components/macros/Radiobutton.tsx +5 -2
  24. package/src/components/macros/SaveManager.tsx +25 -8
  25. package/src/components/macros/Span.tsx +1 -0
  26. package/src/components/macros/StoryTitle.tsx +1 -0
  27. package/src/components/macros/Switch.tsx +13 -0
  28. package/src/components/macros/Unset.tsx +30 -10
  29. package/src/components/macros/VarDisplay.tsx +21 -4
  30. package/src/components/macros/Widget.tsx +20 -1
  31. package/src/components/macros/WidgetInvocation.tsx +17 -75
  32. package/src/components/macros/arg-utils.ts +107 -1
  33. package/src/components/macros/detached-body.tsx +68 -0
  34. package/src/components/macros/option-utils.ts +3 -2
  35. package/src/define-macro.ts +32 -5
  36. package/src/execute-mutation.ts +270 -67
  37. package/src/expression.ts +86 -55
  38. package/src/hooks/use-action.ts +18 -3
  39. package/src/hooks/use-interpolate.ts +36 -5
  40. package/src/index.tsx +10 -1
  41. package/src/interpolation.ts +394 -96
  42. package/src/js-lexer.ts +1231 -97
  43. package/src/markup/code-attributes.ts +64 -0
  44. package/src/markup/markdown.ts +188 -9
  45. package/src/markup/render.tsx +430 -49
  46. package/src/markup/tokenizer.ts +578 -110
  47. package/src/prng.ts +8 -8
  48. package/src/registry.ts +35 -0
  49. package/src/saves/save-manager.ts +339 -158
  50. package/src/saves/storage.ts +16 -7
  51. package/src/saves/types.ts +2 -1
  52. package/src/store.ts +521 -154
  53. package/src/story-api.ts +31 -67
  54. package/src/story-init.ts +1 -1
  55. package/src/story-variables.ts +98 -102
  56. package/src/triggers.ts +6 -5
  57. package/src/utils/error-message.ts +12 -0
  58. package/src/utils/live-locals.ts +10 -3
  59. package/src/utils/namespace.ts +71 -0
  60. package/src/utils/object-path.ts +99 -14
  61. package/src/utils/stable-key.ts +14 -9
  62. package/src/widgets/widget-registry.ts +9 -0
  63. package/types/index.d.ts +43 -7
  64. package/types/tooling.d.ts +1 -0
@@ -1,6 +1,15 @@
1
1
  import { isDraft } from 'immer';
2
2
 
3
- /** Traverse dot-path segments on an object and return the nested value. */
3
+ const hasOwn = (obj: object, key: string): boolean =>
4
+ Object.prototype.hasOwnProperty.call(obj, key);
5
+
6
+ /**
7
+ * Traverse dot-path segments on an object and return the nested value.
8
+ * Members of Object.prototype (`constructor`, `toString`, `__proto__`, ...)
9
+ * are not story state: unless an object holds one as its own property, it
10
+ * reads as missing, as setByPath() treats it. Other inherited properties
11
+ * (class getters, `size` of a Map) are read.
12
+ */
4
13
  export function getByPath(
5
14
  obj: Record<string, unknown>,
6
15
  segments: readonly string[],
@@ -8,23 +17,50 @@ export function getByPath(
8
17
  let current: unknown = obj;
9
18
  for (const seg of segments) {
10
19
  if (current == null || typeof current !== 'object') return undefined;
20
+ if (seg in Object.prototype && !hasOwn(current, seg)) return undefined;
11
21
  current = (current as Record<string, unknown>)[seg];
12
22
  }
13
23
  return current;
14
24
  }
15
25
 
26
+ /** Array indices ("0", "1", …) and "length": the keys an array holds. */
27
+ const isArrayKey = (key: string): boolean =>
28
+ key === 'length' || /^(0|[1-9]\d*)$/.test(key);
29
+
30
+ /**
31
+ * Built-ins whose content is not their properties: a property written into
32
+ * one would be dropped by clones and saves (and by Immer, for Map and Set).
33
+ */
34
+ const builtinName = (value: object): string | undefined =>
35
+ value instanceof Map
36
+ ? 'a Map'
37
+ : value instanceof Set
38
+ ? 'a Set'
39
+ : value instanceof Date
40
+ ? 'a Date'
41
+ : value instanceof RegExp
42
+ ? 'a RegExp'
43
+ : undefined;
44
+
16
45
  /**
17
46
  * Shallow copy that keeps the prototype, so a registered class instance
18
47
  * stays an instance of its class (with the same own keys deepClone copies).
19
48
  */
20
49
  function shallowCopy(value: object): Record<string, unknown> {
21
- let copy: object;
22
- if (Array.isArray(value)) copy = [];
23
- else if (value instanceof Map) copy = new Map(value);
24
- else if (value instanceof Set) copy = new Set(value);
25
- else if (value instanceof Date) copy = new Date(value.getTime());
26
- else copy = Object.create(Object.getPrototypeOf(value)) as object;
27
- return Object.assign(copy, value) as Record<string, unknown>;
50
+ const copy = Array.isArray(value)
51
+ ? []
52
+ : (Object.create(Object.getPrototypeOf(value) as object | null) as object);
53
+ // Define rather than assign (Object.assign), so that a "__proto__" key
54
+ // stays a key instead of replacing the copy's prototype
55
+ for (const key of Object.keys(value)) {
56
+ Object.defineProperty(copy, key, {
57
+ value: (value as Record<string, unknown>)[key],
58
+ enumerable: true,
59
+ writable: true,
60
+ configurable: true,
61
+ });
62
+ }
63
+ return copy as Record<string, unknown>;
28
64
  }
29
65
 
30
66
  export interface SetByPathOptions {
@@ -45,6 +81,12 @@ export interface SetByPathOptions {
45
81
  * instance shared with the previous state and with history, and subscribers
46
82
  * would see no change. Outside a draft (a private working copy) the path is
47
83
  * written in place.
84
+ *
85
+ * The path goes through own properties of plain objects, class instances
86
+ * and arrays (by index); anything else throws a TypeError rather than
87
+ * writing where no clone or save would see it: an inherited property such
88
+ * as a method counts as missing, and Map, Set, Date and RegExp values and
89
+ * other keys of arrays are refused, as is a "__proto__" segment.
48
90
  */
49
91
  export function setByPath(
50
92
  root: Record<string, unknown>,
@@ -59,22 +101,56 @@ export function setByPath(
59
101
  /**
60
102
  * Delete the property at dot-path `segments` below `root`, copying
61
103
  * undrafted objects on the way down like setByPath(). Does nothing when an
62
- * intermediate is missing or not an object, or the property is absent.
104
+ * intermediate is missing or not an object, or the property is absent (or
105
+ * only inherited).
63
106
  */
64
107
  export function deleteByPath(
65
108
  root: Record<string, unknown>,
66
109
  segments: readonly string[],
67
110
  ): void {
68
111
  const last = segments[segments.length - 1]!;
69
- // Check first, so a no-op delete copies nothing.
70
- const holder = getByPath(root, segments.slice(0, -1));
71
- if (holder == null || typeof holder !== 'object' || !(last in holder)) {
112
+ checkSegments(segments);
113
+ // Check first, so a no-op delete copies nothing
114
+ let holder: unknown = root;
115
+ for (const seg of segments.slice(0, -1)) {
116
+ if (holder == null || typeof holder !== 'object' || !hasOwn(holder, seg)) {
117
+ return;
118
+ }
119
+ holder = (holder as Record<string, unknown>)[seg];
120
+ }
121
+ if (holder == null || typeof holder !== 'object' || !hasOwn(holder, last)) {
72
122
  return;
73
123
  }
74
124
  const parent = walkToParent(root, segments, false);
75
125
  delete parent[last];
76
126
  }
77
127
 
128
+ /** Refuse a segment that would reach a prototype instead of story state. */
129
+ function checkSegments(segments: readonly string[]): void {
130
+ if (segments.includes('__proto__')) {
131
+ throw new TypeError(
132
+ `spindle: Cannot use "__proto__" in a variable path ("${segments.join('.')}")`,
133
+ );
134
+ }
135
+ }
136
+
137
+ /** Throw unless `holder` can take `key` as story state (see setByPath). */
138
+ function checkHolder(
139
+ holder: object,
140
+ key: string,
141
+ segments: readonly string[],
142
+ depth: number,
143
+ ): void {
144
+ const builtin = builtinName(holder);
145
+ const kind =
146
+ builtin ?? (Array.isArray(holder) && !isArrayKey(key) ? 'an array' : '');
147
+ if (kind) {
148
+ throw new TypeError(
149
+ `spindle: Cannot set property "${key}" on ${kind} (at "${segments.slice(0, depth).join('.')}")`,
150
+ );
151
+ }
152
+ }
153
+
78
154
  /**
79
155
  * Walk to the object holding the last of `segments`, copying undrafted
80
156
  * objects when `root` is an Immer draft (see setByPath). A missing or
@@ -86,11 +162,14 @@ function walkToParent(
86
162
  segments: readonly string[],
87
163
  createMissing: boolean,
88
164
  ): Record<string, unknown> {
165
+ checkSegments(segments);
89
166
  const copyOnWrite = isDraft(root);
90
167
  let current: Record<string, unknown> = root;
91
168
  for (let i = 0; i < segments.length - 1; i++) {
92
169
  const seg = segments[i]!;
93
- let next = current[seg];
170
+ checkHolder(current, seg, segments, i);
171
+ // An inherited property (a method, "constructor") counts as missing
172
+ let next = hasOwn(current, seg) ? current[seg] : undefined;
94
173
  if (next == null || typeof next !== 'object') {
95
174
  if (!createMissing) {
96
175
  throw new TypeError(
@@ -99,11 +178,17 @@ function walkToParent(
99
178
  }
100
179
  next = {};
101
180
  current[seg] = next;
102
- } else if (copyOnWrite && !isDraft(next)) {
181
+ } else if (copyOnWrite && !isDraft(next) && !builtinName(next)) {
103
182
  next = shallowCopy(next);
104
183
  current[seg] = next;
105
184
  }
106
185
  current = next as Record<string, unknown>;
107
186
  }
187
+ checkHolder(
188
+ current,
189
+ segments[segments.length - 1]!,
190
+ segments,
191
+ segments.length - 1,
192
+ );
108
193
  return current;
109
194
  }
@@ -1,6 +1,4 @@
1
- import { getClassName } from '../class-registry';
2
-
3
- type Constructor = new (...args: any[]) => any;
1
+ import { registeredClassName } from '../class-registry';
4
2
 
5
3
  /**
6
4
  * Content-derived string key for a value, used to remount components when
@@ -10,9 +8,10 @@ type Constructor = new (...args: any[]) => any;
10
8
  * plain objects of those) produce exactly their `JSON.stringify` output. Other
11
9
  * values get distinct, unambiguous markers so their contents are reflected
12
10
  * too: Map and Set entries (nested at any depth), Date, RegExp, BigInt,
13
- * undefined, NaN/±Infinity, symbols, functions and registered class
14
- * instances. Cyclic references become a back-reference marker instead of
15
- * throwing, and the function never throws.
11
+ * undefined, NaN/±Infinity, symbols (by description), functions (by name)
12
+ * and registered class instances. A hole in an array reads as undefined.
13
+ * Cyclic references become a back-reference marker instead of throwing,
14
+ * and the function never throws.
16
15
  */
17
16
  export function stableKey(value: unknown): string {
18
17
  try {
@@ -35,7 +34,11 @@ function keyOf(val: unknown, ancestors: object[]): string {
35
34
  case 'undefined':
36
35
  return 'undefined';
37
36
  case 'symbol':
38
- return val.toString();
37
+ // Quote the description: `Symbol('a),Symbol(b')` must not read like
38
+ // two symbols.
39
+ return val.description === undefined
40
+ ? 'Symbol()'
41
+ : `Symbol(${JSON.stringify(val.description)})`;
39
42
  case 'function':
40
43
  return `<function ${JSON.stringify(val.name)}>`;
41
44
  }
@@ -51,7 +54,9 @@ function keyOf(val: unknown, ancestors: object[]): string {
51
54
  ancestors.push(obj);
52
55
  try {
53
56
  if (Array.isArray(val)) {
54
- return `[${val.map((v) => keyOf(v, ancestors)).join(',')}]`;
57
+ // Array.from reads a hole as undefined (map would skip it, giving
58
+ // `[,]` the key of `[]`), matching deepClone, which fills holes.
59
+ return `[${Array.from(val, (v) => keyOf(v, ancestors)).join(',')}]`;
55
60
  }
56
61
  if (val instanceof Map) {
57
62
  const entries = [...val].map(
@@ -67,7 +72,7 @@ function keyOf(val: unknown, ancestors: object[]): string {
67
72
  const body = Object.keys(record)
68
73
  .map((k) => `${JSON.stringify(k)}:${keyOf(record[k], ancestors)}`)
69
74
  .join(',');
70
- const className = getClassName(obj.constructor as Constructor);
75
+ const className = registeredClassName(obj);
71
76
  return className === undefined
72
77
  ? `{${body}}`
73
78
  : `Class(${JSON.stringify(className)}){${body}}`;
@@ -1,4 +1,5 @@
1
1
  import type { ASTNode } from '../markup/ast';
2
+ import { checkVariableName } from '../utils/namespace';
2
3
 
3
4
  interface WidgetEntry {
4
5
  body: ASTNode[];
@@ -8,6 +9,11 @@ interface WidgetEntry {
8
9
 
9
10
  const widgets = new Map<string, WidgetEntry>();
10
11
 
12
+ /**
13
+ * Register a widget. Its `@` parameters become locals of its body, so one
14
+ * that no namespace can hold (`@__proto__`) throws a TypeError and the
15
+ * widget is not registered.
16
+ */
11
17
  export function registerWidget(
12
18
  name: string,
13
19
  bodyAST: ASTNode[],
@@ -15,6 +21,9 @@ export function registerWidget(
15
21
  isBlock = false,
16
22
  ): void {
17
23
  const filteredParams = params.filter((p) => p !== '@children');
24
+ for (const param of filteredParams) {
25
+ if (param.startsWith('@')) checkVariableName(param.slice(1), param);
26
+ }
18
27
  widgets.set(name.toLowerCase(), {
19
28
  body: bodyAST,
20
29
  params: filteredParams,
package/types/index.d.ts CHANGED
@@ -401,6 +401,8 @@ export interface MacroContext {
401
401
  nobr?: boolean;
402
402
  locals?: Record<string, unknown>;
403
403
  inline?: boolean;
404
+ /** Literal content, as inside `<pre>`: no markdown processing. */
405
+ raw?: boolean;
404
406
  },
405
407
  ) => ComponentChildren;
406
408
  /** Render AST nodes as inline content (no markdown block processing). */
@@ -417,6 +419,20 @@ export interface MacroContext {
417
419
  };
418
420
  }
419
421
 
422
+ /**
423
+ * Context object passed to a macro's text form (`MacroDefinition.text`).
424
+ * @see {@link ../../src/registry.ts} for the implementation.
425
+ */
426
+ export interface MacroTextContext {
427
+ /** Evaluate an expression in the current scope. */
428
+ evaluate: (expr: string) => unknown;
429
+ /**
430
+ * The text of AST nodes (a body or branch) in the current scope, with
431
+ * `locals` (keys without `@`) added on top of the current locals.
432
+ */
433
+ renderText: (nodes: ASTNode[], locals?: Record<string, unknown>) => string;
434
+ }
435
+
420
436
  /**
421
437
  * Configuration object for `Story.defineMacro()`.
422
438
  * @see {@link ../../src/define-macro.ts} for the implementation.
@@ -427,7 +443,7 @@ export interface MacroDefinition {
427
443
  subMacros?: string[];
428
444
  /** Accept a `{name}...{/name}` body. Inferred from `subMacros` when omitted. */
429
445
  block?: boolean;
430
- /** Resolve `{$var}` interpolation in the macro's class/id (`ctx.resolve`). */
446
+ /** Resolve markup (`{$var}`, expressions, macros) in the macro's class/id, and provide `ctx.resolve`. */
431
447
  interpolate?: boolean;
432
448
  /** Provide `ctx.merged` and `ctx.evaluate` (variables, temporaries, locals, transients). */
433
449
  merged?: boolean;
@@ -438,6 +454,12 @@ export interface MacroDefinition {
438
454
  /** Tooling hint: positional parameters. */
439
455
  parameters?: ParameterDef[];
440
456
  render: (props: MacroProps, ctx: MacroContext) => ComponentChildren;
457
+ /**
458
+ * The macro's text form, used where markup becomes a string: HTML
459
+ * attribute values, image alt text and link titles, macro labels. Without
460
+ * one, the macro can't be used there and is reported as an error.
461
+ */
462
+ text?: (props: MacroProps, ctx: MacroTextContext) => string;
441
463
  }
442
464
 
443
465
  /**
@@ -549,8 +571,11 @@ export interface StoryAPI {
549
571
  save(slot?: string, custom?: Record<string, unknown>): Promise<void>;
550
572
 
551
573
  /**
552
- * Load a saved state (quick load). Resolves once the loaded state is applied
553
- * (immediately if the slot is empty); rejects if loading fails.
574
+ * Load a saved state (quick load). The game moves to the loaded save's
575
+ * playthrough, in call order: a save issued after the load belongs to it.
576
+ * Resolves once the loaded state is applied (immediately if the slot is
577
+ * empty, without loading if a restart was issued after the load); rejects
578
+ * if loading fails.
554
579
  */
555
580
  load(slot?: string): Promise<void>;
556
581
 
@@ -671,11 +696,19 @@ export interface StoryAPI {
671
696
  getInfo(): Promise<StorageInfo>;
672
697
  /** Get browser storage quota estimate. */
673
698
  getQuota(): Promise<StorageQuota>;
674
- /** Delete all saves for the current game. */
699
+ /**
700
+ * Delete all saves and playthroughs of the current game and restart it.
701
+ * The restart happens at once; the promise settles once the data is
702
+ * deleted.
703
+ */
675
704
  clearGameData(): Promise<void>;
676
- /** Delete all Spindle data across all games. */
705
+ /** Delete all Spindle data across all games and restart, as clearGameData. */
677
706
  clearAllData(): Promise<void>;
678
- /** Delete a specific playthrough and its saves. */
707
+ /**
708
+ * Delete a specific playthrough and its saves. Deleting the current
709
+ * playthrough (the one the game started, restarted or last loaded a save
710
+ * in) moves the running game to a new one.
711
+ */
679
712
  deletePlaythrough(playthroughId: string): Promise<void>;
680
713
  /** The active storage backend. */
681
714
  readonly backend: 'indexeddb' | 'localstorage' | 'memory';
@@ -731,7 +764,10 @@ export interface StoryAPI {
731
764
 
732
765
  /** Story configuration. */
733
766
  readonly config: {
734
- /** Maximum number of history moments to retain. */
767
+ /**
768
+ * Maximum number of history moments to retain. Lowering it trims history
769
+ * at once, keeping the newest moments that include the current one.
770
+ */
735
771
  maxHistory: number;
736
772
  /**
737
773
  * Key that triggers a quick save (`KeyboardEvent.key`, default `'F6'`).
@@ -26,6 +26,7 @@ export interface MacroDefinition {
26
26
  description?: string;
27
27
  parameters?: ParameterDef[];
28
28
  render: (...args: any[]) => any;
29
+ text?: (...args: any[]) => string;
29
30
  }
30
31
 
31
32
  /**