@rohal12/spindle 0.50.0 → 0.51.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.50.0",
3
+ "version": "0.51.0",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -224,6 +224,17 @@ export function deserialize<T>(value: T): T {
224
224
  return instance;
225
225
  }
226
226
 
227
+ // Already-live built-in — pass through as-is, so deserializing an
228
+ // already-deserialized value is a no-op instead of flattening it to {}
229
+ if (
230
+ val instanceof Date ||
231
+ val instanceof RegExp ||
232
+ val instanceof Map ||
233
+ val instanceof Set
234
+ ) {
235
+ return val;
236
+ }
237
+
227
238
  // Already-live registered class instance — pass through as-is
228
239
  const ctor = (obj as object).constructor as Constructor;
229
240
  if (ctorToName.has(ctor)) {
@@ -6,7 +6,7 @@ import {
6
6
  useState,
7
7
  } from 'preact/hooks';
8
8
  import { tokenize } from '../markup/tokenizer';
9
- import { buildAST } from '../markup/ast';
9
+ import { buildAST, type ASTNode } from '../markup/ast';
10
10
  import { renderNodes, NobrContext } from '../markup/render';
11
11
  import { useStoryStore } from '../store';
12
12
  import type { Passage as PassageData } from '../parser';
@@ -14,9 +14,27 @@ import { sourceLocationOf } from '../utils/source-location';
14
14
  import { emitFromRender } from '../event-emitter';
15
15
  import { markPassageRendered } from '../passage-render-state';
16
16
 
17
+ /**
18
+ * Parsed AST per passage. The renderer keys children by AST node identity,
19
+ * so re-rendering a passage (e.g. PassageReady on every PassageDisplay
20
+ * render) must reuse its AST — a fresh parse would remount every macro in
21
+ * it and re-run its {set}/{do} side effects (#175).
22
+ */
23
+ const astCache = new WeakMap<
24
+ PassageData,
25
+ { content: string; ast: ASTNode[] }
26
+ >();
27
+
28
+ function parsePassage(passage: PassageData): ASTNode[] {
29
+ const cached = astCache.get(passage);
30
+ if (cached && cached.content === passage.content) return cached.ast;
31
+ const ast = buildAST(tokenize(passage.content));
32
+ astCache.set(passage, { content: passage.content, ast });
33
+ return ast;
34
+ }
35
+
17
36
  export function renderPassageContent(passage: PassageData) {
18
- const tokens = tokenize(passage.content);
19
- const ast = buildAST(tokens);
37
+ const ast = parsePassage(passage);
20
38
  const nobr = passage.tags.includes('nobr');
21
39
  return renderNodes(ast, nobr ? { nobr: true } : undefined);
22
40
  }
@@ -7,6 +7,7 @@ import {
7
7
  LocalsValuesContext,
8
8
  NobrContext,
9
9
  } from '../../markup/render';
10
+ import { liveLocalsView } from '../../utils/live-locals';
10
11
 
11
12
  defineMacro({
12
13
  name: 'button',
@@ -29,7 +30,7 @@ defineMacro({
29
30
  { value: { update: ctx.update, getValues: ctx.getValues } },
30
31
  h(
31
32
  LocalsValuesContext.Provider,
32
- { value: ctx.getValues() },
33
+ { value: liveLocalsView(ctx.getValues) },
33
34
  renderNodes(children),
34
35
  ),
35
36
  ),
@@ -4,6 +4,7 @@ defineMacro({
4
4
  name: 'do',
5
5
  block: true,
6
6
  render({ children = [] }, ctx) {
7
+ // The tokenizer keeps the body verbatim as a single text node
7
8
  const code = ctx.collectText(children);
8
9
 
9
10
  ctx.hooks.useLayoutEffect(() => {
@@ -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.passages.get(passageName);
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: displayedPassage controls which passage is visually shown.
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
- const [displayedPassage, setDisplayedPassage] = useState(currentPassage);
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 passage change
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
- // If this is the same passage (initial render), just set it
122
- if (currentPassage === displayedPassage) {
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
- }, [currentPassage]);
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-${currentPassage}`}
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' : displayedPassage}
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
- const payload = getSavePayload();
108
- await createSave(ifid, playthroughId, payload);
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
- const payload = getSavePayload();
119
- await overwriteSave(saveId, payload);
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
- loadFromPayload(save.payload);
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 { addTrigger, removeTrigger } from '../../triggers';
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
- addTrigger(condition, options);
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]);
@@ -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
- for (const key of Object.keys(vars)) {
19
- if (vars[key] !== state.variables[key]) {
20
- state.setVariable(key, vars[key]);
21
- }
22
- }
23
- for (const key of Object.keys(temps)) {
24
- if (temps[key] !== state.temporary[key]) {
25
- state.setTemporary(key, temps[key]);
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 keys
40
- for (const key of Object.keys(state.variables)) {
41
- if (!(key in vars)) {
42
- state.deleteVariable(key);
43
- }
44
- }
45
- for (const key of Object.keys(state.temporary)) {
46
- if (!(key in temps)) {
47
- state.deleteTemporary(key);
48
- }
49
- }
50
- for (const key of Object.keys(state.transient)) {
51
- if (!(key in trans)) {
52
- state.deleteTransient(key);
53
- }
54
- }
106
+ // Detect deleted locals
55
107
  for (const key of Object.keys(mergedLocals)) {
56
108
  if (!(key in localsClone)) {
57
109
  scopeUpdate(key, undefined);
package/src/index.tsx CHANGED
@@ -10,13 +10,14 @@ import {
10
10
  } from './story-api';
11
11
  import { resetIdCounters } from './action-registry';
12
12
  import { executeStoryInit } from './story-init';
13
- import { checkTriggers, reinitTriggerState } from './triggers';
13
+ import { connectTriggersToStore } from './triggers';
14
14
  import { loadSession } from './saves/save-manager';
15
15
  import {
16
16
  parseStoryVariables,
17
17
  validatePassages,
18
18
  extractDefaults,
19
19
  } from './story-variables';
20
+ import { getMacroRegistry } from './registry';
20
21
  import { tokenize } from './markup/tokenizer';
21
22
  import { buildAST, registerBlockMacro } from './markup/ast';
22
23
  import { registerWidget } from './widgets/widget-registry';
@@ -97,7 +98,11 @@ export function boot() {
97
98
  }
98
99
 
99
100
  const schema = parseStoryVariables(storyVarsPassage.content);
100
- const errors = validatePassages(storyData.passages, schema);
101
+ // Include input macros registered by author JS, which ran above.
102
+ const storeVarMacros = getMacroRegistry()
103
+ .filter((m) => m.storeVar)
104
+ .map((m) => m.name);
105
+ const errors = validatePassages(storyData.passages, schema, storeVarMacros);
101
106
 
102
107
  // Parse StoryTransients (optional — no error if missing)
103
108
  let transientDefaults: Record<string, unknown> = {};
@@ -194,29 +199,18 @@ export function boot() {
194
199
  }
195
200
  }
196
201
 
197
- // Reset action ID counters on passage change
198
- let prevPassage = '';
202
+ // Reset action ID counters on every navigation (the passage remounts even
203
+ // when its name is unchanged)
204
+ let prevNavigationId = useStoryStore.getState().navigationId;
199
205
  useStoryStore.subscribe((state) => {
200
- if (state.currentPassage !== prevPassage) {
201
- prevPassage = state.currentPassage;
206
+ if (state.navigationId !== prevNavigationId) {
207
+ prevNavigationId = state.navigationId;
202
208
  resetIdCounters();
203
209
  }
204
210
  });
205
211
 
206
212
  // Wire up trigger system: check triggers on variable mutations, reinit on history nav/load
207
- let prevVars = useStoryStore.getState().variables;
208
- let prevHistoryIndex = useStoryStore.getState().historyIndex;
209
- useStoryStore.subscribe((state) => {
210
- if (state.variables !== prevVars) {
211
- if (state.historyIndex === prevHistoryIndex) {
212
- checkTriggers();
213
- } else {
214
- reinitTriggerState();
215
- }
216
- }
217
- prevVars = state.variables;
218
- prevHistoryIndex = state.historyIndex;
219
- });
213
+ connectTriggersToStore();
220
214
 
221
215
  // Warn if StoryInterface passage exists but doesn't contain {passage}
222
216
  const storyInterfacePassage = storyData.passages.get('StoryInterface');
@@ -1,4 +1,5 @@
1
1
  import { evaluate } from './expression';
2
+ import { scanBalancedBrace } from './markup/tokenizer';
2
3
 
3
4
  /** Detects any {…} block that starts with a sigil ($, _, @, %). */
4
5
  const INTERP_TEST = /\{[\$_@%]\w/;
@@ -97,16 +98,10 @@ export function interpolate(
97
98
  continue;
98
99
  }
99
100
 
100
- // Scan for balanced closing }
101
- let depth = 1;
102
- let j = i;
103
- while (j < template.length && depth > 0) {
104
- j++;
105
- if (template[j] === '{') depth++;
106
- else if (template[j] === '}') depth--;
107
- }
101
+ // Scan for balanced closing }, ignoring braces inside strings
102
+ const j = scanBalancedBrace(template, i);
108
103
 
109
- if (depth !== 0) {
104
+ if (j === -1) {
110
105
  // Unbalanced — emit as text
111
106
  result += '{';
112
107
  continue;