@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/src/structural.ts
CHANGED
|
@@ -2,12 +2,21 @@
|
|
|
2
2
|
// and merging the differences between two versions of a value. Mutation
|
|
3
3
|
// commits, history and save hooks all go through these, so they agree on
|
|
4
4
|
// what a change is.
|
|
5
|
+
//
|
|
6
|
+
// They handle the value types a save holds (see serialize() in
|
|
7
|
+
// class-registry.ts): plain and prototype-less objects, arrays (holes kept),
|
|
8
|
+
// registered class instances, Date, RegExp, Map, Set, typed arrays,
|
|
9
|
+
// ArrayBuffer, DataView, URL, URLSearchParams, boxed primitives, errors and
|
|
10
|
+
// Temporal values, with shared references and cycles.
|
|
5
11
|
|
|
6
12
|
import { registeredClassName } from './class-registry';
|
|
7
13
|
import { hasOwn, setOwn } from './utils/namespace';
|
|
8
14
|
import { deleteByPath, getByPath, setByPath } from './utils/object-path';
|
|
15
|
+
import { isAtomic, isBoxed, isTemporal } from './utils/value-kinds';
|
|
9
16
|
|
|
10
|
-
|
|
17
|
+
export { isAtomic };
|
|
18
|
+
|
|
19
|
+
// --- Value kinds ---
|
|
11
20
|
|
|
12
21
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
13
22
|
if (typeof value !== 'object' || value === null) return false;
|
|
@@ -15,6 +24,11 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
|
15
24
|
return proto === Object.prototype || proto === null;
|
|
16
25
|
}
|
|
17
26
|
|
|
27
|
+
/** Own keys of an error that are not enumerable but are data. */
|
|
28
|
+
const ERROR_HIDDEN_KEYS = ['message', 'cause', 'errors'] as const;
|
|
29
|
+
|
|
30
|
+
// --- Deep Clone ---
|
|
31
|
+
|
|
18
32
|
export interface DeepCloneOptions {
|
|
19
33
|
/**
|
|
20
34
|
* Return instances of unregistered classes (DOM nodes, promises, other
|
|
@@ -24,31 +38,85 @@ export interface DeepCloneOptions {
|
|
|
24
38
|
keepUnregistered?: boolean;
|
|
25
39
|
}
|
|
26
40
|
|
|
41
|
+
type TypedArrayCtor = new (
|
|
42
|
+
buffer: ArrayBufferLike,
|
|
43
|
+
byteOffset: number,
|
|
44
|
+
length: number,
|
|
45
|
+
) => ArrayBufferView;
|
|
46
|
+
|
|
27
47
|
export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
|
|
28
48
|
const seen = new Map<object, object>();
|
|
29
49
|
|
|
50
|
+
/** Record `copy` as the copy of `obj` before its contents are copied. */
|
|
51
|
+
const keep = <C extends object>(obj: object, copy: C): C => {
|
|
52
|
+
seen.set(obj, copy);
|
|
53
|
+
return copy;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Copy the own (enumerable) keys of `obj` into `copy`. */
|
|
57
|
+
function copyKeys(obj: object, copy: object): object {
|
|
58
|
+
for (const key of Object.keys(obj)) {
|
|
59
|
+
setOwn(copy, key, clone((obj as Record<string, unknown>)[key]));
|
|
60
|
+
}
|
|
61
|
+
return copy;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function cloneBuiltin(val: object): object | undefined {
|
|
65
|
+
if (val instanceof Date) return keep(val, new Date(val.getTime()));
|
|
66
|
+
if (val instanceof RegExp) {
|
|
67
|
+
return keep(val, new RegExp(val.source, val.flags));
|
|
68
|
+
}
|
|
69
|
+
if (val instanceof ArrayBuffer) return keep(val, val.slice(0));
|
|
70
|
+
if (ArrayBuffer.isView(val)) {
|
|
71
|
+
// The buffer through the copies, so views of one buffer share it
|
|
72
|
+
const buffer = clone(val.buffer) as ArrayBuffer;
|
|
73
|
+
if (val instanceof DataView) {
|
|
74
|
+
return keep(val, new DataView(buffer, val.byteOffset, val.byteLength));
|
|
75
|
+
}
|
|
76
|
+
const ctor = val.constructor as TypedArrayCtor;
|
|
77
|
+
const length = (val as unknown as { length: number }).length;
|
|
78
|
+
return keep(val, new ctor(buffer, val.byteOffset, length));
|
|
79
|
+
}
|
|
80
|
+
if (val instanceof URL) return keep(val, new URL(val.href));
|
|
81
|
+
if (val instanceof URLSearchParams) {
|
|
82
|
+
return keep(val, new URLSearchParams(val));
|
|
83
|
+
}
|
|
84
|
+
if (isBoxed(val)) return keep(val, Object((val as Number).valueOf()));
|
|
85
|
+
if (isTemporal(val)) return val;
|
|
86
|
+
if (val instanceof Error) {
|
|
87
|
+
// Keeps its class (built-in, or a registered or other subclass)
|
|
88
|
+
const copy = keep(val, Object.create(Object.getPrototypeOf(val)));
|
|
89
|
+
for (const key of [...ERROR_HIDDEN_KEYS, 'stack']) {
|
|
90
|
+
if (hasOwn(val, key)) {
|
|
91
|
+
Object.defineProperty(copy, key, {
|
|
92
|
+
value: clone((val as unknown as Record<string, unknown>)[key]),
|
|
93
|
+
writable: true,
|
|
94
|
+
configurable: true,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return copyKeys(val, copy);
|
|
99
|
+
}
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
30
103
|
function clone(val: unknown): unknown {
|
|
31
104
|
if (val === null || typeof val !== 'object') return val;
|
|
32
105
|
|
|
33
106
|
const obj = val as object;
|
|
34
107
|
if (seen.has(obj)) return seen.get(obj);
|
|
35
108
|
|
|
36
|
-
if (val instanceof Date) return new Date(val.getTime()) as unknown;
|
|
37
|
-
if (val instanceof RegExp)
|
|
38
|
-
return new RegExp(val.source, val.flags) as unknown;
|
|
39
|
-
|
|
40
109
|
if (Array.isArray(val)) {
|
|
41
|
-
|
|
42
|
-
|
|
110
|
+
// Holes stay holes
|
|
111
|
+
const arr = keep(obj, new Array(val.length) as unknown[]);
|
|
43
112
|
for (let i = 0; i < val.length; i++) {
|
|
44
|
-
arr[i] = clone(val[i]);
|
|
113
|
+
if (i in val) arr[i] = clone(val[i]);
|
|
45
114
|
}
|
|
46
115
|
return arr;
|
|
47
116
|
}
|
|
48
117
|
|
|
49
118
|
if (val instanceof Map) {
|
|
50
|
-
const copy = new Map();
|
|
51
|
-
seen.set(obj, copy);
|
|
119
|
+
const copy = keep(obj, new Map());
|
|
52
120
|
for (const [k, v] of val) {
|
|
53
121
|
copy.set(clone(k), clone(v));
|
|
54
122
|
}
|
|
@@ -56,30 +124,29 @@ export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
|
|
|
56
124
|
}
|
|
57
125
|
|
|
58
126
|
if (val instanceof Set) {
|
|
59
|
-
const copy = new Set();
|
|
60
|
-
seen.set(obj, copy);
|
|
127
|
+
const copy = keep(obj, new Set());
|
|
61
128
|
for (const v of val) {
|
|
62
129
|
copy.add(clone(v));
|
|
63
130
|
}
|
|
64
131
|
return copy;
|
|
65
132
|
}
|
|
66
133
|
|
|
134
|
+
const builtin = cloneBuiltin(obj);
|
|
135
|
+
if (builtin !== undefined) return builtin;
|
|
136
|
+
|
|
67
137
|
// A registered class instance keeps its class, and a plain object its
|
|
68
138
|
// prototype (which may be null). An instance of an unregistered class
|
|
69
139
|
// is treated as plain, unless it is kept by reference.
|
|
70
140
|
const keepsProto =
|
|
71
141
|
registeredClassName(obj) !== undefined || isPlainObject(val);
|
|
72
142
|
if (!keepsProto && options.keepUnregistered) return val;
|
|
73
|
-
const copy = (
|
|
143
|
+
const copy = keep(
|
|
144
|
+
obj,
|
|
74
145
|
keepsProto
|
|
75
146
|
? Object.create(Object.getPrototypeOf(obj) as object | null)
|
|
76
|
-
: {}
|
|
77
|
-
)
|
|
78
|
-
|
|
79
|
-
for (const key of Object.keys(obj)) {
|
|
80
|
-
setOwn(copy, key, clone((obj as Record<string, unknown>)[key]));
|
|
81
|
-
}
|
|
82
|
-
return copy;
|
|
147
|
+
: {},
|
|
148
|
+
);
|
|
149
|
+
return copyKeys(obj, copy);
|
|
83
150
|
}
|
|
84
151
|
|
|
85
152
|
return clone(value) as T;
|
|
@@ -88,27 +155,179 @@ export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
|
|
|
88
155
|
// --- Deep Equal ---
|
|
89
156
|
|
|
90
157
|
/**
|
|
91
|
-
* Structural equality over the value types deepClone() supports
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* are compared by length and index
|
|
95
|
-
*
|
|
158
|
+
* Structural equality over the value types deepClone() supports, nested at
|
|
159
|
+
* any depth and with cycles. Objects must have the same prototype. Map and
|
|
160
|
+
* Set entries are compared in insertion order, so reordering one is a
|
|
161
|
+
* change. Arrays are compared by length and index; a hole differs from an
|
|
162
|
+
* undefined element. Errors are compared by class, message, cause, errors
|
|
163
|
+
* and own keys (not their stack).
|
|
96
164
|
*/
|
|
97
165
|
export function deepEqual(a: unknown, b: unknown): boolean {
|
|
98
|
-
return equal(a, b,
|
|
166
|
+
return equal(a, b, loosePairs());
|
|
99
167
|
}
|
|
100
168
|
|
|
101
169
|
/**
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
170
|
+
* Called on meeting the pair (a, b) of objects to compare: true when the
|
|
171
|
+
* pair is already being compared (a cycle: assume it equal), false when it
|
|
172
|
+
* cannot be equal, undefined to compare it.
|
|
105
173
|
*/
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
174
|
+
type Pairs = ((a: object, b: object) => boolean | undefined) & {
|
|
175
|
+
/** Identical objects are paired and compared too, not taken as equal. */
|
|
176
|
+
strict?: boolean;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Pairs for deepEqual(). Every object may pair with several others, since
|
|
181
|
+
* cycles of different lengths can still unfold to the same value, and
|
|
182
|
+
* shared references may equal distinct copies.
|
|
183
|
+
*/
|
|
184
|
+
function loosePairs(): Pairs {
|
|
185
|
+
const assumed = new Map<object, Set<object>>();
|
|
186
|
+
return (a, b) => {
|
|
187
|
+
const pairs = assumed.get(a);
|
|
188
|
+
if (pairs?.has(b)) return true;
|
|
189
|
+
if (pairs) pairs.add(b);
|
|
190
|
+
else assumed.set(a, new Set([b]));
|
|
191
|
+
return undefined;
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Pairs that also require the same reference structure: each object of one
|
|
197
|
+
* value pairs with exactly one of the other, so shared references and
|
|
198
|
+
* cycles are shared at the same places.
|
|
199
|
+
*/
|
|
200
|
+
function strictPairs(): Pairs {
|
|
201
|
+
const ab = new Map<object, object>();
|
|
202
|
+
const ba = new Map<object, object>();
|
|
203
|
+
const pairs: Pairs = (a, b) => {
|
|
204
|
+
const known = ab.get(a);
|
|
205
|
+
if (known !== undefined) return known === b;
|
|
206
|
+
if (ba.has(b)) return false;
|
|
207
|
+
ab.set(a, b);
|
|
208
|
+
ba.set(b, a);
|
|
209
|
+
return undefined;
|
|
210
|
+
};
|
|
211
|
+
pairs.strict = true;
|
|
212
|
+
return pairs;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Whether `a` and `b` are equal as deepEqual() has it and also share
|
|
217
|
+
* references at the same places (a save of one loads as the other).
|
|
218
|
+
*/
|
|
219
|
+
export function deepEqualStrict(a: unknown, b: unknown): boolean {
|
|
220
|
+
return equal(a, b, strictPairs());
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* `curr` with each part that equals (see deepEqualStrict) the part of
|
|
225
|
+
* `prev` at the same place replaced by `prev`'s: the history moments of a
|
|
226
|
+
* save then share what did not change between them, which a save stores
|
|
227
|
+
* once. Containers on the way to a change are copied; neither value is
|
|
228
|
+
* changed. Shared references and cycles within `curr` stay so.
|
|
229
|
+
*/
|
|
230
|
+
export function shareEqual<T>(prev: unknown, curr: T): T {
|
|
231
|
+
const done = new Map<object, unknown>();
|
|
232
|
+
function share(p: unknown, c: unknown): unknown {
|
|
233
|
+
if (p === c || !isObjectValue(c) || !isObjectValue(p)) return c;
|
|
234
|
+
if (done.has(c)) return done.get(c);
|
|
235
|
+
if (deepEqualStrict(p, c)) {
|
|
236
|
+
done.set(c, p);
|
|
237
|
+
return p;
|
|
238
|
+
}
|
|
239
|
+
if (c instanceof Map && p instanceof Map) {
|
|
240
|
+
const out = new Map();
|
|
241
|
+
done.set(c, out);
|
|
242
|
+
for (const [k, v] of c) out.set(k, p.has(k) ? share(p.get(k), v) : v);
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
if (Array.isArray(c) && Array.isArray(p)) {
|
|
246
|
+
const out = new Array(c.length) as unknown[];
|
|
247
|
+
done.set(c, out);
|
|
248
|
+
for (let i = 0; i < c.length; i++) {
|
|
249
|
+
if (i in c) out[i] = i < p.length ? share(p[i], c[i]) : c[i];
|
|
250
|
+
}
|
|
251
|
+
return out;
|
|
252
|
+
}
|
|
253
|
+
if (mergesWith(p, c, false)) {
|
|
254
|
+
const out = Object.create(Object.getPrototypeOf(c) as object | null);
|
|
255
|
+
done.set(c, out);
|
|
256
|
+
const cr = c as Record<string, unknown>;
|
|
257
|
+
for (const key of Object.keys(cr)) {
|
|
258
|
+
setOwn(out, key, hasOwn(p, key) ? share(p[key], cr[key]) : cr[key]);
|
|
259
|
+
}
|
|
260
|
+
return out;
|
|
261
|
+
}
|
|
262
|
+
done.set(c, c);
|
|
263
|
+
return c;
|
|
264
|
+
}
|
|
265
|
+
return share(prev, curr) as T;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const isObjectValue = (v: unknown): v is object =>
|
|
269
|
+
typeof v === 'object' && v !== null;
|
|
270
|
+
|
|
271
|
+
function equalBytes(a: ArrayBufferView | ArrayBuffer, b: typeof a): boolean {
|
|
272
|
+
const x = ArrayBuffer.isView(a)
|
|
273
|
+
? new Uint8Array(a.buffer, a.byteOffset, a.byteLength)
|
|
274
|
+
: new Uint8Array(a);
|
|
275
|
+
const y = ArrayBuffer.isView(b)
|
|
276
|
+
? new Uint8Array(b.buffer, b.byteOffset, b.byteLength)
|
|
277
|
+
: new Uint8Array(b as ArrayBuffer);
|
|
278
|
+
if (x.length !== y.length) return false;
|
|
279
|
+
for (let i = 0; i < x.length; i++) if (x[i] !== y[i]) return false;
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Equality of the content of atomic built-ins (see isAtomic) but Map/Set. */
|
|
284
|
+
function equalBuiltin(
|
|
285
|
+
a: object,
|
|
286
|
+
b: object,
|
|
287
|
+
assumed: Pairs,
|
|
288
|
+
): boolean | undefined {
|
|
289
|
+
if (a instanceof Date) return Object.is(a.getTime(), (b as Date).getTime());
|
|
290
|
+
if (a instanceof RegExp) return String(a) === String(b);
|
|
291
|
+
if (ArrayBuffer.isView(a) || a instanceof ArrayBuffer) {
|
|
292
|
+
return equalBytes(a, b as ArrayBuffer);
|
|
293
|
+
}
|
|
294
|
+
if (a instanceof URL || a instanceof URLSearchParams) {
|
|
295
|
+
return String(a) === String(b);
|
|
296
|
+
}
|
|
297
|
+
if (isBoxed(a)) {
|
|
298
|
+
return Object.is((a as Number).valueOf(), (b as Number).valueOf());
|
|
299
|
+
}
|
|
300
|
+
if (isTemporal(a)) return String(a) === String(b);
|
|
301
|
+
if (a instanceof Error) {
|
|
302
|
+
return (
|
|
303
|
+
ERROR_HIDDEN_KEYS.every(
|
|
304
|
+
(key) =>
|
|
305
|
+
hasOwn(a, key) === hasOwn(b, key) &&
|
|
306
|
+
equal(
|
|
307
|
+
(a as unknown as Record<string, unknown>)[key],
|
|
308
|
+
(b as unknown as Record<string, unknown>)[key],
|
|
309
|
+
assumed,
|
|
310
|
+
),
|
|
311
|
+
) && equalKeys(a, b, assumed)
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
return undefined;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function equalKeys(a: object, b: object, assumed: Pairs): boolean {
|
|
318
|
+
const ao = a as Record<string, unknown>;
|
|
319
|
+
const bo = b as Record<string, unknown>;
|
|
320
|
+
const keys = Object.keys(ao);
|
|
321
|
+
if (keys.length !== Object.keys(bo).length) return false;
|
|
322
|
+
for (const key of keys) {
|
|
323
|
+
if (!hasOwn(bo, key) || !equal(ao[key], bo[key], assumed)) return false;
|
|
324
|
+
}
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** `assumed` tracks the pairs of objects met (see Pairs). */
|
|
329
|
+
function equal(a: unknown, b: unknown, assumed: Pairs): boolean {
|
|
330
|
+
if (Object.is(a, b) && !(assumed.strict && isObjectValue(a))) return true;
|
|
112
331
|
if (
|
|
113
332
|
a === null ||
|
|
114
333
|
b === null ||
|
|
@@ -118,13 +337,9 @@ function equal(
|
|
|
118
337
|
return false;
|
|
119
338
|
}
|
|
120
339
|
if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;
|
|
121
|
-
const
|
|
122
|
-
if (
|
|
123
|
-
if (pairs) pairs.add(b);
|
|
124
|
-
else assumed.set(a, new Set([b]));
|
|
340
|
+
const met = assumed(a, b);
|
|
341
|
+
if (met !== undefined) return met;
|
|
125
342
|
|
|
126
|
-
if (a instanceof Date) return Object.is(a.getTime(), (b as Date).getTime());
|
|
127
|
-
if (a instanceof RegExp) return String(a) === String(b);
|
|
128
343
|
if (a instanceof Map || a instanceof Set) {
|
|
129
344
|
const bc = b as Map<unknown, unknown> | Set<unknown>;
|
|
130
345
|
if (a.size !== bc.size) return false;
|
|
@@ -140,42 +355,36 @@ function equal(
|
|
|
140
355
|
return true;
|
|
141
356
|
}
|
|
142
357
|
|
|
358
|
+
const builtin = equalBuiltin(a, b, assumed);
|
|
359
|
+
if (builtin !== undefined) return builtin;
|
|
360
|
+
|
|
143
361
|
if (Array.isArray(a)) {
|
|
144
362
|
const bc = b as unknown[];
|
|
145
363
|
if (a.length !== bc.length) return false;
|
|
146
364
|
for (let i = 0; i < a.length; i++) {
|
|
147
|
-
if (!equal(a[i], bc[i], assumed)) return false;
|
|
365
|
+
if (i in a !== i in bc || !equal(a[i], bc[i], assumed)) return false;
|
|
148
366
|
}
|
|
149
367
|
return true;
|
|
150
368
|
}
|
|
151
369
|
|
|
152
|
-
|
|
153
|
-
const bo = b as Record<string, unknown>;
|
|
154
|
-
const keys = Object.keys(ao);
|
|
155
|
-
if (keys.length !== Object.keys(bo).length) return false;
|
|
156
|
-
for (const key of keys) {
|
|
157
|
-
if (!hasOwn(bo, key) || !equal(ao[key], bo[key], assumed)) return false;
|
|
158
|
-
}
|
|
159
|
-
return true;
|
|
370
|
+
return equalKeys(a, b, assumed);
|
|
160
371
|
}
|
|
161
372
|
|
|
162
373
|
// --- Structural diff ---
|
|
163
374
|
|
|
164
375
|
/**
|
|
165
376
|
* Objects merged property by property: plain objects and (registered)
|
|
166
|
-
* class instances. Arrays
|
|
167
|
-
*
|
|
168
|
-
* merge by (a shift moves
|
|
377
|
+
* class instances. Arrays and atomic built-ins (Map, Set, Date, typed
|
|
378
|
+
* arrays, errors..., see isAtomic) are values that are replaced as a whole,
|
|
379
|
+
* since their elements have no stable identity to merge by (a shift moves
|
|
380
|
+
* every index) or their content is not in their keys.
|
|
169
381
|
*/
|
|
170
382
|
export function isMergeable(value: unknown): value is Record<string, unknown> {
|
|
171
383
|
return (
|
|
172
384
|
typeof value === 'object' &&
|
|
173
385
|
value !== null &&
|
|
174
386
|
!Array.isArray(value) &&
|
|
175
|
-
!(value
|
|
176
|
-
!(value instanceof Set) &&
|
|
177
|
-
!(value instanceof Date) &&
|
|
178
|
-
!(value instanceof RegExp)
|
|
387
|
+
!isAtomic(value)
|
|
179
388
|
);
|
|
180
389
|
}
|
|
181
390
|
|
package/src/styles.css
CHANGED
|
@@ -118,6 +118,51 @@ tw-storydata {
|
|
|
118
118
|
background: rgba(239, 83, 80, 0.1);
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/* Runtime errors shown on the page (see src/components/RuntimeErrors.tsx) */
|
|
122
|
+
.spindle-error-banners {
|
|
123
|
+
position: fixed;
|
|
124
|
+
top: 0.75em;
|
|
125
|
+
left: 50%;
|
|
126
|
+
z-index: 300;
|
|
127
|
+
display: flex;
|
|
128
|
+
flex-direction: column;
|
|
129
|
+
gap: 0.5em;
|
|
130
|
+
width: min(42em, calc(100% - 1.5em));
|
|
131
|
+
transform: translateX(-50%);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.spindle-error-banner {
|
|
135
|
+
position: relative;
|
|
136
|
+
padding: 0.75em 2.75em 0.75em 1em;
|
|
137
|
+
border: 1px solid #ef5350;
|
|
138
|
+
border-radius: 4px;
|
|
139
|
+
background: #2b1a1e;
|
|
140
|
+
color: #ffcdd2;
|
|
141
|
+
font-size: 0.9em;
|
|
142
|
+
line-height: 1.5;
|
|
143
|
+
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.5);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.spindle-error-banner-message {
|
|
147
|
+
font-family: monospace;
|
|
148
|
+
color: #ef9a9a;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.spindle-error-banner-dismiss {
|
|
152
|
+
position: absolute;
|
|
153
|
+
top: 0.5em;
|
|
154
|
+
right: 0.5em;
|
|
155
|
+
background: transparent;
|
|
156
|
+
border: none;
|
|
157
|
+
color: inherit;
|
|
158
|
+
font-size: 1em;
|
|
159
|
+
cursor: pointer;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.spindle-error-banner-dismiss:hover {
|
|
163
|
+
color: #fff;
|
|
164
|
+
}
|
|
165
|
+
|
|
121
166
|
.loading {
|
|
122
167
|
text-align: center;
|
|
123
168
|
padding: 2em;
|
package/src/tooling.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the tooling entry point (`@rohal12/spindle/tooling`) bundles as
|
|
3
|
+
* dist/pkg/story-variables.js: the StoryVariables parser and the markup
|
|
4
|
+
* validator, with no Preact or DOM dependencies. pkg/tooling.js re-exports
|
|
5
|
+
* them, binding the validator to its macro metadata.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
validateMarkup,
|
|
9
|
+
type MarkupDiagnostic,
|
|
10
|
+
type MarkupPassage,
|
|
11
|
+
} from './markup/validate';
|
|
12
|
+
import { isBlockMacro } from './markup/ast';
|
|
13
|
+
import { blockWidgetNames } from './widgets/widget-def';
|
|
14
|
+
|
|
15
|
+
export { parseStoryVariables } from './story-variables';
|
|
16
|
+
export { formatDiagnostic } from './markup/validate';
|
|
17
|
+
export type { MarkupDiagnostic, MarkupPassage } from './markup/validate';
|
|
18
|
+
|
|
19
|
+
/** What tooling knows about a macro (see MacroMetadata). */
|
|
20
|
+
export interface ToolingMacro {
|
|
21
|
+
name: string;
|
|
22
|
+
block: boolean;
|
|
23
|
+
subMacros: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Validate the markup of a story's passages as Spindle does when the story
|
|
28
|
+
* starts, against the given macros (built-in and user-defined): malformed
|
|
29
|
+
* markup and unknown macros, with their passage, line and column.
|
|
30
|
+
*/
|
|
31
|
+
export function validateStoryMarkup(
|
|
32
|
+
passages: Iterable<MarkupPassage>,
|
|
33
|
+
macros: Iterable<ToolingMacro>,
|
|
34
|
+
): MarkupDiagnostic[] {
|
|
35
|
+
const list = [...passages];
|
|
36
|
+
const known = new Set<string>();
|
|
37
|
+
const blocks = new Set(blockWidgetNames(list).map((n) => n.toLowerCase()));
|
|
38
|
+
for (const macro of macros) {
|
|
39
|
+
const name = macro.name.toLowerCase();
|
|
40
|
+
known.add(name);
|
|
41
|
+
for (const sub of macro.subMacros) known.add(sub.toLowerCase());
|
|
42
|
+
if (macro.block) blocks.add(name);
|
|
43
|
+
}
|
|
44
|
+
return validateMarkup(list, {
|
|
45
|
+
isKnownMacro: (name) => known.has(name),
|
|
46
|
+
macroNames: known,
|
|
47
|
+
isBlockMacro: (name) =>
|
|
48
|
+
blocks.has(name.toLowerCase()) || isBlockMacro(name),
|
|
49
|
+
});
|
|
50
|
+
}
|
package/src/types-drift-check.ts
CHANGED
|
@@ -21,7 +21,19 @@ import type {
|
|
|
21
21
|
MacroProps as PublishedMacroProps,
|
|
22
22
|
ASTNode as PublishedASTNode,
|
|
23
23
|
} from '../types/index';
|
|
24
|
-
import type {
|
|
24
|
+
import type {
|
|
25
|
+
parseStoryVariables as PublishedParse,
|
|
26
|
+
formatDiagnostic as PublishedFormatDiagnostic,
|
|
27
|
+
MarkupDiagnostic as PublishedMarkupDiagnostic,
|
|
28
|
+
MarkupPassage as PublishedMarkupPassage,
|
|
29
|
+
validateMarkup as PublishedValidateMarkup,
|
|
30
|
+
} from '../types/tooling';
|
|
31
|
+
import type {
|
|
32
|
+
formatDiagnostic as SourceFormatDiagnostic,
|
|
33
|
+
MarkupDiagnostic as SourceMarkupDiagnostic,
|
|
34
|
+
MarkupPassage as SourceMarkupPassage,
|
|
35
|
+
validateStoryMarkup as SourceValidateStoryMarkup,
|
|
36
|
+
} from './tooling';
|
|
25
37
|
import type { bootStory as PublishedBootStory } from '../types/headless';
|
|
26
38
|
|
|
27
39
|
/** What the source declares, by published name. */
|
|
@@ -30,6 +42,11 @@ interface Source {
|
|
|
30
42
|
// Tooling entry point (`@rohal12/spindle/tooling`): the parser that
|
|
31
43
|
// dist/pkg/tooling.js re-exports.
|
|
32
44
|
parseStoryVariables: typeof SourceParse;
|
|
45
|
+
// Its markup validator (pkg/tooling.js binds validateStoryMarkup to the
|
|
46
|
+
// macro metadata, so validateMarkup itself takes only the passages).
|
|
47
|
+
MarkupPassage: SourceMarkupPassage;
|
|
48
|
+
MarkupDiagnostic: SourceMarkupDiagnostic;
|
|
49
|
+
formatDiagnostic: typeof SourceFormatDiagnostic;
|
|
33
50
|
// Custom macro API: the MacroContext/MacroDefinition defineMacro()
|
|
34
51
|
// actually passes and accepts.
|
|
35
52
|
MacroContext: SourceMacroContext;
|
|
@@ -44,6 +61,9 @@ interface Source {
|
|
|
44
61
|
interface Published {
|
|
45
62
|
StoryAPI: PublishedAPI;
|
|
46
63
|
parseStoryVariables: typeof PublishedParse;
|
|
64
|
+
MarkupPassage: PublishedMarkupPassage;
|
|
65
|
+
MarkupDiagnostic: PublishedMarkupDiagnostic;
|
|
66
|
+
formatDiagnostic: typeof PublishedFormatDiagnostic;
|
|
47
67
|
MacroContext: PublishedMacroContext;
|
|
48
68
|
MacroDefinition: PublishedMacroDefinition;
|
|
49
69
|
MacroProps: PublishedMacroProps;
|
|
@@ -90,3 +110,10 @@ const _exampleMacro: PublishedMacroDefinition = {
|
|
|
90
110
|
);
|
|
91
111
|
},
|
|
92
112
|
};
|
|
113
|
+
|
|
114
|
+
// pkg/tooling.js's validateMarkup is validateStoryMarkup bound to the macro
|
|
115
|
+
// metadata.
|
|
116
|
+
declare const validateStoryMarkup: typeof SourceValidateStoryMarkup;
|
|
117
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
118
|
+
const _validateMarkup: typeof PublishedValidateMarkup = (passages) =>
|
|
119
|
+
validateStoryMarkup(passages, []);
|
package/src/utils/object-path.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { isDraft } from 'immer';
|
|
1
|
+
import { current as draftState, isDraft } from 'immer';
|
|
2
2
|
import { hasOwn } from './namespace';
|
|
3
|
+
import { atomicName } from './value-kinds';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Traverse dot-path segments on an object and return the nested value.
|
|
@@ -29,22 +30,13 @@ const isArrayKey = (key: string): boolean =>
|
|
|
29
30
|
* Built-ins whose content is not their properties: a property written into
|
|
30
31
|
* one would be dropped by clones and saves (and by Immer, for Map and Set).
|
|
31
32
|
*/
|
|
32
|
-
const builtinName =
|
|
33
|
-
value instanceof Map
|
|
34
|
-
? 'a Map'
|
|
35
|
-
: value instanceof Set
|
|
36
|
-
? 'a Set'
|
|
37
|
-
: value instanceof Date
|
|
38
|
-
? 'a Date'
|
|
39
|
-
: value instanceof RegExp
|
|
40
|
-
? 'a RegExp'
|
|
41
|
-
: undefined;
|
|
33
|
+
const builtinName = atomicName;
|
|
42
34
|
|
|
43
35
|
/**
|
|
44
36
|
* Shallow copy that keeps the prototype, so a registered class instance
|
|
45
37
|
* stays an instance of its class (with the same own keys deepClone copies).
|
|
46
38
|
*/
|
|
47
|
-
function shallowCopy(value: object): Record<string, unknown> {
|
|
39
|
+
export function shallowCopy(value: object): Record<string, unknown> {
|
|
48
40
|
const copy = Array.isArray(value)
|
|
49
41
|
? []
|
|
50
42
|
: (Object.create(Object.getPrototypeOf(value) as object | null) as object);
|
|
@@ -120,6 +112,15 @@ export function deleteByPath(
|
|
|
120
112
|
return;
|
|
121
113
|
}
|
|
122
114
|
const parent = walkToParent(root, segments, false);
|
|
115
|
+
if (Array.isArray(parent) && isDraft(parent) && segments.length > 1) {
|
|
116
|
+
// Immer turns `delete` of an array element into writing undefined: to
|
|
117
|
+
// leave a hole, as `delete` does, put in a copy of the array with one
|
|
118
|
+
const copy = (draftState(parent) as unknown[]).slice();
|
|
119
|
+
delete copy[Number(last)];
|
|
120
|
+
const holder = walkToParent(root, segments.slice(0, -1), false);
|
|
121
|
+
holder[segments[segments.length - 2]!] = copy;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
123
124
|
delete parent[last];
|
|
124
125
|
}
|
|
125
126
|
|
package/src/utils/stable-key.ts
CHANGED
|
@@ -55,7 +55,8 @@ function keyOf(val: unknown, ancestors: object[]): string {
|
|
|
55
55
|
try {
|
|
56
56
|
if (Array.isArray(val)) {
|
|
57
57
|
// Array.from reads a hole as undefined (map would skip it, giving
|
|
58
|
-
// `[,]` the key of `[]`)
|
|
58
|
+
// `[,]` the key of `[]`): as JSON.stringify, a key does not tell a
|
|
59
|
+
// hole from an undefined element (deepEqual does)
|
|
59
60
|
return `[${Array.from(val, (v) => keyOf(v, ancestors)).join(',')}]`;
|
|
60
61
|
}
|
|
61
62
|
if (val instanceof Map) {
|