@rohal12/spindle 0.50.0 → 0.50.1
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 +828 -243
- package/dist/pkg/story-variables.js +898 -6
- package/package.json +1 -1
- package/src/class-registry.ts +11 -0
- package/src/components/Passage.tsx +21 -3
- package/src/components/macros/Button.tsx +2 -1
- package/src/components/macros/Do.tsx +1 -0
- package/src/components/macros/For.tsx +4 -0
- package/src/components/macros/Include.tsx +13 -6
- package/src/components/macros/MacroLink.tsx +2 -1
- package/src/components/macros/PassageDisplay.tsx +17 -10
- package/src/components/macros/SaveManager.tsx +18 -5
- package/src/components/macros/Watch.tsx +3 -2
- package/src/components/macros/WidgetInvocation.tsx +4 -0
- package/src/execute-mutation.ts +83 -31
- package/src/index.tsx +13 -19
- package/src/interpolation.ts +4 -9
- package/src/markup/render.tsx +68 -13
- package/src/markup/tokenizer.ts +150 -37
- package/src/saves/save-manager.ts +97 -39
- package/src/settings.ts +15 -0
- package/src/store.ts +209 -56
- package/src/story-api.ts +21 -19
- package/src/story-init.ts +27 -4
- package/src/story-variables.ts +182 -5
- package/src/triggers.ts +130 -28
- package/src/utils/live-locals.ts +22 -0
package/package.json
CHANGED
package/src/class-registry.ts
CHANGED
|
@@ -224,6 +224,17 @@ export function deserialize<T>(value: T): T {
|
|
|
224
224
|
return instance;
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
+
// Already-live built-in — pass through as-is, so deserializing an
|
|
228
|
+
// already-deserialized value is a no-op instead of flattening it to {}
|
|
229
|
+
if (
|
|
230
|
+
val instanceof Date ||
|
|
231
|
+
val instanceof RegExp ||
|
|
232
|
+
val instanceof Map ||
|
|
233
|
+
val instanceof Set
|
|
234
|
+
) {
|
|
235
|
+
return val;
|
|
236
|
+
}
|
|
237
|
+
|
|
227
238
|
// Already-live registered class instance — pass through as-is
|
|
228
239
|
const ctor = (obj as object).constructor as Constructor;
|
|
229
240
|
if (ctorToName.has(ctor)) {
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
useState,
|
|
7
7
|
} from 'preact/hooks';
|
|
8
8
|
import { tokenize } from '../markup/tokenizer';
|
|
9
|
-
import { buildAST } from '../markup/ast';
|
|
9
|
+
import { buildAST, type ASTNode } from '../markup/ast';
|
|
10
10
|
import { renderNodes, NobrContext } from '../markup/render';
|
|
11
11
|
import { useStoryStore } from '../store';
|
|
12
12
|
import type { Passage as PassageData } from '../parser';
|
|
@@ -14,9 +14,27 @@ import { sourceLocationOf } from '../utils/source-location';
|
|
|
14
14
|
import { emitFromRender } from '../event-emitter';
|
|
15
15
|
import { markPassageRendered } from '../passage-render-state';
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Parsed AST per passage. The renderer keys children by AST node identity,
|
|
19
|
+
* so re-rendering a passage (e.g. PassageReady on every PassageDisplay
|
|
20
|
+
* render) must reuse its AST — a fresh parse would remount every macro in
|
|
21
|
+
* it and re-run its {set}/{do} side effects (#175).
|
|
22
|
+
*/
|
|
23
|
+
const astCache = new WeakMap<
|
|
24
|
+
PassageData,
|
|
25
|
+
{ content: string; ast: ASTNode[] }
|
|
26
|
+
>();
|
|
27
|
+
|
|
28
|
+
function parsePassage(passage: PassageData): ASTNode[] {
|
|
29
|
+
const cached = astCache.get(passage);
|
|
30
|
+
if (cached && cached.content === passage.content) return cached.ast;
|
|
31
|
+
const ast = buildAST(tokenize(passage.content));
|
|
32
|
+
astCache.set(passage, { content: passage.content, ast });
|
|
33
|
+
return ast;
|
|
34
|
+
}
|
|
35
|
+
|
|
17
36
|
export function renderPassageContent(passage: PassageData) {
|
|
18
|
-
const
|
|
19
|
-
const ast = buildAST(tokens);
|
|
37
|
+
const ast = parsePassage(passage);
|
|
20
38
|
const nobr = passage.tags.includes('nobr');
|
|
21
39
|
return renderNodes(ast, nobr ? { nobr: true } : undefined);
|
|
22
40
|
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
LocalsValuesContext,
|
|
8
8
|
NobrContext,
|
|
9
9
|
} from '../../markup/render';
|
|
10
|
+
import { liveLocalsView } from '../../utils/live-locals';
|
|
10
11
|
|
|
11
12
|
defineMacro({
|
|
12
13
|
name: 'button',
|
|
@@ -29,7 +30,7 @@ defineMacro({
|
|
|
29
30
|
{ value: { update: ctx.update, getValues: ctx.getValues } },
|
|
30
31
|
h(
|
|
31
32
|
LocalsValuesContext.Provider,
|
|
32
|
-
{ value: ctx.getValues
|
|
33
|
+
{ value: liveLocalsView(ctx.getValues) },
|
|
33
34
|
renderNodes(children),
|
|
34
35
|
),
|
|
35
36
|
),
|
|
@@ -88,6 +88,10 @@ function ForIteration({
|
|
|
88
88
|
|
|
89
89
|
const getValues = useCallback(() => valuesRef.current, []);
|
|
90
90
|
const update = useCallback((key: string, value: unknown) => {
|
|
91
|
+
// Apply synchronously so later macros in the same render pass (e.g. a
|
|
92
|
+
// second {set}) read the new value via getValues(); the state update
|
|
93
|
+
// then re-renders consumers of LocalsValuesContext.
|
|
94
|
+
valuesRef.current = { ...valuesRef.current, [key]: value };
|
|
91
95
|
setLocalMutations((prev) => ({ ...prev, [key]: value }));
|
|
92
96
|
}, []);
|
|
93
97
|
const updater = useMemo(() => ({ update, getValues }), [update, getValues]);
|
|
@@ -11,8 +11,6 @@ defineMacro({
|
|
|
11
11
|
render({ rawArgs }, ctx) {
|
|
12
12
|
const storyData = useStoryStore((s) => s.storyData);
|
|
13
13
|
|
|
14
|
-
if (!storyData) return null;
|
|
15
|
-
|
|
16
14
|
const inline = /\binline\b/.test(rawArgs);
|
|
17
15
|
const nameExpr = rawArgs.replace(/\binline\b/, '').trim();
|
|
18
16
|
|
|
@@ -24,19 +22,28 @@ defineMacro({
|
|
|
24
22
|
passageName = nameExpr.replace(/^["']|["']$/g, '');
|
|
25
23
|
}
|
|
26
24
|
|
|
27
|
-
const passage = storyData
|
|
25
|
+
const passage = storyData?.passages.get(passageName);
|
|
26
|
+
// Parse once per included passage: the renderer keys children by AST
|
|
27
|
+
// node identity, so a fresh AST is what remounts the content when the
|
|
28
|
+
// included passage changes, while re-renders of the same passage keep
|
|
29
|
+
// its macros mounted (#175).
|
|
30
|
+
const ast = ctx.hooks.useMemo(
|
|
31
|
+
() => (passage ? buildAST(tokenize(passage.content)) : null),
|
|
32
|
+
[passage],
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
if (!storyData) return null;
|
|
36
|
+
|
|
28
37
|
if (passage) {
|
|
29
38
|
useStoryStore.getState().trackRender(passageName);
|
|
30
39
|
}
|
|
31
|
-
if (!passage) {
|
|
40
|
+
if (!passage || !ast) {
|
|
32
41
|
return (
|
|
33
42
|
<span class="error">{`{include${ctx.sourceLocation()}: passage "${passageName}" not found}`}</span>
|
|
34
43
|
);
|
|
35
44
|
}
|
|
36
45
|
|
|
37
46
|
const nobr = passage.tags.includes('nobr');
|
|
38
|
-
const tokens = tokenize(passage.content);
|
|
39
|
-
const ast = buildAST(tokens);
|
|
40
47
|
const content = inline
|
|
41
48
|
? ctx.renderInlineNodes(ast)
|
|
42
49
|
: ctx.renderNodes(ast, nobr ? { nobr: true } : undefined);
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
NobrContext,
|
|
9
9
|
} from '../../markup/render';
|
|
10
10
|
import { defineMacro } from '../../define-macro';
|
|
11
|
+
import { liveLocalsView } from '../../utils/live-locals';
|
|
11
12
|
|
|
12
13
|
function parseArgs(rawArgs: string): {
|
|
13
14
|
display: string;
|
|
@@ -45,7 +46,7 @@ function renderChildrenDetached(
|
|
|
45
46
|
{ value: { update, getValues } },
|
|
46
47
|
h(
|
|
47
48
|
LocalsValuesContext.Provider,
|
|
48
|
-
{ value: getValues
|
|
49
|
+
{ value: liveLocalsView(getValues) },
|
|
49
50
|
renderNodes(children),
|
|
50
51
|
),
|
|
51
52
|
),
|
|
@@ -70,13 +70,21 @@ defineMacro({
|
|
|
70
70
|
interpolate: true,
|
|
71
71
|
render(_props, ctx) {
|
|
72
72
|
const currentPassage = useStoryStore((s) => s.currentPassage);
|
|
73
|
+
const navigationId = useStoryStore((s) => s.navigationId);
|
|
73
74
|
const storyData = useStoryStore((s) => s.storyData);
|
|
74
75
|
const renderDeferred = useStoryStore((s) => s.renderDeferred);
|
|
75
76
|
|
|
76
|
-
// Render-gating:
|
|
77
|
+
// Render-gating: displayed controls which passage is visually shown.
|
|
77
78
|
// The store updates immediately, but the visual swap is deferred by the
|
|
78
|
-
// transition state machine.
|
|
79
|
-
|
|
79
|
+
// transition state machine. Identity is the navigation id, not the passage
|
|
80
|
+
// name, so revisiting/restarting/loading the same passage remounts it.
|
|
81
|
+
const [displayed, setDisplayed] = useState({
|
|
82
|
+
passage: currentPassage,
|
|
83
|
+
id: navigationId,
|
|
84
|
+
});
|
|
85
|
+
const displayedPassage = displayed.passage;
|
|
86
|
+
const setDisplayedPassage = (passage: string) =>
|
|
87
|
+
setDisplayed({ passage, id: navigationId });
|
|
80
88
|
|
|
81
89
|
// Track the resolved transition type for the data-transition prop
|
|
82
90
|
const resolvedTypeRef = useRef<string>('fade');
|
|
@@ -97,7 +105,7 @@ defineMacro({
|
|
|
97
105
|
cleanupSnapshots(containerRef.current);
|
|
98
106
|
}, []);
|
|
99
107
|
|
|
100
|
-
// State machine effect — triggers on
|
|
108
|
+
// State machine effect — triggers on every navigation
|
|
101
109
|
useEffect(() => {
|
|
102
110
|
const store = useStoryStore.getState();
|
|
103
111
|
const { history, historyIndex, transitionConfig } = store;
|
|
@@ -118,9 +126,8 @@ defineMacro({
|
|
|
118
126
|
prevLen > 1 &&
|
|
119
127
|
Math.abs(history.length - prevLen) > 1;
|
|
120
128
|
|
|
121
|
-
//
|
|
122
|
-
if (
|
|
123
|
-
// Update prevHistoryLen on initial render
|
|
129
|
+
// Already showing this navigation (initial render)
|
|
130
|
+
if (navigationId === displayed.id) {
|
|
124
131
|
return;
|
|
125
132
|
}
|
|
126
133
|
|
|
@@ -244,7 +251,7 @@ defineMacro({
|
|
|
244
251
|
// Fallback: unknown type, just mount
|
|
245
252
|
setDisplayedPassage(currentPassage);
|
|
246
253
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
247
|
-
}, [
|
|
254
|
+
}, [navigationId]);
|
|
248
255
|
|
|
249
256
|
// When render is deferred, show StoryLoading passage or nothing
|
|
250
257
|
const effectivePassage = renderDeferred
|
|
@@ -270,7 +277,7 @@ defineMacro({
|
|
|
270
277
|
>
|
|
271
278
|
{!renderDeferred && readyPassage && (
|
|
272
279
|
<div
|
|
273
|
-
key={`ready-${
|
|
280
|
+
key={`ready-${navigationId}`}
|
|
274
281
|
hidden
|
|
275
282
|
>
|
|
276
283
|
{renderPassageContent(readyPassage)}
|
|
@@ -283,7 +290,7 @@ defineMacro({
|
|
|
283
290
|
{effectivePassage && (
|
|
284
291
|
<Passage
|
|
285
292
|
passage={effectivePassage}
|
|
286
|
-
key={renderDeferred ? 'loading' :
|
|
293
|
+
key={renderDeferred ? 'loading' : `nav-${displayed.id}`}
|
|
287
294
|
dataTransition={renderDeferred ? 'none' : resolvedTypeRef.current}
|
|
288
295
|
/>
|
|
289
296
|
)}
|
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
renameSave,
|
|
16
16
|
exportSave,
|
|
17
17
|
importSave,
|
|
18
|
+
deserializePayload,
|
|
19
|
+
saveWithHooks,
|
|
18
20
|
type PlaythroughGroup,
|
|
19
21
|
} from '../../saves/save-manager';
|
|
20
22
|
import { DialogCloseContext } from '../PassageDialog';
|
|
@@ -101,11 +103,13 @@ export function SaveManagerContent() {
|
|
|
101
103
|
});
|
|
102
104
|
};
|
|
103
105
|
|
|
106
|
+
// Dialog saves are not slot saves, so the save hooks get no slot.
|
|
104
107
|
const handleNewSave = async () => {
|
|
105
108
|
if (!ifid || !playthroughId) return;
|
|
106
109
|
try {
|
|
107
|
-
|
|
108
|
-
|
|
110
|
+
await saveWithHooks(undefined, undefined, getSavePayload, (payload) =>
|
|
111
|
+
createSave(ifid, playthroughId, payload),
|
|
112
|
+
);
|
|
109
113
|
showStatus('Save created');
|
|
110
114
|
await refresh();
|
|
111
115
|
} catch {
|
|
@@ -115,8 +119,16 @@ export function SaveManagerContent() {
|
|
|
115
119
|
|
|
116
120
|
const handleOverwrite = async (saveId: string) => {
|
|
117
121
|
try {
|
|
118
|
-
|
|
119
|
-
|
|
122
|
+
await saveWithHooks(
|
|
123
|
+
undefined,
|
|
124
|
+
undefined,
|
|
125
|
+
getSavePayload,
|
|
126
|
+
async (payload) => {
|
|
127
|
+
if (!(await overwriteSave(saveId, payload))) {
|
|
128
|
+
throw new Error('Save not found');
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
);
|
|
120
132
|
showStatus('Save overwritten');
|
|
121
133
|
await refresh();
|
|
122
134
|
} catch {
|
|
@@ -126,7 +138,8 @@ export function SaveManagerContent() {
|
|
|
126
138
|
|
|
127
139
|
const handleLoad = async (save: SaveRecord) => {
|
|
128
140
|
try {
|
|
129
|
-
|
|
141
|
+
// Stored records hold serialized variables; the store expects live ones
|
|
142
|
+
loadFromPayload(deserializePayload(save.payload));
|
|
130
143
|
showStatus('Game loaded');
|
|
131
144
|
if (closeDialog) setTimeout(closeDialog, 500);
|
|
132
145
|
} catch {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineMacro } from '../../define-macro';
|
|
2
|
-
import {
|
|
2
|
+
import { addMacroTrigger, removeTrigger } from '../../triggers';
|
|
3
3
|
import type { WatchOptions } from '../../triggers';
|
|
4
4
|
|
|
5
5
|
defineMacro({
|
|
@@ -48,10 +48,11 @@ defineMacro({
|
|
|
48
48
|
|
|
49
49
|
// Register during render (like {set}) — triggers survive navigation
|
|
50
50
|
// and are cleaned up via resetTriggers() on restart or {unwatch}.
|
|
51
|
+
// Remounts (revisits, re-rendered branches) keep the registered watcher.
|
|
51
52
|
const registered = hooks.useRef(false);
|
|
52
53
|
if (!registered.current) {
|
|
53
54
|
registered.current = true;
|
|
54
|
-
|
|
55
|
+
addMacroTrigger(condition, options);
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
return null;
|
|
@@ -200,6 +200,10 @@ function WidgetBody({
|
|
|
200
200
|
|
|
201
201
|
const getValues = useCallback(() => valuesRef.current, []);
|
|
202
202
|
const update = useCallback((key: string, value: unknown) => {
|
|
203
|
+
// Apply synchronously so later macros in the same render pass (e.g. a
|
|
204
|
+
// second {set}) read the new value via getValues(); the state update
|
|
205
|
+
// then re-renders consumers of LocalsValuesContext.
|
|
206
|
+
valuesRef.current = { ...valuesRef.current, [key]: value };
|
|
203
207
|
setLocalMutations((prev) => ({ ...prev, [key]: value }));
|
|
204
208
|
}, []);
|
|
205
209
|
const updater = useMemo(() => ({ update, getValues }), [update, getValues]);
|
package/src/execute-mutation.ts
CHANGED
|
@@ -2,6 +2,79 @@ import { useStoryStore } from './store';
|
|
|
2
2
|
import { execute } from './expression';
|
|
3
3
|
import { deepClone } from './class-registry';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Structural equality for a deepClone() copy against its source. Lets
|
|
7
|
+
* executeMutation write back only the variables the code actually changed,
|
|
8
|
+
* so untouched clones don't overwrite updates made during execution
|
|
9
|
+
* (e.g. Story.set called from inside {do}).
|
|
10
|
+
*/
|
|
11
|
+
function deepEqual(
|
|
12
|
+
a: unknown,
|
|
13
|
+
b: unknown,
|
|
14
|
+
seen: Map<object, object> = new Map(),
|
|
15
|
+
): boolean {
|
|
16
|
+
if (Object.is(a, b)) return true;
|
|
17
|
+
if (
|
|
18
|
+
a === null ||
|
|
19
|
+
b === null ||
|
|
20
|
+
typeof a !== 'object' ||
|
|
21
|
+
typeof b !== 'object'
|
|
22
|
+
) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;
|
|
26
|
+
if (seen.get(a) === b) return true;
|
|
27
|
+
seen.set(a, b);
|
|
28
|
+
|
|
29
|
+
if (a instanceof Date) return a.getTime() === (b as Date).getTime();
|
|
30
|
+
if (a instanceof RegExp) return String(a) === String(b);
|
|
31
|
+
if (a instanceof Map || a instanceof Set) {
|
|
32
|
+
const bc = b as Map<unknown, unknown> | Set<unknown>;
|
|
33
|
+
if (a.size !== bc.size) return false;
|
|
34
|
+
const ai = a.entries();
|
|
35
|
+
const bi = bc.entries();
|
|
36
|
+
for (
|
|
37
|
+
let x = ai.next(), y = bi.next();
|
|
38
|
+
!x.done;
|
|
39
|
+
x = ai.next(), y = bi.next()
|
|
40
|
+
) {
|
|
41
|
+
if (!deepEqual(x.value, y.value, seen)) return false;
|
|
42
|
+
}
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const ao = a as Record<string, unknown>;
|
|
47
|
+
const bo = b as Record<string, unknown>;
|
|
48
|
+
const keys = Object.keys(ao);
|
|
49
|
+
if (keys.length !== Object.keys(bo).length) return false;
|
|
50
|
+
for (const key of keys) {
|
|
51
|
+
if (!(key in bo) || !deepEqual(ao[key], bo[key], seen)) return false;
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Write the keys `code` changed (`clone` vs the pre-execution `before`) into
|
|
58
|
+
* an Immer draft of the same namespace, and delete the keys it removed.
|
|
59
|
+
* Untouched keys are left alone, so updates made during execution survive.
|
|
60
|
+
*/
|
|
61
|
+
function commitNamespace(
|
|
62
|
+
draft: Record<string, unknown>,
|
|
63
|
+
clone: Record<string, unknown>,
|
|
64
|
+
before: Record<string, unknown>,
|
|
65
|
+
): void {
|
|
66
|
+
for (const key of Object.keys(clone)) {
|
|
67
|
+
if (!deepEqual(clone[key], before[key])) {
|
|
68
|
+
draft[key] = clone[key];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
for (const key of Object.keys(before)) {
|
|
72
|
+
if (!(key in clone)) {
|
|
73
|
+
delete draft[key];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
5
78
|
export function executeMutation(
|
|
6
79
|
code: string,
|
|
7
80
|
mergedLocals: Record<string, unknown>,
|
|
@@ -15,43 +88,22 @@ export function executeMutation(
|
|
|
15
88
|
|
|
16
89
|
execute(code, vars, temps, localsClone, trans);
|
|
17
90
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
for (const key of Object.keys(trans)) {
|
|
29
|
-
if (trans[key] !== state.transient[key]) {
|
|
30
|
-
state.setTransient(key, trans[key]);
|
|
31
|
-
}
|
|
32
|
-
}
|
|
91
|
+
// Commit every store change in one update: watchers must see the whole
|
|
92
|
+
// mutation, and a watcher's run action must not be overwritten by keys
|
|
93
|
+
// this commit writes after it fired.
|
|
94
|
+
state.updateVariables((draft) => {
|
|
95
|
+
commitNamespace(draft.variables, vars, state.variables);
|
|
96
|
+
commitNamespace(draft.temporary, temps, state.temporary);
|
|
97
|
+
commitNamespace(draft.transient, trans, state.transient);
|
|
98
|
+
});
|
|
99
|
+
|
|
33
100
|
for (const key of Object.keys(localsClone)) {
|
|
34
101
|
if (localsClone[key] !== mergedLocals[key]) {
|
|
35
102
|
scopeUpdate(key, localsClone[key]);
|
|
36
103
|
}
|
|
37
104
|
}
|
|
38
105
|
|
|
39
|
-
// Detect deleted
|
|
40
|
-
for (const key of Object.keys(state.variables)) {
|
|
41
|
-
if (!(key in vars)) {
|
|
42
|
-
state.deleteVariable(key);
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
for (const key of Object.keys(state.temporary)) {
|
|
46
|
-
if (!(key in temps)) {
|
|
47
|
-
state.deleteTemporary(key);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
for (const key of Object.keys(state.transient)) {
|
|
51
|
-
if (!(key in trans)) {
|
|
52
|
-
state.deleteTransient(key);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
106
|
+
// Detect deleted locals
|
|
55
107
|
for (const key of Object.keys(mergedLocals)) {
|
|
56
108
|
if (!(key in localsClone)) {
|
|
57
109
|
scopeUpdate(key, undefined);
|
package/src/index.tsx
CHANGED
|
@@ -10,13 +10,14 @@ import {
|
|
|
10
10
|
} from './story-api';
|
|
11
11
|
import { resetIdCounters } from './action-registry';
|
|
12
12
|
import { executeStoryInit } from './story-init';
|
|
13
|
-
import {
|
|
13
|
+
import { connectTriggersToStore } from './triggers';
|
|
14
14
|
import { loadSession } from './saves/save-manager';
|
|
15
15
|
import {
|
|
16
16
|
parseStoryVariables,
|
|
17
17
|
validatePassages,
|
|
18
18
|
extractDefaults,
|
|
19
19
|
} from './story-variables';
|
|
20
|
+
import { getMacroRegistry } from './registry';
|
|
20
21
|
import { tokenize } from './markup/tokenizer';
|
|
21
22
|
import { buildAST, registerBlockMacro } from './markup/ast';
|
|
22
23
|
import { registerWidget } from './widgets/widget-registry';
|
|
@@ -97,7 +98,11 @@ export function boot() {
|
|
|
97
98
|
}
|
|
98
99
|
|
|
99
100
|
const schema = parseStoryVariables(storyVarsPassage.content);
|
|
100
|
-
|
|
101
|
+
// Include input macros registered by author JS, which ran above.
|
|
102
|
+
const storeVarMacros = getMacroRegistry()
|
|
103
|
+
.filter((m) => m.storeVar)
|
|
104
|
+
.map((m) => m.name);
|
|
105
|
+
const errors = validatePassages(storyData.passages, schema, storeVarMacros);
|
|
101
106
|
|
|
102
107
|
// Parse StoryTransients (optional — no error if missing)
|
|
103
108
|
let transientDefaults: Record<string, unknown> = {};
|
|
@@ -194,29 +199,18 @@ export function boot() {
|
|
|
194
199
|
}
|
|
195
200
|
}
|
|
196
201
|
|
|
197
|
-
// Reset action ID counters on passage
|
|
198
|
-
|
|
202
|
+
// Reset action ID counters on every navigation (the passage remounts even
|
|
203
|
+
// when its name is unchanged)
|
|
204
|
+
let prevNavigationId = useStoryStore.getState().navigationId;
|
|
199
205
|
useStoryStore.subscribe((state) => {
|
|
200
|
-
if (state.
|
|
201
|
-
|
|
206
|
+
if (state.navigationId !== prevNavigationId) {
|
|
207
|
+
prevNavigationId = state.navigationId;
|
|
202
208
|
resetIdCounters();
|
|
203
209
|
}
|
|
204
210
|
});
|
|
205
211
|
|
|
206
212
|
// Wire up trigger system: check triggers on variable mutations, reinit on history nav/load
|
|
207
|
-
|
|
208
|
-
let prevHistoryIndex = useStoryStore.getState().historyIndex;
|
|
209
|
-
useStoryStore.subscribe((state) => {
|
|
210
|
-
if (state.variables !== prevVars) {
|
|
211
|
-
if (state.historyIndex === prevHistoryIndex) {
|
|
212
|
-
checkTriggers();
|
|
213
|
-
} else {
|
|
214
|
-
reinitTriggerState();
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
prevVars = state.variables;
|
|
218
|
-
prevHistoryIndex = state.historyIndex;
|
|
219
|
-
});
|
|
213
|
+
connectTriggersToStore();
|
|
220
214
|
|
|
221
215
|
// Warn if StoryInterface passage exists but doesn't contain {passage}
|
|
222
216
|
const storyInterfacePassage = storyData.passages.get('StoryInterface');
|
package/src/interpolation.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { evaluate } from './expression';
|
|
2
|
+
import { scanBalancedBrace } from './markup/tokenizer';
|
|
2
3
|
|
|
3
4
|
/** Detects any {…} block that starts with a sigil ($, _, @, %). */
|
|
4
5
|
const INTERP_TEST = /\{[\$_@%]\w/;
|
|
@@ -97,16 +98,10 @@ export function interpolate(
|
|
|
97
98
|
continue;
|
|
98
99
|
}
|
|
99
100
|
|
|
100
|
-
// Scan for balanced closing }
|
|
101
|
-
|
|
102
|
-
let j = i;
|
|
103
|
-
while (j < template.length && depth > 0) {
|
|
104
|
-
j++;
|
|
105
|
-
if (template[j] === '{') depth++;
|
|
106
|
-
else if (template[j] === '}') depth--;
|
|
107
|
-
}
|
|
101
|
+
// Scan for balanced closing }, ignoring braces inside strings
|
|
102
|
+
const j = scanBalancedBrace(template, i);
|
|
108
103
|
|
|
109
|
-
if (
|
|
104
|
+
if (j === -1) {
|
|
110
105
|
// Unbalanced — emit as text
|
|
111
106
|
result += '{';
|
|
112
107
|
continue;
|