@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.
- package/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +7318 -7089
- package/dist/pkg/story-variables.js +221 -147
- package/package.json +1 -1
- package/src/components/StoryInterface.tsx +17 -6
- package/src/components/macros/Watch.tsx +11 -0
- package/src/hooks/use-modal-focus.ts +103 -19
- package/src/markup/ast.ts +2 -0
- package/src/markup/render.tsx +46 -11
- package/src/markup/spindle.peggy +6 -4
- package/src/saves/format.ts +3 -1
- package/src/saves/types.ts +5 -0
- package/src/store.ts +25 -2
- package/src/structural.ts +1 -3
- package/src/tooling.ts +5 -2
- package/src/triggers.ts +48 -2
- package/src/utils/stable-key.ts +57 -3
- package/src/utils/value-kinds.ts +3 -0
- package/src/widgets/ast-scanner.ts +76 -6
- package/src/widgets/widget-def.ts +8 -4
package/src/markup/render.tsx
CHANGED
|
@@ -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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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':
|
package/src/markup/spindle.peggy
CHANGED
|
@@ -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
|
-
|
|
335
|
+
const node = lexeme('text', location(), { value });
|
|
336
|
+
node.comment = true;
|
|
337
|
+
return node;
|
|
336
338
|
}
|
|
337
339
|
|
|
338
340
|
/**
|
package/src/saves/format.ts
CHANGED
|
@@ -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
|
|
package/src/saves/types.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
|
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
|
|
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(
|
|
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 {
|
package/src/utils/stable-key.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
|
+
import { hasOwn } from './namespace';
|
|
1
2
|
import { registeredClassName } from '../class-registry';
|
|
2
|
-
import {
|
|
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).
|
|
15
|
-
*
|
|
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,
|
package/src/utils/value-kinds.ts
CHANGED
|
@@ -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(
|
|
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'
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
token.
|
|
76
|
-
|
|
77
|
-
|
|
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
|
}
|