@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.51.4",
3
+ "version": "0.52.1",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -53,7 +53,7 @@
53
53
  "test:coverage": "vitest run --coverage",
54
54
  "test:watch": "vitest",
55
55
  "test:e2e": "vitest run --config vitest.e2e.config.ts",
56
- "typecheck": "tsc --noEmit",
56
+ "typecheck": "tsc --noEmit && tsc --noEmit -p test",
57
57
  "format": "prettier --write .",
58
58
  "format:check": "prettier --check .",
59
59
  "docs:dev": "vitepress dev docs",
@@ -74,12 +74,15 @@
74
74
  "*": "prettier --write --ignore-unknown"
75
75
  },
76
76
  "devDependencies": {
77
+ "@fast-check/vitest": "^0.5.0",
77
78
  "@preact/preset-vite": "^2.10.5",
78
79
  "@rohal12/twee-ts": "^1.14.0",
79
80
  "@types/js-yaml": "^4.0.9",
80
81
  "@types/node": "^25.5.0",
81
82
  "@types/react": "^19.2.14",
82
83
  "@vitest/coverage-v8": "^4.1.2",
84
+ "fake-indexeddb": "^6.2.5",
85
+ "fast-check": "^4.10.2",
83
86
  "happy-dom": "^20.8.9",
84
87
  "husky": "^9.1.7",
85
88
  "js-yaml": "^4.1.1",
@@ -29,9 +29,35 @@ export interface StoryAction {
29
29
  perform: (value?: unknown) => void;
30
30
  }
31
31
 
32
- const actions = new Map<string, StoryAction>();
32
+ /** A mounted control's hold on its action ID. */
33
+ export interface ActionRegistration {
34
+ /**
35
+ * Replace what the action exposes, in place: one `actionsChanged`
36
+ * notification, and it keeps its position in getActions().
37
+ */
38
+ update(action: StoryAction): void;
39
+ /** Remove this registration, and nothing that has taken its ID since. */
40
+ unregister(): void;
41
+ }
42
+
43
+ interface Entry {
44
+ action: StoryAction;
45
+ }
46
+
47
+ /**
48
+ * The registrations holding each ID, oldest first; the newest is the one
49
+ * listed. Generated IDs don't collide, but author IDs can, and one control's
50
+ * cleanup must not remove another control holding the same ID (#233).
51
+ */
52
+ const actions = new Map<string, Entry[]>();
33
53
  const idCounters = new Map<string, number>();
34
54
 
55
+ /**
56
+ * The counters reset on every navigation, so a passage's controls get the
57
+ * same IDs each time it is shown. Controls that stay mounted across
58
+ * navigations (e.g. in StoryInterface) keep theirs, so allocation skips every
59
+ * ID still registered (#233).
60
+ */
35
61
  export function generateActionId(
36
62
  type: ActionType,
37
63
  key: string,
@@ -40,35 +66,61 @@ export function generateActionId(
40
66
  if (authorId) return authorId;
41
67
 
42
68
  const base = `${type}:${key}`;
43
- const count = (idCounters.get(base) ?? 0) + 1;
69
+ let count = idCounters.get(base) ?? 0;
70
+ let id: string;
71
+ do {
72
+ count++;
73
+ id = count === 1 ? base : `${base}:${count}`;
74
+ } while (actions.has(id));
44
75
  idCounters.set(base, count);
45
- return count === 1 ? base : `${base}:${count}`;
76
+ return id;
46
77
  }
47
78
 
48
- export function registerAction(action: StoryAction): () => void {
49
- actions.set(action.id, action);
79
+ export function registerAction(action: StoryAction): ActionRegistration {
80
+ const id = action.id;
81
+ const entry: Entry = { action };
82
+ const entries = actions.get(id);
83
+ if (entries) {
84
+ console.warn(
85
+ `spindle: two mounted controls share the action ID "${id}"; ` +
86
+ 'Story.getActions() lists only the newer one.',
87
+ );
88
+ entries.push(entry);
89
+ } else {
90
+ actions.set(id, [entry]);
91
+ }
50
92
  notify();
51
- return () => {
52
- actions.delete(action.id);
53
- notify();
54
- };
55
- }
56
93
 
57
- export function updateAction(action: StoryAction): () => void {
58
- actions.set(action.id, action);
59
- notify();
60
- return () => {
61
- actions.delete(action.id);
62
- notify();
94
+ return {
95
+ update(next) {
96
+ const current = actions.get(id);
97
+ if (!current?.includes(entry)) return;
98
+ entry.action = next;
99
+ // An older registration of a shared ID isn't listed
100
+ if (current[current.length - 1] === entry) notify();
101
+ },
102
+ unregister() {
103
+ const current = actions.get(id);
104
+ const index = current ? current.indexOf(entry) : -1;
105
+ if (!current || index === -1) return;
106
+ current.splice(index, 1);
107
+ if (current.length === 0) actions.delete(id);
108
+ if (index === current.length) notify(); // it was the listed one
109
+ },
63
110
  };
64
111
  }
65
112
 
66
113
  export function getActions(): StoryAction[] {
67
- return Array.from(actions.values());
114
+ return Array.from(actions.values(), listed);
68
115
  }
69
116
 
70
117
  export function getAction(id: string): StoryAction | undefined {
71
- return actions.get(id);
118
+ const entries = actions.get(id);
119
+ return entries && listed(entries);
120
+ }
121
+
122
+ function listed(entries: Entry[]): StoryAction {
123
+ return entries[entries.length - 1]!.action;
72
124
  }
73
125
 
74
126
  export function clearActions(): void {
@@ -7,6 +7,7 @@ import type {
7
7
  } from './types';
8
8
  import type { StoryAPI } from '../story-api';
9
9
  import type { StoryAction } from '../action-registry';
10
+ import { errorMessage } from '../utils/error-message';
10
11
 
11
12
  export interface RunOptions {
12
13
  onStep?: (stepIndex: number, step: AutomationStep) => void;
@@ -35,7 +36,7 @@ export async function runAutomation(
35
36
  } catch (err) {
36
37
  errors.push({
37
38
  step: i,
38
- message: err instanceof Error ? err.message : String(err),
39
+ message: errorMessage(err),
39
40
  });
40
41
  break;
41
42
  }
@@ -14,6 +14,20 @@ export function getClassName(ctor: Constructor): string | undefined {
14
14
  return ctorToName.get(ctor);
15
15
  }
16
16
 
17
+ /**
18
+ * The registered name of the class `value` is an instance of, or undefined.
19
+ * Looks the constructor up on the prototype, so an own property named
20
+ * "constructor" (plain data) cannot hide or fake the class.
21
+ */
22
+ export function registeredClassName(value: object): string | undefined {
23
+ const proto = Object.getPrototypeOf(value) as {
24
+ constructor?: unknown;
25
+ } | null;
26
+ return proto === null
27
+ ? undefined
28
+ : ctorToName.get(proto.constructor as Constructor);
29
+ }
30
+
17
31
  export function clearRegistry(): void {
18
32
  registry.clear();
19
33
  ctorToName.clear();
@@ -27,6 +41,23 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
27
41
  return proto === Object.prototype || proto === null;
28
42
  }
29
43
 
44
+ /**
45
+ * Set an own enumerable property. A "__proto__" key is defined as an own
46
+ * property (as JSON.parse does) instead of replacing the prototype.
47
+ */
48
+ function setOwn(target: object, key: string, value: unknown): void {
49
+ if (key === '__proto__') {
50
+ Object.defineProperty(target, key, {
51
+ value,
52
+ enumerable: true,
53
+ writable: true,
54
+ configurable: true,
55
+ });
56
+ } else {
57
+ (target as Record<string, unknown>)[key] = value;
58
+ }
59
+ }
60
+
30
61
  export interface DeepCloneOptions {
31
62
  /**
32
63
  * Return instances of unregistered classes (DOM nodes, promises, other
@@ -77,8 +108,7 @@ export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
77
108
  }
78
109
 
79
110
  // Registered class instance
80
- const ctor = obj.constructor as Constructor;
81
- const name = ctorToName.get(ctor);
111
+ const name = registeredClassName(obj);
82
112
  if (name !== undefined) {
83
113
  const copy = Object.create(Object.getPrototypeOf(obj)) as Record<
84
114
  string,
@@ -86,23 +116,23 @@ export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
86
116
  >;
87
117
  seen.set(obj, copy);
88
118
  for (const key of Object.keys(obj)) {
89
- copy[key] = clone((obj as Record<string, unknown>)[key]);
119
+ setOwn(copy, key, clone((obj as Record<string, unknown>)[key]));
90
120
  }
91
121
  return copy;
92
122
  }
93
123
 
94
- // Plain object (or unregistered class — treat as plain)
95
- if (!isPlainObject(val) && options.keepUnregistered) return val;
96
- if (isPlainObject(val) || typeof val === 'object') {
97
- const copy: Record<string, unknown> = {};
98
- seen.set(obj, copy);
99
- for (const key of Object.keys(obj)) {
100
- copy[key] = clone((obj as Record<string, unknown>)[key]);
101
- }
102
- return copy;
124
+ // Plain object (or unregistered class — treat as plain). A plain object
125
+ // keeps its prototype, which may be null.
126
+ const plain = isPlainObject(val);
127
+ if (!plain && options.keepUnregistered) return val;
128
+ const copy = (
129
+ plain ? Object.create(Object.getPrototypeOf(obj) as object | null) : {}
130
+ ) as Record<string, unknown>;
131
+ seen.set(obj, copy);
132
+ for (const key of Object.keys(obj)) {
133
+ setOwn(copy, key, clone((obj as Record<string, unknown>)[key]));
103
134
  }
104
-
105
- return val;
135
+ return copy;
106
136
  }
107
137
 
108
138
  return clone(value) as T;
@@ -113,12 +143,23 @@ export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
113
143
  /**
114
144
  * Structural equality over the value types deepClone() supports: primitives,
115
145
  * arrays, plain objects, class instances, Date, RegExp, Map and Set (nested
116
- * at any depth). Map and Set entries are compared in insertion order.
146
+ * at any depth). Map and Set entries are compared in insertion order. Arrays
147
+ * are compared by length and index, so a hole equals an undefined element
148
+ * (deepClone() and save/load turn holes into undefined elements).
117
149
  */
118
- export function deepEqual(
150
+ export function deepEqual(a: unknown, b: unknown): boolean {
151
+ return equal(a, b, new Map());
152
+ }
153
+
154
+ /**
155
+ * `assumed` holds the pairs already being compared: meeting one again (a
156
+ * cycle) assumes it equal. Every object may pair with several others, since
157
+ * cycles of different lengths can still unfold to the same value.
158
+ */
159
+ function equal(
119
160
  a: unknown,
120
161
  b: unknown,
121
- seen: Map<object, object> = new Map(),
162
+ assumed: Map<object, Set<object>>,
122
163
  ): boolean {
123
164
  if (Object.is(a, b)) return true;
124
165
  if (
@@ -130,8 +171,10 @@ export function deepEqual(
130
171
  return false;
131
172
  }
132
173
  if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;
133
- if (seen.get(a) === b) return true;
134
- seen.set(a, b);
174
+ const pairs = assumed.get(a);
175
+ if (pairs?.has(b)) return true;
176
+ if (pairs) pairs.add(b);
177
+ else assumed.set(a, new Set([b]));
135
178
 
136
179
  if (a instanceof Date) return Object.is(a.getTime(), (b as Date).getTime());
137
180
  if (a instanceof RegExp) return String(a) === String(b);
@@ -145,7 +188,16 @@ export function deepEqual(
145
188
  !x.done;
146
189
  x = ai.next(), y = bi.next()
147
190
  ) {
148
- if (!deepEqual(x.value, y.value, seen)) return false;
191
+ if (!equal(x.value, y.value, assumed)) return false;
192
+ }
193
+ return true;
194
+ }
195
+
196
+ if (Array.isArray(a)) {
197
+ const bc = b as unknown[];
198
+ if (a.length !== bc.length) return false;
199
+ for (let i = 0; i < a.length; i++) {
200
+ if (!equal(a[i], bc[i], assumed)) return false;
149
201
  }
150
202
  return true;
151
203
  }
@@ -155,81 +207,122 @@ export function deepEqual(
155
207
  const keys = Object.keys(ao);
156
208
  if (keys.length !== Object.keys(bo).length) return false;
157
209
  for (const key of keys) {
158
- if (!(key in bo) || !deepEqual(ao[key], bo[key], seen)) return false;
210
+ if (!hasOwn(bo, key) || !equal(ao[key], bo[key], assumed)) return false;
159
211
  }
160
212
  return true;
161
213
  }
162
214
 
215
+ const hasOwn = (obj: object, key: string): boolean =>
216
+ Object.prototype.hasOwnProperty.call(obj, key);
217
+
163
218
  // --- Serialize ---
164
219
 
165
220
  const CLASS_TAG = '__spindle_class__';
166
221
  const DATA_TAG = '__spindle_data__';
167
222
 
223
+ /** A tagged value, as serialize() writes and deserialize() reads it. */
224
+ const tagged = (name: string, data: Record<string, unknown>) => ({
225
+ [CLASS_TAG]: name,
226
+ [DATA_TAG]: data,
227
+ });
228
+
229
+ /** Numbers JSON text cannot hold (it writes null for them, and 0 for -0). */
230
+ const SPECIAL_NUMBERS: Record<string, number> = {
231
+ NaN: NaN,
232
+ Infinity: Infinity,
233
+ '-Infinity': -Infinity,
234
+ '-0': -0,
235
+ };
236
+
237
+ function specialNumberName(value: number): string | undefined {
238
+ if (Number.isNaN(value)) return 'NaN';
239
+ if (value === Infinity) return 'Infinity';
240
+ if (value === -Infinity) return '-Infinity';
241
+ if (Object.is(value, -0)) return '-0';
242
+ return undefined;
243
+ }
244
+
245
+ /**
246
+ * Turn a story value into data that survives JSON text (saves, exports,
247
+ * the session) and that deserialize() restores. Date, RegExp, Map, Set and
248
+ * registered class instances become tagged objects, and so do the values
249
+ * JSON text would drop or change: undefined (also array holes), NaN,
250
+ * ±Infinity, -0, bigint, invalid dates, and plain objects with a key named
251
+ * like the class tag. Throws on circular references and on a property
252
+ * named "__proto__", which story state cannot hold.
253
+ */
168
254
  export function serialize<T>(value: T): T {
169
255
  const seen = new Set<object>();
170
256
 
171
257
  function ser(val: unknown): unknown {
258
+ if (val === undefined) return tagged('__Undefined__', {});
259
+ if (typeof val === 'number') {
260
+ const name = specialNumberName(val);
261
+ return name === undefined ? val : tagged('__Number__', { value: name });
262
+ }
263
+ if (typeof val === 'bigint') {
264
+ return tagged('__BigInt__', { value: val.toString() });
265
+ }
172
266
  if (val === null || typeof val !== 'object') return val;
173
267
 
174
- const obj = val as object;
175
- if (seen.has(obj)) {
268
+ if (seen.has(val)) {
176
269
  throw new Error('spindle: Cannot serialize circular references');
177
270
  }
178
- seen.add(obj);
271
+ seen.add(val);
272
+ try {
273
+ return serObject(val);
274
+ } finally {
275
+ seen.delete(val);
276
+ }
277
+ }
278
+
279
+ function serKeys(obj: object): Record<string, unknown> {
280
+ const data: Record<string, unknown> = {};
281
+ for (const key of Object.keys(obj)) {
282
+ // Story state cannot hold one: replaying history (Immer patches)
283
+ // would turn it into the object's prototype
284
+ if (key === '__proto__') {
285
+ throw new Error('spindle: Cannot save a property named "__proto__"');
286
+ }
287
+ data[key] = ser((obj as Record<string, unknown>)[key]);
288
+ }
289
+ return data;
290
+ }
179
291
 
292
+ function serObject(val: object): unknown {
180
293
  if (val instanceof Date) {
181
- seen.delete(obj);
182
- return {
183
- [CLASS_TAG]: '__Date__',
184
- [DATA_TAG]: { iso: val.toISOString() },
185
- };
294
+ const valid = !Number.isNaN(val.getTime());
295
+ return tagged('__Date__', { iso: valid ? val.toISOString() : null });
186
296
  }
187
297
 
188
298
  if (val instanceof RegExp) {
189
- seen.delete(obj);
190
- return {
191
- [CLASS_TAG]: '__RegExp__',
192
- [DATA_TAG]: { source: val.source, flags: val.flags },
193
- };
299
+ return tagged('__RegExp__', { source: val.source, flags: val.flags });
194
300
  }
195
301
 
196
302
  if (Array.isArray(val)) {
197
- const result = val.map((item) => ser(item));
198
- seen.delete(obj);
303
+ // Index by index: map() would keep holes, which JSON writes as null
304
+ const result: unknown[] = [];
305
+ for (let i = 0; i < val.length; i++) result.push(ser(val[i]));
199
306
  return result;
200
307
  }
201
308
 
202
309
  if (val instanceof Map) {
203
310
  const entries = [...val].map(([k, v]) => [ser(k), ser(v)]);
204
- seen.delete(obj);
205
- return { [CLASS_TAG]: '__Map__', [DATA_TAG]: { entries } };
311
+ return tagged('__Map__', { entries });
206
312
  }
207
313
 
208
314
  if (val instanceof Set) {
209
- const entries = [...val].map((v) => ser(v));
210
- seen.delete(obj);
211
- return { [CLASS_TAG]: '__Set__', [DATA_TAG]: { entries } };
315
+ return tagged('__Set__', { entries: [...val].map((v) => ser(v)) });
212
316
  }
213
317
 
214
318
  // Registered class instance
215
- const ctor = obj.constructor as Constructor;
216
- const name = ctorToName.get(ctor);
217
- if (name !== undefined) {
218
- const data: Record<string, unknown> = {};
219
- for (const key of Object.keys(obj)) {
220
- data[key] = ser((obj as Record<string, unknown>)[key]);
221
- }
222
- seen.delete(obj);
223
- return { [CLASS_TAG]: name, [DATA_TAG]: data };
224
- }
319
+ const name = registeredClassName(val);
320
+ if (name !== undefined) return tagged(name, serKeys(val));
225
321
 
226
- // Plain object
227
- const result: Record<string, unknown> = {};
228
- for (const key of Object.keys(obj)) {
229
- result[key] = ser((obj as Record<string, unknown>)[key]);
230
- }
231
- seen.delete(obj);
232
- return result;
322
+ // Plain object. One with a key named like the class tag is wrapped, so
323
+ // that it is not read back as a tagged value.
324
+ const data = serKeys(val);
325
+ return hasOwn(val, CLASS_TAG) ? tagged('__Object__', data) : data;
233
326
  }
234
327
 
235
328
  return ser(value) as T;
@@ -238,6 +331,13 @@ export function serialize<T>(value: T): T {
238
331
  // --- Deserialize ---
239
332
 
240
333
  export function deserialize<T>(value: T): T {
334
+ function deserKeys(target: object, data: Record<string, unknown>): object {
335
+ for (const key of Object.keys(data)) {
336
+ setOwn(target, key, deser(data[key]));
337
+ }
338
+ return target;
339
+ }
340
+
241
341
  function deser(val: unknown): unknown {
242
342
  if (val === null || typeof val !== 'object') return val;
243
343
 
@@ -247,25 +347,31 @@ export function deserialize<T>(value: T): T {
247
347
 
248
348
  const obj = val as Record<string, unknown>;
249
349
 
250
- // Tagged class instance (from serialized data)
350
+ // Tagged value (from serialized data)
251
351
  if (CLASS_TAG in obj && DATA_TAG in obj) {
252
352
  const name = obj[CLASS_TAG] as string;
253
353
  const data = obj[DATA_TAG] as Record<string, unknown>;
254
354
 
255
- // Built-in types
256
- if (name === '__Date__') {
257
- return new Date(data.iso as string);
258
- }
259
- if (name === '__RegExp__') {
260
- return new RegExp(data.source as string, data.flags as string);
261
- }
262
- if (name === '__Map__') {
263
- const entries = data.entries as [unknown, unknown][];
264
- return new Map(entries.map(([k, v]) => [deser(k), deser(v)]));
265
- }
266
- if (name === '__Set__') {
267
- const entries = data.entries as unknown[];
268
- return new Set(entries.map((v) => deser(v)));
355
+ // Built-in types and values JSON cannot hold
356
+ switch (name) {
357
+ case '__Date__':
358
+ return new Date(data.iso === null ? NaN : (data.iso as string));
359
+ case '__RegExp__':
360
+ return new RegExp(data.source as string, data.flags as string);
361
+ case '__Map__': {
362
+ const entries = data.entries as [unknown, unknown][];
363
+ return new Map(entries.map(([k, v]) => [deser(k), deser(v)]));
364
+ }
365
+ case '__Set__':
366
+ return new Set((data.entries as unknown[]).map((v) => deser(v)));
367
+ case '__Undefined__':
368
+ return undefined;
369
+ case '__Number__':
370
+ return SPECIAL_NUMBERS[data.value as string];
371
+ case '__BigInt__':
372
+ return BigInt(data.value as string);
373
+ case '__Object__':
374
+ return deserKeys({}, data);
269
375
  }
270
376
 
271
377
  const ctor = registry.get(name);
@@ -273,17 +379,9 @@ export function deserialize<T>(value: T): T {
273
379
  console.warn(
274
380
  `spindle: Class "${name}" not registered. Falling back to plain object.`,
275
381
  );
276
- const plain: Record<string, unknown> = {};
277
- for (const key of Object.keys(data)) {
278
- plain[key] = deser(data[key]);
279
- }
280
- return plain;
382
+ return deserKeys({}, data);
281
383
  }
282
- const instance = Object.create(ctor.prototype) as Record<string, unknown>;
283
- for (const key of Object.keys(data)) {
284
- instance[key] = deser(data[key]);
285
- }
286
- return instance;
384
+ return deserKeys(Object.create(ctor.prototype) as object, data);
287
385
  }
288
386
 
289
387
  // Already-live built-in — pass through as-is, so deserializing an
@@ -298,17 +396,12 @@ export function deserialize<T>(value: T): T {
298
396
  }
299
397
 
300
398
  // Already-live registered class instance — pass through as-is
301
- const ctor = (obj as object).constructor as Constructor;
302
- if (ctorToName.has(ctor)) {
399
+ if (registeredClassName(obj) !== undefined) {
303
400
  return val;
304
401
  }
305
402
 
306
403
  // Plain object
307
- const result: Record<string, unknown> = {};
308
- for (const key of Object.keys(obj)) {
309
- result[key] = deser(obj[key]);
310
- }
311
- return result;
404
+ return deserKeys({}, obj);
312
405
  }
313
406
 
314
407
  return deser(value) as T;
@@ -323,10 +416,12 @@ function isDataRecord(value: unknown): value is Record<string, unknown> {
323
416
  /**
324
417
  * Whether `value` is serialized data that deserialize() can restore: every
325
418
  * tagged value has the shape serialize() writes for its tag (a valid ISO
326
- * date, a compilable RegExp, Map entries as `[key, value]` pairs, Set
327
- * entries as an array, class data as an object), at any depth. Use it on
328
- * data from outside the running story, such as an imported save, before
329
- * storing it. Tags of unregistered classes pass; they load as plain objects.
419
+ * date or null, a compilable RegExp, Map entries as `[key, value]` pairs,
420
+ * Set entries as an array, a special number's name, a bigint in decimal,
421
+ * class data as an object) and no object has a "__proto__" key, at any
422
+ * depth. Use it on data from outside the running story, such as an
423
+ * imported save, before storing it. Tags of unregistered classes pass;
424
+ * they load as plain objects.
330
425
  */
331
426
  export function isDeserializable(value: unknown): boolean {
332
427
  // Objects on the current path; JSON can't hold cycles, and deserialize()
@@ -342,13 +437,18 @@ export function isDeserializable(value: unknown): boolean {
342
437
  return ok;
343
438
  }
344
439
 
440
+ // A "__proto__" key is refused: serialize() never writes one, and story
441
+ // state cannot hold one (see serialize)
442
+ const checkKeys = (obj: Record<string, unknown>): boolean =>
443
+ Object.keys(obj).every((key) => key !== '__proto__' && check(obj[key]));
444
+
345
445
  function checkObject(val: object): boolean {
346
- if (Array.isArray(val)) return val.every(check);
446
+ // Array.from: every() skips the holes of a sparse array, which
447
+ // deserialize() restores as undefined (and a Map cannot take as entries)
448
+ if (Array.isArray(val)) return Array.from(val).every(check);
347
449
 
348
450
  const obj = val as Record<string, unknown>;
349
- if (!(CLASS_TAG in obj && DATA_TAG in obj)) {
350
- return Object.keys(obj).every((key) => check(obj[key]));
351
- }
451
+ if (!(CLASS_TAG in obj && DATA_TAG in obj)) return checkKeys(obj);
352
452
 
353
453
  const name = obj[CLASS_TAG];
354
454
  const data = obj[DATA_TAG];
@@ -357,8 +457,9 @@ export function isDeserializable(value: unknown): boolean {
357
457
  switch (name) {
358
458
  case '__Date__':
359
459
  return (
360
- typeof data.iso === 'string' &&
361
- !Number.isNaN(new Date(data.iso).getTime())
460
+ data.iso === null ||
461
+ (typeof data.iso === 'string' &&
462
+ !Number.isNaN(new Date(data.iso).getTime()))
362
463
  );
363
464
  case '__RegExp__':
364
465
  if (typeof data.source !== 'string' || typeof data.flags !== 'string')
@@ -372,7 +473,7 @@ export function isDeserializable(value: unknown): boolean {
372
473
  case '__Map__':
373
474
  return (
374
475
  Array.isArray(data.entries) &&
375
- data.entries.every(
476
+ Array.from(data.entries).every(
376
477
  (entry: unknown) =>
377
478
  Array.isArray(entry) &&
378
479
  entry.length === 2 &&
@@ -381,9 +482,19 @@ export function isDeserializable(value: unknown): boolean {
381
482
  )
382
483
  );
383
484
  case '__Set__':
384
- return Array.isArray(data.entries) && data.entries.every(check);
485
+ return (
486
+ Array.isArray(data.entries) && Array.from(data.entries).every(check)
487
+ );
488
+ case '__Undefined__':
489
+ return true;
490
+ case '__Number__':
491
+ return (
492
+ typeof data.value === 'string' && hasOwn(SPECIAL_NUMBERS, data.value)
493
+ );
494
+ case '__BigInt__':
495
+ return typeof data.value === 'string' && /^-?\d+$/.test(data.value);
385
496
  default:
386
- return Object.keys(data).every((key) => check(data[key]));
497
+ return checkKeys(data);
387
498
  }
388
499
  }
389
500
 
@@ -13,6 +13,7 @@ import type { Passage as PassageData } from '../parser';
13
13
  import { sourceLocationOf } from '../utils/source-location';
14
14
  import { emitFromRender } from '../event-emitter';
15
15
  import { markPassageRendered } from '../passage-render-state';
16
+ import { errorMessage } from '../utils/error-message';
16
17
 
17
18
  /**
18
19
  * Parsed AST per passage. The renderer keys children by AST node identity,
@@ -69,8 +70,7 @@ export function Passage({
69
70
  return (
70
71
  <div class="error">
71
72
  Error parsing passage &ldquo;{passage.name}&rdquo;
72
- {sourceLocationOf(passage)}:{' '}
73
- {err instanceof Error ? err.message : String(err)}
73
+ {sourceLocationOf(passage)}: {errorMessage(err)}
74
74
  </div>
75
75
  );
76
76
  }