@rohal12/spindle 0.28.3 → 0.29.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.28.3",
3
+ "version": "0.29.0",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -1,5 +1,37 @@
1
1
  import yaml from 'js-yaml';
2
- import type { AutomationScript } from './types';
2
+ import type { AutomationScript, AutomationStep } from './types';
3
+
4
+ function validateStep(step: unknown, index: number): AutomationStep {
5
+ if (typeof step !== 'object' || step === null) {
6
+ throw new Error(`Invalid automation step ${index + 1}: expected an object`);
7
+ }
8
+ const s = step as Record<string, unknown>;
9
+
10
+ const hasAction = 'action' in s;
11
+ const hasAssert = 'assert' in s;
12
+ const hasWait = 'wait' in s;
13
+ const hasSet = 'set' in s;
14
+
15
+ if (!hasAction && !hasAssert && !hasWait && !hasSet) {
16
+ throw new Error(
17
+ `Invalid automation step ${index + 1}: must have at least one of action, assert, wait, set`,
18
+ );
19
+ }
20
+
21
+ if (hasWait && typeof s.wait !== 'number') {
22
+ throw new Error(
23
+ `Invalid automation step ${index + 1}: "wait" must be a number`,
24
+ );
25
+ }
26
+
27
+ if (hasSet && (typeof s.set !== 'object' || s.set === null)) {
28
+ throw new Error(
29
+ `Invalid automation step ${index + 1}: "set" must be an object`,
30
+ );
31
+ }
32
+
33
+ return s as AutomationStep;
34
+ }
3
35
 
4
36
  export function parseAutomationYaml(yamlContent: string): AutomationScript {
5
37
  const doc = yaml.load(yamlContent) as Record<string, unknown>;
@@ -16,5 +48,11 @@ export function parseAutomationYaml(yamlContent: string): AutomationScript {
16
48
  throw new Error('Invalid automation script: missing "steps" array');
17
49
  }
18
50
 
19
- return doc as unknown as AutomationScript;
51
+ const steps = doc.steps.map((step, i) => validateStep(step, i));
52
+
53
+ return {
54
+ name: doc.name,
55
+ start: typeof doc.start === 'string' ? doc.start : undefined,
56
+ steps,
57
+ };
20
58
  }
@@ -1,3 +1,4 @@
1
+ import { useMemo } from 'preact/hooks';
1
2
  import { useStoryStore } from '../store';
2
3
  import { tokenize } from '../markup/tokenizer';
3
4
  import { buildAST } from '../markup/ast';
@@ -14,20 +15,24 @@ export function StoryInterface() {
14
15
  overridePassage !== undefined ? overridePassage.content : DEFAULT_MARKUP;
15
16
  const nobr = overridePassage?.tags.includes('nobr') ?? false;
16
17
 
17
- try {
18
- const tokens = tokenize(markup);
19
- const ast = buildAST(tokens);
20
- const rendered = <>{renderInlineNodes(ast)}</>;
21
- return nobr ? (
22
- <NobrContext.Provider value={true}>{rendered}</NobrContext.Provider>
23
- ) : (
24
- rendered
25
- );
26
- } catch (err) {
27
- return (
28
- <span class="error">
29
- Error in StoryInterface: {(err as Error).message}
30
- </span>
31
- );
32
- }
18
+ const rendered = useMemo(() => {
19
+ try {
20
+ const tokens = tokenize(markup);
21
+ const ast = buildAST(tokens);
22
+ return <>{renderInlineNodes(ast)}</>;
23
+ } catch (err) {
24
+ return (
25
+ <span class="error">
26
+ Error in StoryInterface:{' '}
27
+ {err instanceof Error ? err.message : String(err)}
28
+ </span>
29
+ );
30
+ }
31
+ }, [markup]);
32
+
33
+ return nobr ? (
34
+ <NobrContext.Provider value={true}>{rendered}</NobrContext.Provider>
35
+ ) : (
36
+ rendered
37
+ );
33
38
  }
