@rohal12/spindle 0.59.17 → 0.59.19

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.
@@ -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).
@@ -82,6 +84,27 @@ const SvgContext = createContext(false);
82
84
 
83
85
  const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
84
86
 
87
+ /**
88
+ * Elements whose children must be elements of particular types: markdown
89
+ * paragraphs between them would pull the children out of the structure (#423).
90
+ * Text between the children, when it is only whitespace, is dropped.
91
+ */
92
+ const STRUCTURAL_ELEMENTS = new Set([
93
+ 'table',
94
+ 'thead',
95
+ 'tbody',
96
+ 'tfoot',
97
+ 'tr',
98
+ 'colgroup',
99
+ 'ul',
100
+ 'ol',
101
+ 'menu',
102
+ 'dl',
103
+ 'select',
104
+ 'optgroup',
105
+ 'datalist',
106
+ ]);
107
+
85
108
  /** Elements whose content is literal text, not markdown. */
86
109
  const PREFORMATTED_ELEMENTS = new Set(['pre', 'textarea', 'style']);
87
110
  export const WidgetChildrenContext = createContext<WidgetChildren | null>(null);
@@ -691,16 +714,22 @@ function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
691
714
  tag === 'details' ? leadingSummaryIndex(node.children) : -1;
692
715
  const body = (nodes: ASTNode[]) =>
693
716
  renderNodes(nodes, { nobr, locals, inline: isInline });
694
- children =
695
- summaryAt === -1 ? (
696
- body(node.children)
697
- ) : (
698
- <>
699
- {body(node.children.slice(0, summaryAt))}
700
- {renderSingleNode(node.children[summaryAt]!)}
701
- {body(node.children.slice(summaryAt + 1))}
702
- </>
703
- );
717
+ const structural =
718
+ STRUCTURAL_ELEMENTS.has(tag) &&
719
+ node.children.every((n) => n.type !== 'text' || n.value.trim() === '');
720
+ children = structural ? (
721
+ node.children
722
+ .filter((n) => n.type !== 'text')
723
+ .map((n) => renderSingleNode(n))
724
+ ) : summaryAt === -1 ? (
725
+ body(node.children)
726
+ ) : (
727
+ <>
728
+ {body(node.children.slice(0, summaryAt))}
729
+ {renderSingleNode(node.children[summaryAt]!)}
730
+ {body(node.children.slice(summaryAt + 1))}
731
+ </>
732
+ );
704
733
  if (isInline !== parentInline) {
705
734
  children = (
706
735
  <InlineContext.Provider value={isInline}>
@@ -710,6 +739,12 @@ function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
710
739
  }
711
740
  }
712
741
  }
742
+ // The content of a foreignObject is HTML again, with HTML attributes
743
+ if (inSvg && tag === 'foreignobject' && children) {
744
+ children = (
745
+ <SvgContext.Provider value={false}>{children}</SvgContext.Provider>
746
+ );
747
+ }
713
748
  let element = h(node.tag, props, children);
714
749
  if (isSvgRoot && !inSvg) {
715
750
  element = <SvgContext.Provider value={true}>{element}</SvgContext.Provider>;
@@ -806,7 +841,7 @@ function renderMacro(node: MacroNode, key: string) {
806
841
  */
807
842
  function renderSingleNode(node: ASTNode): preact.ComponentChildren {
808
843
  // Authored text outside markdown still has its character references decoded
809
- if (node.type === 'text') return decodeText(node.value);
844
+ if (node.type === 'text') return node.comment ? null : decodeText(node.value);
810
845
  const key = nodeKey(node);
811
846
  switch (node.type) {
812
847
  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 {
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,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
  }