@rohal12/spindle 0.28.2 → 0.28.4

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.2",
3
+ "version": "0.28.4",
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>
@@ -100,7 +100,7 @@ function WidgetBody({
100
100
  return (
101
101
  <LocalsUpdateContext.Provider value={updater}>
102
102
  <LocalsValuesContext.Provider value={localState}>
103
- {renderNodes(body, nobr ? { nobr: true } : undefined)}
103
+ {renderNodes(body, { nobr, locals: localState })}
104
104
  </LocalsValuesContext.Provider>
105
105
  </LocalsUpdateContext.Provider>
106
106
  );
@@ -116,7 +116,7 @@ export function WidgetInvocation({
116
116
  const [mergedVars, mergedTemps, mergedLocals] = useMergedLocals();
117
117
 
118
118
  if (params.length === 0 || !rawArgs) {
119
- return <>{renderNodes(body, nobr ? { nobr: true } : undefined)}</>;
119
+ return <>{renderNodes(body, { nobr, locals: parentValues })}</>;
120
120
  }
121
121
 
122
122
  const argExprs = splitArgs(rawArgs);
@@ -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 {
@@ -83,6 +83,7 @@ function convertDomNode(
83
83
  function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
84
84
  const resolve = useInterpolate();
85
85
  const nobr = useContext(NobrContext);
86
+ const locals = useContext(LocalsValuesContext);
86
87
  const attrs: Record<string, unknown> = {};
87
88
  for (const [k, v] of Object.entries(node.attributes)) {
88
89
  attrs[k] = resolve(v) ?? v;
@@ -91,7 +92,7 @@ function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
91
92
  node.tag,
92
93
  attrs,
93
94
  node.children.length > 0
94
- ? renderNodes(node.children, nobr ? { nobr: true } : undefined)
95
+ ? renderNodes(node.children, { nobr, locals })
95
96
  : undefined,
96
97
  );
97
98
  }
@@ -193,7 +194,10 @@ function hasUnclosedBacktick(s: string): boolean {
193
194
  return count % 2 === 1;
194
195
  }
195
196
 
196
- function getVariableTextValue(node: VariableNode): string {
197
+ function getVariableTextValue(
198
+ node: VariableNode,
199
+ locals: Record<string, unknown>,
200
+ ): string {
197
201
  const state = useStoryStore.getState();
198
202
  const parts = node.name.split('.');
199
203
  const root = parts[0]!;
@@ -201,6 +205,7 @@ function getVariableTextValue(node: VariableNode): string {
201
205
  let value: unknown;
202
206
  if (node.scope === 'variable') value = state.variables[root];
203
207
  else if (node.scope === 'temporary') value = state.temporary[root];
208
+ else value = locals[root];
204
209
 
205
210
  for (let i = 1; i < parts.length; i++) {
206
211
  if (value == null || typeof value !== 'object') return '';
@@ -223,7 +228,7 @@ function getVariableTextValue(node: VariableNode): string {
223
228
  */
224
229
  export function renderNodes(
225
230
  nodes: ASTNode[],
226
- options?: { nobr?: boolean },
231
+ options?: { nobr?: boolean; locals?: Record<string, unknown> },
227
232
  ): preact.ComponentChildren {
228
233
  if (nodes.length === 0) return null;
229
234
 
@@ -236,6 +241,7 @@ export function renderNodes(
236
241
  // Build combined markdown string with placeholders for non-text nodes
237
242
  const components: preact.ComponentChildren[] = [];
238
243
  let combined = '';
244
+ const locals = options?.locals ?? {};
239
245
 
240
246
  for (let i = 0; i < nodes.length; i++) {
241
247
  const node = nodes[i]!;
@@ -243,7 +249,7 @@ export function renderNodes(
243
249
  combined += node.value.replace(/^[ \t]{4,}/gm, ' ');
244
250
  } else if (node.type === 'variable' && hasUnclosedBacktick(combined)) {
245
251
  // Inline variable value to avoid placeholder inside code span
246
- combined += getVariableTextValue(node);
252
+ combined += getVariableTextValue(node, locals);
247
253
  } else {
248
254
  const phIdx = components.length;
249
255
  components.push(renderSingleNode(node, i));
package/src/parser.ts CHANGED
@@ -18,10 +18,25 @@ export interface StoryData {
18
18
  userScript: string;
19
19
  }
20
20
 
21
+ /**
22
+ * Decode the HTML entities that browsers use when serializing innerHTML.
23
+ * &amp; is decoded last to avoid double-decoding (e.g. &amp;lt; → &lt;, not <).
24
+ */
25
+ function decodeHtmlEntities(html: string): string {
26
+ return html
27
+ .replace(/&lt;/g, '<')
28
+ .replace(/&gt;/g, '>')
29
+ .replace(/&quot;/g, '"')
30
+ .replace(/&#39;/g, "'")
31
+ .replace(/&amp;/g, '&');
32
+ }
33
+
21
34
  /**
22
35
  * Parse <tw-storydata> and all <tw-passagedata> elements from the DOM.
23
- * The browser auto-decodes HTML entities when building the DOM, so
24
- * textContent gives us the original passage text.
36
+ *
37
+ * Uses innerHTML + entity decoding instead of textContent so that HTML
38
+ * tags inside passage markup (e.g. <div> inside {button}) are preserved
39
+ * regardless of whether the compiler HTML-encoded them.
25
40
  */
26
41
  export function parseStoryData(): StoryData {
27
42
  const storyEl = document.querySelector('tw-storydata');
@@ -52,7 +67,7 @@ export function parseStoryData(): StoryData {
52
67
  const tags = (el.getAttribute('tags') || '')
53
68
  .split(/\s+/)
54
69
  .filter((t) => t.length > 0);
55
- const content = el.textContent || '';
70
+ const content = decodeHtmlEntities(el.innerHTML);
56
71
 
57
72
  const metadata: Record<string, string> = {};
58
73
  const skipAttrs = new Set(['pid', 'name', 'tags']);
@@ -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 = {};