@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.
- package/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +7167 -6968
- package/dist/pkg/story-variables.js +221 -147
- package/package.json +1 -1
- package/src/components/StoryInterface.tsx +17 -6
- package/src/components/macros/PassageDisplay.tsx +9 -1
- package/src/components/macros/Repeat.tsx +6 -1
- package/src/components/macros/Timed.tsx +5 -1
- package/src/components/macros/Watch.tsx +11 -0
- package/src/components/macros/detached-body.tsx +14 -3
- package/src/define-macro.ts +10 -2
- package/src/hooks/use-modal-focus.ts +2 -0
- package/src/hooks/use-story-fields.ts +8 -0
- package/src/markup/ast.ts +2 -0
- package/src/markup/render.tsx +9 -1
- 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 +47 -16
- package/src/tooling.ts +5 -2
- package/src/triggers.ts +48 -2
- package/src/utils/object-path.ts +13 -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
|
@@ -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 {
|
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).
|
|
@@ -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':
|
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 {
|
|
@@ -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
|
|
892
|
-
|
|
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(
|
|
896
|
-
|
|
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
|
-
}
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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/object-path.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
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
|
}
|