@rohal12/spindle 0.17.0 → 0.19.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -47,7 +47,7 @@
47
47
  "docs:dev": "vitepress dev docs",
48
48
  "docs:build": "vitepress build docs",
49
49
  "docs:preview": "vitepress preview docs",
50
- "prepublishOnly": "bun run test && bun run build",
50
+ "prepublishOnly": "bun run build && bun run test",
51
51
  "prepare": "husky"
52
52
  },
53
53
  "dependencies": {
@@ -1,6 +1,7 @@
1
1
  import { useEffect } from 'preact/hooks';
2
2
  import { useStoryStore } from '../store';
3
3
  import { StoryInterface } from './StoryInterface';
4
+ import { TriggerDialogHost } from './TriggerDialogHost';
4
5
 
5
6
  export function App() {
6
7
  const storyData = useStoryStore((s) => s.storyData);
@@ -24,5 +25,10 @@ export function App() {
24
25
  return <div class="loading">Loading...</div>;
25
26
  }
26
27
 
27
- return <StoryInterface />;
28
+ return (
29
+ <>
30
+ <StoryInterface />
31
+ <TriggerDialogHost />
32
+ </>
33
+ );
28
34
  }
@@ -1,20 +1,36 @@
1
- import { useMemo } from 'preact/hooks';
1
+ import { useMemo, useEffect, useState } from 'preact/hooks';
2
2
  import { tokenize } from '../markup/tokenizer';
3
3
  import { buildAST } from '../markup/ast';
4
4
  import { renderNodes } from '../markup/render';
5
+ import { useStoryStore } from '../store';
5
6
  import type { Passage as PassageData } from '../parser';
6
7
  import { sourceLocationOf } from '../utils/source-location';
7
8
 
9
+ export function renderPassageContent(passage: PassageData) {
10
+ const tokens = tokenize(passage.content);
11
+ const ast = buildAST(tokens);
12
+ return renderNodes(ast);
13
+ }
14
+
8
15
  interface PassageProps {
9
16
  passage: PassageData;
10
17
  }
11
18
 
19
+ const CODE_PASSAGES = new Set([
20
+ 'PassageReady',
21
+ 'PassageHeader',
22
+ 'PassageFooter',
23
+ 'PassageDone',
24
+ ]);
25
+
12
26
  export function Passage({ passage }: PassageProps) {
27
+ const storyData = useStoryStore((s) => s.storyData);
28
+ const isCodePassage = CODE_PASSAGES.has(passage.name);
29
+ const [doneReady, setDoneReady] = useState(false);
30
+
13
31
  const content = useMemo(() => {
14
32
  try {
15
- const tokens = tokenize(passage.content);
16
- const ast = buildAST(tokens);
17
- return renderNodes(ast);
33
+ return renderPassageContent(passage);
18
34
  } catch (err) {
19
35
  return (
20
36
  <div class="error">
@@ -25,13 +41,62 @@ export function Passage({ passage }: PassageProps) {
25
41
  }
26
42
  }, [passage.content, passage.name]);
27
43
 
44
+ const headerPassage = isCodePassage
45
+ ? undefined
46
+ : storyData?.passages.get('PassageHeader');
47
+ const footerPassage = isCodePassage
48
+ ? undefined
49
+ : storyData?.passages.get('PassageFooter');
50
+ const donePassage = isCodePassage
51
+ ? undefined
52
+ : storyData?.passages.get('PassageDone');
53
+
54
+ const headerContent = useMemo(() => {
55
+ if (!headerPassage) return null;
56
+ try {
57
+ return renderPassageContent(headerPassage);
58
+ } catch (err) {
59
+ console.error('spindle: Error in PassageHeader:', err);
60
+ return null;
61
+ }
62
+ }, [headerPassage?.content]);
63
+
64
+ const footerContent = useMemo(() => {
65
+ if (!footerPassage) return null;
66
+ try {
67
+ return renderPassageContent(footerPassage);
68
+ } catch (err) {
69
+ console.error('spindle: Error in PassageFooter:', err);
70
+ return null;
71
+ }
72
+ }, [footerPassage?.content]);
73
+
74
+ // Defer PassageDone to after DOM commit
75
+ useEffect(() => {
76
+ if (donePassage) setDoneReady(true);
77
+ return () => setDoneReady(false);
78
+ }, [passage.name]);
79
+
80
+ const doneContent = useMemo(() => {
81
+ if (!doneReady || !donePassage) return null;
82
+ try {
83
+ return renderPassageContent(donePassage);
84
+ } catch (err) {
85
+ console.error('spindle: Error in PassageDone:', err);
86
+ return null;
87
+ }
88
+ }, [doneReady, donePassage?.content]);
89
+
28
90
  return (
29
91
  <div
30
92
  class="passage"
31
93
  data-passage={passage.name}
32
94
  data-tags={passage.tags.join(' ')}
33
95
  >
96
+ {headerContent && <div class="passage-header">{headerContent}</div>}
34
97
  {content}
98
+ {footerContent && <div class="passage-footer">{footerContent}</div>}
99
+ {doneContent && <div hidden>{doneContent}</div>}
35
100
  </div>
36
101
  );
37
102
  }
@@ -1,7 +1,7 @@
1
1
  import { useStoryStore } from '../store';
2
2
  import { tokenize } from '../markup/tokenizer';
3
3
  import { buildAST } from '../markup/ast';
4
- import { renderNodes } from '../markup/render';
4
+ import { renderInlineNodes } from '../markup/render';
5
5
 
6
6
  const DEFAULT_MARKUP =
7
7
  '<header class="story-menubar">{story-title}{back}{forward}{restart}{quicksave}{quickload}{saves}{settings}</header>\n{passage}';
@@ -16,7 +16,7 @@ export function StoryInterface() {
16
16
  try {
17
17
  const tokens = tokenize(markup);
18
18
  const ast = buildAST(tokens);
19
- return <>{renderNodes(ast)}</>;
19
+ return <>{renderInlineNodes(ast)}</>;
20
20
  } catch (err) {
21
21
  return (
22
22
  <span class="error">
@@ -0,0 +1,42 @@
1
+ import { useState, useEffect, useCallback } from 'preact/hooks';
2
+ import { PassageDialog } from './PassageDialog';
3
+ import {
4
+ subscribeTriggerDialogs,
5
+ shiftDialogQueue,
6
+ dialogQueueLength,
7
+ } from '../triggers';
8
+
9
+ export function TriggerDialogHost() {
10
+ const [current, setCurrent] = useState<string | null>(null);
11
+
12
+ const advance = useCallback(() => {
13
+ const next = shiftDialogQueue();
14
+ setCurrent(next ?? null);
15
+ }, []);
16
+
17
+ useEffect(() => {
18
+ return subscribeTriggerDialogs(() => {
19
+ setCurrent((prev) => {
20
+ if (prev !== null) return prev; // already showing one
21
+ return shiftDialogQueue() ?? null;
22
+ });
23
+ });
24
+ }, []);
25
+
26
+ const handleClose = useCallback(() => {
27
+ setCurrent(null);
28
+ // Show next queued dialog after a tick
29
+ if (dialogQueueLength() > 0) {
30
+ requestAnimationFrame(advance);
31
+ }
32
+ }, [advance]);
33
+
34
+ if (!current) return null;
35
+
36
+ return (
37
+ <PassageDialog
38
+ passageName={current}
39
+ onClose={handleClose}
40
+ />
41
+ );
42
+ }
@@ -1,25 +1,40 @@
1
+ import { h, render } from 'preact';
1
2
  import { defineMacro } from '../../define-macro';
3
+ import {
4
+ renderNodes,
5
+ LocalsUpdateContext,
6
+ LocalsValuesContext,
7
+ } from '../../markup/render';
2
8
 
3
9
  defineMacro({
4
10
  name: 'button',
5
11
  interpolate: true,
6
12
  render({ rawArgs, children = [] }, ctx) {
13
+ const label = ctx.resolve?.(rawArgs.replace(/^["']|["']$/g, '')) ?? rawArgs;
14
+
7
15
  const handleClick = () => {
8
- try {
9
- ctx.mutate(rawArgs);
10
- } catch (err) {
11
- console.error(
12
- `spindle: Error in {button ${rawArgs}}${ctx.sourceLocation()}:`,
13
- err,
14
- );
15
- }
16
+ // Render children into a detached DOM node — all macro side effects
17
+ // ({set}, {if}, {unwatch}, etc.) fire through the normal Preact pipeline.
18
+ // Wrap with locals context so @local variables from for-loops are available.
19
+ const container = document.createElement('div');
20
+ const vnode = h(
21
+ LocalsUpdateContext.Provider,
22
+ { value: { update: ctx.update, getValues: ctx.getValues } },
23
+ h(
24
+ LocalsValuesContext.Provider,
25
+ { value: ctx.getValues() },
26
+ renderNodes(children),
27
+ ),
28
+ );
29
+ render(vnode, container);
30
+ render(null, container);
16
31
  };
17
32
 
18
33
  ctx.useAction({
19
34
  type: 'button',
20
35
  key: rawArgs,
21
36
  authorId: ctx.id,
22
- label: ctx.collectText(children) || rawArgs,
37
+ label,
23
38
  perform: handleClick,
24
39
  });
25
40
 
@@ -29,7 +44,7 @@ defineMacro({
29
44
  class={ctx.cls}
30
45
  onClick={handleClick}
31
46
  >
32
- {ctx.renderInlineNodes(children)}
47
+ {label}
33
48
  </button>
34
49
  );
35
50
  },
@@ -3,21 +3,15 @@ import { PassageDialog } from '../PassageDialog';
3
3
 
4
4
  defineMacro({
5
5
  name: 'dialog',
6
- merged: true,
6
+ interpolate: true,
7
7
  render({ rawArgs, children = [] }, ctx) {
8
8
  const [open, setOpen] = ctx.hooks.useState(false);
9
9
 
10
- let passageName: string;
11
- try {
12
- const result = ctx.evaluate!(rawArgs);
13
- passageName = String(result);
14
- } catch {
15
- passageName = rawArgs.replace(/^["']|["']$/g, '').trim();
16
- }
17
-
18
- const label = children.length
19
- ? ctx.renderInlineNodes(children)
20
- : passageName;
10
+ const label = ctx.resolve?.(rawArgs.replace(/^["']|["']$/g, '')) ?? rawArgs;
11
+ const passageName = ctx
12
+ .collectText(children)
13
+ .trim()
14
+ .replace(/^["']|["']$/g, '');
21
15
 
22
16
  return (
23
17
  <>
@@ -1,8 +1,10 @@
1
+ import { h, render } from 'preact';
1
2
  import { useStoryStore } from '../../store';
2
- import type { ASTNode } from '../../markup/ast';
3
- import { executeMutation } from '../../execute-mutation';
4
- import { collectText } from '../../utils/extract-text';
5
- import { currentSourceLocation } from '../../utils/source-location';
3
+ import {
4
+ renderNodes,
5
+ LocalsUpdateContext,
6
+ LocalsValuesContext,
7
+ } from '../../markup/render';
6
8
  import { defineMacro } from '../../define-macro';
7
9
 
8
10
  function parseArgs(rawArgs: string): {
@@ -11,10 +13,10 @@ function parseArgs(rawArgs: string): {
11
13
  } {
12
14
  // {link "text" "Passage"} or {link "text"}
13
15
  const parts: string[] = [];
14
- const re = /["']([^"']+)["']/g;
16
+ const re = /(["'])(.*?)\1/g;
15
17
  let m;
16
18
  while ((m = re.exec(rawArgs)) !== null) {
17
- parts.push(m[1]!);
19
+ parts.push(m[2]!);
18
20
  }
19
21
  if (parts.length >= 2) {
20
22
  return { display: parts[0]!, passage: parts[1]! };
@@ -26,37 +28,23 @@ function parseArgs(rawArgs: string): {
26
28
  return { display: rawArgs.trim(), passage: null };
27
29
  }
28
30
 
29
- /**
30
- * Execute the children imperatively: walk AST for {set} and {do} macros.
31
- */
32
- function executeChildren(
33
- children: ASTNode[],
34
- mergedLocals: Record<string, unknown>,
35
- scopeUpdate: (key: string, value: unknown) => void,
31
+ function renderChildrenDetached(
32
+ children: import('../../markup/ast').ASTNode[],
33
+ getValues: () => Record<string, unknown>,
34
+ update: (key: string, value: unknown) => void,
36
35
  ) {
37
- for (const node of children) {
38
- if (node.type !== 'macro') continue;
39
- if (node.name === 'set') {
40
- try {
41
- executeMutation(node.rawArgs, mergedLocals, scopeUpdate);
42
- } catch (err) {
43
- console.error(
44
- `spindle: Error in {link} child {set}${currentSourceLocation()}:`,
45
- err,
46
- );
47
- }
48
- } else if (node.name === 'do') {
49
- const code = collectText(node.children);
50
- try {
51
- executeMutation(code, mergedLocals, scopeUpdate);
52
- } catch (err) {
53
- console.error(
54
- `spindle: Error in {link} child {do}${currentSourceLocation()}:`,
55
- err,
56
- );
57
- }
58
- }
59
- }
36
+ const container = document.createElement('div');
37
+ const vnode = h(
38
+ LocalsUpdateContext.Provider,
39
+ { value: { update, getValues } },
40
+ h(
41
+ LocalsValuesContext.Provider,
42
+ { value: getValues() },
43
+ renderNodes(children),
44
+ ),
45
+ );
46
+ render(vnode, container);
47
+ render(null, container);
60
48
  }
61
49
 
62
50
  defineMacro({
@@ -67,7 +55,7 @@ defineMacro({
67
55
 
68
56
  const handleClick = (e: Event) => {
69
57
  e.preventDefault();
70
- executeChildren(children, ctx.getValues(), ctx.update);
58
+ renderChildrenDetached(children, ctx.getValues, ctx.update);
71
59
  if (passage) {
72
60
  useStoryStore.getState().navigate(passage);
73
61
  }
@@ -80,7 +68,7 @@ defineMacro({
80
68
  label: display,
81
69
  target: passage ?? undefined,
82
70
  perform: () => {
83
- executeChildren(children, ctx.getValues(), ctx.update);
71
+ renderChildrenDetached(children, ctx.getValues, ctx.update);
84
72
  if (passage) {
85
73
  useStoryStore.getState().navigate(passage);
86
74
  }
@@ -1,5 +1,5 @@
1
1
  import { useStoryStore } from '../../store';
2
- import { Passage } from '../Passage';
2
+ import { Passage, renderPassageContent } from '../Passage';
3
3
  import { defineMacro } from '../../define-macro';
4
4
 
5
5
  defineMacro({
@@ -18,11 +18,21 @@ defineMacro({
18
18
  );
19
19
  }
20
20
 
21
+ const readyPassage = storyData?.passages.get('PassageReady');
22
+
21
23
  return (
22
24
  <div
23
25
  id={ctx.id ?? 'story'}
24
26
  class={ctx.className ?? 'story'}
25
27
  >
28
+ {readyPassage && (
29
+ <div
30
+ key={`ready-${currentPassage}`}
31
+ hidden
32
+ >
33
+ {renderPassageContent(readyPassage)}
34
+ </div>
35
+ )}
26
36
  <Passage
27
37
  passage={passage}
28
38
  key={currentPassage}
@@ -0,0 +1,8 @@
1
+ import { defineMacro } from '../../define-macro';
2
+
3
+ defineMacro({
4
+ name: 'span',
5
+ render({ children = [] }, ctx) {
6
+ return ctx.wrap(ctx.renderNodes(children));
7
+ },
8
+ });
@@ -32,22 +32,24 @@ defineMacro({
32
32
  return () => clearTimeout(timer);
33
33
  }, [visibleIndex, sections]);
34
34
 
35
- if (visibleIndex < 0) return null;
35
+ if (visibleIndex < 0) return ctx.wrap(null);
36
36
 
37
37
  const section = sections[visibleIndex]!;
38
38
  const content = ctx.renderNodes(section.nodes);
39
39
  const sectionClass = ctx.resolve!(section.className);
40
40
  const sectionId = ctx.resolve!(section.id);
41
41
 
42
- if (sectionClass || sectionId)
43
- return (
42
+ const inner =
43
+ sectionClass || sectionId ? (
44
44
  <span
45
45
  id={sectionId}
46
46
  class={sectionClass}
47
47
  >
48
48
  {content}
49
49
  </span>
50
+ ) : (
51
+ content
50
52
  );
51
- return <>{content}</>;
53
+ return ctx.wrap(inner);
52
54
  },
53
55
  });
@@ -0,0 +1,75 @@
1
+ import { defineMacro } from '../../define-macro';
2
+ import { addTrigger, removeTrigger } from '../../triggers';
3
+ import type { WatchOptions } from '../../triggers';
4
+
5
+ defineMacro({
6
+ name: 'watch',
7
+ render(props, ctx) {
8
+ const { hooks } = ctx;
9
+
10
+ // Parse: condition string + keyword options
11
+ const raw = props.rawArgs.trim();
12
+ // First quoted string is the condition
13
+ const condMatch = raw.match(/^(['"])(.*?)\1\s*/);
14
+ if (!condMatch) return null;
15
+
16
+ const condition = condMatch[2]!;
17
+ const rest = raw.slice(condMatch[0].length);
18
+
19
+ const options: WatchOptions = {};
20
+ // Parse keyword args: goto "X", dialog "X", run "X", once, name "X", priority N
21
+ const kwRe = /(\w+)\s+(?:"([^"]*?)"|'([^']*?)'|(\d+))|(\w+)/g;
22
+ let m: RegExpExecArray | null;
23
+ while ((m = kwRe.exec(rest)) !== null) {
24
+ const key = m[1] ?? m[5];
25
+ const val = m[2] ?? m[3] ?? m[4];
26
+ if (!key) continue;
27
+ switch (key) {
28
+ case 'goto':
29
+ options.goto = val;
30
+ break;
31
+ case 'dialog':
32
+ options.dialog = val;
33
+ break;
34
+ case 'run':
35
+ options.run = val;
36
+ break;
37
+ case 'name':
38
+ options.name = val;
39
+ break;
40
+ case 'priority':
41
+ options.priority = val ? Number(val) : 0;
42
+ break;
43
+ case 'once':
44
+ options.once = true;
45
+ break;
46
+ }
47
+ }
48
+
49
+ // Register during render (like {set}) — triggers survive navigation
50
+ // and are cleaned up via resetTriggers() on restart or {unwatch}.
51
+ const registered = hooks.useRef(false);
52
+ if (!registered.current) {
53
+ registered.current = true;
54
+ addTrigger(condition, options);
55
+ }
56
+
57
+ return null;
58
+ },
59
+ });
60
+
61
+ defineMacro({
62
+ name: 'unwatch',
63
+ render(props, ctx) {
64
+ const raw = props.rawArgs.trim();
65
+ const name = raw.replace(/^['"]|['"]$/g, '');
66
+
67
+ const ran = ctx.hooks.useRef(false);
68
+ if (!ran.current) {
69
+ ran.current = true;
70
+ removeTrigger(name);
71
+ }
72
+
73
+ return null;
74
+ },
75
+ });
package/src/expression.ts CHANGED
@@ -6,12 +6,12 @@ import { random, randomInt } from './prng';
6
6
  interface ExpressionFns {
7
7
  currentPassage: () => Passage | undefined;
8
8
  previousPassage: () => Passage | undefined;
9
- visited: (name: string) => number;
10
- hasVisited: (name: string) => boolean;
9
+ visited: (name?: string) => number;
10
+ hasVisited: (name?: string) => boolean;
11
11
  hasVisitedAny: (...names: string[]) => boolean;
12
12
  hasVisitedAll: (...names: string[]) => boolean;
13
- rendered: (name: string) => number;
14
- hasRendered: (name: string) => boolean;
13
+ rendered: (name?: string) => number;
14
+ hasRendered: (name?: string) => boolean;
15
15
  hasRenderedAny: (...names: string[]) => boolean;
16
16
  hasRenderedAll: (...names: string[]) => boolean;
17
17
  random: () => number;
@@ -191,15 +191,17 @@ export function buildExpressionFns() {
191
191
  return cachedFns;
192
192
  }
193
193
 
194
- const visited = (name: string): number => visitCounts[name] ?? 0;
195
- const hasVisited = (name: string): boolean => visited(name) > 0;
194
+ const visited = (name?: string): number =>
195
+ visitCounts[name ?? useStoryStore.getState().currentPassage] ?? 0;
196
+ const hasVisited = (name?: string): boolean => visited(name) > 0;
196
197
  const hasVisitedAny = (...names: string[]): boolean =>
197
198
  names.some((n) => visited(n) > 0);
198
199
  const hasVisitedAll = (...names: string[]): boolean =>
199
200
  names.every((n) => visited(n) > 0);
200
201
 
201
- const rendered = (name: string): number => renderCounts[name] ?? 0;
202
- const hasRendered = (name: string): boolean => rendered(name) > 0;
202
+ const rendered = (name?: string): number =>
203
+ renderCounts[name ?? useStoryStore.getState().currentPassage] ?? 0;
204
+ const hasRendered = (name?: string): boolean => rendered(name) > 0;
203
205
  const hasRenderedAny = (...names: string[]): boolean =>
204
206
  names.some((n) => rendered(n) > 0);
205
207
  const hasRenderedAll = (...names: string[]): boolean =>
package/src/index.tsx CHANGED
@@ -5,6 +5,8 @@ import { useStoryStore } from './store';
5
5
  import { installStoryAPI } from './story-api';
6
6
  import { resetIdCounters } from './action-registry';
7
7
  import { executeStoryInit } from './story-init';
8
+ import { checkTriggers, reinitTriggerState } from './triggers';
9
+ import { loadSession } from './saves/save-manager';
8
10
  import {
9
11
  parseStoryVariables,
10
12
  validatePassages,
@@ -89,6 +91,12 @@ function boot() {
89
91
  // Execute StoryInit passage if it exists
90
92
  executeStoryInit();
91
93
 
94
+ // Restore session state if the page was refreshed
95
+ const sessionPayload = loadSession(storyData.ifid);
96
+ if (sessionPayload) {
97
+ useStoryStore.getState().loadFromPayload(sessionPayload);
98
+ }
99
+
92
100
  // Register widgets from passages tagged "widget"
93
101
  for (const [, passage] of storyData.passages) {
94
102
  if (passage.tags.includes('widget')) {
@@ -119,6 +127,21 @@ function boot() {
119
127
  }
120
128
  });
121
129
 
130
+ // Wire up trigger system: check triggers on variable mutations, reinit on history nav/load
131
+ let prevVars = useStoryStore.getState().variables;
132
+ let prevHistoryIndex = useStoryStore.getState().historyIndex;
133
+ useStoryStore.subscribe((state) => {
134
+ if (state.variables !== prevVars) {
135
+ if (state.historyIndex === prevHistoryIndex) {
136
+ checkTriggers();
137
+ } else {
138
+ reinitTriggerState();
139
+ }
140
+ }
141
+ prevVars = state.variables;
142
+ prevHistoryIndex = state.historyIndex;
143
+ });
144
+
122
145
  // Warn if StoryInterface passage exists but doesn't contain {passage}
123
146
  const storyInterfacePassage = storyData.passages.get('StoryInterface');
124
147
  if (
@@ -29,4 +29,6 @@ import '../components/macros/Checkbox';
29
29
  import '../components/macros/Radiobutton';
30
30
  import '../components/macros/Listbox';
31
31
  import '../components/macros/Cycle';
32
+ import '../components/macros/Span';
32
33
  import '../components/macros/PassageDisplay';
34
+ import '../components/macros/Watch';
package/src/markup/ast.ts CHANGED
@@ -53,6 +53,7 @@ const BLOCK_MACROS = new Set([
53
53
  'repeat',
54
54
  'type',
55
55
  'widget',
56
+ 'span',
56
57
  ]);
57
58
 
58
59
  /** Map from branch macro name → required parent macro name */