@rohal12/spindle 0.52.9 → 0.54.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 (50) hide show
  1. package/README.md +5 -1
  2. package/dist/pkg/format.js +1 -1
  3. package/dist/pkg/headless.js +6484 -2558
  4. package/dist/pkg/story-variables.js +4009 -940
  5. package/dist/pkg/tooling.js +13 -1
  6. package/package.json +6 -1
  7. package/src/class-registry.ts +252 -250
  8. package/src/components/App.tsx +2 -0
  9. package/src/components/Passage.tsx +3 -3
  10. package/src/components/PassageDialog.tsx +2 -4
  11. package/src/components/RuntimeErrors.tsx +46 -0
  12. package/src/components/StoryInterface.tsx +2 -4
  13. package/src/components/macros/Computed.tsx +5 -2
  14. package/src/components/macros/Do.tsx +4 -2
  15. package/src/components/macros/Goto.tsx +13 -3
  16. package/src/components/macros/Include.tsx +2 -3
  17. package/src/components/macros/MacroError.tsx +14 -7
  18. package/src/components/macros/SaveManager.tsx +2 -2
  19. package/src/components/macros/VarDisplay.tsx +1 -1
  20. package/src/components/macros/Widget.tsx +5 -33
  21. package/src/index.tsx +57 -37
  22. package/src/interpolation.ts +4 -5
  23. package/src/markup/ast.ts +11 -226
  24. package/src/markup/code-attributes.ts +3 -3
  25. package/src/markup/code-end.ts +121 -0
  26. package/src/markup/parse.ts +118 -0
  27. package/src/markup/render.tsx +1 -1
  28. package/src/markup/spindle.d.peggy.ts +24 -0
  29. package/src/markup/spindle.peggy +463 -0
  30. package/src/markup/tokens.ts +95 -0
  31. package/src/markup/validate.ts +219 -0
  32. package/src/parser.ts +19 -4
  33. package/src/runtime-errors.ts +65 -0
  34. package/src/saves/format.ts +199 -0
  35. package/src/saves/save-manager.ts +45 -63
  36. package/src/saves/types.ts +71 -88
  37. package/src/store.ts +167 -87
  38. package/src/story-init.ts +3 -5
  39. package/src/story-variables.ts +18 -4
  40. package/src/structural.ts +266 -57
  41. package/src/styles.css +45 -0
  42. package/src/tooling.ts +50 -0
  43. package/src/types-drift-check.ts +28 -1
  44. package/src/utils/object-path.ts +13 -12
  45. package/src/utils/stable-key.ts +2 -1
  46. package/src/utils/value-kinds.ts +45 -0
  47. package/src/widgets/widget-def.ts +60 -0
  48. package/types/index.d.ts +32 -8
  49. package/types/tooling.d.ts +42 -0
  50. package/src/markup/tokenizer.ts +0 -1112
