@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.
- package/README.md +5 -1
- package/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +6484 -2558
- package/dist/pkg/story-variables.js +4009 -940
- package/dist/pkg/tooling.js +13 -1
- package/package.json +6 -1
- package/src/class-registry.ts +252 -250
- package/src/components/App.tsx +2 -0
- package/src/components/Passage.tsx +3 -3
- package/src/components/PassageDialog.tsx +2 -4
- package/src/components/RuntimeErrors.tsx +46 -0
- package/src/components/StoryInterface.tsx +2 -4
- package/src/components/macros/Computed.tsx +5 -2
- package/src/components/macros/Do.tsx +4 -2
- package/src/components/macros/Goto.tsx +13 -3
- package/src/components/macros/Include.tsx +2 -3
- package/src/components/macros/MacroError.tsx +14 -7
- package/src/components/macros/SaveManager.tsx +2 -2
- package/src/components/macros/VarDisplay.tsx +1 -1
- package/src/components/macros/Widget.tsx +5 -33
- package/src/index.tsx +57 -37
- package/src/interpolation.ts +4 -5
- package/src/markup/ast.ts +11 -226
- package/src/markup/code-attributes.ts +3 -3
- package/src/markup/code-end.ts +121 -0
- package/src/markup/parse.ts +118 -0
- package/src/markup/render.tsx +1 -1
- package/src/markup/spindle.d.peggy.ts +24 -0
- package/src/markup/spindle.peggy +463 -0
- package/src/markup/tokens.ts +95 -0
- package/src/markup/validate.ts +219 -0
- package/src/parser.ts +19 -4
- package/src/runtime-errors.ts +65 -0
- package/src/saves/format.ts +199 -0
- package/src/saves/save-manager.ts +45 -63
- package/src/saves/types.ts +71 -88
- package/src/store.ts +167 -87
- package/src/story-init.ts +3 -5
- package/src/story-variables.ts +18 -4
- package/src/structural.ts +266 -57
- package/src/styles.css +45 -0
- package/src/tooling.ts +50 -0
- package/src/types-drift-check.ts +28 -1
- package/src/utils/object-path.ts +13 -12
- package/src/utils/stable-key.ts +2 -1
- package/src/utils/value-kinds.ts +45 -0
- package/src/widgets/widget-def.ts +60 -0
- package/types/index.d.ts +32 -8
- package/types/tooling.d.ts +42 -0
- package/src/markup/tokenizer.ts +0 -1112
package/dist/pkg/tooling.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|
package/src/class-registry.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Class registry for preserving class instances across clone/save/load cycles.
|
|
2
2
|
|
|
3
|
-
import {
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
*
|
|
67
|
-
*
|
|
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
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
198
|
-
if (
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
205
|
-
}
|
|
171
|
+
return keys.flatMap((k) => [k, (v as Record<string, unknown>)[k]]);
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
206
176
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
233
|
-
|
|
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
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
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
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
}
|
package/src/components/App.tsx
CHANGED
|
@@ -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 {
|
|
9
|
-
import {
|
|
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 =
|
|
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 {
|
|
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
|
|
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>
|