@rohal12/spindle 0.49.1 → 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 +7396 -0
- package/dist/pkg/story-variables.js +898 -6
- package/package.json +6 -1
- package/src/class-registry.ts +11 -0
- package/src/components/Passage.tsx +23 -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/headless.ts +87 -0
- package/src/index.tsx +21 -26
- package/src/interpolation.ts +4 -9
- package/src/main.tsx +8 -0
- package/src/markup/render.tsx +68 -13
- package/src/markup/tokenizer.ts +150 -37
- package/src/passage-render-state.ts +14 -0
- 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 +40 -25
- package/src/story-init.ts +27 -4
- package/src/story-variables.ts +182 -5
- package/src/triggers.ts +130 -28
- package/src/types-drift-check.ts +12 -0
- package/src/utils/live-locals.ts +22 -0
- package/types/headless.d.ts +27 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rohal12/spindle",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.50.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A Preact-based story format for Twine 2.",
|
|
6
6
|
"license": "Unlicense",
|
|
@@ -29,12 +29,17 @@
|
|
|
29
29
|
"./tooling": {
|
|
30
30
|
"types": "./types/tooling.d.ts",
|
|
31
31
|
"import": "./dist/pkg/tooling.js"
|
|
32
|
+
},
|
|
33
|
+
"./headless": {
|
|
34
|
+
"types": "./types/headless.d.ts",
|
|
35
|
+
"import": "./dist/pkg/headless.js"
|
|
32
36
|
}
|
|
33
37
|
},
|
|
34
38
|
"files": [
|
|
35
39
|
"dist/pkg/index.js",
|
|
36
40
|
"dist/pkg/format.js",
|
|
37
41
|
"dist/pkg/tooling.js",
|
|
42
|
+
"dist/pkg/headless.js",
|
|
38
43
|
"dist/pkg/story-variables.js",
|
|
39
44
|
"dist/pkg/macro-registry.json",
|
|
40
45
|
"types",
|
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,16 +6,35 @@ 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';
|
|
13
13
|
import { sourceLocationOf } from '../utils/source-location';
|
|
14
14
|
import { emitFromRender } from '../event-emitter';
|
|
15
|
+
import { markPassageRendered } from '../passage-render-state';
|
|
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
|
+
}
|
|
15
35
|
|
|
16
36
|
export function renderPassageContent(passage: PassageData) {
|
|
17
|
-
const
|
|
18
|
-
const ast = buildAST(tokens);
|
|
37
|
+
const ast = parsePassage(passage);
|
|
19
38
|
const nobr = passage.tags.includes('nobr');
|
|
20
39
|
return renderNodes(ast, nobr ? { nobr: true } : undefined);
|
|
21
40
|
}
|
|
@@ -102,6 +121,7 @@ export function Passage({ passage, dataTransition }: PassageProps) {
|
|
|
102
121
|
// Passage so every navigation that shows a passage mounts a new one.
|
|
103
122
|
const elRef = useRef<HTMLDivElement>(null);
|
|
104
123
|
useLayoutEffect(() => {
|
|
124
|
+
markPassageRendered(passage.name);
|
|
105
125
|
if (elRef.current) {
|
|
106
126
|
emitFromRender('passagerender', passage.name, elRef.current);
|
|
107
127
|
}
|
|
@@ -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/headless.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node entry point (`@rohal12/spindle/headless`): boot a compiled story in
|
|
3
|
+
* the DOM the caller provides (a vitest `happy-dom`/`jsdom` environment, or
|
|
4
|
+
* a global registrator), so the automation API works without a browser.
|
|
5
|
+
*
|
|
6
|
+
* DOM shims don't execute the story format's `<script type="module">`, so
|
|
7
|
+
* loading the compiled HTML alone never starts Spindle. This entry runs the
|
|
8
|
+
* same boot sequence directly.
|
|
9
|
+
*/
|
|
10
|
+
import { boot } from './index';
|
|
11
|
+
import { useStoryStore } from './store';
|
|
12
|
+
import type { StoryAPI } from './story-api';
|
|
13
|
+
|
|
14
|
+
export interface BootStoryOptions {
|
|
15
|
+
/**
|
|
16
|
+
* A compiled story: a full HTML file produced by Twine/twee-ts with the
|
|
17
|
+
* Spindle format, or any HTML containing its `<tw-storydata>` element.
|
|
18
|
+
*/
|
|
19
|
+
html: string;
|
|
20
|
+
/**
|
|
21
|
+
* Keep passage transitions. Default `false`: the default transition is set
|
|
22
|
+
* to `none` before boot, because headless runs have nothing to animate and
|
|
23
|
+
* `fade-through` delays mounting the next passage (and its actions) by its
|
|
24
|
+
* duration. Passage `[transition:…]` tags and the story's own
|
|
25
|
+
* `Story.setTransition()` calls still apply.
|
|
26
|
+
*/
|
|
27
|
+
transitions?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let booted = false;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Boot the story in the current `document` and resolve with the `Story` API
|
|
34
|
+
* once the first passage is shown (`:storyready`; after `Story.ready()` if
|
|
35
|
+
* the story defers rendering).
|
|
36
|
+
*
|
|
37
|
+
* The document body is replaced by the story's `<tw-storydata>` element and a
|
|
38
|
+
* `<div id="root">`. Spindle keeps its state in module scope, so a module
|
|
39
|
+
* instance can boot one story; use a separate test file (vitest isolates
|
|
40
|
+
* modules per file) for each fresh story.
|
|
41
|
+
*/
|
|
42
|
+
export function bootStory(options: BootStoryOptions): Promise<StoryAPI> {
|
|
43
|
+
if (typeof document === 'undefined') {
|
|
44
|
+
return Promise.reject(
|
|
45
|
+
new Error(
|
|
46
|
+
'spindle: bootStory() needs a DOM. Run it in a happy-dom or jsdom environment.',
|
|
47
|
+
),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
if (booted) {
|
|
51
|
+
return Promise.reject(
|
|
52
|
+
new Error(
|
|
53
|
+
'spindle: bootStory() was already called in this module instance; boot each story in its own test file.',
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const parsed = new DOMParser().parseFromString(options.html, 'text/html');
|
|
59
|
+
const storyData = parsed.querySelector('tw-storydata');
|
|
60
|
+
if (!storyData) {
|
|
61
|
+
return Promise.reject(
|
|
62
|
+
new Error('spindle: bootStory() found no <tw-storydata> in the HTML.'),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
booted = true;
|
|
66
|
+
|
|
67
|
+
if (!options.transitions) {
|
|
68
|
+
useStoryStore.getState().setTransition({ type: 'none' });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const root = document.createElement('div');
|
|
72
|
+
root.id = 'root';
|
|
73
|
+
document.body.replaceChildren(document.importNode(storyData, true), root);
|
|
74
|
+
|
|
75
|
+
return new Promise<StoryAPI>((resolve, reject) => {
|
|
76
|
+
document.addEventListener(':storyready', () => resolve(window.Story), {
|
|
77
|
+
once: true,
|
|
78
|
+
});
|
|
79
|
+
try {
|
|
80
|
+
boot();
|
|
81
|
+
} catch (err) {
|
|
82
|
+
reject(err);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type { StoryAPI };
|
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';
|
|
@@ -45,7 +46,14 @@ function renderErrors(root: HTMLElement, errors: string[]) {
|
|
|
45
46
|
root.appendChild(container);
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Boot Spindle in the current document: parse `<tw-storydata>`, install the
|
|
51
|
+
* `Story` API, run author JavaScript, validate `StoryVariables`, run
|
|
52
|
+
* `StoryInit`, and render into `#root`. Dispatches `:storyready` when the
|
|
53
|
+
* first passage is shown. Call once per page (module state is global).
|
|
54
|
+
* The browser story format calls it from `main.tsx`.
|
|
55
|
+
*/
|
|
56
|
+
export function boot() {
|
|
49
57
|
const storyData = parseStoryData();
|
|
50
58
|
|
|
51
59
|
// Inject built-in styles with an id so they can be disabled at runtime
|
|
@@ -90,7 +98,11 @@ function boot() {
|
|
|
90
98
|
}
|
|
91
99
|
|
|
92
100
|
const schema = parseStoryVariables(storyVarsPassage.content);
|
|
93
|
-
|
|
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);
|
|
94
106
|
|
|
95
107
|
// Parse StoryTransients (optional — no error if missing)
|
|
96
108
|
let transientDefaults: Record<string, unknown> = {};
|
|
@@ -187,29 +199,18 @@ function boot() {
|
|
|
187
199
|
}
|
|
188
200
|
}
|
|
189
201
|
|
|
190
|
-
// Reset action ID counters on passage
|
|
191
|
-
|
|
202
|
+
// Reset action ID counters on every navigation (the passage remounts even
|
|
203
|
+
// when its name is unchanged)
|
|
204
|
+
let prevNavigationId = useStoryStore.getState().navigationId;
|
|
192
205
|
useStoryStore.subscribe((state) => {
|
|
193
|
-
if (state.
|
|
194
|
-
|
|
206
|
+
if (state.navigationId !== prevNavigationId) {
|
|
207
|
+
prevNavigationId = state.navigationId;
|
|
195
208
|
resetIdCounters();
|
|
196
209
|
}
|
|
197
210
|
});
|
|
198
211
|
|
|
199
212
|
// Wire up trigger system: check triggers on variable mutations, reinit on history nav/load
|
|
200
|
-
|
|
201
|
-
let prevHistoryIndex = useStoryStore.getState().historyIndex;
|
|
202
|
-
useStoryStore.subscribe((state) => {
|
|
203
|
-
if (state.variables !== prevVars) {
|
|
204
|
-
if (state.historyIndex === prevHistoryIndex) {
|
|
205
|
-
checkTriggers();
|
|
206
|
-
} else {
|
|
207
|
-
reinitTriggerState();
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
prevVars = state.variables;
|
|
211
|
-
prevHistoryIndex = state.historyIndex;
|
|
212
|
-
});
|
|
213
|
+
connectTriggersToStore();
|
|
213
214
|
|
|
214
215
|
// Warn if StoryInterface passage exists but doesn't contain {passage}
|
|
215
216
|
const storyInterfacePassage = storyData.passages.get('StoryInterface');
|
|
@@ -239,9 +240,3 @@ function boot() {
|
|
|
239
240
|
document.dispatchEvent(new CustomEvent(':storyready'));
|
|
240
241
|
}
|
|
241
242
|
}
|
|
242
|
-
|
|
243
|
-
if (document.readyState === 'loading') {
|
|
244
|
-
document.addEventListener('DOMContentLoaded', boot);
|
|
245
|
-
} else {
|
|
246
|
-
boot();
|
|
247
|
-
}
|