@rohal12/spindle 0.59.13 → 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.
@@ -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';
@@ -9,6 +9,7 @@ import { markdownToHtml } from './markdown';
9
9
  import { h } from 'preact';
10
10
  import type { ASTNode, HtmlNode, MacroNode } from './ast';
11
11
  import { useTextScope } from '../hooks/use-interpolate';
12
+ import type { WidgetChildren } from '../interpolation';
12
13
  import { useRenderOptions } from '../hooks/use-render-options';
13
14
  import {
14
15
  hasInterpolation,
@@ -57,6 +58,22 @@ export const InlineContext = createContext(false);
57
58
  */
58
59
  export const RawTextContext = createContext(false);
59
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
+
60
77
  /**
61
78
  * True inside an `<svg>` element. SVG attributes are case-sensitive and have
62
79
  * no live form properties, so they are set as written (see splitAttributes).
@@ -67,7 +84,7 @@ const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
67
84
 
68
85
  /** Elements whose content is literal text, not markdown. */
69
86
  const PREFORMATTED_ELEMENTS = new Set(['pre', 'textarea', 'style']);
70
- export const WidgetChildrenContext = createContext<ASTNode[] | null>(null);
87
+ export const WidgetChildrenContext = createContext<WidgetChildren | null>(null);
71
88
 
72
89
  /**
73
90
  * Components rendered for the non-text nodes of one renderNodes() call. Each
@@ -708,10 +725,16 @@ function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
708
725
  }
709
726
 
710
727
  function ChildrenSlot() {
711
- const childrenAST = useContext(WidgetChildrenContext);
728
+ const children = useContext(WidgetChildrenContext);
712
729
  const renderOptions = useRenderOptions();
713
- if (!childrenAST || childrenAST.length === 0) return null;
714
- return <>{renderNodes(childrenAST, renderOptions)}</>;
730
+ if (!children) return null;
731
+ // The children render with the widget's locals, but a {@children} among
732
+ // them forwards the enclosing widget's children (#386).
733
+ return (
734
+ <WidgetChildrenContext.Provider value={children.outer}>
735
+ {renderNodes(children.nodes, renderOptions)}
736
+ </WidgetChildrenContext.Provider>
737
+ );
715
738
  }
716
739
 
717
740
  /**
@@ -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
package/src/structural.ts CHANGED
@@ -12,7 +12,17 @@
12
12
  import { registeredClassName } from './class-registry';
13
13
  import { hasOwn, setOwn } from './utils/namespace';
14
14
  import { deleteByPath, getByPath, setByPath } from './utils/object-path';
15
- import { isAtomic, isBoxed, isTemporal } from './utils/value-kinds';
15
+ import {
16
+ collectionSize,
17
+ extraKeys,
18
+ isAtomic,
19
+ isBoxed,
20
+ isTemporal,
21
+ mapEntries,
22
+ mapGet,
23
+ mapSet,
24
+ setMembers,
25
+ } from './utils/value-kinds';
16
26
 
17
27
  export { isAtomic };
18
28
 
@@ -24,6 +34,26 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
24
34
  return proto === Object.prototype || proto === null;
25
35
  }
26
36
 
37
+ /**
38
+ * `empty`, a new built-in (an Array, Map, Set, Date or RegExp) standing for
39
+ * `obj`, made like it: an instance of a registered subclass keeps its class
40
+ * and its extra keys (#391, #393), with the values `value` gives for them;
41
+ * other built-ins are plain. `made` gets it before its keys are filled, so
42
+ * that cycles through it find it.
43
+ */
44
+ function builtinLike<C extends object>(
45
+ obj: object,
46
+ empty: C,
47
+ made: (copy: C) => void,
48
+ value: (key: string) => unknown,
49
+ ): C {
50
+ made(empty);
51
+ if (registeredClassName(obj) === undefined) return empty;
52
+ Object.setPrototypeOf(empty, Object.getPrototypeOf(obj) as object);
53
+ for (const key of extraKeys(obj)) setOwn(empty, key, value(key));
54
+ return empty;
55
+ }
56
+
27
57
  /** Own keys of an error that are not enumerable but are data. */
28
58
  const ERROR_HIDDEN_KEYS = ['message', 'cause', 'errors'] as const;
29
59
 
@@ -83,11 +113,17 @@ export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
83
113
  }
