@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.
- package/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +4512 -1702
- package/dist/pkg/macro-registry.json +7 -7
- package/dist/pkg/story-variables.js +1636 -177
- package/package.json +5 -2
- package/src/action-registry.ts +70 -18
- package/src/automation/runner.ts +2 -1
- package/src/class-registry.ts +214 -103
- package/src/components/Passage.tsx +2 -2
- package/src/components/PassageDialog.tsx +2 -5
- package/src/components/StoryInterface.tsx +2 -4
- package/src/components/macros/Button.tsx +5 -31
- package/src/components/macros/Checkbox.tsx +7 -4
- package/src/components/macros/Computed.tsx +28 -14
- package/src/components/macros/For.tsx +29 -3
- package/src/components/macros/If.tsx +8 -0
- package/src/components/macros/Include.tsx +7 -6
- package/src/components/macros/MacroError.tsx +2 -1
- package/src/components/macros/MacroLink.tsx +12 -45
- package/src/components/macros/Meter.tsx +11 -3
- package/src/components/macros/Nobr.tsx +1 -0
- package/src/components/macros/PassageDisplay.tsx +3 -0
- package/src/components/macros/Print.tsx +4 -0
- package/src/components/macros/Radiobutton.tsx +5 -2
- package/src/components/macros/SaveManager.tsx +25 -8
- package/src/components/macros/Set.tsx +5 -4
- package/src/components/macros/Span.tsx +1 -0
- package/src/components/macros/StoryTitle.tsx +1 -0
- package/src/components/macros/Switch.tsx +13 -0
- package/src/components/macros/Unset.tsx +31 -10
- package/src/components/macros/VarDisplay.tsx +21 -4
- package/src/components/macros/Widget.tsx +20 -1
- package/src/components/macros/WidgetInvocation.tsx +17 -75
- package/src/components/macros/arg-utils.ts +107 -1
- package/src/components/macros/detached-body.tsx +68 -0
- package/src/components/macros/option-utils.ts +3 -2
- package/src/define-macro.ts +32 -5
- package/src/execute-mutation.ts +271 -68
- package/src/expression.ts +91 -59
- package/src/hooks/use-action.ts +24 -3
- package/src/hooks/use-interpolate.ts +36 -5
- package/src/index.tsx +12 -2
- package/src/interpolation.ts +394 -96
- package/src/js-lexer.ts +1231 -97
- package/src/markup/ast.ts +7 -2
- package/src/markup/code-attributes.ts +64 -0
- package/src/markup/markdown.ts +188 -9
- package/src/markup/render.tsx +430 -49
- package/src/markup/tokenizer.ts +578 -110
- package/src/prng.ts +41 -8
- package/src/registry.ts +35 -0
- package/src/saves/save-manager.ts +346 -160
- package/src/saves/storage.ts +16 -7
- package/src/saves/types.ts +2 -1
- package/src/settings.ts +12 -9
- package/src/store.ts +640 -186
- package/src/story-api.ts +38 -73
- package/src/story-init.ts +1 -1
- package/src/story-variables.ts +98 -102
- package/src/triggers.ts +10 -12
- package/src/utils/counts.ts +45 -0
- package/src/utils/error-message.ts +12 -0
- package/src/utils/live-locals.ts +10 -3
- package/src/utils/namespace.ts +71 -0
- package/src/utils/object-path.ts +99 -14
- package/src/utils/stable-key.ts +14 -9
- package/src/widgets/widget-registry.ts +9 -0
- package/types/index.d.ts +43 -7
- 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.
|
|
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",
|
package/src/action-registry.ts
CHANGED
|
@@ -29,9 +29,35 @@ export interface StoryAction {
|
|
|
29
29
|
perform: (value?: unknown) => void;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
|
|
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
|
-
|
|
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
|
|
76
|
+
return id;
|
|
46
77
|
}
|
|
47
78
|
|
|
48
|
-
export function registerAction(action: StoryAction):
|
|
49
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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 {
|
package/src/automation/runner.ts
CHANGED
|
@@ -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:
|
|
39
|
+
message: errorMessage(err),
|
|
39
40
|
});
|
|
40
41
|
break;
|
|
41
42
|
}
|
package/src/class-registry.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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 (!
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
|
|
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
|
-
|
|
205
|
-
return { [CLASS_TAG]: '__Map__', [DATA_TAG]: { entries } };
|
|
311
|
+
return tagged('__Map__', { entries });
|
|
206
312
|
}
|
|
207
313
|
|
|
208
314
|
if (val instanceof Set) {
|
|
209
|
-
|
|
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
|
|
216
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
302
|
-
if (ctorToName.has(ctor)) {
|
|
399
|
+
if (registeredClassName(obj) !== undefined) {
|
|
303
400
|
return val;
|
|
304
401
|
}
|
|
305
402
|
|
|
306
403
|
// Plain object
|
|
307
|
-
|
|
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,
|
|
327
|
-
* entries as an array,
|
|
328
|
-
* data
|
|
329
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
361
|
-
|
|
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
|
|
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
|
|
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 “{passage.name}”
|
|
72
|
-
{sourceLocationOf(passage)}:{
|
|
73
|
-
{err instanceof Error ? err.message : String(err)}
|
|
73
|
+
{sourceLocationOf(passage)}: {errorMessage(err)}
|
|
74
74
|
</div>
|
|
75
75
|
);
|
|
76
76
|
}
|