@rohal12/spindle 0.59.16 → 0.59.18

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.
@@ -15,6 +15,14 @@ export const FrozenStateContext = createContext<
15
15
  ((state: StoryState) => boolean) | null
16
16
  >(null);
17
17
 
18
+ /**
19
+ * Tells a timer ({repeat}, {timed}) whether the passage it belongs to has
20
+ * been left: it must not run a new body then, for that would write into the
21
+ * destination's state until the outgoing tree unmounts (#410). Null outside
22
+ * a passage.
23
+ */
24
+ export const OutgoingContext = createContext<(() => boolean) | null>(null);
25
+
18
26
  /**
19
27
  * Story state fields `keys`, re-rendering when any of them changes (as one
20
28
  * useStoryStore() selector per field would). The result keeps its identity
package/src/markup/ast.ts CHANGED
@@ -8,6 +8,8 @@ import { NameSet } from '../utils/macro-names';
8
8
  export interface TextNode {
9
9
  type: 'text';
10
10
  value: string;
11
+ /** A closed HTML comment: markdown drops it, and so does raw rendering. */
12
+ comment?: true;
11
13
  }
12
14
 
13
15
  export interface VariableNode extends Selectors {
@@ -44,6 +44,8 @@ export const LocalsValuesContext =
44
44
  createContext<Record<string, unknown>>(EMPTY_NAMESPACE);
45
45
  export const LocalsUpdateContext = createContext<LocalsUpdater>(defaultUpdater);
46
46
  export const NobrContext = createContext(false);
47
+ /** Whether the markup is the story interface, which stays mounted (#419). */
48
+ export const InterfaceContext = createContext(false);
47
49
  /**
48
50
  * True while rendering inside an inline HTML element (e.g. `<span>`), where
49
51
  * block-level markdown and `<p>` wrappers would produce invalid HTML (#220).
@@ -710,6 +712,12 @@ function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
710
712
  }
711
713
  }
712
714
  }
715
+ // The content of a foreignObject is HTML again, with HTML attributes
716
+ if (inSvg && tag === 'foreignobject' && children) {
717
+ children = (
718
+ <SvgContext.Provider value={false}>{children}</SvgContext.Provider>
719
+ );
720
+ }
713
721
  let element = h(node.tag, props, children);
714
722
  if (isSvgRoot && !inSvg) {
715
723
  element = <SvgContext.Provider value={true}>{element}</SvgContext.Provider>;
@@ -806,7 +814,7 @@ function renderMacro(node: MacroNode, key: string) {
806
814
  */
807
815
  function renderSingleNode(node: ASTNode): preact.ComponentChildren {
808
816
  // Authored text outside markdown still has its character references decoded
809
- if (node.type === 'text') return decodeText(node.value);
817
+ if (node.type === 'text') return node.comment ? null : decodeText(node.value);
810
818
  const key = nodeKey(node);
811
819
  switch (node.type) {
812
820
  case 'variable':
@@ -104,7 +104,7 @@ function lexeme(lex, loc, fields) {
104
104
  function nodeOf(x) {
105
105
  switch (x.lex) {
106
106
  case 'text':
107
- return { type: 'text', value: x.value };
107
+ return x.comment ? { type: 'text', value: x.value, comment: true } : { type: 'text', value: x.value };
108
108
  case 'link':
109
109
  return withSelectors({ type: 'macro', name: 'link', rawArgs: quoteArg(x.display) + ' ' + quoteArg(x.target), children: [] }, x);
110
110
  case 'variable':
@@ -137,7 +137,7 @@ function tokenOf(x) {
137
137
 
138
138
  /**
139
139
  * Flatten items (lexemes, nodes, arrays of them, nulls) and join adjacent
140
- * text, except escaped braces, which stay a text item of their own.
140
+ * text, except escaped braces and comments, which stay a text item of their own.
141
141
  */
142
142
  function joinText(items, convert) {
143
143
  const out = [];
@@ -145,7 +145,7 @@ function joinText(items, convert) {
145
145
  const add = (item) => {
146
146
  if (item === null || item === undefined) return;
147
147
  if (Array.isArray(item)) return item.forEach(add);
148
- if (item.lex === 'text' && !item.escape && last && last.lex === 'text' && !last.escape && last.end === item.start) {
148
+ if (item.lex === 'text' && !item.escape && !item.comment && last && last.lex === 'text' && !last.escape && !last.comment && last.end === item.start) {
149
149
  last.value += item.value;
150
150
  last.end = item.end;
151
151
  return;
@@ -332,7 +332,9 @@ TextRun
332
332
  */
333
333
  Comment
334
334
  = value:$("<!--" (">" / "->" / (!"-->" .)* "-->")) {
335
- return lexeme('text', location(), { value });
335
+ const node = lexeme('text', location(), { value });
336
+ node.comment = true;
337
+ return node;
336
338
  }
337
339
 
338
340
  /**
@@ -175,7 +175,9 @@ function isPayload(value: unknown): value is Record<string, unknown> {
175
175
  isCountsMap(value.visitCounts) &&
176
176
  isCountsMap(value.renderCounts) &&
177
177
  isOptionalPRNGSnapshot(value.prng) &&
178
- isWatchers(value.watchers)
178
+ isWatchers(value.watchers) &&
179
+ (value.interfaceWatchers === undefined ||
180
+ typeof value.interfaceWatchers === 'boolean')
179
181
  );
180
182
  }
181
183
 
@@ -28,6 +28,11 @@ export interface SavePayload {
28
28
  prng?: PRNGSnapshot | null;
29
29
  /** The {watch} macro watchers registered (absent in older saves). */
30
30
  watchers?: SavedWatcher[];
31
+ /**
32
+ * Whether the story interface had mounted when the save was made, so that
33
+ * `watchers` includes its {watch} macros (absent in older saves).
34
+ */
35
+ interfaceWatchers?: boolean;
31
36
  }
32
37
 
33
38
  export interface SaveMeta {
package/src/store.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  resetTriggers,
24
24
  checkTriggersOnNavigation,
25
25
  restoreMacroWatchers,
26
+ interfaceMounted,
26
27
  savedMacroWatchers,
27
28
  reinitTriggerState,
28
29
  } from './triggers';
@@ -307,6 +308,7 @@ function persistSession(get: () => StoryState): { error: unknown } | undefined {
307
308
  renderCounts,
308
309
  prng: snapshotPRNG(),
309
310
  watchers: savedMacroWatchers(),
311
+ interfaceWatchers: interfaceMounted(),
310
312
  });
311
313
  }
312
314
 
@@ -576,10 +578,18 @@ function knownPlaythroughId(): string {
576
578
  * switch establishes.
577
579
  */
578
580
  export function resolvePlaythroughId(): Promise<string> {
581
+ if (leavingPlaythrough) return leavingPlaythrough;
579
582
  const current = knownPlaythroughId();
580
583
  return playthroughSetup.then((established) => current || established);
581
584
  }
582
585
 
586
+ /**
587
+ * While the `beforeload` handlers of a load from a slot run, the playthrough
588
+ * the game is leaving: the saves they issue describe that game, though the
589
+ * load has already switched to the save's playthrough (#416).
590
+ */
591
+ let leavingPlaythrough: Promise<string> | null = null;
592
+
583
593
  function setPlaythroughId(id: string): void {
584
594
  if (useStoryStore.getState().playthroughId === id) return;
585
595
  useStoryStore.setState((state) => {
@@ -661,6 +671,8 @@ let latestStateApplied = 0;
661
671
 
662
672
  /** The number of the slot load that is calling loadFromPayload. */
663
673
  let slotLoadApplying: number | null = null;
674
+ /** The playthrough the slot load calling loadFromPayload leaves. */
675
+ let slotLoadLeaving: Promise<string> | null = null;
664
676
 
665
677
  /** A replacement of the game state applied at its call. */
666
678
  function replaceStateNow(): void {
@@ -1529,6 +1541,7 @@ export const useStoryStore = create<StoryState>()(
1529
1541
  // A restart, boot or direct load issued after this one won
1530
1542
  if (latestStateApplied > replacement) return;
1531
1543
  slotLoadApplying = replacement;
1544
+ slotLoadLeaving = previous;
1532
1545
  get().loadFromPayload(loaded.payload, slot);
1533
1546
  }),
1534
1547
  'spindle: failed to load save',
@@ -1679,6 +1692,7 @@ export const useStoryStore = create<StoryState>()(
1679
1692
  renderCounts: { ...renderCounts },
1680
1693
  prng: snapshotPRNG(),
1681
1694
  watchers: savedMacroWatchers(),
1695
+ interfaceWatchers: interfaceMounted(),
1682
1696
  };
1683
1697
  },
1684
1698
 
@@ -1698,6 +1712,8 @@ export const useStoryStore = create<StoryState>()(
1698
1712
  ) => {
1699
1713
  const applying = slotLoadApplying;
1700
1714
  slotLoadApplying = null;
1715
+ const leaving = slotLoadLeaving;
1716
+ slotLoadLeaving = null;
1701
1717
  const replacement = applying ?? ++stateReplacementsIssued;
1702
1718
  if (payload.history.length === 0) {
1703
1719
  console.warn('loadFromPayload: rejecting payload with empty history');
@@ -1708,7 +1724,12 @@ export const useStoryStore = create<StoryState>()(
1708
1724
  assertPassagesExist(get().storyData, payload);
1709
1725
  applyReplacement(replacement);
1710
1726
 
1711
- emit('beforeload', slot);
1727
+ leavingPlaythrough = leaving;
1728
+ try {
1729
+ emit('beforeload', slot);
1730
+ } finally {
1731
+ leavingPlaythrough = null;
1732
+ }
1712
1733
 
1713
1734
  // Loading a save moves the game to the save's playthrough, after the
1714
1735
  // `beforeload` handlers (whose saves belong to the game being left).
@@ -1769,7 +1790,9 @@ export const useStoryStore = create<StoryState>()(
1769
1790
  // The watchers of the loaded game (a payload without any, from an
1770
1791
  // older version, leaves the registered ones), then: loaded state is
1771
1792
  // not a change watchers react to
1772
- if (payload.watchers) restoreMacroWatchers(payload.watchers);
1793
+ if (payload.watchers) {
1794
+ restoreMacroWatchers(payload.watchers, payload.interfaceWatchers);
1795
+ }
1773
1796
  reinitTriggerState();
1774
1797
 
1775
1798
  // The next navigate() diffs from the snapshot recorded for the current
package/src/structural.ts CHANGED
@@ -13,6 +13,7 @@ import { registeredClassName } from './class-registry';
13
13
  import { hasOwn, setOwn } from './utils/namespace';
14
14
  import { deleteByPath, getByPath, setByPath } from './utils/object-path';
15
15
  import {
16
+ ERROR_HIDDEN_KEYS,
16
17
  collectionSize,
17
18
  extraKeys,
18
19
  isAtomic,
@@ -54,9 +55,6 @@ function builtinLike<C extends object>(
54
55
  return empty;
55
56
  }
56
57
 
57
- /** Own keys of an error that are not enumerable but are data. */
58
- const ERROR_HIDDEN_KEYS = ['message', 'cause', 'errors'] as const;
59
-
60
58
  // --- Deep Clone ---
61
59
 
62
60
  export interface DeepCloneOptions {
@@ -859,6 +857,28 @@ function setEntryValue(
859
857
  return true;
860
858
  }
861
859
 
860
+ /**
861
+ * The own enumerable fields of a Map, Set, Date or RegExp (an instance of a
862
+ * registered subclass may hold some besides its built-in content, #412);
863
+ * none for any other value.
864
+ */
865
+ function builtinFields(value: object): string[] {
866
+ return value instanceof Map ||
867
+ value instanceof Set ||
868
+ value instanceof Date ||
869
+ value instanceof RegExp
870
+ ? extraKeys(value)
871
+ : [];
872
+ }
873
+
874
+ /** Whether `x` and `y` are built-ins of one class that may hold fields. */
875
+ function sameBuiltin(x: object, y: object): boolean {
876
+ return (
877
+ Object.getPrototypeOf(x) === Object.getPrototypeOf(y) &&
878
+ (builtinFields(x).length > 0 || builtinFields(y).length > 0)
879
+ );
880
+ }
881
+
862
882
  /**
863
883
  * Every path each object (or array, or other value) of `root` is at, the
864
884
  * first appearance first, in depth-first order. Plain objects and arrays are
@@ -888,12 +908,17 @@ function objectPaths(
888
908
  all.set(value, [at]);
889
909
  if (isMergeable(value) || Array.isArray(value)) {
890
910
  walk(value as Record<string, unknown>, at);
891
- } else if (entries && entryChildren(value)) {
892
- collections.push([value, at]);
911
+ } else {
912
+ walk(value as Record<string, unknown>, at, builtinFields(value));
913
+ if (entries && entryChildren(value)) collections.push([value, at]);
893
914
  }
894
915
  }
895
- function walk(node: Record<string, unknown>, path: string[]): void {
896
- for (const key of Object.keys(node)) {
916
+ function walk(
917
+ node: Record<string, unknown>,
918
+ path: string[],
919
+ keys: string[] = Object.keys(node),
920
+ ): void {
921
+ for (const key of keys) {
897
922
  const value = node[key];
898
923
  if (!isObjectValue(value)) continue;
899
924
  const at = [...path, key];
@@ -963,12 +988,16 @@ function aliasMoved(
963
988
  for (const [segment, a] of xs) {
964
989
  if (held.has(segment)) pairs.push([segment, a, held.get(segment)]);
965
990
  }
966
- } else if ((isMergeable(x) || Array.isArray(x)) && mergesWith(x, y, true)) {
967
- for (const key of Object.keys(y)) {
968
- if (hasOwn(x, key)) {
969
- pairs.push([key, (x as any)[key], (y as any)[key]]);
970
- }
971
- }
991
+ }
992
+ // The properties both hold: of objects that merge, and the fields of a
993
+ // built-in subclass instance besides its entries (#412)
994
+ const keys = mergesWith(x, y, true)
995
+ ? Object.keys(y)
996
+ : sameBuiltin(x, y)
997
+ ? builtinFields(y)
998
+ : [];
999
+ for (const key of keys) {
1000
+ if (hasOwn(x, key)) pairs.push([key, (x as any)[key], (y as any)[key]]);
972
1001
  }
973
1002
  return pairs.some(([segment, a, b]) => {
974
1003
  if (!isObjectValue(a) || !isObjectValue(b)) return false;
@@ -1002,7 +1031,9 @@ export function aliasChanges(
1002
1031
  a: Record<string, unknown>,
1003
1032
  path: string[],
1004
1033
  ): void {
1005
- for (const key of Object.keys(a)) {
1034
+ for (const key of Array.isArray(a) || isMergeable(a)
1035
+ ? Object.keys(a)
1036
+ : builtinFields(a)) {
1006
1037
  if (!hasOwn(b, key)) continue;
1007
1038
  const x = b[key];
1008
1039
  const y = a[key];
@@ -1023,8 +1054,8 @@ export function aliasChanges(
1023
1054
  // Only paths that are first appearances are entered: elsewhere the
1024
1055
  // contents are those of the object met first.
1025
1056
  if (
1026
- (isMergeable(x) || Array.isArray(x)) &&
1027
- mergesWith(x, y, true) &&
1057
+ (((isMergeable(x) || Array.isArray(x)) && mergesWith(x, y, true)) ||
1058
+ sameBuiltin(x, y)) &&
1028
1059
  pathKey(isAt) === k &&
1029
1060
  pathKey(wasAt) === k
1030
1061
  ) {
package/src/tooling.ts CHANGED
@@ -97,7 +97,10 @@ export function validateStoryMarkup(
97
97
  const list = [...passages];
98
98
  const all = [...macros];
99
99
  const known = new Set<string>();
100
- const blocks = new Set(blockWidgetNames(list).map((n) => n.toLowerCase()));
100
+ const parametersOf = parameterLookup(all);
101
+ const blocks = new Set(
102
+ blockWidgetNames(list, parametersOf).map((n) => n.toLowerCase()),
103
+ );
101
104
  for (const macro of all) {
102
105
  const name = macro.name.toLowerCase();
103
106
  known.add(name);
@@ -107,7 +110,7 @@ export function validateStoryMarkup(
107
110
  return validateMarkup(list, {
108
111
  isKnownMacro: (name) => known.has(name),
109
112
  macroNames: known,
110
- parametersOf: parameterLookup(all),
113
+ parametersOf,
111
114
  isBlockMacro: (name) =>
112
115
  blocks.has(name.toLowerCase()) || isBlockMacro(name),
113
116
  checkPassageNames: options.checkPassageNames,
package/src/triggers.ts CHANGED
@@ -134,16 +134,62 @@ export function savedMacroWatchers(): SavedWatcher[] {
134
134
  .map((t) => ({ condition: t.condition, options: { ...t.options } }));
135
135
  }
136
136
 
137
+ /**
138
+ * The {watch} macros of the story interface that are mounted, and how many
139
+ * interfaces are: the interface mounts once the game has started, so a save
140
+ * made earlier (in StoryInit) holds none of its watchers.
141
+ */
142
+ const interfaceWatches = new Set<SavedWatcher>();
143
+ let interfacesMounted = 0;
144
+
145
+ /** Record a mounted interface {watch}; call the result when it unmounts. */
146
+ export function declareInterfaceWatch(
147
+ condition: string,
148
+ options: WatchOptions,
149
+ ): () => void {
150
+ const declared: SavedWatcher = { condition, options };
151
+ interfaceWatches.add(declared);
152
+ return () => {
153
+ interfaceWatches.delete(declared);
154
+ };
155
+ }
156
+
157
+ /** Record a mounted story interface; call the result when it unmounts. */
158
+ export function declareInterfaceMounted(): () => void {
159
+ interfacesMounted++;
160
+ return () => {
161
+ interfacesMounted--;
162
+ };
163
+ }
164
+
165
+ /**
166
+ * Whether the story interface has mounted, so that its watchers are among
167
+ * `savedMacroWatchers()`: a save records it (see restoreMacroWatchers).
168
+ */
169
+ export function interfaceMounted(): boolean {
170
+ return interfacesMounted > 0;
171
+ }
172
+
137
173
  /**
138
174
  * Make the {watch} macro watchers those of a loaded game: the ones of
139
175
  * `watchers` replace the registered ones (watchers added by Story.watch
140
- * code stay). The passage shown mounts its own {watch} macros again.
176
+ * code stay). The passage shown mounts its own {watch} macros again. A save
177
+ * made before the story interface mounted (`interfaceSaved` false) cannot
178
+ * hold its watchers, so the ones of the mounted interface stay; in a later
179
+ * save a missing one was removed by `once` or {unwatch}, and stays so.
141
180
  */
142
- export function restoreMacroWatchers(watchers: readonly SavedWatcher[]): void {
181
+ export function restoreMacroWatchers(
182
+ watchers: readonly SavedWatcher[],
183
+ interfaceSaved?: boolean,
184
+ ): void {
143
185
  triggers = triggers.filter((t) => t.macroKey === undefined);
144
186
  for (const { condition, options } of watchers) {
145
187
  addMacroTrigger(condition, options);
146
188
  }
189
+ if (interfaceSaved !== false) return;
190
+ for (const { condition, options } of interfaceWatches) {
191
+ addMacroTrigger(condition, options);
192
+ }
147
193
  }
148
194
 
149
195
  export function removeTrigger(name: string): void {
@@ -1,4 +1,4 @@
1
- import { current as draftState, isDraft } from 'immer';
1
+ import { current as draftState, isDraft, original } from 'immer';
2
2
  import { registeredClassName } from '../class-registry';
3
3
  import { hasOwn } from './namespace';
4
4
  import { atomicName } from './value-kinds';
@@ -18,7 +18,18 @@ export function getByPath(obj: object, segments: readonly string[]): unknown {
18
18
  if (seg in Object.prototype && !hasOwn(Object(current), seg)) {
19
19
  return undefined;
20
20
  }
21
- current = (current as Record<string, unknown>)[seg];
21
+ const holder = current as Record<string, unknown>;
22
+ current = holder[seg];
23
+ // An Immer draft of a Map or Set does not carry the own properties of a
24
+ // registered subclass instance: they are those of its base (#412)
25
+ if (
26
+ current === undefined &&
27
+ isDraft(holder) &&
28
+ (holder instanceof Map || holder instanceof Set)
29
+ ) {
30
+ const base = original(holder) as Record<string, unknown> | undefined;
31
+ if (base && hasOwn(base, seg)) current = base[seg];
32
+ }
22
33
  }
23
34
  return current;
24
35
  }
@@ -1,5 +1,14 @@
1
+ import { hasOwn } from './namespace';
1
2
  import { registeredClassName } from '../class-registry';
2
- import { extraKeys, mapEntries, setMembers } from './value-kinds';
3
+ import {
4
+ ERROR_HIDDEN_KEYS,
5
+ extraKeys,
6
+ isBoxed,
7
+ isTemporal,
8
+ mapEntries,
9
+ setMembers,
10
+ toStringTag,
11
+ } from './value-kinds';
3
12
 
4
13
  /**
5
14
  * Content-derived string key for a value, used to remount components when
@@ -11,8 +20,11 @@ import { extraKeys, mapEntries, setMembers } from './value-kinds';
11
20
  * are reflected too: Map and Set entries (nested at any depth), Date,
12
21
  * RegExp, BigInt, undefined, NaN/±Infinity, symbols (by description),
13
22
  * functions (by name) and registered class instances, with the class name
14
- * and, for a subclass of a built-in, its own fields (#407). A hole in an
15
- * array reads as undefined.
23
+ * and, for a subclass of a built-in, its own fields (#407). The atomic
24
+ * built-ins (see isAtomic) have their contents in the key: the bytes of a
25
+ * buffer, the place of a view in its buffer, the text of a URL or
26
+ * URLSearchParams, the class, message, cause and errors of an Error, the
27
+ * value of a boxed primitive (#418). A hole in an array reads as undefined.
16
28
  *
17
29
  * An object met again, through a cycle or a second reference to it, is a
18
30
  * marker naming where it was first met rather than another copy of its
@@ -88,6 +100,8 @@ function keyOf(val: unknown, seen: Map<object, number>): string {
88
100
  // hole from an undefined element (deepEqual does)
89
101
  return withFields(`[${Array.from(val, (v) => keyOf(v, seen)).join(',')}]`);
90
102
  }
103
+ const atomic = atomicKey(obj, seen);
104
+ if (atomic !== undefined) return named(atomic);
91
105
  if (val instanceof Map) {
92
106
  const entries = Array.from(
93
107
  mapEntries(val),
@@ -102,6 +116,46 @@ function keyOf(val: unknown, seen: Map<object, number>): string {
102
116
  return named(fieldsOf(obj, Object.keys(obj), seen));
103
117
  }
104
118
 
119
+ /**
120
+ * The key of an atomic built-in other than a Map, Set, Date or RegExp (see
121
+ * isAtomic), or undefined for any other object. Read with the built-in
122
+ * methods, as a subclass may override them.
123
+ */
124
+ function atomicKey(obj: object, seen: Map<object, number>): string | undefined {
125
+ if (obj instanceof ArrayBuffer) {
126
+ return `ArrayBuffer[${new Uint8Array(obj).join(',')}]`;
127
+ }
128
+ if (ArrayBuffer.isView(obj)) {
129
+ // A view is its place in its buffer, which other views can share
130
+ return `${toStringTag(obj)}(${obj.byteOffset},${obj.byteLength},${keyOf(obj.buffer, seen)})`;
131
+ }
132
+ let key: string;
133
+ if (obj instanceof URL) {
134
+ key = `URL(${JSON.stringify(Object.getOwnPropertyDescriptor(URL.prototype, 'href')!.get!.call(obj))})`;
135
+ } else if (obj instanceof URLSearchParams) {
136
+ key = `URLSearchParams(${JSON.stringify(URLSearchParams.prototype.toString.call(obj))})`;
137
+ } else if (isBoxed(obj)) {
138
+ key = `${toStringTag(obj)}(${keyOf((obj as Number).valueOf(), seen)})`;
139
+ } else if (isTemporal(obj)) {
140
+ key = `${toStringTag(obj)}(${JSON.stringify(String(obj))})`;
141
+ } else if (obj instanceof Error) {
142
+ const record = obj as unknown as Record<string, unknown>;
143
+ const held = ERROR_HIDDEN_KEYS.filter((k) => hasOwn(obj, k)).map(
144
+ (k) => `${k}:${keyOf(record[k], seen)}`,
145
+ );
146
+ const proto = Object.getPrototypeOf(obj) as {
147
+ constructor?: unknown;
148
+ } | null;
149
+ const ctor = proto?.constructor;
150
+ const name = typeof ctor === 'function' ? ctor.name : '';
151
+ key = `Error(${JSON.stringify(name)},${held.join(',')})`;
152
+ } else {
153
+ return undefined;
154
+ }
155
+ const keys = Object.keys(obj);
156
+ return keys.length ? `${key}${fieldsOf(obj, keys, seen)}` : key;
157
+ }
158
+
105
159
  /** `{"key":value,...}` for the `keys` of `obj`. */
106
160
  function fieldsOf(
107
161
  obj: object,
@@ -46,6 +46,9 @@ export function atomicName(value: object): string | undefined {
46
46
  return `${/^[AEIOU]/.test(tag) ? 'an' : 'a'} ${tag}`;
47
47
  }
48
48
 
49
+ /** Own keys of an error that are not enumerable but are data. */
50
+ export const ERROR_HIDDEN_KEYS = ['message', 'cause', 'errors'] as const;
51
+
49
52
  /** Whether `key` is an index of an array element. */
50
53
  const isIndexKey = (key: string): boolean =>
51
54
  key !== '4294967295' && String(Number(key) >>> 0) === key;
@@ -1,10 +1,61 @@
1
1
  import type { ASTNode } from '../markup/ast';
2
+ import type { Token } from '../markup/tokens';
3
+ import { codeAndText, parameterLookup, type ParametersOf } from '../code-check';
4
+ import { parseMarkup } from '../markup/parse';
5
+ import { getMacroRegistry } from '../registry';
6
+
7
+ /** The declared parameters of the macros registered so far. */
8
+ export const registeredParameters: ParametersOf = (name) =>
9
+ parameterLookup(getMacroRegistry())(name);
10
+
11
+ /**
12
+ * Whether the text of an attribute value or macro label renders a
13
+ * `{@children}`: it is markup, parsed as the renderer parses it.
14
+ */
15
+ function textContainsChildren(
16
+ text: string,
17
+ parametersOf: ParametersOf,
18
+ ): boolean {
19
+ if (!text.includes('{')) return false;
20
+ let nodes: ASTNode[];
21
+ try {
22
+ nodes = parseMarkup(text, { text: true });
23
+ } catch {
24
+ // The renderer reports the markup that does not parse
25
+ return false;
26
+ }
27
+ return astContainsChildren(nodes, parametersOf);
28
+ }
29
+
30
+ /**
31
+ * Whether a tag renders a `{@children}` in an attribute value or a macro
32
+ * argument that holds markup (`<span title="{@children}">`,
33
+ * `{button "{@children}"}`). Literal arguments and code are not markup.
34
+ */
35
+ export function tokenTextContainsChildren(
36
+ token: Token,
37
+ parametersOf: ParametersOf = registeredParameters,
38
+ ): boolean {
39
+ for (const piece of codeAndText('', [token], parametersOf)) {
40
+ if (
41
+ piece.kind === 'text' &&
42
+ textContainsChildren(piece.text, parametersOf)
43
+ ) {
44
+ return true;
45
+ }
46
+ }
47
+ return false;
48
+ }
2
49
 
3
50
  /**
4
51
  * Recursively scan an AST node array for a VariableNode
5
- * with scope 'local' and name 'children' (@children).
52
+ * with scope 'local' and name 'children' (@children), in the content and in
53
+ * the attributes and labels that render markup.
6
54
  */
7
- export function astContainsChildren(nodes: ASTNode[]): boolean {
55
+ export function astContainsChildren(
56
+ nodes: ASTNode[],
57
+ parametersOf: ParametersOf = registeredParameters,
58
+ ): boolean {
8
59
  for (const node of nodes) {
9
60
  if (
10
61
  node.type === 'variable' &&
@@ -13,14 +64,33 @@ export function astContainsChildren(nodes: ASTNode[]): boolean {
13
64
  ) {
14
65
  return true;
15
66
  }
16
- if (node.type === 'html' && astContainsChildren(node.children)) {
17
- return true;
67
+ if (node.type === 'html') {
68
+ const span = { start: 0, end: 0 };
69
+ const tag: Token = {
70
+ isClose: false,
71
+ isSelfClose: false,
72
+ ...node,
73
+ ...span,
74
+ };
75
+ if (
76
+ tokenTextContainsChildren(tag, parametersOf) ||
77
+ astContainsChildren(node.children, parametersOf)
78
+ ) {
79
+ return true;
80
+ }
18
81
  }
19
82
  if (node.type === 'macro') {
20
- if (astContainsChildren(node.children)) return true;
83
+ const span = { start: 0, end: 0 };
84
+ const tag: Token = { isClose: false, ...node, ...span };
85
+ if (
86
+ tokenTextContainsChildren(tag, parametersOf) ||
87
+ astContainsChildren(node.children, parametersOf)
88
+ ) {
89
+ return true;
90
+ }
21
91
  if (node.branches) {
22
92
  for (const branch of node.branches) {
23
- if (astContainsChildren(branch.children)) return true;
93
+ if (astContainsChildren(branch.children, parametersOf)) return true;
24
94
  }
25
95
  }
26
96
  }
@@ -5,6 +5,8 @@
5
5
  import { parseMacroArgs } from '../components/macros/macro-args';
6
6
  import type { MacroArgs } from '../registry';
7
7
  import { tokenizeMarkupTolerant } from '../markup/parse';
8
+ import type { ParametersOf } from '../code-check';
9
+ import { registeredParameters, tokenTextContainsChildren } from './ast-scanner';
8
10
 
9
11
  /** A {widget} definition's name, then its `@` parameters. */
10
12
  export const WIDGET_PARAMETERS = [
@@ -49,6 +51,7 @@ export function parseWidgetDef(rawArgs: string): WidgetDef {
49
51
  */
50
52
  export function blockWidgetNames(
51
53
  passages: Iterable<{ name: string; tags?: string[]; content: string }>,
54
+ parametersOf: ParametersOf = registeredParameters,
52
55
  ): string[] {
53
56
  const names: string[] = [];
54
57
  for (const passage of passages) {
@@ -71,10 +74,11 @@ export function blockWidgetNames(
71
74
  // The {widget} macro reports a definition it cannot read
72
75
  }
73
76
  } else if (
74
- token.type === 'variable' &&
75
- token.scope === 'local' &&
76
- token.name === 'children' &&
77
- open.length > 0
77
+ open.length > 0 &&
78
+ ((token.type === 'variable' &&
79
+ token.scope === 'local' &&
80
+ token.name === 'children') ||
81
+ tokenTextContainsChildren(token, parametersOf))
78
82
  ) {
79
83
  open[open.length - 1]!.isBlock = true;
80
84
  }