@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/src/story-init.ts
CHANGED
|
@@ -1,16 +1,33 @@
|
|
|
1
1
|
import { h, render } from 'preact';
|
|
2
|
-
import { useStoryStore } from './store';
|
|
2
|
+
import { useStoryStore, recordStoryInitState } from './store';
|
|
3
3
|
import { tokenize } from './markup/tokenizer';
|
|
4
4
|
import { buildAST } from './markup/ast';
|
|
5
5
|
import { renderNodes } from './markup/render';
|
|
6
6
|
import { setSaveTitlePassage } from './saves/save-manager';
|
|
7
7
|
|
|
8
|
+
/** Hidden container holding the currently mounted StoryInit tree. */
|
|
9
|
+
let storyInitContainer: HTMLElement | null = null;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Unmount the current StoryInit tree (running its effect cleanups, e.g.
|
|
13
|
+
* pending {timed}/{repeat} timers) and remove its container.
|
|
14
|
+
*/
|
|
15
|
+
function unmountStoryInit(): void {
|
|
16
|
+
if (!storyInitContainer) return;
|
|
17
|
+
render(null, storyInitContainer);
|
|
18
|
+
storyInitContainer.remove();
|
|
19
|
+
storyInitContainer = null;
|
|
20
|
+
}
|
|
21
|
+
|
|
8
22
|
/**
|
|
9
23
|
* Execute the StoryInit passage: tokenize, parse, and render all macros
|
|
10
24
|
* into a detached DOM node so their side effects fire through the normal
|
|
11
25
|
* Preact pipeline. This is macro-agnostic — any macro works in StoryInit.
|
|
26
|
+
* Re-executing (on restart) first unmounts the previous StoryInit tree.
|
|
12
27
|
*/
|
|
13
28
|
export function executeStoryInit() {
|
|
29
|
+
unmountStoryInit();
|
|
30
|
+
|
|
14
31
|
const state = useStoryStore.getState();
|
|
15
32
|
if (!state.storyData) return;
|
|
16
33
|
|
|
@@ -19,16 +36,22 @@ export function executeStoryInit() {
|
|
|
19
36
|
const tokens = tokenize(storyInit.content);
|
|
20
37
|
const ast = buildAST(tokens);
|
|
21
38
|
|
|
22
|
-
// Mount into a persistent hidden container.
|
|
23
|
-
//
|
|
24
|
-
// inside StoryInit macros fire through the normal
|
|
39
|
+
// Mount into a persistent hidden container. It stays mounted until the
|
|
40
|
+
// next execution (restart) — this lets async effects (useEffect,
|
|
41
|
+
// setTimeout, etc.) inside StoryInit macros fire through the normal
|
|
42
|
+
// Preact pipeline.
|
|
25
43
|
const container = document.createElement('div');
|
|
26
44
|
container.style.display = 'none';
|
|
27
45
|
document.body.appendChild(container);
|
|
46
|
+
storyInitContainer = container;
|
|
28
47
|
render(
|
|
29
48
|
h(() => renderNodes(ast) as any, null),
|
|
30
49
|
container,
|
|
31
50
|
);
|
|
51
|
+
|
|
52
|
+
// The start moment was recorded before StoryInit ran; its synchronous
|
|
53
|
+
// changes ({set}, {do}) belong to it.
|
|
54
|
+
recordStoryInitState();
|
|
32
55
|
}
|
|
33
56
|
|
|
34
57
|
// Register SaveTitle passage if it exists
|
package/src/story-variables.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Passage } from './parser';
|
|
2
|
+
import { tokenize } from './markup/tokenizer';
|
|
2
3
|
|
|
3
4
|
export type VarType = 'number' | 'string' | 'boolean' | 'array' | 'object';
|
|
4
5
|
|
|
@@ -17,6 +18,10 @@ function declarationRegex(sigil: string): RegExp {
|
|
|
17
18
|
return new RegExp(`^${escaped}(\\w+)\\s*=\\s*(.+)$`);
|
|
18
19
|
}
|
|
19
20
|
const VAR_REF_RE = /\$(\w+(?:\.\w+)*)/g;
|
|
21
|
+
/** `{` followed by a sigil starts an interpolation block inside literal text. */
|
|
22
|
+
const INTERP_START_RE = /^[$_@%]\w/;
|
|
23
|
+
/** Quoted first argument of an input macro naming a story variable. */
|
|
24
|
+
const QUOTED_VAR_ARG_RE = /^["']\$(\w+(?:\.\w+)*)["']?$/;
|
|
20
25
|
const FOR_LOCAL_RE = /\{for\s+@(\w+)(?:\s*,\s*@(\w+))?\s+of\b/g;
|
|
21
26
|
|
|
22
27
|
const VALID_VAR_TYPES = new Set<string>(['number', 'string', 'boolean']);
|
|
@@ -148,15 +153,190 @@ function validateRef(
|
|
|
148
153
|
return null;
|
|
149
154
|
}
|
|
150
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Built-in input macros whose first argument names the bound story variable,
|
|
158
|
+
* quoted or not (e.g. `{textbox "$name"}`).
|
|
159
|
+
*/
|
|
160
|
+
const BUILTIN_STORE_VAR_MACROS: readonly string[] = [
|
|
161
|
+
'checkbox',
|
|
162
|
+
'cycle',
|
|
163
|
+
'listbox',
|
|
164
|
+
'numberbox',
|
|
165
|
+
'radiobutton',
|
|
166
|
+
'textarea',
|
|
167
|
+
'textbox',
|
|
168
|
+
];
|
|
169
|
+
|
|
170
|
+
type RefCallback = (ref: string) => void;
|
|
171
|
+
|
|
172
|
+
/** Report every `$var.path` in a code segment free of strings/comments. */
|
|
173
|
+
function scanRefs(segment: string, onRef: RefCallback): void {
|
|
174
|
+
for (const match of segment.matchAll(VAR_REF_RE)) onRef(match[1]!);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Index of the quote closing the string opened at `start` (or the end). */
|
|
178
|
+
function findClosingQuote(code: string, start: number): number {
|
|
179
|
+
const quote = code[start];
|
|
180
|
+
let i = start + 1;
|
|
181
|
+
while (i < code.length && code[i] !== quote) {
|
|
182
|
+
i += code[i] === '\\' ? 2 : 1;
|
|
183
|
+
}
|
|
184
|
+
return Math.min(i, code.length);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Scan literal text (string contents, HTML attribute values) for `{$…}`
|
|
189
|
+
* interpolation blocks, which interpolating macros and HTML attributes
|
|
190
|
+
* resolve at runtime. A bare `$word` in literal text is not a reference.
|
|
191
|
+
*/
|
|
192
|
+
function scanInterpolations(text: string, onRef: RefCallback): void {
|
|
193
|
+
let i = text.indexOf('{');
|
|
194
|
+
while (i !== -1) {
|
|
195
|
+
const next = INTERP_START_RE.test(text.slice(i + 1, i + 3))
|
|
196
|
+
? scanCode(text, i + 1, onRef, true)
|
|
197
|
+
: i + 1;
|
|
198
|
+
i = text.indexOf('{', next);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Scan a template literal starting just after its opening backtick: literal
|
|
204
|
+
* parts are text, `${…}` parts are code. Returns the index just past the
|
|
205
|
+
* closing backtick.
|
|
206
|
+
*/
|
|
207
|
+
function scanTemplate(code: string, start: number, onRef: RefCallback): number {
|
|
208
|
+
let i = start;
|
|
209
|
+
let textStart = start;
|
|
210
|
+
while (i < code.length) {
|
|
211
|
+
const ch = code[i];
|
|
212
|
+
if (ch === '\\') {
|
|
213
|
+
i += 2;
|
|
214
|
+
} else if (ch === '`') {
|
|
215
|
+
scanInterpolations(code.slice(textStart, i), onRef);
|
|
216
|
+
return i + 1;
|
|
217
|
+
} else if (ch === '$' && code[i + 1] === '{') {
|
|
218
|
+
scanInterpolations(code.slice(textStart, i), onRef);
|
|
219
|
+
i = textStart = scanCode(code, i + 2, onRef, true);
|
|
220
|
+
} else {
|
|
221
|
+
i++;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
scanInterpolations(code.slice(textStart), onRef);
|
|
225
|
+
return code.length;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Report `$var` references in JavaScript code. Like the string-aware
|
|
230
|
+
* expression transformer, sigils inside string literals are left alone while
|
|
231
|
+
* template-literal `${…}` parts are code; comments are skipped too. When
|
|
232
|
+
* `nested`, stops at the `}` closing the enclosing block and returns the index
|
|
233
|
+
* just past it.
|
|
234
|
+
*/
|
|
235
|
+
function scanCode(
|
|
236
|
+
code: string,
|
|
237
|
+
start: number,
|
|
238
|
+
onRef: RefCallback,
|
|
239
|
+
nested = false,
|
|
240
|
+
): number {
|
|
241
|
+
let i = start;
|
|
242
|
+
let segStart = start;
|
|
243
|
+
let depth = 0;
|
|
244
|
+
while (i < code.length) {
|
|
245
|
+
const ch = code[i];
|
|
246
|
+
const next = code[i + 1];
|
|
247
|
+
const isComment = ch === '/' && (next === '/' || next === '*');
|
|
248
|
+
if (ch === '"' || ch === "'" || ch === '`' || isComment) {
|
|
249
|
+
scanRefs(code.slice(segStart, i), onRef);
|
|
250
|
+
if (ch === '`') {
|
|
251
|
+
i = scanTemplate(code, i + 1, onRef);
|
|
252
|
+
} else if (isComment) {
|
|
253
|
+
const close = code.indexOf(next === '/' ? '\n' : '*/', i + 2);
|
|
254
|
+
i = close === -1 ? code.length : next === '/' ? close : close + 2;
|
|
255
|
+
} else {
|
|
256
|
+
const close = findClosingQuote(code, i);
|
|
257
|
+
scanInterpolations(code.slice(i + 1, close), onRef);
|
|
258
|
+
i = Math.min(close + 1, code.length);
|
|
259
|
+
}
|
|
260
|
+
segStart = i;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (nested && ch === '{') {
|
|
264
|
+
depth++;
|
|
265
|
+
} else if (nested && ch === '}' && depth-- === 0) {
|
|
266
|
+
scanRefs(code.slice(segStart, i), onRef);
|
|
267
|
+
return i + 1;
|
|
268
|
+
}
|
|
269
|
+
i++;
|
|
270
|
+
}
|
|
271
|
+
scanRefs(code.slice(segStart), onRef);
|
|
272
|
+
return code.length;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Report the `$var` references a passage evaluates at runtime: `{$var}`
|
|
277
|
+
* displays, `{$expr}` expressions, macro arguments and `{do}` bodies (as
|
|
278
|
+
* code), quoted variable names bound by input macros, and `{$…}`
|
|
279
|
+
* interpolations in HTML attributes. Prose is literal text and not scanned.
|
|
280
|
+
*/
|
|
281
|
+
function collectPassageRefs(
|
|
282
|
+
content: string,
|
|
283
|
+
storeVarMacros: ReadonlySet<string>,
|
|
284
|
+
onRef: RefCallback,
|
|
285
|
+
): void {
|
|
286
|
+
const tokens = tokenize(content);
|
|
287
|
+
for (let t = 0; t < tokens.length; t++) {
|
|
288
|
+
const token = tokens[t]!;
|
|
289
|
+
if (token.type === 'variable') {
|
|
290
|
+
if (token.scope === 'variable' && token.name) onRef(token.name);
|
|
291
|
+
} else if (token.type === 'expression') {
|
|
292
|
+
scanCode(token.expression, 0, onRef);
|
|
293
|
+
} else if (token.type === 'html') {
|
|
294
|
+
for (const value of Object.values(token.attributes)) {
|
|
295
|
+
scanInterpolations(value, onRef);
|
|
296
|
+
}
|
|
297
|
+
} else if (token.type === 'macro' && !token.isClose) {
|
|
298
|
+
scanCode(token.rawArgs, 0, onRef);
|
|
299
|
+
|
|
300
|
+
if (storeVarMacros.has(token.name.toLowerCase())) {
|
|
301
|
+
const first = token.rawArgs.trim().split(/\s+/)[0] ?? '';
|
|
302
|
+
const quoted = QUOTED_VAR_ARG_RE.exec(first);
|
|
303
|
+
if (quoted) onRef(quoted[1]!);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (token.name === 'do') {
|
|
307
|
+
// A {do} body is JavaScript: scan its source text as code, however
|
|
308
|
+
// the markup tokenizer split it up.
|
|
309
|
+
let close = t + 1;
|
|
310
|
+
while (close < tokens.length) {
|
|
311
|
+
const c = tokens[close]!;
|
|
312
|
+
if (c.type === 'macro' && c.isClose && c.name === 'do') break;
|
|
313
|
+
close++;
|
|
314
|
+
}
|
|
315
|
+
if (close < tokens.length) {
|
|
316
|
+
scanCode(content.slice(token.end, tokens[close]!.start), 0, onRef);
|
|
317
|
+
t = close;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
151
324
|
/**
|
|
152
325
|
* Scan all passages for $var references, check against schema.
|
|
153
326
|
* Returns list of error messages (empty = valid).
|
|
327
|
+
*
|
|
328
|
+
* `storeVarMacros` lists the input macros whose first argument names a bound
|
|
329
|
+
* variable; it defaults to the built-in ones.
|
|
154
330
|
*/
|
|
155
331
|
export function validatePassages(
|
|
156
332
|
passages: Map<string, Passage>,
|
|
157
333
|
schema: Map<string, VariableSchema>,
|
|
334
|
+
storeVarMacros: Iterable<string> = BUILTIN_STORE_VAR_MACROS,
|
|
158
335
|
): string[] {
|
|
159
336
|
const errors: string[] = [];
|
|
337
|
+
const storeVarSet = new Set(
|
|
338
|
+
Array.from(storeVarMacros, (m) => m.toLowerCase()),
|
|
339
|
+
);
|
|
160
340
|
|
|
161
341
|
for (const [name, passage] of passages) {
|
|
162
342
|
// Don't validate the StoryVariables/StoryTransients passages themselves
|
|
@@ -164,15 +344,12 @@ export function validatePassages(
|
|
|
164
344
|
|
|
165
345
|
const forLocals = extractForLocals(passage.content);
|
|
166
346
|
|
|
167
|
-
|
|
168
|
-
VAR_REF_RE.lastIndex = 0;
|
|
169
|
-
while ((match = VAR_REF_RE.exec(passage.content)) !== null) {
|
|
170
|
-
const ref = match[1]!;
|
|
347
|
+
collectPassageRefs(passage.content, storeVarSet, (ref) => {
|
|
171
348
|
const error = validateRef(ref, schema, forLocals);
|
|
172
349
|
if (error) {
|
|
173
350
|
errors.push(`Passage "${name}": ${error}`);
|
|
174
351
|
}
|
|
175
|
-
}
|
|
352
|
+
});
|
|
176
353
|
}
|
|
177
354
|
|
|
178
355
|
return errors;
|
package/src/triggers.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { evaluate
|
|
1
|
+
import { evaluate } from './expression';
|
|
2
|
+
import { executeMutation } from './execute-mutation';
|
|
2
3
|
import { useStoryStore } from './store';
|
|
3
4
|
|
|
4
5
|
export interface WatchOptions {
|
|
@@ -25,6 +26,8 @@ interface Trigger {
|
|
|
25
26
|
options?: WatchOptions;
|
|
26
27
|
lastResult: boolean;
|
|
27
28
|
priority: number;
|
|
29
|
+
/** Identity of a {watch} macro's watcher (see addMacroTrigger). */
|
|
30
|
+
macroKey?: string;
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
let nextId = 0;
|
|
@@ -58,9 +61,10 @@ function evalCondition(condition: string): boolean {
|
|
|
58
61
|
}
|
|
59
62
|
}
|
|
60
63
|
|
|
61
|
-
|
|
64
|
+
function registerTrigger(
|
|
62
65
|
condition: string,
|
|
63
66
|
callbackOrOptions: (() => void) | WatchOptions,
|
|
67
|
+
macroKey?: string,
|
|
64
68
|
): () => void {
|
|
65
69
|
const id = nextId++;
|
|
66
70
|
const isCallback = typeof callbackOrOptions === 'function';
|
|
@@ -75,6 +79,7 @@ export function addTrigger(
|
|
|
75
79
|
options,
|
|
76
80
|
lastResult: evalCondition(condition),
|
|
77
81
|
priority: options?.priority ?? 0,
|
|
82
|
+
macroKey,
|
|
78
83
|
};
|
|
79
84
|
|
|
80
85
|
triggers.push(trigger);
|
|
@@ -85,6 +90,32 @@ export function addTrigger(
|
|
|
85
90
|
};
|
|
86
91
|
}
|
|
87
92
|
|
|
93
|
+
export function addTrigger(
|
|
94
|
+
condition: string,
|
|
95
|
+
callbackOrOptions: (() => void) | WatchOptions,
|
|
96
|
+
): () => void {
|
|
97
|
+
return registerTrigger(condition, callbackOrOptions);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Register the watcher of a {watch} macro. Watchers outlive the passage
|
|
102
|
+
* that declared them, and that passage mounts its macros again on every
|
|
103
|
+
* visit (and on re-renders that remount them), so an identical watcher
|
|
104
|
+
* (same condition and options) that is still registered is kept instead
|
|
105
|
+
* of being added a second time.
|
|
106
|
+
*/
|
|
107
|
+
export function addMacroTrigger(
|
|
108
|
+
condition: string,
|
|
109
|
+
options: WatchOptions,
|
|
110
|
+
): void {
|
|
111
|
+
const sortedOptions = Object.fromEntries(
|
|
112
|
+
Object.entries(options).sort(([a], [b]) => (a < b ? -1 : 1)),
|
|
113
|
+
);
|
|
114
|
+
const macroKey = JSON.stringify([condition, sortedOptions]);
|
|
115
|
+
if (triggers.some((t) => t.macroKey === macroKey)) return;
|
|
116
|
+
registerTrigger(condition, options, macroKey);
|
|
117
|
+
}
|
|
118
|
+
|
|
88
119
|
export function removeTrigger(name: string): void {
|
|
89
120
|
triggers = triggers.filter((t) => t.name !== name);
|
|
90
121
|
}
|
|
@@ -100,8 +131,16 @@ function fireTrigger(trigger: Trigger): void {
|
|
|
100
131
|
if (!options) return;
|
|
101
132
|
|
|
102
133
|
if (options.run) {
|
|
103
|
-
|
|
104
|
-
|
|
134
|
+
// Store state is frozen; go through the mutation pipeline so the run
|
|
135
|
+
// action works on clones and its changes are committed to the store.
|
|
136
|
+
try {
|
|
137
|
+
executeMutation(options.run, {}, () => {});
|
|
138
|
+
} catch (err) {
|
|
139
|
+
console.error(
|
|
140
|
+
`spindle: Error in watch run action for "${trigger.condition}":`,
|
|
141
|
+
err,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
105
144
|
}
|
|
106
145
|
|
|
107
146
|
if (options.dialog) {
|
|
@@ -116,42 +155,105 @@ function fireTrigger(trigger: Trigger): void {
|
|
|
116
155
|
|
|
117
156
|
const MAX_RECHECK_DEPTH = 10;
|
|
118
157
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
try {
|
|
124
|
-
for (let depth = 0; depth < MAX_RECHECK_DEPTH; depth++) {
|
|
125
|
-
let anyFired = false;
|
|
158
|
+
function runCheckLoop(): void {
|
|
159
|
+
for (let depth = 0; depth < MAX_RECHECK_DEPTH; depth++) {
|
|
160
|
+
let anyFired = false;
|
|
126
161
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
162
|
+
// Snapshot triggers list — firing may remove `once` triggers
|
|
163
|
+
const current = [...triggers];
|
|
164
|
+
for (const trigger of current) {
|
|
165
|
+
// Skip if removed during this cycle
|
|
166
|
+
if (!triggers.includes(trigger)) continue;
|
|
132
167
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
168
|
+
const result = evalCondition(trigger.condition);
|
|
169
|
+
const wasFalse = !trigger.lastResult;
|
|
170
|
+
trigger.lastResult = result;
|
|
136
171
|
|
|
137
|
-
|
|
138
|
-
|
|
172
|
+
if (result && wasFalse) {
|
|
173
|
+
anyFired = true;
|
|
139
174
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
fireTrigger(trigger);
|
|
175
|
+
if (trigger.options?.once) {
|
|
176
|
+
triggers = triggers.filter((t) => t.id !== trigger.id);
|
|
145
177
|
}
|
|
146
|
-
}
|
|
147
178
|
|
|
148
|
-
|
|
179
|
+
fireTrigger(trigger);
|
|
180
|
+
}
|
|
149
181
|
}
|
|
182
|
+
|
|
183
|
+
if (!anyFired) break;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function checkTriggers(): void {
|
|
188
|
+
if (checking) return;
|
|
189
|
+
checking = true;
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
runCheckLoop();
|
|
150
193
|
} finally {
|
|
151
194
|
checking = false;
|
|
152
195
|
}
|
|
153
196
|
}
|
|
154
197
|
|
|
198
|
+
/** Number of live connectTriggersToStore() subscriptions. */
|
|
199
|
+
let connections = 0;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Check watchers against a navigation that navigate() has just completed.
|
|
203
|
+
* Called by the store before it records the entered moment, so run actions
|
|
204
|
+
* become part of that moment. Runs even when the navigation itself came
|
|
205
|
+
* from a watcher (a check already in progress), since that check started
|
|
206
|
+
* before the navigation and cannot see it.
|
|
207
|
+
*/
|
|
208
|
+
export function checkTriggersOnNavigation(): void {
|
|
209
|
+
if (connections === 0) return;
|
|
210
|
+
const wasChecking = checking;
|
|
211
|
+
checking = true;
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
runCheckLoop();
|
|
215
|
+
} finally {
|
|
216
|
+
checking = wasChecking;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Re-evaluate watchers whenever a namespace their conditions can read
|
|
222
|
+
* ($variables, _temporary, %transient) changes. Navigation is left to the
|
|
223
|
+
* store: navigate() checks watchers once the new moment is complete
|
|
224
|
+
* (checkTriggersOnNavigation), while history traversal and loads
|
|
225
|
+
* reinitialize watcher state instead of firing.
|
|
226
|
+
*/
|
|
227
|
+
export function connectTriggersToStore(): () => void {
|
|
228
|
+
let prev = useStoryStore.getState();
|
|
229
|
+
connections++;
|
|
230
|
+
const unsubscribe = useStoryStore.subscribe((state) => {
|
|
231
|
+
const before = prev;
|
|
232
|
+
// Update before checking: run/goto actions re-enter this listener.
|
|
233
|
+
prev = state;
|
|
234
|
+
|
|
235
|
+
if (state.navigationId !== before.navigationId) return;
|
|
236
|
+
|
|
237
|
+
if (
|
|
238
|
+
state.variables === before.variables &&
|
|
239
|
+
state.temporary === before.temporary &&
|
|
240
|
+
state.transient === before.transient
|
|
241
|
+
) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
checkTriggers();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
let connected = true;
|
|
249
|
+
return () => {
|
|
250
|
+
if (!connected) return;
|
|
251
|
+
connected = false;
|
|
252
|
+
connections--;
|
|
253
|
+
unsubscribe();
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
155
257
|
export function reinitTriggerState(): void {
|
|
156
258
|
for (const trigger of triggers) {
|
|
157
259
|
trigger.lastResult = evalCondition(trigger.condition);
|
package/src/types-drift-check.ts
CHANGED
|
@@ -91,3 +91,15 @@ const _exampleMacro: PublishedMacroDefinition = {
|
|
|
91
91
|
);
|
|
92
92
|
},
|
|
93
93
|
};
|
|
94
|
+
|
|
95
|
+
// Headless entry point (`@rohal12/spindle/headless`): types/headless.d.ts must
|
|
96
|
+
// match src/headless.ts.
|
|
97
|
+
import type { bootStory as SourceBootStory } from './headless';
|
|
98
|
+
import type { bootStory as PublishedBootStory } from '../types/headless';
|
|
99
|
+
|
|
100
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
101
|
+
const _bootSourceToPublished: typeof PublishedBootStory =
|
|
102
|
+
{} as typeof SourceBootStory;
|
|
103
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
104
|
+
const _bootPublishedToSource: typeof SourceBootStory =
|
|
105
|
+
{} as typeof PublishedBootStory;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only view of a locals scope that always reflects its current values.
|
|
3
|
+
*
|
|
4
|
+
* Used as the LocalsValuesContext value for one-shot detached renders
|
|
5
|
+
* ({button}/{link} bodies). Those trees are unmounted right after rendering,
|
|
6
|
+
* so the owning scope's re-render never reaches them; reading through
|
|
7
|
+
* getValues() lets a macro see a local assigned earlier in the same body.
|
|
8
|
+
*/
|
|
9
|
+
export function liveLocalsView(
|
|
10
|
+
getValues: () => Record<string, unknown>,
|
|
11
|
+
): Record<string, unknown> {
|
|
12
|
+
return new Proxy({} as Record<string, unknown>, {
|
|
13
|
+
get: (_, key) => (typeof key === 'string' ? getValues()[key] : undefined),
|
|
14
|
+
has: (_, key) => key in getValues(),
|
|
15
|
+
ownKeys: () => Reflect.ownKeys(getValues()),
|
|
16
|
+
getOwnPropertyDescriptor: (_, key) => {
|
|
17
|
+
const desc = Object.getOwnPropertyDescriptor(getValues(), key);
|
|
18
|
+
// Keys don't exist on the proxy target, so they must be configurable.
|
|
19
|
+
return desc ? { ...desc, configurable: true } : undefined;
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { StoryAPI } from './index';
|
|
2
|
+
|
|
3
|
+
/** Options for {@link bootStory}. */
|
|
4
|
+
export interface BootStoryOptions {
|
|
5
|
+
/**
|
|
6
|
+
* A compiled story: a full HTML file produced by Twine/twee-ts with the
|
|
7
|
+
* Spindle format, or any HTML containing its `<tw-storydata>` element.
|
|
8
|
+
*/
|
|
9
|
+
html: string;
|
|
10
|
+
/**
|
|
11
|
+
* Keep passage transitions. Default `false`: the default transition is set
|
|
12
|
+
* to `none` before boot, because headless runs have nothing to animate and
|
|
13
|
+
* `fade-through` delays mounting the next passage (and its actions) by its
|
|
14
|
+
* duration. Passage `[transition:…]` tags and the story's own
|
|
15
|
+
* `Story.setTransition()` calls still apply.
|
|
16
|
+
*/
|
|
17
|
+
transitions?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Boot a compiled story in the current `document` (provided by happy-dom,
|
|
22
|
+
* jsdom, ...) and resolve with the `Story` API once the first passage is
|
|
23
|
+
* shown. One story per module instance: boot each story in its own test file.
|
|
24
|
+
*/
|
|
25
|
+
export function bootStory(options: BootStoryOptions): Promise<StoryAPI>;
|
|
26
|
+
|
|
27
|
+
export type { StoryAPI };
|