@@ -2,7 +2,9 @@ import { readFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
 
5
- export { parseStoryVariables } from './story-variables.js';
5
+ import { validateStoryMarkup } from './story-variables.js';
6
+
7
+ export { parseStoryVariables, formatDiagnostic } from './story-variables.js';
6
8
 
7
9
  const __dirname = dirname(fileURLToPath(import.meta.url));
8
10
 
@@ -44,3 +46,13 @@ export function defineMacro(config) {
44
46
  export function getMacroRegistry() {
45
47
  return Array.from(metadata.values());
46
48
  }
49
+
50
+ /**
51
+ * Validate the markup of a story's passages as Spindle does when the story
52
+ * starts: malformed markup and unknown macros (checked against the built-in
53
+ * macros and those registered with defineMacro), with passage, line and
54
+ * column.
55
+ */
56
+ export function validateMarkup(passages) {
57
+ return validateStoryMarkup(passages, metadata.values());
58
+ }
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.52.9",
3
+ "version": "0.54.0",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
7
7
  "author": "Rohal12",
8
+ "engines": {
9
+ "node": ">=22.17"
10
+ },
8
11
  "publishConfig": {
9
12
  "access": "public"
10
13
  },
@@ -64,6 +67,7 @@
64
67
  "prepare": "husky"
65
68
  },
66
69
  "dependencies": {
70
+ "devalue": "^6",
67
71
  "immer": "^11.1.4",
68
72
  "micromark": "^4.0.2",
69
73
  "micromark-extension-gfm-strikethrough": "^2.1.0",
@@ -90,6 +94,7 @@
90
94
  "js-yaml": "^4.1.1",
91
95
  "jscpd": "5.4.0",
92
96
  "lint-staged": "^16.4.0",
97
+ "peggy": "5.1.0",
93
98
  "playwright": "^1.58.2",
94
99
  "prettier": "^3.8.1",
95
100
  "typescript": "^6.0.2",
@@ -1,6 +1,7 @@
1
1
  // Class registry for preserving class instances across clone/save/load cycles.
2
2
 
3
- import { hasOwn, setOwn } from './utils/namespace';
3
+ import { parse, stringify } from 'devalue';
4
+ import { hasOwn } from './utils/namespace';
4
5
 
5
6
  type Constructor = new (...args: any[]) => any;
6
7
 
@@ -35,288 +36,289 @@ export function clearRegistry(): void {
35
36
  ctorToName.clear();
36
37
  }
37
38
 
38
- // --- Serialize ---
39
+ // --- Serialize (devalue) ---
40
+ //
41
+ // Serialized data is the JSON text of devalue's flattened form: an array
42
+ // that holds every value once, so shared references and cycles stay shared.
43
+ // devalue itself handles undefined, NaN, ±Infinity, -0, bigint, sparse
44
+ // arrays, Date, RegExp, Map, Set, null-prototype objects, typed arrays,
45
+ // ArrayBuffer, DataView, URL, URLSearchParams, boxed primitives and Temporal
46
+ // values. Spindle adds, with reducers and revivers:
47
+ //
48
+ // - `c:<name>`: an instance of the class registered as <name>, with its own
49
+ // enumerable keys (and, for Error subclasses, its message and cause);
50
+ // - `E:<name>`: a built-in error (Error, TypeError, ..., AggregateError),
51
+ // with its message, cause, errors and own enumerable keys (not its stack);
52
+ // - `S`: a symbol from the global registry (Symbol.for);
53
+ // - `K`: a plain object holding a one-character key that JSON escapes (see
54
+ // GUARD_V8_KEYS).
55
+ //
56
+ // Everything else is refused when saving, with the path to the value:
57
+ // functions, unique symbols, symbol keys, instances of unregistered classes,
58
+ // and a property named "__proto__".
59
+
60
+ const CLASS_PREFIX = 'c:';
61
+ const ERROR_PREFIX = 'E:';
62
+ const SYMBOL_TAG = 'S';
63
+ const KEYS_TAG = 'K';
39
64
 
40
- const CLASS_TAG = '__spindle_class__';
41
- const DATA_TAG = '__spindle_data__';
42
-
43
- /** A tagged value, as serialize() writes and deserialize() reads it. */
44
- const tagged = (name: string, data: Record<string, unknown>) => ({
45
- [CLASS_TAG]: name,
46
- [DATA_TAG]: data,
47
- });
48
-
49
- /** Numbers JSON text cannot hold (it writes null for them, and 0 for -0). */
50
- const SPECIAL_NUMBERS: Record<string, number> = {
51
- NaN: NaN,
52
- Infinity: Infinity,
53
- '-Infinity': -Infinity,
54
- '-0': -0,
65
+ /**
66
+ * Work around Chromium issue 521080746 (V8 in Chrome/Chromium 14x-153+,
67
+ * Node 24 and 26): after JSON.parse() has read an object key "\", a later
68
+ * parse can read a one-character escaped key at the same position in an
69
+ * object of the same shape ("\"", "\n", "\t", "\u0000"...) as "\". Plain
70
+ * objects holding such a key are saved as a list of entries instead, which
71
+ * puts the key in a JSON string, not a JSON object key.
72
+ */
73
+ const GUARD_V8_KEYS = true;
74
+
75
+ const BUILTIN_ERRORS: ReadonlyMap<object, string> = new Map(
76
+ (
77
+ [
78
+ Error,
79
+ EvalError,
80
+ RangeError,
81
+ ReferenceError,
82
+ SyntaxError,
83
+ TypeError,
84
+ URIError,
85
+ (globalThis as unknown as { AggregateError: ErrorConstructor })
86
+ .AggregateError,
87
+ ] as const
88
+ ).map((ctor) => [ctor.prototype, ctor.name]),
89
+ );
90
+ const ERROR_CTORS = new Map(
91
+ [...BUILTIN_ERRORS].map(([proto, name]) => [
92
+ name,
93
+ (proto as { constructor: ErrorConstructor }).constructor,
94
+ ]),
95
+ );
96
+
97
+ /** Error keys that are own but not enumerable on a live error. */
98
+ const ERROR_HIDDEN_KEYS = ['message', 'cause', 'errors'] as const;
99
+
100
+ const isObject = (v: unknown): v is object =>
101
+ typeof v === 'object' && v !== null;
102
+
103
+ const defineData = (target: object, key: string, value: unknown): void => {
104
+ // Define, so that a "__proto__" key stays a key (devalue refuses it)
105
+ Object.defineProperty(target, key, {
106
+ value,
107
+ enumerable: true,
108
+ writable: true,
109
+ configurable: true,
110
+ });
55
111
  };
56
112
 
57
- function specialNumberName(value: number): string | undefined {
58
- if (Number.isNaN(value)) return 'NaN';
59
- if (value === Infinity) return 'Infinity';
60
- if (value === -Infinity) return '-Infinity';
61
- if (Object.is(value, -0)) return '-0';
62
- return undefined;
63
- }
64
-
65
113
  /**
66
- * Turn a story value into data that survives JSON text (saves, exports,
67
- * the session) and that deserialize() restores. Date, RegExp, Map, Set and
68
- * registered class instances become tagged objects, and so do the values
69
- * JSON text would drop or change: undefined (also array holes), NaN,
70
- * ±Infinity, -0, bigint, invalid dates, and plain objects with a key named
71
- * like the class tag. Throws on circular references and on a property
72
- * named "__proto__", which story state cannot hold.
114
+ * Own enumerable keys of `value`, as a plain object; for an error also its
115
+ * message, cause and (AggregateError) errors, which are not enumerable.
73
116
  */
74
- export function serialize<T>(value: T): T {
75
- const seen = new Set<object>();
76
-
77
- function ser(val: unknown): unknown {
78
- if (val === undefined) return tagged('__Undefined__', {});
79
- if (typeof val === 'number') {
80
- const name = specialNumberName(val);
81
- return name === undefined ? val : tagged('__Number__', { value: name });
82
- }
83
- if (typeof val === 'bigint') {
84
- return tagged('__BigInt__', { value: val.toString() });
85
- }
86
- if (val === null || typeof val !== 'object') return val;
87
-
88
- if (seen.has(val)) {
89
- throw new Error('spindle: Cannot serialize circular references');
90
- }
91
- seen.add(val);
92
- try {
93
- return serObject(val);
94
- } finally {
95
- seen.delete(val);
117
+ function ownData(value: object): Record<string, unknown> {
118
+ const data: Record<string, unknown> = {};
119
+ if (value instanceof Error) {
120
+ for (const key of ERROR_HIDDEN_KEYS) {
121
+ if (hasOwn(value, key)) defineData(data, key, value[key as keyof Error]);
96
122
  }
97
123
  }
98
-
99
- function serKeys(obj: object): Record<string, unknown> {
100
- const data: Record<string, unknown> = {};
101
- for (const key of Object.keys(obj)) {
102
- // Story state cannot hold one: replaying history (Immer patches)
103
- // would turn it into the object's prototype
104
- if (key === '__proto__') {
105
- throw new Error('spindle: Cannot save a property named "__proto__"');
106
- }
107
- data[key] = ser((obj as Record<string, unknown>)[key]);
108
- }
109
- return data;
124
+ for (const key of Object.keys(value)) {
125
+ defineData(data, key, (value as Record<string, unknown>)[key]);
110
126
  }
127
+ return data;
128
+ }
111
129
 
112
- function serObject(val: object): unknown {
113
- if (val instanceof Date) {
114
- const valid = !Number.isNaN(val.getTime());
115
- return tagged('__Date__', { iso: valid ? val.toISOString() : null });
116
- }
117
-
118
- if (val instanceof RegExp) {
119
- return tagged('__RegExp__', { source: val.source, flags: val.flags });
120
- }
121
-
122
- if (Array.isArray(val)) {
123
- // Index by index: map() would keep holes, which JSON writes as null
124
- const result: unknown[] = [];
125
- for (let i = 0; i < val.length; i++) result.push(ser(val[i]));
126
- return result;
127
- }
128
-
129
- if (val instanceof Map) {
130
- const entries = [...val].map(([k, v]) => [ser(k), ser(v)]);
131
- return tagged('__Map__', { entries });
132
- }
133
-
134
- if (val instanceof Set) {
135
- return tagged('__Set__', { entries: [...val].map((v) => ser(v)) });
130
+ /** Make an error's message, cause and errors non-enumerable again. */
131
+ function hideErrorKeys(value: object): void {
132
+ for (const key of ERROR_HIDDEN_KEYS) {
133
+ if (hasOwn(value, key)) {
134
+ Object.defineProperty(value, key, { enumerable: false });
136
135
  }
137
-
138
- // Registered class instance
139
- const name = registeredClassName(val);
140
- if (name !== undefined) return tagged(name, serKeys(val));
141
-
142
- // Plain object. One with a key named like the class tag is wrapped, so
143
- // that it is not read back as a tagged value.
144
- const data = serKeys(val);
145
- return hasOwn(val, CLASS_TAG) ? tagged('__Object__', data) : data;
146
136
  }
147
-
148
- return ser(value) as T;
149
137
  }
150
138
 
151
- // --- Deserialize ---
139
+ /** Whether JSON.stringify writes `key` as a one-character escape sequence. */
140
+ const isEscapedOneCharKey = (key: string): boolean =>
141
+ key.length === 1 && JSON.stringify(key).length > 3;
152
142
 
153
- export function deserialize<T>(value: T): T {
154
- function deserKeys(target: object, data: Record<string, unknown>): object {
155
- for (const key of Object.keys(data)) {
156
- setOwn(target, key, deser(data[key]));
157
- }
158
- return target;
143
+ function reducers(): Record<string, (value: unknown) => unknown> {
144
+ const out: Record<string, (value: unknown) => unknown> = {};
145
+ for (const [name, ctor] of registry) {
146
+ out[CLASS_PREFIX + name] = (v) =>
147
+ isObject(v) && Object.getPrototypeOf(v) === ctor.prototype && ownData(v);
159
148
  }
160
-
161
- function deser(val: unknown): unknown {
162
- if (val === null || typeof val !== 'object') return val;
163
-
164
- if (Array.isArray(val)) {
165
- return val.map((item) => deser(item));
166
- }
167
-
168
- const obj = val as Record<string, unknown>;
169
-
170
- // Tagged value (from serialized data)
171
- if (CLASS_TAG in obj && DATA_TAG in obj) {
172
- const name = obj[CLASS_TAG] as string;
173
- const data = obj[DATA_TAG] as Record<string, unknown>;
174
-
175
- // Built-in types and values JSON cannot hold
176
- switch (name) {
177
- case '__Date__':
178
- return new Date(data.iso === null ? NaN : (data.iso as string));
179
- case '__RegExp__':
180
- return new RegExp(data.source as string, data.flags as string);
181
- case '__Map__': {
182
- const entries = data.entries as [unknown, unknown][];
183
- return new Map(entries.map(([k, v]) => [deser(k), deser(v)]));
184
- }
185
- case '__Set__':
186
- return new Set((data.entries as unknown[]).map((v) => deser(v)));
187
- case '__Undefined__':
188
- return undefined;
189
- case '__Number__':
190
- return SPECIAL_NUMBERS[data.value as string];
191
- case '__BigInt__':
192
- return BigInt(data.value as string);
193
- case '__Object__':
194
- return deserKeys({}, data);
149
+ for (const [proto, name] of BUILTIN_ERRORS) {
150
+ out[ERROR_PREFIX + name] = (v) =>
151
+ isObject(v) && Object.getPrototypeOf(v) === proto && ownData(v);
152
+ }
153
+ // A unique symbol is left to devalue, which refuses it with its path
154
+ out[SYMBOL_TAG] = (v) => {
155
+ if (typeof v !== 'symbol') return false;
156
+ const key = Symbol.keyFor(v);
157
+ return key !== undefined && [key];
158
+ };
159
+ if (GUARD_V8_KEYS) {
160
+ out[KEYS_TAG] = (v) => {
161
+ if (!isObject(v) || Object.getPrototypeOf(v) !== Object.prototype) {
162
+ return false;
195
163
  }
196
-
197
- const ctor = registry.get(name);
198
- if (!ctor) {
199
- console.warn(
200
- `spindle: Class "${name}" not registered. Falling back to plain object.`,
164
+ const keys = Object.keys(v);
165
+ if (!keys.some(isEscapedOneCharKey)) return false;
166
+ if (keys.includes('__proto__')) {
167
+ throw new TypeError(
168
+ 'spindle: Cannot save a property named "__proto__"',
201
169
  );
202
- return deserKeys({}, data);
203
170
  }
204
- return deserKeys(Object.create(ctor.prototype) as object, data);
205
- }
171
+ return keys.flatMap((k) => [k, (v as Record<string, unknown>)[k]]);
172
+ };
173
+ }
174
+ return out;
175
+ }
206
176
 
207
- // Already-live built-in — pass through as-is, so deserializing an
208
- // already-deserialized value is a no-op instead of flattening it to {}
209
- if (
210
- val instanceof Date ||
211
- val instanceof RegExp ||
212
- val instanceof Map ||
213
- val instanceof Set
214
- ) {
215
- return val;
177
+ /** Restore a registered class instance in place, so cycles through it hold. */
178
+ function reviveClass(name: string) {
179
+ return (data: Record<string, unknown>): unknown => {
180
+ if (!isObject(data) || Object.getPrototypeOf(data) !== Object.prototype) {
181
+ // Already revived: a cycle through the instance revives it twice
182
+ if (isObject(data) && registeredClassName(data) === name) return data;
183
+ throw new TypeError(`spindle: Malformed data for class "${name}"`);
184
+ }
185
+ const ctor = registry.get(name);
186
+ if (!ctor) {
187
+ throw new TypeError(
188
+ `spindle: The save holds an instance of class "${name}", which is not registered`,
189
+ );
216
190
  }
191
+ Object.setPrototypeOf(data, ctor.prototype as object);
192
+ if (data instanceof Error) hideErrorKeys(data);
193
+ return data;
194
+ };
195
+ }
217
196
 
218
- // Already-live registered class instance — pass through as-is
219
- if (registeredClassName(obj) !== undefined) {
220
- return val;
197
+ /** Restore a built-in error in place, like a class instance. */
198
+ function reviveError(name: string) {
199
+ const ctor = ERROR_CTORS.get(name);
200
+ if (!ctor) return undefined;
201
+ return (data: Record<string, unknown>): unknown => {
202
+ if (Object.getPrototypeOf(data) === ctor.prototype) return data;
203
+ if (!isObject(data) || Object.getPrototypeOf(data) !== Object.prototype) {
204
+ throw new TypeError(`spindle: Malformed data for "${name}"`);
221
205
  }
206
+ Object.setPrototypeOf(data, ctor.prototype);
207
+ hideErrorKeys(data);
208
+ return data;
209
+ };
210
+ }
222
211
 
223
- // Plain object
224
- return deserKeys({}, obj);
212
+ /**
213
+ * The object of a `K` entry list. A cycle through the object revives it
214
+ * before its list is complete (devalue holds unread entries as holes), so
215
+ * the object is kept per list and filled with what the list holds so far.
216
+ */
217
+ const keyedObjects = new WeakMap<unknown[], Record<string, unknown>>();
218
+ function reviveKeyed(entries: unknown[]): Record<string, unknown> {
219
+ if (!Array.isArray(entries) || entries.length % 2 !== 0) {
220
+ throw new TypeError('spindle: Malformed object entries');
225
221
  }
226
-
227
- return deser(value) as T;
222
+ let obj = keyedObjects.get(entries);
223
+ if (!obj) keyedObjects.set(entries, (obj = {}));
224
+ for (let i = 0; i < entries.length; i += 2) {
225
+ if (!hasOwn(entries, i)) break;
226
+ const key = entries[i];
227
+ if (typeof key !== 'string' || key === '__proto__') {
228
+ throw new TypeError('spindle: Malformed object entries');
229
+ }
230
+ defineData(obj, key, hasOwn(entries, i + 1) ? entries[i + 1] : undefined);
231
+ }
232
+ return obj;
228
233
  }
229
234
 
230
- // --- Validate ---
235
+ /** Revivers for every tag a save may hold. */
236
+ const revivers = new Proxy({} as Record<string, (value: any) => unknown>, {
237
+ getOwnPropertyDescriptor(_, key) {
238
+ const fn = reviverFor(key);
239
+ return fn && { value: fn, enumerable: true, configurable: true };
240
+ },
241
+ get: (_, key) => reviverFor(key),
242
+ });
243
+
244
+ function reviverFor(key: string | symbol) {
245
+ if (typeof key !== 'string') return undefined;
246
+ if (key === SYMBOL_TAG) {
247
+ return (data: unknown) => {
248
+ if (!Array.isArray(data) || typeof data[0] !== 'string') {
249
+ throw new TypeError('spindle: Malformed symbol');
250
+ }
251
+ return Symbol.for(data[0]);
252
+ };
253
+ }
254
+ if (key === KEYS_TAG) return reviveKeyed;
255
+ if (key.startsWith(CLASS_PREFIX)) {
256
+ return reviveClass(key.slice(CLASS_PREFIX.length));
257
+ }
258
+ if (key.startsWith(ERROR_PREFIX)) {
259
+ return reviveError(key.slice(ERROR_PREFIX.length));
260
+ }
261
+ return undefined;
262
+ }
231
263
 
232
- function isDataRecord(value: unknown): value is Record<string, unknown> {
233
- return typeof value === 'object' && value !== null && !Array.isArray(value);
264
+ /** A clearer message for devalue's refusals, with the path to the value. */
265
+ function saveError(err: unknown, value: unknown): Error {
266
+ if (!(err instanceof Error) || err.name !== 'DevalueError') {
267
+ return err instanceof Error ? err : new Error(String(err));
268
+ }
269
+ const { path, value: what } = err as Error & { path: string; value: unknown };
270
+ const at = path ? ` (at ${path})` : '';
271
+ let reason = err.message;
272
+ if (typeof what === 'function') reason = 'Cannot save a function';
273
+ else if (isObject(what) && /non-POJO/.test(err.message)) {
274
+ const name = (
275
+ Object.getPrototypeOf(what) as { constructor?: { name?: string } } | null
276
+ )?.constructor?.name;
277
+ reason = `Cannot save an instance of class "${name ?? '?'}", which is not registered (see Story.registerClass)`;
278
+ } else if (typeof what === 'symbol') {
279
+ reason = 'Cannot save a unique symbol (only Symbol.for() symbols)';
280
+ } else if (/symbolic keys/.test(err.message)) {
281
+ reason = 'Cannot save an object with symbol keys';
282
+ } else if (/__proto__/.test(err.message)) {
283
+ reason = 'Cannot save a property named "__proto__"';
284
+ }
285
+ void value;
286
+ return new TypeError(`spindle: ${reason}${at}`);
234
287
  }
235
288
 
236
289
  /**
237
- * Whether `value` is serialized data that deserialize() can restore: every
238
- * tagged value has the shape serialize() writes for its tag (a valid ISO
239
- * date or null, a compilable RegExp, Map entries as `[key, value]` pairs,
240
- * Set entries as an array, a special number's name, a bigint in decimal,
241
- * class data as an object) and no object has a "__proto__" key, at any
242
- * depth. Use it on data from outside the running story, such as an
243
- * imported save, before storing it. Tags of unregistered classes pass;
244
- * they load as plain objects.
290
+ * Turn a story value into JSON text that deserialize() restores (saves,
291
+ * exports, the session). Throws, naming the path to the value, on what a
292
+ * save cannot hold: functions, unique symbols, symbol keys, instances of
293
+ * unregistered classes, and a property named "__proto__".
245
294
  */
246
- export function isDeserializable(value: unknown): boolean {
247
- // Objects on the current path; JSON can't hold cycles, and deserialize()
248
- // can't restore them
249
- const path = new Set<object>();
250
-
251
- function check(val: unknown): boolean {
252
- if (val === null || typeof val !== 'object') return true;
253
- if (path.has(val)) return false;
254
- path.add(val);
255
- const ok = checkObject(val);
256
- path.delete(val);
257
- return ok;
295
+ export function serialize(value: unknown): string {
296
+ try {
297
+ return stringify(value, reducers());
298
+ } catch (err) {
299
+ throw saveError(err, value);
258
300
  }
301
+ }
259
302
 
260
- // A "__proto__" key is refused: serialize() never writes one, and story
261
- // state cannot hold one (see serialize)
262
- const checkKeys = (obj: Record<string, unknown>): boolean =>
263
- Object.keys(obj).every((key) => key !== '__proto__' && check(obj[key]));
264
-
265
- function checkObject(val: object): boolean {
266
- // Array.from: every() skips the holes of a sparse array, which
267
- // deserialize() restores as undefined (and a Map cannot take as entries)
268
- if (Array.isArray(val)) return Array.from(val).every(check);
269
-
270
- const obj = val as Record<string, unknown>;
271
- if (!(CLASS_TAG in obj && DATA_TAG in obj)) return checkKeys(obj);
272
-
273
- const name = obj[CLASS_TAG];
274
- const data = obj[DATA_TAG];
275
- if (typeof name !== 'string' || !isDataRecord(data)) return false;
303
+ /**
304
+ * Restore a value serialize() wrote. Throws on malformed text, and on an
305
+ * instance of a class that is not registered.
306
+ */
307
+ export function deserialize<T = unknown>(text: string): T {
308
+ return parse(text, revivers) as T;
309
+ }
276
310
 
277
- switch (name) {
278
- case '__Date__':
279
- return (
280
- data.iso === null ||
281
- (typeof data.iso === 'string' &&
282
- !Number.isNaN(new Date(data.iso).getTime()))
283
- );
284
- case '__RegExp__':
285
- if (typeof data.source !== 'string' || typeof data.flags !== 'string')
286
- return false;
287
- try {
288
- new RegExp(data.source, data.flags);
289
- return true;
290
- } catch {
291
- return false;
292
- }
293
- case '__Map__':
294
- return (
295
- Array.isArray(data.entries) &&
296
- Array.from(data.entries).every(
297
- (entry: unknown) =>
298
- Array.isArray(entry) &&
299
- entry.length === 2 &&
300
- check(entry[0]) &&
301
- check(entry[1]),
302
- )
303
- );
304
- case '__Set__':
305
- return (
306
- Array.isArray(data.entries) && Array.from(data.entries).every(check)
307
- );
308
- case '__Undefined__':
309
- return true;
310
- case '__Number__':
311
- return (
312
- typeof data.value === 'string' && hasOwn(SPECIAL_NUMBERS, data.value)
313
- );
314
- case '__BigInt__':
315
- return typeof data.value === 'string' && /^-?\d+$/.test(data.value);
316
- default:
317
- return checkKeys(data);
318
- }
311
+ /**
312
+ * Whether `text` is serialized data that deserialize() can restore. Use it
313
+ * on data from outside the running story, such as an imported save, before
314
+ * storing it.
315
+ */
316
+ export function isDeserializable(text: unknown): boolean {
317
+ if (typeof text !== 'string') return false;
318
+ try {
319
+ deserialize(text);
320
+ return true;
321
+ } catch {
322
+ return false;
319
323
  }
320
-
321
- return check(value);
322
324
  }
@@ -4,6 +4,7 @@ import { useStoryFields } from '../hooks/use-story-fields';
4
4
  import { NobrContext } from '../markup/render';
5
5
  import { StoryInterface } from './StoryInterface';
6
6
  import { TriggerDialogHost } from './TriggerDialogHost';
7
+ import { RuntimeErrors } from './RuntimeErrors';
7
8
 
8
9
  export function App() {
9
10
  const { storyData, currentPassage, nobr } = useStoryFields(
@@ -36,6 +37,7 @@ export function App() {
36
37
  <>
37
38
  <StoryInterface />
38
39
  <TriggerDialogHost />
40
+ <RuntimeErrors />
39
41
  </>
40
42
  );
41
43
 
@@ -5,8 +5,8 @@ import {
5
5
  useRef,
6
6
  useState,
7
7
  } from 'preact/hooks';
8
- import { tokenize } from '../markup/tokenizer';
9
- import { buildAST, type ASTNode } from '../markup/ast';
8
+ import { parseMarkup } from '../markup/parse';
9
+ import type { ASTNode } from '../markup/ast';
10
10
  import { renderNodes, NobrContext } from '../markup/render';
11
11
  import { useStoryFields } from '../hooks/use-story-fields';
12
12
  import type { Passage as PassageData } from '../parser';
@@ -29,7 +29,7 @@ const astCache = new WeakMap<
29
29
  function parsePassage(passage: PassageData): ASTNode[] {
30
30
  const cached = astCache.get(passage);
31
31
  if (cached && cached.content === passage.content) return cached.ast;
32
- const ast = buildAST(tokenize(passage.content));
32
+ const ast = parseMarkup(passage.content);
33
33
  astCache.set(passage, { content: passage.content, ast });
34
34
  return ast;
35
35
  }
@@ -1,7 +1,6 @@
1
1
  import { createContext } from 'preact';
2
2
  import { useCallback, useLayoutEffect, useMemo, useRef } from 'preact/hooks';
3
- import { tokenize } from '../markup/tokenizer';
4
- import { buildAST } from '../markup/ast';
3
+ import { parseMarkup } from '../markup/parse';
5
4
  import { renderNodes, NobrContext } from '../markup/render';
6
5
  import { useStoryFields } from '../hooks/use-story-fields';
7
6
  import { emitFromRender } from '../event-emitter';
@@ -53,8 +52,7 @@ export function PassageDialog({
53
52
  return <div class="error">Dialog: no content available</div>;
54
53
  }
55
54
  try {
56
- const tokens = tokenize(markup);
57
- const ast = buildAST(tokens);
55
+ const ast = parseMarkup(markup);
58
56
  const nodes = renderNodes(ast, nobr ? { nobr: true } : undefined);
59
57
  return nobr ? (
60
58
  <NobrContext.Provider value={true}>{nodes}</NobrContext.Provider>