@rohal12/spindle 0.59.14 → 0.59.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.59.14",
3
+ "version": "0.59.15",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -2,14 +2,48 @@
2
2
 
3
3
  import { parse, stringify } from 'devalue';
4
4
  import { hasOwn } from './utils/namespace';
5
- import { extraKeys } from './utils/value-kinds';
5
+ import { extraKeys, mapEntries, setMembers } from './utils/value-kinds';
6
6
 
7
7
  type Constructor = new (...args: any[]) => any;
8
8
 
9
9
  const registry = new Map<string, Constructor>();
10
10
  const ctorToName = new Map<Constructor, string>();
11
11
 
12
+ /**
13
+ * Built-ins whose instances hold their value in internal slots that a save
14
+ * cannot rebuild: a registered class cannot extend them. (Array, Map, Set,
15
+ * Date, RegExp and Error can be extended, see classData.)
16
+ */
17
+ const UNSUPPORTED_BASES: readonly Constructor[] = [
18
+ ArrayBuffer,
19
+ DataView,
20
+ Object.getPrototypeOf(Int8Array) as Constructor, // every typed array
21
+ URL,
22
+ URLSearchParams,
23
+ Number,
24
+ String,
25
+ Boolean,
26
+ Promise,
27
+ WeakMap,
28
+ WeakSet,
29
+ Function,
30
+ ] as Constructor[];
31
+
32
+ /**
33
+ * Register `ctor` as `name`, so that its instances keep their class through
34
+ * copies, history and saves. Throws for a class extending a built-in whose
35
+ * instances cannot be saved (see UNSUPPORTED_BASES).
36
+ */
12
37
  export function registerClass(name: string, ctor: Constructor): void {
38
+ const base =
39
+ typeof ctor === 'function'
40
+ ? UNSUPPORTED_BASES.find((b) => ctor.prototype instanceof b)
41
+ : undefined;
42
+ if (base) {
43
+ throw new TypeError(
44
+ `spindle: Cannot register class "${name}": it extends ${base.name}, whose instances cannot be saved (a registered class may extend Array, Map, Set, Date, RegExp or Error)`,
45
+ );
46
+ }
13
47
  registry.set(name, ctor);
14
48
  ctorToName.set(ctor, name);
15
49
  }
