@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/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. We intentionally do NOT
23
- // unmount — this lets async effects (useEffect, setTimeout, etc.)
24
- // inside StoryInit macros fire through the normal Preact pipeline.
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
@@ -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
- let match: RegExpExecArray | null;
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, execute } from './expression';
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
- export function addTrigger(
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
- const state = useStoryStore.getState();
104
- execute(options.run, state.variables, state.temporary);
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
- export function checkTriggers(): void {
120
- if (checking) return;
121
- checking = true;
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
- // Snapshot triggers list — firing may remove `once` triggers
128
- const current = [...triggers];
129
- for (const trigger of current) {
130
- // Skip if removed during this cycle
131
- if (!triggers.includes(trigger)) continue;
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
- const result = evalCondition(trigger.condition);
134
- const wasFalse = !trigger.lastResult;
135
- trigger.lastResult = result;
168
+ const result = evalCondition(trigger.condition);
169
+ const wasFalse = !trigger.lastResult;
170
+ trigger.lastResult = result;
136
171
 
137
- if (result && wasFalse) {
138
- anyFired = true;
172
+ if (result && wasFalse) {
173
+ anyFired = true;
139
174
 
140
- if (trigger.options?.once) {
141
- triggers = triggers.filter((t) => t.id !== trigger.id);
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
- if (!anyFired) break;
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);
@@ -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 };