@@ -1,33 +1,24 @@
1
- import { useStoryStore } from '../../store';
2
1
  import { defineMacro } from '../../define-macro';
3
2
 
4
- function parseArgs(rawArgs: string): { varName: string; label: string } {
5
- const match = rawArgs.match(/^\s*(["']?\$\w+["']?)\s+["']?(.+?)["']?\s*$/);
6
- if (!match) {
7
- return { varName: rawArgs.trim(), label: '' };
8
- }
9
- const varName = match[1]!.replace(/["']/g, '');
10
- const label = match[2]!;
11
- return { varName, label };
3
+ function parseLabel(rawArgs: string): string {
4
+ const match = rawArgs.match(/^\s*["']?\$?\w+["']?\s+["']?(.+?)["']?\s*$/);
5
+ return match?.[1] ?? '';
12
6
  }
13
7
 
14
8
  defineMacro({
15
9
  name: 'checkbox',
10
+ storeVar: true,
16
11
  render({ rawArgs }, ctx) {
17
- const { varName, label } = parseArgs(rawArgs);
18
- const name = varName.startsWith('$') ? varName.slice(1) : varName;
19
-
20
- const value = useStoryStore((s) => s.variables[name]);
21
- const setVariable = useStoryStore((s) => s.setVariable);
12
+ const label = parseLabel(rawArgs);
22
13
 
23
14
  ctx.useAction({
24
15
  type: 'checkbox',
25
- key: `$${name}`,
16
+ key: `$${ctx.varName}`,
26
17
  authorId: ctx.id,
27
- label: label || name,
28
- variable: name,
29
- value: !!value,
30
- perform: (v) => setVariable(name, v !== undefined ? !!v : !value),
18
+ label: label || ctx.varName || '',
19
+ variable: ctx.varName,
20
+ value: !!ctx.value,
21
+ perform: (v) => ctx.setValue!(v !== undefined ? !!v : !ctx.value),
31
22
  });
32
23
 
33
24
  return (
@@ -37,8 +28,8 @@ defineMacro({
37
28
  >
38
29
  <input
39
30
  type="checkbox"
40
- checked={!!value}
41
- onChange={() => setVariable(name, !value)}
31
+ checked={!!ctx.value}
32
+ onChange={() => ctx.setValue!(!ctx.value)}
42
33
  />
43
34
  {label ? ` ${label}` : null}
44
35
  </label>
@@ -1,3 +1,4 @@
1
+ import { useStoryStore } from '../../store';
1
2
  import { defineMacro } from '../../define-macro';
2
3
 
3
4
  defineMacro({
@@ -8,7 +9,8 @@ defineMacro({
8
9
 
9
10
  const handleClick = () => {
10
11
  if (options.length === 0) return;
11
- const currentIndex = options.indexOf(String(ctx.value));
12
+ const current = useStoryStore.getState().variables[ctx.varName!];
13
+ const currentIndex = options.indexOf(String(current));
12
14
  const nextIndex = (currentIndex + 1) % options.length;
13
15
  ctx.setValue!(options[nextIndex]);
14
16
  };
@@ -82,7 +82,7 @@ function ForIteration({
82
82
  return (
83
83
  <LocalsUpdateContext.Provider value={updater}>
84
84
  <LocalsValuesContext.Provider value={localState}>
85
- {renderNodes(children, nobr ? { nobr: true } : undefined)}
85
+ {renderNodes(children, { nobr, locals: localState })}
86
86
  </LocalsValuesContext.Provider>
87
87
  </LocalsUpdateContext.Provider>
88
88
  );
@@ -28,9 +28,7 @@ export function defineMenubarAction(config: MenubarActionConfig) {
28
28
  render(_, ctx) {
29
29
  if (config.hidden?.()) return null;
30
30
 
31
- const [dialogOpen, setDialogOpen] = config.dialog
32
- ? useState(false)
33
- : [false, undefined as never];
31
+ const [dialogOpen, setDialogOpen] = useState(false);
34
32
 
35
33
  const setup = config.setup?.() ?? { perform: () => {} };
36
34
  const { disabled } = setup;
@@ -1,48 +1,30 @@
1
- import { useStoryStore } from '../../store';
2
1
  import { defineMacro } from '../../define-macro';
3
2
 
4
- function parseArgs(rawArgs: string): {
5
- varName: string;
6
- value: string;
7
- label: string;
8
- } {
9
- // {radiobutton "$var" "value" "Label text"}
3
+ function parseRadioArgs(rawArgs: string): { value: string; label: string } {
10
4
  const match = rawArgs.match(
11
- /^\s*(["']?\$\w+["']?)\s+["'](.+?)["']\s+["']?(.+?)["']?\s*$/,
5
+ /^\s*["']?\$?\w+["']?\s+["'](.+?)["']\s+["']?(.+?)["']?\s*$/,
12
6
  );
13
7
  if (!match) {
14
- // Try simpler: $var value label
15
- const parts = rawArgs.trim().split(/\s+/);
16
- return {
17
- varName: (parts[0] ?? '').replace(/["']/g, ''),
18
- value: parts[1] ?? '',
19
- label: parts.slice(2).join(' '),
20
- };
8
+ const parts = rawArgs.trim().split(/\s+/).slice(1);
9
+ return { value: parts[0] ?? '', label: parts.slice(1).join(' ') };
21
10
  }
22
- return {
23
- varName: match[1]!.replace(/["']/g, ''),
24
- value: match[2]!,
25
- label: match[3]!,
26
- };
11
+ return { value: match[1]!, label: match[2]! };
27
12
  }
28
13
 
29
14
  defineMacro({
30
15
  name: 'radiobutton',
16
+ storeVar: true,
31
17
  render({ rawArgs }, ctx) {
32
- const { varName, value: radioValue, label } = parseArgs(rawArgs);
33
- const name = varName.startsWith('$') ? varName.slice(1) : varName;
34
-
35
- const currentValue = useStoryStore((s) => s.variables[name]);
36
- const setVariable = useStoryStore((s) => s.setVariable);
18
+ const { value: radioValue, label } = parseRadioArgs(rawArgs);
37
19
 
38
20
  ctx.useAction({
39
21
  type: 'radiobutton',
40
- key: `$${name}:${radioValue}`,
22
+ key: `$${ctx.varName}:${radioValue}`,
41
23
  authorId: ctx.id,
42
24
  label: label || radioValue,
43
- variable: name,
44
- value: currentValue,
45
- perform: () => setVariable(name, radioValue),
25
+ variable: ctx.varName,
26
+ value: ctx.value,
27
+ perform: () => ctx.setValue!(radioValue),
46
28
  });
47
29
 
48
30
  return (
@@ -52,9 +34,9 @@ defineMacro({
52
34
  >
53
35
  <input
54
36
  type="radio"
55
- name={`radio-${name}`}
56
- checked={currentValue === radioValue}
57
- onChange={() => setVariable(name, radioValue)}
37
+ name={`radio-${ctx.varName}`}
38
+ checked={ctx.value === radioValue}
39
+ onChange={() => ctx.setValue!(radioValue)}
58
40
  />
59
41
  {label ? ` ${label}` : null}
60
42
  </label>
@@ -1,7 +1,5 @@
1
1
  import { createContext } from 'preact';
2
2
  import { parseDelay } from '../../utils/parse-delay';
3
- import { hasInterpolation, interpolate } from '../../interpolation';
4
- import { useStoryStore } from '../../store';
5
3
  import { defineMacro } from '../../define-macro';
6
4
 
7
5
  export const RepeatContext = createContext<{ stop: () => void }>({
@@ -10,17 +8,9 @@ export const RepeatContext = createContext<{ stop: () => void }>({
10
8
 
11
9
  defineMacro({
12
10
  name: 'repeat',
13
- render({ rawArgs, children = [], className: rawClassName, id: rawId }, ctx) {
14
- const { useState, useEffect, useCallback, useMemo } = ctx.hooks;
15
-
16
- const [className, id] = useMemo(() => {
17
- const resolveOnce = (s: string | undefined) => {
18
- if (!s || !hasInterpolation(s)) return s;
19
- const st = useStoryStore.getState();
20
- return interpolate(s, st.variables, st.temporary, {});
21
- };
22
- return [resolveOnce(rawClassName), resolveOnce(rawId)];
23
- }, [rawClassName, rawId]);
11
+ interpolate: true,
12
+ render({ rawArgs, children = [] }, ctx) {
13
+ const { useState, useEffect, useCallback } = ctx.hooks;
24
14
 
25
15
  const delay = parseDelay(rawArgs);
26
16
  const [count, setCount] = useState(0);
@@ -38,19 +28,17 @@ defineMacro({
38
28
 
39
29
  if (count === 0 && !stopped) return null;
40
30
 
41
- const cls = className ? `macro-repeat ${className}` : undefined;
42
-
43
31
  const content = (
44
32
  <RepeatContext.Provider value={{ stop }}>
45
33
  <span key={count}>{ctx.renderNodes(children)}</span>
46
34
  </RepeatContext.Provider>
47
35
  );
48
36
 
49
- if (cls || id)
37
+ if (ctx.className || ctx.id)
50
38
  return (
51
39
  <span
52
- id={id}
53
- class={cls}
40
+ id={ctx.id}
41
+ class={ctx.cls}
54
42
  >
55
43
  {content}
56
44
  </span>
@@ -9,6 +9,7 @@ import {
9
9
  LocalsValuesContext,
10
10
  LocalsUpdateContext,
11
11
  NobrContext,
12
+ WidgetChildrenContext,
12
13
  renderNodes,
13
14
  } from '../../markup/render';
14
15
  import { useMergedLocals } from '../../hooks/use-merged-locals';
@@ -19,6 +20,7 @@ interface WidgetInvocationProps {
19
20
  body: ASTNode[];
20
21
  params: string[];
21
22
  rawArgs?: string;
23
+ invocationChildren?: ASTNode[];
22
24
  }
23
25
 
24
26
  /**
@@ -100,7 +102,7 @@ function WidgetBody({
100
102
  return (
101
103
  <LocalsUpdateContext.Provider value={updater}>
102
104
  <LocalsValuesContext.Provider value={localState}>
103
- {renderNodes(body, nobr ? { nobr: true } : undefined)}
105
+ {renderNodes(body, { nobr, locals: localState })}
104
106
  </LocalsValuesContext.Provider>
105
107
  </LocalsUpdateContext.Provider>
106
108
  );
@@ -110,13 +112,20 @@ export function WidgetInvocation({
110
112
  body,
111
113
  params,
112
114
  rawArgs,
115
+ invocationChildren,
113
116
  }: WidgetInvocationProps) {
114
117
  const parentValues = useContext(LocalsValuesContext);
115
118
  const nobr = useContext(NobrContext);
116
119
  const [mergedVars, mergedTemps, mergedLocals] = useMergedLocals();
117
120
 
121
+ const childrenValue = invocationChildren?.length ? invocationChildren : null;
122
+
118
123
  if (params.length === 0 || !rawArgs) {
119
- return <>{renderNodes(body, nobr ? { nobr: true } : undefined)}</>;
124
+ return (
125
+ <WidgetChildrenContext.Provider value={childrenValue}>
126
+ {renderNodes(body, { nobr, locals: parentValues })}
127
+ </WidgetChildrenContext.Provider>
128
+ );
120
129
  }
121
130
 
122
131
  const argExprs = splitArgs(rawArgs);
@@ -137,10 +146,12 @@ export function WidgetInvocation({
137
146
  }
138
147
 
139
148
  return (
140
- <WidgetBody
141
- body={body}
142
- parentValues={parentValues}
143
- ownKeys={ownKeys}
144
- />
149
+ <WidgetChildrenContext.Provider value={childrenValue}>
150
+ <WidgetBody
151
+ body={body}
152
+ parentValues={parentValues}
153
+ ownKeys={ownKeys}
154
+ />
155
+ </WidgetChildrenContext.Provider>
145
156
  );
146
157
  }
@@ -13,6 +13,7 @@ import { useInterpolate } from './hooks/use-interpolate';
13
13
  import { useMergedLocals } from './hooks/use-merged-locals';
14
14
  import {
15
15
  LocalsUpdateContext,
16
+ LocalsValuesContext,
16
17
  NobrContext,
17
18
  renderNodes as _renderNodes,
18
19
  renderInlineNodes,
@@ -107,10 +108,11 @@ export function defineMacro(config: MacroDefinition): void {
107
108
  // Always-on: cssClass + mutation
108
109
  const { update, getValues } = useContext(LocalsUpdateContext);
109
110
  const nobr = useContext(NobrContext);
110
- const renderNodes = nobr
111
- ? (nodes: ASTNode[], options?: { nobr?: boolean }) =>
112
- _renderNodes(nodes, { nobr: true, ...options })
113
- : _renderNodes;
111
+ const localsValues = useContext(LocalsValuesContext);
112
+ const renderNodes = (
113
+ nodes: ASTNode[],
114
+ options?: { nobr?: boolean; locals?: Record<string, unknown> },
115
+ ) => _renderNodes(nodes, { nobr, locals: localsValues, ...options });
114
116
  const ctx: MacroContext = {
115
117
  collectText,
116
118
  sourceLocation: currentSourceLocation,
@@ -29,4 +29,21 @@ export function executeMutation(
29
29
  scopeUpdate(key, localsClone[key]);
30
30
  }
31
31
  }
32
+
33
+ // Detect deleted keys
34
+ for (const key of Object.keys(state.variables)) {
35
+ if (!(key in vars)) {
36
+ state.deleteVariable(key);
37
+ }
38
+ }
39
+ for (const key of Object.keys(state.temporary)) {
40
+ if (!(key in temps)) {
41
+ state.deleteTemporary(key);
42
+ }
43
+ }
44
+ for (const key of Object.keys(mergedLocals)) {
45
+ if (!(key in localsClone)) {
46
+ scopeUpdate(key, undefined);
47
+ }
48
+ }
32
49
  }
package/src/expression.ts CHANGED
@@ -34,7 +34,7 @@ const fnCache = new Map<string, CompiledExpression>();
34
34
  * but authors already have full JS access so this is acceptable).
35
35
  */
36
36
  const VAR_RE = /\$(\w+)/g;
37
- const TEMP_RE = /\b_(\w+)/g;
37
+ const TEMP_RE = /(?<![.\w])_(\w+)/g;
38
38
  const LOCAL_RE = /@(\w+)/g;
39
39
 
40
40
  function transformSegment(segment: string): string {
package/src/index.tsx CHANGED
@@ -13,8 +13,9 @@ import {
13
13
  extractDefaults,
14
14
  } from './story-variables';
15
15
  import { tokenize } from './markup/tokenizer';
16
- import { buildAST } from './markup/ast';
16
+ import { buildAST, registerBlockMacro } from './markup/ast';
17
17
  import { registerWidget } from './widgets/widget-registry';
18
+ import { astContainsChildren } from './widgets/ast-scanner';
18
19
  import type { ASTNode } from './markup/ast';
19
20
  import './macros/register-builtins';
20
21
  import builtinCSS from './styles.css?inline';
@@ -122,7 +123,12 @@ function boot() {
122
123
  (t) =>
123
124
  t.startsWith('$') || t.startsWith('_') || t.startsWith('@'),
124
125
  );
125
- registerWidget(widgetName, node.children as ASTNode[], params);
126
+ const children = node.children as ASTNode[];
127
+ const isBlock = astContainsChildren(children);
128
+ registerWidget(widgetName, children, params, isBlock);
129
+ if (isBlock) {
130
+ registerBlockMacro(widgetName);
131
+ }
126
132
  }
127
133
  }
128
134
  }
@@ -23,6 +23,7 @@ const defaultUpdater: LocalsUpdater = {
23
23
  export const LocalsValuesContext = createContext<Record<string, unknown>>({});
24
24
  export const LocalsUpdateContext = createContext<LocalsUpdater>(defaultUpdater);
25
25
  export const NobrContext = createContext(false);
26
+ export const WidgetChildrenContext = createContext<ASTNode[] | null>(null);
26
27
 
27
28
  /**
28
29
  * Convert an HTML string (from micromark) to Preact VNodes,
@@ -83,6 +84,7 @@ function convertDomNode(
83
84
  function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
84
85
  const resolve = useInterpolate();
85
86
  const nobr = useContext(NobrContext);
87
+ const locals = useContext(LocalsValuesContext);
86
88
  const attrs: Record<string, unknown> = {};
87
89
  for (const [k, v] of Object.entries(node.attributes)) {
88
90
  attrs[k] = resolve(v) ?? v;
@@ -91,11 +93,19 @@ function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
91
93
  node.tag,
92
94
  attrs,
93
95
  node.children.length > 0
94
- ? renderNodes(node.children, nobr ? { nobr: true } : undefined)
96
+ ? renderNodes(node.children, { nobr, locals })
95
97
  : undefined,
96
98
  );
97
99
  }
98
100
 
101
+ function ChildrenSlot() {
102
+ const childrenAST = useContext(WidgetChildrenContext);
103
+ const nobr = useContext(NobrContext);
104
+ const locals = useContext(LocalsValuesContext);
105
+ if (!childrenAST || childrenAST.length === 0) return null;
106
+ return <>{renderNodes(childrenAST, { nobr, locals })}</>;
107
+ }
108
+
99
109
  function renderMacro(node: MacroNode, key: number) {
100
110
  if (isSubMacro(node.name)) return null;
101
111
 
@@ -107,6 +117,7 @@ function renderMacro(node: MacroNode, key: number) {
107
117
  body={widget.body}
108
118
  params={widget.params}
109
119
  rawArgs={node.rawArgs}
120
+ invocationChildren={node.children}
110
121
  />
111
122
  );
112
123
  }
@@ -147,6 +158,9 @@ function renderSingleNode(
147
158
  return node.value;
148
159
 
149
160
  case 'variable':
161
+ if (node.scope === 'local' && node.name === 'children') {
162
+ return <ChildrenSlot key={key} />;
163
+ }
150
164
  return (
151
165
  <VarDisplay
152
166
  key={key}
@@ -193,7 +207,10 @@ function hasUnclosedBacktick(s: string): boolean {
193
207
  return count % 2 === 1;
194
208
  }
195
209
 
196
- function getVariableTextValue(node: VariableNode): string {
210
+ function getVariableTextValue(
211
+ node: VariableNode,
212
+ locals: Record<string, unknown>,
213
+ ): string {
197
214
  const state = useStoryStore.getState();
198
215
  const parts = node.name.split('.');
199
216
  const root = parts[0]!;
@@ -201,6 +218,7 @@ function getVariableTextValue(node: VariableNode): string {
201
218
  let value: unknown;
202
219
  if (node.scope === 'variable') value = state.variables[root];
203
220
  else if (node.scope === 'temporary') value = state.temporary[root];
221
+ else value = locals[root];
204
222
 
205
223
  for (let i = 1; i < parts.length; i++) {
206
224
  if (value == null || typeof value !== 'object') return '';
@@ -223,7 +241,7 @@ function getVariableTextValue(node: VariableNode): string {
223
241
  */
224
242
  export function renderNodes(
225
243
  nodes: ASTNode[],
226
- options?: { nobr?: boolean },
244
+ options?: { nobr?: boolean; locals?: Record<string, unknown> },
227
245
  ): preact.ComponentChildren {
228
246
  if (nodes.length === 0) return null;
229
247
 
@@ -236,6 +254,7 @@ export function renderNodes(
236
254
  // Build combined markdown string with placeholders for non-text nodes
237
255
  const components: preact.ComponentChildren[] = [];
238
256
  let combined = '';
257
+ const locals = options?.locals ?? {};
239
258
 
240
259
  for (let i = 0; i < nodes.length; i++) {
241
260
  const node = nodes[i]!;
@@ -243,7 +262,7 @@ export function renderNodes(
243
262
  combined += node.value.replace(/^[ \t]{4,}/gm, ' ');
244
263
  } else if (node.type === 'variable' && hasUnclosedBacktick(combined)) {
245
264
  // Inline variable value to avoid placeholder inside code span
246
- combined += getVariableTextValue(node);
265
+ combined += getVariableTextValue(node, locals);
247
266
  } else {
248
267
  const phIdx = components.length;
249
268
  components.push(renderSingleNode(node, i));
@@ -5,6 +5,7 @@ import type {
5
5
  PlaythroughRecord,
6
6
  SaveExport,
7
7
  } from './types';
8
+ import { isSavePayload } from './types';
8
9
  import {
9
10
  putSave,
10
11
  getSave,
@@ -322,7 +323,9 @@ export function loadSession(ifid: string): SavePayload | undefined {
322
323
  try {
323
324
  const raw = sessionStorage.getItem(`${SESSION_KEY_PREFIX}${ifid}`);
324
325
  if (!raw) return undefined;
325
- const payload: SavePayload = JSON.parse(raw);
326
+ const parsed: unknown = JSON.parse(raw);
327
+ if (!isSavePayload(parsed)) return undefined;
328
+ const payload = parsed;
326
329
  payload.variables = deserialize(payload.variables);
327
330
  payload.history = payload.history.map((m) => ({
328
331
  ...m,
@@ -76,3 +76,13 @@ export function isSaveExport(value: unknown): value is SaveExport {
76
76
 
77
77
  return true;
78
78
  }
79
+
80
+ export function isSavePayload(value: unknown): value is SavePayload {
81
+ if (typeof value !== 'object' || value === null) return false;
82
+ const obj = value as Record<string, unknown>;
83
+ if (typeof obj.passage !== 'string') return false;
84
+ if (typeof obj.variables !== 'object' || obj.variables === null) return false;
85
+ if (!Array.isArray(obj.history)) return false;
86
+ if (typeof obj.historyIndex !== 'number') return false;
87
+ return true;
88
+ }
package/src/store.ts CHANGED
@@ -608,7 +608,10 @@ export const useStoryStore = create<StoryState>()(
608
608
  timestamp: m.timestamp,
609
609
  prng: m.prng,
610
610
  }));
611
- state.historyIndex = payload.historyIndex;
611
+ state.historyIndex = Math.max(
612
+ 0,
613
+ Math.min(payload.historyIndex, state.history.length - 1),
614
+ );
612
615
  state.visitCounts = payload.visitCounts ?? {};
613
616
  state.renderCounts = payload.renderCounts ?? {};
614
617
  state.temporary = {};
@@ -0,0 +1,29 @@
1
+ import type { ASTNode } from '../markup/ast';
2
+
3
+ /**
4
+ * Recursively scan an AST node array for a VariableNode
5
+ * with scope 'local' and name 'children' (@children).
6
+ */
7
+ export function astContainsChildren(nodes: ASTNode[]): boolean {
8
+ for (const node of nodes) {
9
+ if (
10
+ node.type === 'variable' &&
11
+ node.scope === 'local' &&
12
+ node.name === 'children'
13
+ ) {
14
+ return true;
15
+ }
16
+ if (node.type === 'html' && astContainsChildren(node.children)) {
17
+ return true;
18
+ }
19
+ if (node.type === 'macro') {
20
+ if (astContainsChildren(node.children)) return true;
21
+ if (node.branches) {
22
+ for (const branch of node.branches) {
23
+ if (astContainsChildren(branch.children)) return true;
24
+ }
25
+ }
26
+ }
27
+ }
28
+ return false;
29
+ }