84
114
 
85
115
  function cloneBuiltin(val: object): object | undefined {
86
- if (val instanceof Date) return keep(val, new Date(val.getTime()));
116
+ // Read with the built-in getters: a subclass may override them
117
+ if (val instanceof Date) {
118
+ return builtin(val, new Date(Date.prototype.getTime.call(val)));
119
+ }
87
120
  if (val instanceof RegExp) {
88
- const copy = new RegExp(val.source, val.flags);
121
+ const copy = new RegExp(
122
+ Reflect.get(RegExp.prototype, 'source', val) as string,
123
+ Reflect.get(RegExp.prototype, 'flags', val) as string,
124
+ );
89
125
  copy.lastIndex = val.lastIndex; // the scanning cursor of g/y patterns
90
- return keep(val, copy);
126
+ return builtin(val, copy);
91
127
  }
92
128
  if (val instanceof ArrayBuffer) return keep(val, val.slice(0));
93
129
  if (ArrayBuffer.isView(val)) {
@@ -131,6 +167,15 @@ export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
131
167
  : copy;
132
168
  }
133
169
 
170
+ /** `empty`, made the copy of the built-in `obj` (see builtinLike). */
171
+ const builtin = <C extends object>(obj: object, empty: C): C =>
172
+ builtinLike(
173
+ obj,
174
+ empty,
175
+ (copy) => keep(obj, copy),
176
+ (key) => clone((obj as Record<string, unknown>)[key]),
177
+ );
178
+
134
179
  function clone(val: unknown): unknown {
135
180
  if (val === null || typeof val !== 'object') return val;
136
181
 
@@ -139,27 +184,33 @@ export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
139
184
 
140
185
  if (Array.isArray(val)) {
141
186
  // Holes stay holes
142
- const arr = keep(obj, new Array(val.length) as unknown[]);
187
+ const arr = builtin(obj, new Array(val.length) as unknown[]);
143
188
  for (let i = 0; i < val.length; i++) {
144
189
  if (i in val) arr[i] = clone(val[i]);
145
190
  }
146
191
  return arr;
147
192
  }
148
193
 
194
+ // Read and filled with the built-in methods: a subclass may override
195
+ // them (see mapEntries)
149
196
  if (val instanceof Map) {
150
- const copy = keep(obj, new Map());
151
- for (const [k, v] of val) copy.set(settled(clone(k), k), clone(v));
197
+ const copy = builtin(obj, new Map());
198
+ for (const [k, v] of mapEntries(val)) {
199
+ Map.prototype.set.call(copy, settled(clone(k), k), clone(v));
200
+ }
152
201
  return copy;
153
202
  }
154
203
 
155
204
  if (val instanceof Set) {
156
- const copy = keep(obj, new Set());
157
- for (const v of val) copy.add(settled(clone(v), v));
205
+ const copy = builtin(obj, new Set());
206
+ for (const v of setMembers(val)) {
207
+ Set.prototype.add.call(copy, settled(clone(v), v));
208
+ }
158
209
  return copy;
159
210
  }
160
211
 
161
- const builtin = cloneBuiltin(obj);
162
- if (builtin !== undefined) return builtin;
212
+ const copied = cloneBuiltin(obj);
213
+ if (copied !== undefined) return copied;
163
214
 
164
215
  // A registered class instance keeps its class, and a plain object its
165
216
  // prototype (which may be null). An instance of an unregistered class
@@ -295,15 +346,23 @@ export function shareEqual<T>(prev: unknown, curr: T): T {
295
346
  if (placed !== undefined) return placed;
296
347
  if (!isObjectValue(p)) return fresh(c);
297
348
  if (reuse(p, c)) return p;
349
+ const pr = p as Record<string, unknown>;
350
+ const cr = c as Record<string, unknown>;
351
+ /** What the result holds at `key` of `c`. */
352
+ const shareKey = (key: string) =>
353
+ share(hasOwn(pr, key) ? pr[key] : undefined, cr[key]);
354
+ /** `empty`, made the result for the collection `c` (see builtinLike). */
355
+ const collection = <C extends object>(empty: C): C =>
356
+ builtinLike(c, empty, (out) => done.set(c, out), shareKey);
298
357
  if (c instanceof Map && p instanceof Map) {
299
- const out = new Map();
300
- done.set(c, out);
301
- for (const [k, v] of c) out.set(fresh(k), share(p.get(k), v));
358
+ const out = collection(new Map());
359
+ for (const [k, v] of mapEntries(c)) {
360
+ Map.prototype.set.call(out, fresh(k), share(mapGet(p, k), v));
361
+ }
302
362
  return out;
303
363
  }
304
364
  if (Array.isArray(c) && Array.isArray(p)) {
305
- const out = new Array(c.length) as unknown[];
306
- done.set(c, out);
365
+ const out = collection(new Array(c.length) as unknown[]);
307
366
  for (let i = 0; i < c.length; i++) {
308
367
  if (i in c) out[i] = share(p[i], c[i]);
309
368
  }
@@ -312,10 +371,7 @@ export function shareEqual<T>(prev: unknown, curr: T): T {
312
371
  if (mergesWith(p, c, false)) {
313
372
  const out = Object.create(Object.getPrototypeOf(c) as object | null);
314
373
  done.set(c, out);
315
- const cr = c as Record<string, unknown>;
316
- for (const key of Object.keys(cr)) {
317
- setOwn(out, key, share(hasOwn(p, key) ? p[key] : undefined, cr[key]));
318
- }
374
+ for (const key of Object.keys(cr)) setOwn(out, key, shareKey(key));
319
375
  return out;
320
376
  }
321
377
  return fresh(c);
@@ -344,9 +400,20 @@ function equalBuiltin(
344
400
  b: object,
345
401
  assumed: Pairs,
346
402
  ): boolean | undefined {
347
- if (a instanceof Date) return Object.is(a.getTime(), (b as Date).getTime());
403
+ if (a instanceof Date) {
404
+ const time = Date.prototype.getTime;
405
+ return (
406
+ Object.is(time.call(a), time.call(b as Date)) &&
407
+ equalExtraKeys(a, b, assumed)
408
+ );
409
+ }
348
410
  if (a instanceof RegExp) {
349
- return String(a) === String(b) && a.lastIndex === (b as RegExp).lastIndex;
411
+ const text = RegExp.prototype.toString;
412
+ return (
413
+ text.call(a) === text.call(b as RegExp) &&
414
+ a.lastIndex === (b as RegExp).lastIndex &&
415
+ equalExtraKeys(a, b, assumed)
416
+ );
350
417
  }
351
418
  if (ArrayBuffer.isView(a)) {
352
419
  // A view is its place in its backing buffer, and the whole buffer (which
@@ -382,17 +449,42 @@ function equalBuiltin(
382
449
  return undefined;
383
450
  }
384
451
 
385
- function equalKeys(a: object, b: object, assumed: Pairs): boolean {
452
+ function equalKeys(
453
+ a: object,
454
+ b: object,
455
+ assumed: Pairs,
456
+ keysOf: (o: object) => string[] = Object.keys,
457
+ ): boolean {
386
458
  const ao = a as Record<string, unknown>;
387
459
  const bo = b as Record<string, unknown>;
388
- const keys = Object.keys(ao);
389
- if (keys.length !== Object.keys(bo).length) return false;
460
+ const keys = keysOf(ao);
461
+ if (keys.length !== keysOf(bo).length) return false;
390
462
  for (const key of keys) {
391
463
  if (!hasOwn(bo, key) || !equal(ao[key], bo[key], assumed)) return false;
392
464
  }
393
465
  return true;
394
466
  }
395
467
 
468
+ /** Built-in prototypes (see builtinLike), whose instances hold no extra keys. */
469
+ const BUILTIN_PROTOS: ReadonlySet<object | null> = new Set([
470
+ Array.prototype,
471
+ Map.prototype,
472
+ Set.prototype,
473
+ Date.prototype,
474
+ RegExp.prototype,
475
+ ]);
476
+
477
+ /**
478
+ * Equality of the extra keys (see extraKeys) of two built-ins of one
479
+ * class: those of a subclass instance are part of its value.
480
+ */
481
+ function equalExtraKeys(a: object, b: object, assumed: Pairs): boolean {
482
+ return (
483
+ BUILTIN_PROTOS.has(Object.getPrototypeOf(a) as object | null) ||
484
+ equalKeys(a, b, assumed, extraKeys)
485
+ );
486
+ }
487
+
396
488
  /** `assumed` tracks the pairs of objects met (see Pairs). */
397
489
  function equal(a: unknown, b: unknown, assumed: Pairs): boolean {
398
490
  if (Object.is(a, b) && !(assumed.strict && isObjectValue(a))) return true;
@@ -410,9 +502,9 @@ function equal(a: unknown, b: unknown, assumed: Pairs): boolean {
410
502
 
411
503
  if (a instanceof Map || a instanceof Set) {
412
504
  const bc = b as Map<unknown, unknown> | Set<unknown>;
413
- if (a.size !== bc.size) return false;
414
- const ai = a.entries();
415
- const bi = bc.entries();
505
+ if (collectionSize(a) !== collectionSize(bc)) return false;
506
+ const ai = a instanceof Map ? mapEntries(a) : setMembers(a);
507
+ const bi = bc instanceof Map ? mapEntries(bc) : setMembers(bc);
416
508
  for (
417
509
  let x = ai.next(), y = bi.next();
418
510
  !x.done;
@@ -420,7 +512,7 @@ function equal(a: unknown, b: unknown, assumed: Pairs): boolean {
420
512
  ) {
421
513
  if (!equal(x.value, y.value, assumed)) return false;
422
514
  }
423
- return true;
515
+ return equalExtraKeys(a, b, assumed);
424
516
  }
425
517
 
426
518
  const builtin = equalBuiltin(a, b, assumed);
@@ -432,7 +524,7 @@ function equal(a: unknown, b: unknown, assumed: Pairs): boolean {
432
524
  for (let i = 0; i < a.length; i++) {
433
525
  if (i in a !== i in bc || !equal(a[i], bc[i], assumed)) return false;
434
526
  }
435
- return true;
527
+ return equalExtraKeys(a, b, assumed);
436
528
  }
437
529
 
438
530
  return equalKeys(a, b, assumed);
@@ -466,7 +558,11 @@ export function mergesWith(
466
558
  arrays: boolean,
467
559
  ): a is Record<string, unknown> {
468
560
  if (arrays && (Array.isArray(a) || Array.isArray(b))) {
469
- return Array.isArray(a) && Array.isArray(b);
561
+ return (
562
+ Array.isArray(a) &&
563
+ Array.isArray(b) &&
564
+ Object.getPrototypeOf(a) === Object.getPrototypeOf(b)
565
+ );
470
566
  }
471
567
  return (
472
568
  isMergeable(a) &&
@@ -698,13 +794,15 @@ function entryChildren(value: object): [string, unknown][] | undefined {
698
794
  const children: [string, unknown][] = [];
699
795
  if (value instanceof Map) {
700
796
  let index = 0;
701
- for (const [k, v] of value) {
797
+ for (const [k, v] of mapEntries(value)) {
702
798
  children.push([entryKey('k', index), k], [entryKey('v', index), v]);
703
799
  index++;
704
800
  }
705
801
  } else if (value instanceof Set) {
706
802
  let index = 0;
707
- for (const member of value) children.push([entryKey('s', index++), member]);
803
+ for (const member of setMembers(value)) {
804
+ children.push([entryKey('s', index++), member]);
805
+ }
708
806
  } else if (value instanceof Error) {
709
807
  for (const key of ['cause', 'errors'] as const) {
710
808
  if (hasOwn(value, key)) {
@@ -755,8 +853,9 @@ function setEntryValue(
755
853
  const holder = getByEntryPath(root, path.slice(0, -1));
756
854
  const match = /^\0v(\d+)$/.exec(path[path.length - 1]!);
757
855
  if (!(holder instanceof Map) || !match) return false;
758
- const key = [...holder.keys()][Number(match[1])];
759
- holder.set(key, value);
856
+ const entry = [...mapEntries(holder)][Number(match[1])];
857
+ if (!entry) return false;
858
+ mapSet(holder, entry[0], value);
760
859
  return true;
761
860
  }
762
861
 
@@ -857,7 +956,8 @@ function aliasMoved(
857
956
  if (xs && ys) {
858
957
  if (Object.getPrototypeOf(x) !== Object.getPrototypeOf(y)) return false;
859
958
  if (x instanceof Map || x instanceof Set) {
860
- if (x.size !== (y as Map<unknown, unknown>).size) return false;
959
+ const ySize = collectionSize(y as Map<unknown, unknown>);
960
+ if (collectionSize(x) !== ySize) return false;
861
961
  }
862
962
  const held = new Map(ys);
863
963
  for (const [segment, a] of xs) {
package/src/triggers.ts CHANGED
@@ -294,10 +294,14 @@ export function reinitTriggerState(): void {
294
294
  }
295
295
  }
296
296
 
297
+ /**
298
+ * Forget every watcher and close every dialog on display, whoever opened it
299
+ * (restart): a `{dialog}` or menubar dialog of the story interface, which
300
+ * stays mounted, would otherwise stay open over the new game (#397).
301
+ */
297
302
  export function resetTriggers(): void {
298
303
  triggers = [];
299
- dialogQueue = [];
300
- dialogHostCallbacks?.closeAll();
304
+ closeAllOpenDialogs();
301
305
  }
302
306
 
303
307
  export function subscribeTriggerDialogs(cb: () => void): () => void {
@@ -1,4 +1,5 @@
1
1
  import { registeredClassName } from '../class-registry';
2
+ import { mapEntries, setMembers } from './value-kinds';
2
3
 
3
4
  /**
4
5
  * Content-derived string key for a value, used to remount components when
@@ -48,8 +49,11 @@ function keyOf(val: unknown, ancestors: object[]): string {
48
49
  const depth = ancestors.indexOf(obj);
49
50
  if (depth !== -1) return `<cycle ${ancestors.length - depth}>`;
50
51
 
51
- if (val instanceof Date) return `Date(${val.getTime()})`;
52
- if (val instanceof RegExp) return `RegExp(${String(val)}@${val.lastIndex})`;
52
+ // Read with the built-in methods: a subclass may override them
53
+ if (val instanceof Date) return `Date(${Date.prototype.getTime.call(val)})`;
54
+ if (val instanceof RegExp) {
55
+ return `RegExp(${RegExp.prototype.toString.call(val)}@${val.lastIndex})`;
56
+ }
53
57
 
54
58
  ancestors.push(obj);
55
59
  try {
@@ -60,13 +64,13 @@ function keyOf(val: unknown, ancestors: object[]): string {
60
64
  return `[${Array.from(val, (v) => keyOf(v, ancestors)).join(',')}]`;
61
65
  }
62
66
  if (val instanceof Map) {
63
- const entries = [...val].map(
67
+ const entries = [...mapEntries(val)].map(
64
68
  ([k, v]) => `[${keyOf(k, ancestors)},${keyOf(v, ancestors)}]`,
65
69
  );
66
70
  return `Map[${entries.join(',')}]`;
67
71
  }
68
72
  if (val instanceof Set) {
69
- return `Set[${[...val].map((v) => keyOf(v, ancestors)).join(',')}]`;
73
+ return `Set[${[...setMembers(val)].map((v) => keyOf(v, ancestors)).join(',')}]`;
70
74
  }
71
75
 
72
76
  const record = obj as Record<string, unknown>;
@@ -1,6 +1,8 @@
1
1
  // Kinds of story values that structural operations (clone, equality, merge)
2
2
  // and property paths treat as a whole, by their content.
3
3
 
4
+ import { isDraft } from 'immer';
5
+
4
6
  export const toStringTag = (value: object): string =>
5
7
  Object.prototype.toString.call(value).slice(8, -1);
6
8
 
@@ -43,3 +45,57 @@ export function atomicName(value: object): string | undefined {
43
45
  const tag = value instanceof Error ? 'Error' : toStringTag(value);
44
46
  return `${/^[AEIOU]/.test(tag) ? 'an' : 'a'} ${tag}`;
45
47
  }
48
+
49
+ /** Whether `key` is an index of an array element. */
50
+ const isIndexKey = (key: string): boolean =>
51
+ key !== '4294967295' && String(Number(key) >>> 0) === key;
52
+
53
+ /**
54
+ * The own enumerable string keys of a collection (an Array, Map or Set) that
55
+ * are not its elements: what an instance of a subclass may hold besides
56
+ * them.
57
+ */
58
+ export function extraKeys(collection: object): string[] {
59
+ const keys = Object.keys(collection);
60
+ return Array.isArray(collection) ? keys.filter((k) => !isIndexKey(k)) : keys;
61
+ }
62
+
63
+ // A subclass of Map or Set may override how it is iterated, read or written
64
+ // (an inventory that iterates only the items on hand). What it holds is in
65
+ // its built-in slots, so structural operations read and write those with the
66
+ // built-in methods (#391, #394). An Immer draft keeps its entries in its own
67
+ // state instead, so it is read through its methods.
68
+
69
+ /** The entries of a Map, as the built-in iterator gives them. */
70
+ export function mapEntries<K, V>(map: Map<K, V>): MapIterator<[K, V]> {
71
+ return isDraft(map)
72
+ ? map.entries()
73
+ : (Map.prototype.entries.call(map) as MapIterator<[K, V]>);
74
+ }
75
+
76
+ /** The members of a Set, as the built-in iterator gives them. */
77
+ export function setMembers<T>(set: Set<T>): SetIterator<T> {
78
+ return isDraft(set)
79
+ ? set.values()
80
+ : (Set.prototype.values.call(set) as SetIterator<T>);
81
+ }
82
+
83
+ /** The number of entries of a Map or members of a Set. */
84
+ export function collectionSize(
85
+ collection: Map<unknown, unknown> | Set<unknown>,
86
+ ) {
87
+ if (isDraft(collection)) return collection.size;
88
+ const proto = collection instanceof Map ? Map.prototype : Set.prototype;
89
+ return Reflect.get(proto, 'size', collection) as number;
90
+ }
91
+
92
+ /** The value a Map holds for `key`. */
93
+ export function mapGet<K, V>(map: Map<K, V>, key: K): V | undefined {
94
+ return isDraft(map) ? map.get(key) : Map.prototype.get.call(map, key);
95
+ }
96
+
97
+ /** Make a Map hold `value` for `key`. */
98
+ export function mapSet<K, V>(map: Map<K, V>, key: K, value: V): void {
99
+ if (isDraft(map)) map.set(key, value);
100
+ else Map.prototype.set.call(map, key, value);
101
+ }
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { parseMacroArgs } from '../components/macros/macro-args';
6
6
  import type { MacroArgs } from '../registry';
7
+ import { tokenizeMarkupTolerant } from '../markup/parse';
7
8
 
8
9
  /** A {widget} definition's name, then its `@` parameters. */
9
10
  export const WIDGET_PARAMETERS = [
@@ -36,20 +37,15 @@ export function parseWidgetDef(rawArgs: string): WidgetDef {
36
37
  return widgetDef(parseMacroArgs(rawArgs, WIDGET_PARAMETERS));
37
38
  }
38
39
 
39
- /**
40
- * A definition: its arguments (read like a {widget} macro's, see
41
- * parseWidgetDef) and its body. Macro names are not case-sensitive.
42
- */
43
- const WIDGET_DEFINITION = /\{widget\s+([^}]*)\}([\s\S]*?)\{\/widget\}/gi;
44
-
45
- /** `{@children}`, also with selectors such as `{.highlight @children}`. */
46
- const CHILDREN_PLACEHOLDER = /\{[^{}]*@children\s*\}/;
47
-
48
40
  /**
49
41
  * The names of the block widgets (those whose body renders `{@children}`)
50
42
  * that StoryInit and the passages tagged `widget` define. They must be
51
43
  * known as block macros before any passage is parsed, so that passages
52
44
  * invoking them nest their content whatever the passage order.
45
+ *
46
+ * The passages are read as flat tokens, which need no block macros to be
47
+ * known, so a `{@children}` in an HTML comment or in a `{do}` body is text
48
+ * here as it is to the parser, and does not make a block widget (#387).
53
49
  */
54
50
  export function blockWidgetNames(
55
51
  passages: Iterable<{ name: string; tags?: string[]; content: string }>,
@@ -59,12 +55,28 @@ export function blockWidgetNames(
59
55
  if (passage.name !== 'StoryInit' && !passage.tags?.includes('widget')) {
60
56
  continue;
61
57
  }
62
- for (const match of passage.content.matchAll(WIDGET_DEFINITION)) {
63
- if (!CHILDREN_PLACEHOLDER.test(match[2]!)) continue;
64
- try {
65
- names.push(parseWidgetDef(match[1]!).name);
66
- } catch {
67
- // The {widget} macro reports a definition it cannot read
58
+ // The open definitions: their arguments, and whether a slot was seen
59
+ const open: { rawArgs: string; isBlock: boolean }[] = [];
60
+ for (const token of tokenizeMarkupTolerant(passage.content).tokens) {
61
+ if (token.type === 'macro' && token.name.toLowerCase() === 'widget') {
62
+ if (!token.isClose) {
63
+ open.push({ rawArgs: token.rawArgs, isBlock: false });
64
+ continue;
65
+ }
66
+ const def = open.pop();
67
+ if (!def?.isBlock) continue;
68
+ try {
69
+ names.push(parseWidgetDef(def.rawArgs).name);
70
+ } catch {
71
+ // The {widget} macro reports a definition it cannot read
72
+ }
73
+ } else if (
74
+ token.type === 'variable' &&
75
+ token.scope === 'local' &&
76
+ token.name === 'children' &&
77
+ open.length > 0
78
+ ) {
79
+ open[open.length - 1]!.isBlock = true;
68
80
  }
69
81
  }
70
82
  }
package/types/index.d.ts CHANGED
@@ -794,7 +794,9 @@ export interface StoryAPI {
794
794
  /**
795
795
  * Register a class so its instances keep their class through clones,
796
796
  * history, saves and loads. A save refuses instances of classes that are
797
- * not registered.
797
+ * not registered. A class may extend Array, Map, Set, Date, RegExp or
798
+ * Error; one extending another built-in whose value a save cannot hold (a
799
+ * typed array, URL, Promise...) throws.
798
800
  */
799
801
  registerClass(name: string, ctor: new (...args: any[]) => any): void;
800
802