@@ -48,8 +82,9 @@ export function clearRegistry(): void {
48
82
  //
49
83
  // - `c:<name>`: an instance of the class registered as <name>, with its own
50
84
  // enumerable keys (and, for Error subclasses, its message and cause); for
51
- // a subclass of Array, Map or Set, `[kind, contents, keys]`: the kind of
52
- // collection, its elements (Map entries as pairs) and its other own
85
+ // a subclass of Array, Map, Set, Date or RegExp, `[kind, contents, keys]`:
86
+ // the built-in it extends, what that holds (elements, Map entries as
87
+ // pairs, the time, or the source, flags and lastIndex) and its other own
53
88
  // enumerable keys;
54
89
  // - `E:<name>`: a built-in error (Error, TypeError, ..., AggregateError),
55
90
  // with its message, cause, errors and own enumerable keys (not its stack);
@@ -155,18 +190,24 @@ function ownData(
155
190
  return data;
156
191
  }
157
192
 
158
- /** The kinds of collection a registered class may extend. */
159
- const COLLECTION_KINDS = {
193
+ /**
194
+ * The built-ins a registered class may extend whose value is not in its
195
+ * keys, saved as their kind (see classData).
196
+ */
197
+ const BUILTIN_KINDS = {
160
198
  Array: Array as unknown as Constructor,
161
199
  Map: Map as Constructor,
162
200
  Set: Set as Constructor,
201
+ Date: Date as Constructor,
202
+ RegExp: RegExp as Constructor,
163
203
  };
164
- type CollectionKind = keyof typeof COLLECTION_KINDS;
204
+ type BuiltinKind = keyof typeof BUILTIN_KINDS;
165
205
 
166
206
  /**
167
207
  * The saved form of a registered class instance: its own data (see
168
- * ownData), or for a collection its kind, elements and extra keys, read
169
- * with the built-in methods, which a subclass may override (#391).
208
+ * ownData), or for a subclass of a built-in kind (see BUILTIN_KINDS) its
209
+ * kind, contents and extra keys, read with the built-in methods, which a
210
+ * subclass may override (#391, #393).
170
211
  */
171
212
  function classData(value: object): unknown {
172
213
  const extra = () => ownData(value, extraKeys(value));
@@ -179,10 +220,18 @@ function classData(value: object): unknown {
179
220
  return ['Array', items, extra()];
180
221
  }
181
222
  if (value instanceof Map) {
182
- return ['Map', Array.from(Map.prototype.entries.call(value)), extra()];
223
+ return ['Map', Array.from(mapEntries(value)), extra()];
183
224
  }
184
225
  if (value instanceof Set) {
185
- return ['Set', Array.from(Set.prototype.values.call(value)), extra()];
226
+ return ['Set', Array.from(setMembers(value)), extra()];
227
+ }
228
+ if (value instanceof Date) {
229
+ return ['Date', Date.prototype.getTime.call(value), extra()];
230
+ }
231
+ if (value instanceof RegExp) {
232
+ const source = Reflect.get(RegExp.prototype, 'source', value) as string;
233
+ const flags = Reflect.get(RegExp.prototype, 'flags', value) as string;
234
+ return ['RegExp', [source, flags, value.lastIndex], extra()];
186
235
  }
187
236
  return ownData(value);
188
237
  }
@@ -241,33 +290,64 @@ function reducers(): Record<string, (value: unknown) => unknown> {
241
290
  }
242
291
 
243
292
  /**
244
- * The collection of a registered class that a `[kind, contents, keys]`
245
- * list holds (see classData). A cycle through the collection revives it
246
- * before its list is complete (devalue holds unread parts as holes), so the
247
- * collection is kept per list and filled with what the list holds so far.
293
+ * The RegExp of a `[source, flags, lastIndex]` list (an `R` entry, or the
294
+ * contents of a RegExp subclass), or undefined if the list is malformed.
295
+ */
296
+ function newRegExp(data: unknown): RegExp | undefined {
297
+ if (
298
+ !Array.isArray(data) ||
299
+ typeof data[0] !== 'string' ||
300
+ typeof data[1] !== 'string' ||
301
+ !Number.isSafeInteger(data[2])
302
+ ) {
303
+ return undefined;
304
+ }
305
+ const re = new RegExp(data[0], data[1]);
306
+ re.lastIndex = data[2] as number;
307
+ return re;
308
+ }
309
+
310
+ /**
311
+ * A new Date or RegExp holding `contents` (see classData), or undefined if
312
+ * they are malformed.
313
+ */
314
+ function newAtomic(kind: 'Date' | 'RegExp', contents: unknown) {
315
+ if (kind === 'RegExp') return newRegExp(contents);
316
+ return typeof contents === 'number' ? new Date(contents) : undefined;
317
+ }
318
+
319
+ /**
320
+ * The built-in of a registered class that a `[kind, contents, keys]` list
321
+ * holds (see classData). A cycle through a collection revives it before its
322
+ * list is complete (devalue holds unread parts as holes), so the instance
323
+ * is kept per list and filled with what the list holds so far. (The
324
+ * contents of a Date or RegExp hold no objects, so they are complete.)
248
325
  */
249
326
  const collections = new WeakMap<unknown[], object>();
250
- function reviveCollection(name: string, ctor: Constructor, list: unknown[]) {
327
+ function reviveBuiltin(name: string, ctor: Constructor, list: unknown[]) {
251
328
  const [kind, contents, keys] = list;
252
329
  const base =
253
- typeof kind === 'string' && hasOwn(COLLECTION_KINDS, kind)
254
- ? COLLECTION_KINDS[kind as CollectionKind]
330
+ typeof kind === 'string' && hasOwn(BUILTIN_KINDS, kind)
331
+ ? BUILTIN_KINDS[kind as BuiltinKind]
255
332
  : undefined;
333
+ const atomic = kind === 'Date' || kind === 'RegExp';
256
334
  if (
257
335
  list.length !== 3 ||
258
336
  !base ||
259
337
  !(ctor.prototype instanceof base) ||
260
- (hasOwn(list, 1) && !Array.isArray(contents)) ||
338
+ (!atomic && hasOwn(list, 1) && !Array.isArray(contents)) ||
261
339
  (hasOwn(list, 2) && !isPlainData(keys))
262
340
  ) {
263
341
  throw malformedClass(name);
264
342
  }
265
343
  let made = collections.get(list);
266
344
  if (!made) {
267
- made = Object.setPrototypeOf(new base(), ctor.prototype) as object;
345
+ const value = atomic ? newAtomic(kind, contents) : (new base() as object);
346
+ if (!value) throw malformedClass(name);
347
+ made = Object.setPrototypeOf(value, ctor.prototype) as object;
268
348
  collections.set(list, made);
269
349
  }
270
- if (Array.isArray(contents)) fillCollection(made, contents);
350
+ if (!atomic && Array.isArray(contents)) fillCollection(made, contents);
271
351
  if (isPlainData(keys)) {
272
352
  for (const key of Object.keys(keys)) defineData(made, key, keys[key]);
273
353
  }
@@ -300,7 +380,7 @@ function fillCollection(made: object, contents: unknown[]): void {
300
380
  function reviveClass(name: string) {
301
381
  return (data: Record<string, unknown> | unknown[]): unknown => {
302
382
  if (Array.isArray(data)) {
303
- return reviveCollection(name, registeredCtor(name), data);
383
+ return reviveBuiltin(name, registeredCtor(name), data);
304
384
  }
305
385
  if (!isPlainData(data)) {
306
386
  // Already revived: a cycle through the instance revives it twice
@@ -308,10 +388,8 @@ function reviveClass(name: string) {
308
388
  throw malformedClass(name);
309
389
  }
310
390
  const ctor = registeredCtor(name);
311
- // A plain object cannot be made a collection (see classData)
312
- if (
313
- Object.values(COLLECTION_KINDS).some((c) => ctor.prototype instanceof c)
314
- ) {
391
+ // A plain object cannot be made a built-in (see classData)
392
+ if (Object.values(BUILTIN_KINDS).some((c) => ctor.prototype instanceof c)) {
315
393
  throw malformedClass(name);
316
394
  }
317
395
  Object.setPrototypeOf(data, ctor.prototype as object);
@@ -391,16 +469,8 @@ function reviverFor(key: string | symbol) {
391
469
  if (key === KEYS_TAG) return reviveKeyed;
392
470
  if (key === REGEXP_TAG) {
393
471
  return (data: unknown) => {
394
- if (
395
- !Array.isArray(data) ||
396
- typeof data[0] !== 'string' ||
397
- typeof data[1] !== 'string' ||
398
- !Number.isSafeInteger(data[2])
399
- ) {
400
- throw new TypeError('spindle: Malformed regular expression');
401
- }
402
- const re = new RegExp(data[0], data[1]);
403
- re.lastIndex = data[2] as number;
472
+ const re = newRegExp(data);
473
+ if (!re) throw new TypeError('spindle: Malformed regular expression');
404
474
  return re;
405
475
  };
406
476
  }
@@ -7,7 +7,12 @@ import {
7
7
  } from 'preact/hooks';
8
8
  import { parseMarkup } from '../markup/parse';
9
9
  import type { ASTNode } from '../markup/ast';
10
- import { renderNodes, NobrContext } from '../markup/render';
10
+ import {
11
+ renderNodes,
12
+ NobrContext,
13
+ ViewScopeContext,
14
+ useViewScope,
15
+ } from '../markup/render';
11
16
  import { useStoryFields } from '../hooks/use-story-fields';
12
17
  import type { Passage as PassageData } from '../parser';
13
18
  import { sourceLocationOf } from '../utils/source-location';
@@ -130,20 +135,23 @@ export function Passage({
130
135
  }, [passage.name, navigationId]);
131
136
 
132
137
  const nobr = passage.tags.includes('nobr');
138
+ const viewScope = useViewScope();
133
139
 
134
140
  const inner = (
135
- <div
136
- ref={elRef}
137
- class="passage"
138
- data-passage={passage.name}
139
- data-tags={passage.tags.join(' ')}
140
- data-transition={dataTransition}
141
- >
142
- {headerContent && <div class="passage-header">{headerContent}</div>}
143
- {content}
144
- {footerContent && <div class="passage-footer">{footerContent}</div>}
145
- {doneContent && <div hidden>{doneContent}</div>}
146
- </div>
141
+ <ViewScopeContext.Provider value={viewScope}>
142
+ <div
143
+ ref={elRef}
144
+ class="passage"
145
+ data-passage={passage.name}
146
+ data-tags={passage.tags.join(' ')}
147
+ data-transition={dataTransition}
148
+ >
149
+ {headerContent && <div class="passage-header">{headerContent}</div>}
150
+ {content}
151
+ {footerContent && <div class="passage-footer">{footerContent}</div>}
152
+ {doneContent && <div hidden>{doneContent}</div>}
153
+ </div>
154
+ </ViewScopeContext.Provider>
147
155
  );
148
156
 
149
157
  return nobr ? (
@@ -1,7 +1,12 @@
1
1
  import { createContext } from 'preact';
2
2
  import { useCallback, useLayoutEffect, useMemo, useRef } from 'preact/hooks';
3
3
  import { parseMarkup } from '../markup/parse';
4
- import { renderNodes, NobrContext } from '../markup/render';
4
+ import {
5
+ renderNodes,
6
+ NobrContext,
7
+ ViewScopeContext,
8
+ useViewScope,
9
+ } from '../markup/render';
5
10
  import { useStoryFields } from '../hooks/use-story-fields';
6
11
  import { emitFromRender } from '../event-emitter';
7
12
  import { useModalFocus } from '../hooks/use-modal-focus';
@@ -66,6 +71,7 @@ export function PassageDialog({
66
71
  }, [markup, nobr]);
67
72
 
68
73
  const panelRef = useRef<HTMLDivElement>(null);
74
+ const viewScope = useViewScope();
69
75
 
70
76
  // Focus into the dialog, trap Tab, Escape to close, restore focus on close.
71
77
  // Declared before the dialogrender effect so handlers can move focus.
@@ -113,7 +119,11 @@ export function PassageDialog({
113
119
  ✕
114
120
  </button>
115
121
  )}
116
- <div class="dialog-body">{content}</div>
122
+ <div class="dialog-body">
123
+ <ViewScopeContext.Provider value={viewScope}>
124
+ {content}
125
+ </ViewScopeContext.Provider>
126
+ </div>
117
127
  </div>
118
128
  </div>
119
129
  </DialogCloseContext.Provider>
@@ -1,4 +1,5 @@
1
1
  import { defineMacro } from '../../define-macro';
2
+ import { ViewScopeContext } from '../../markup/render';
2
3
  import { VARIABLE_PARAMETER, useVariableAction } from './input-macro';
3
4
 
4
5
  defineMacro({
@@ -12,6 +13,8 @@ defineMacro({
12
13
  render(_props, ctx) {
13
14
  const radioValue = ctx.args.value ?? '';
14
15
  const label = ctx.args.label ?? '';
16
+ // One group per variable in each view (see ViewScopeContext)
17
+ const view = ctx.hooks.useContext(ViewScopeContext);
15
18
 
16
19
  useVariableAction(ctx, {
17
20
  type: 'radiobutton',
@@ -27,7 +30,7 @@ defineMacro({
27
30
  >
28
31
  <input
29
32
  type="radio"
30
- name={`radio-${ctx.varName}`}
33
+ name={view ? `radio-${view}-${ctx.varName}` : `radio-${ctx.varName}`}
31
34
  checked={ctx.value === radioValue}
32
35
  onChange={() => ctx.setValue!(radioValue)}
33
36
  />
@@ -26,6 +26,7 @@ import {
26
26
  } from './structural';
27
27
  import { getByPath, setByPath } from './utils/object-path';
28
28
  import { asNamespace, hasOwn } from './utils/namespace';
29
+ import { mapEntries, setMembers } from './utils/value-kinds';
29
30
 
30
31
  type NamespaceName = keyof VariableNamespaces;
31
32
 
@@ -150,11 +151,11 @@ function deeplyFrozen(value: unknown, seen = new Set<object>()): boolean {
150
151
  seen.add(value);
151
152
  // What a Map or Set holds is not in its properties (#388)
152
153
  if (value instanceof Map) {
153
- for (const [key, entry] of value) {
154
+ for (const [key, entry] of mapEntries(value)) {
154
155
  if (!deeplyFrozen(key, seen) || !deeplyFrozen(entry, seen)) return false;
155
156
  }
156
157
  } else if (value instanceof Set) {
157
- for (const member of value) {
158
+ for (const member of setMembers(value)) {
158
159
  if (!deeplyFrozen(member, seen)) return false;
159
160
  }
160
161
  }
@@ -1,5 +1,5 @@
1
1
  import { createContext, type RefObject } from 'preact';
2
- import { useContext, useLayoutEffect, useRef } from 'preact/hooks';
2
+ import { useContext, useLayoutEffect, useRef, useState } from 'preact/hooks';
3
3
  import { VarDisplay } from '../components/macros/VarDisplay';
4
4
  import { ExprDisplay } from '../components/macros/ExprDisplay';
5
5
  import { WidgetInvocation } from '../components/macros/WidgetInvocation';
@@ -58,6 +58,22 @@ export const InlineContext = createContext(false);
58
58
  */
59
59
  export const RawTextContext = createContext(false);
60
60
 
61
+ /**
62
+ * The view content is rendered in: '' for the story interface, and an id of
63
+ * its own for each passage and dialog on display (see useViewScope). The
64
+ * radiobuttons of a variable form a native group within their view only:
65
+ * one group across views would let a dialog's checked button uncheck the
66
+ * passage's when it mounts (#396).
67
+ */
68
+ export const ViewScopeContext = createContext('');
69
+
70
+ let viewScopes = 0;
71
+
72
+ /** A new view scope (see ViewScopeContext), kept while mounted. */
73
+ export function useViewScope(): string {
74
+ return useState(() => `v${++viewScopes}`)[0];
75
+ }
76
+
61
77
  /**
62
78
  * True inside an `<svg>` element. SVG attributes are case-sensitive and have
63
79
  * no live form properties, so they are set as written (see splitAttributes).
@@ -31,6 +31,11 @@ const BRANCH_PARENT = { elseif: 'if', else: 'if', case: 'switch', default: 'swit
31
31
  const BRANCHING = new Set(['if', 'switch', 'timed']);
32
32
  const LINK_SEPARATORS = [['|', false], ['->', false], ['<-', true]];
33
33
  const NO_SELECTORS = {};
34
+ /**
35
+ * A CommonMark email autolink, `<` an address `>` (without braces, which
36
+ * are markup), as markdown reads one.
37
+ */
38
+ const EMAIL_AUTOLINK = /^<[a-zA-Z0-9.!#$%&'*+/=?^_`|~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*>$/;
34
39
 
35
40
  /**
36
41
  * A link's display text and target, from its text `inner` (at `at` in the
@@ -235,6 +240,7 @@ Item
235
240
  / Escape
236
241
  / Comment
237
242
  / LinkLexeme
243
+ / EmailAutolink
238
244
  / HtmlElement
239
245
  / VoidCloser
240
246
  / Value
@@ -294,6 +300,7 @@ TokenItem
294
300
  / Escape
295
301
  / Comment
296
302
  / LinkLexeme
303
+ / EmailAutolink
297
304
  / &{ return !textMode; } tag:HtmlOpen { return tag; }
298
305
  / &{ return !textMode; } tag:HtmlClose { return VOID_TAGS.has(tag.lower) ? null : tag; }
299
306
  / Value
@@ -429,6 +436,15 @@ MacroName
429
436
 
430
437
  // -------------------------------------------------------------------- HTML
431
438
 
439
+ /**
440
+ * An email autolink (`<support@example.com>`), text that markdown makes a
441
+ * link: not an element `<support>` with an attribute `@example.com` (#395).
442
+ */
443
+ EmailAutolink
444
+ = value:$("<" [a-zA-Z0-9.!#$%&'*+/=?^_`|~-]+ "@" [a-zA-Z0-9.-]+ ">")
445
+ &{ return EMAIL_AUTOLINK.test(value); }
446
+ { return lexeme('text', location(), { value }); }
447
+
432
448
  /** A redundant closer of a void element (</br>), which is dropped. */
433
449
  VoidCloser
434
450
  = &{ return !textMode; } tag:HtmlClose &{ return VOID_TAGS.has(tag.lower); } { return null; }
package/src/store.ts CHANGED
@@ -444,6 +444,16 @@ function startVariables(defaults: Record<string, unknown>): Namespace {
444
444
  return initialVars;
445
445
  }
446
446
 
447
+ /**
448
+ * Fresh transient variables from their declared defaults (init, restart,
449
+ * load). Transients are never saved, so an instance of an unregistered
450
+ * class (an engine, a library object) is kept as it is, as mutation code
451
+ * keeps it (#321): a copy would lose its class and methods (#398).
452
+ */
453
+ function freshTransients(defaults: Record<string, unknown>): Namespace {
454
+ return createNamespace(deepClone(defaults, { keepUnregistered: true }));
455
+ }
456
+
447
457
  /**
448
458
  * Enter the start passage with `variables`, as the only history moment
449
459
  * (init, restart). Call it inside a store update.
@@ -457,7 +467,7 @@ function enterStart(
457
467
  state.currentPassage = passage;
458
468
  state.navigationId++;
459
469
  state.variables = variables;
460
- state.transient = createNamespace(deepClone(transientDefaults));
470
+ state.transient = freshTransients(transientDefaults);
461
471
  state.temporary = createNamespace();
462
472
  state.history = [{ passage, timestamp: Date.now() }];
463
473
  state.historyIndex = 0;
@@ -1719,7 +1729,7 @@ export const useStoryStore = create<StoryState>()(
1719
1729
  state.visitCounts = createCounts(payload.visitCounts);
1720
1730
  state.renderCounts = createCounts(payload.renderCounts);
1721
1731
  state.temporary = createNamespace();
1722
- state.transient = createNamespace(deepClone(get().transientDefaults));
1732
+ state.transient = freshTransients(get().transientDefaults);
1723
1733
  });
1724
1734
 
1725
1735
  // The watchers of the loaded game (a payload without any, from an