@rohal12/spindle 0.2.0 → 0.3.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.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -58,11 +58,13 @@
58
58
  },
59
59
  "devDependencies": {
60
60
  "@preact/preset-vite": "^2.9.0",
61
+ "@rohal12/twee-ts": "^1.0.0",
62
+ "@types/js-yaml": "^4.0.9",
61
63
  "@vitest/coverage-v8": "^4.0.18",
62
64
  "happy-dom": "^20.7.0",
65
+ "js-yaml": "^4.1.1",
63
66
  "playwright": "^1.58.2",
64
67
  "prettier": "^3.8.1",
65
- "@rohal12/twee-ts": "^1.0.0",
66
68
  "typescript": "^5.7.0",
67
69
  "vite": "^6.0.0",
68
70
  "vite-plugin-singlefile": "^2.0.0",
@@ -0,0 +1,83 @@
1
+ export type ActionType =
2
+ | 'link'
3
+ | 'button'
4
+ | 'cycle'
5
+ | 'textbox'
6
+ | 'numberbox'
7
+ | 'textarea'
8
+ | 'checkbox'
9
+ | 'radiobutton'
10
+ | 'listbox'
11
+ | 'back'
12
+ | 'forward'
13
+ | 'restart'
14
+ | 'save'
15
+ | 'load';
16
+
17
+ export interface StoryAction {
18
+ id: string;
19
+ type: ActionType;
20
+ label: string;
21
+ target?: string;
22
+ variable?: string;
23
+ options?: string[];
24
+ value?: unknown;
25
+ disabled?: boolean;
26
+ perform: (value?: unknown) => void;
27
+ }
28
+
29
+ const actions = new Map<string, StoryAction>();
30
+ const listeners = new Set<() => void>();
31
+ const idCounters = new Map<string, number>();
32
+
33
+ export function generateActionId(
34
+ type: ActionType,
35
+ key: string,
36
+ authorId?: string,
37
+ ): string {
38
+ if (authorId) return authorId;
39
+
40
+ const base = `${type}:${key}`;
41
+ const count = (idCounters.get(base) ?? 0) + 1;
42
+ idCounters.set(base, count);
43
+ return count === 1 ? base : `${base}:${count}`;
44
+ }
45
+
46
+ export function registerAction(action: StoryAction): () => void {
47
+ actions.set(action.id, action);
48
+ notify();
49
+ return () => {
50
+ actions.delete(action.id);
51
+ notify();
52
+ };
53
+ }
54
+
55
+ export function getActions(): StoryAction[] {
56
+ return Array.from(actions.values());
57
+ }
58
+
59
+ export function getAction(id: string): StoryAction | undefined {
60
+ return actions.get(id);
61
+ }
62
+
63
+ export function clearActions(): void {
64
+ actions.clear();
65
+ notify();
66
+ }
67
+
68
+ export function resetIdCounters(): void {
69
+ idCounters.clear();
70
+ }
71
+
72
+ export function onActionsChanged(fn: () => void): () => void {
73
+ listeners.add(fn);
74
+ return () => {
75
+ listeners.delete(fn);
76
+ };
77
+ }
78
+
79
+ function notify(): void {
80
+ for (const fn of listeners) {
81
+ fn();
82
+ }
83
+ }
@@ -0,0 +1,11 @@
1
+ export { runAutomation } from './runner';
2
+ export type { RunOptions } from './runner';
3
+ export { parseAutomationYaml } from './load-yaml';
4
+ export type {
5
+ AutomationScript,
6
+ AutomationStep,
7
+ AssertStep,
8
+ ActionMatcher,
9
+ RunResult,
10
+ StepError,
11
+ } from './types';
@@ -0,0 +1,20 @@
1
+ import yaml from 'js-yaml';
2
+ import type { AutomationScript } from './types';
3
+
4
+ export function parseAutomationYaml(yamlContent: string): AutomationScript {
5
+ const doc = yaml.load(yamlContent) as Record<string, unknown>;
6
+
7
+ if (!doc || typeof doc !== 'object') {
8
+ throw new Error('Invalid YAML: expected an object');
9
+ }
10
+
11
+ if (typeof doc.name !== 'string') {
12
+ throw new Error('Invalid automation script: missing "name" field');
13
+ }
14
+
15
+ if (!Array.isArray(doc.steps)) {
16
+ throw new Error('Invalid automation script: missing "steps" array');
17
+ }
18
+
19
+ return doc as unknown as AutomationScript;
20
+ }
@@ -0,0 +1,139 @@
1
+ import type {
2
+ AutomationScript,
3
+ AutomationStep,
4
+ RunResult,
5
+ StepError,
6
+ ActionMatcher,
7
+ } from './types';
8
+ import type { StoryAPI } from '../story-api';
9
+ import type { StoryAction } from '../action-registry';
10
+
11
+ export interface RunOptions {
12
+ onStep?: (stepIndex: number, step: AutomationStep) => void;
13
+ }
14
+
15
+ export async function runAutomation(
16
+ story: StoryAPI,
17
+ script: AutomationScript,
18
+ options: RunOptions = {},
19
+ ): Promise<RunResult> {
20
+ const errors: StepError[] = [];
21
+ let stepsRun = 0;
22
+
23
+ // Navigate to start passage if specified
24
+ if (script.start) {
25
+ story.goto(script.start);
26
+ await story.waitForActions();
27
+ }
28
+
29
+ for (let i = 0; i < script.steps.length; i++) {
30
+ const step = script.steps[i];
31
+ options.onStep?.(i, step);
32
+
33
+ try {
34
+ await executeStep(story, step, i, errors);
35
+ } catch (err) {
36
+ errors.push({
37
+ step: i,
38
+ message: err instanceof Error ? err.message : String(err),
39
+ });
40
+ break;
41
+ }
42
+
43
+ stepsRun = i + 1;
44
+ }
45
+
46
+ return {
47
+ success: errors.length === 0,
48
+ stepsRun,
49
+ errors,
50
+ };
51
+ }
52
+
53
+ async function executeStep(
54
+ story: StoryAPI,
55
+ step: AutomationStep,
56
+ index: number,
57
+ errors: StepError[],
58
+ ): Promise<void> {
59
+ if (step.set) {
60
+ story.set(step.set);
61
+ }
62
+
63
+ if (step.action !== undefined) {
64
+ const id = typeof step.action === 'string' ? step.action : step.action.id;
65
+ const value =
66
+ typeof step.action === 'string' ? undefined : step.action.value;
67
+ story.performAction(id, value);
68
+ await story.waitForActions();
69
+ }
70
+
71
+ if (step.wait !== undefined) {
72
+ await new Promise((resolve) => setTimeout(resolve, step.wait));
73
+ }
74
+
75
+ if (step.assert) {
76
+ const { assert } = step;
77
+
78
+ if (assert.passage !== undefined) {
79
+ if (story.passage !== assert.passage) {
80
+ errors.push({
81
+ step: index,
82
+ message: `Expected passage "${assert.passage}", got "${story.passage}"`,
83
+ });
84
+ }
85
+ }
86
+
87
+ if (assert.variables) {
88
+ for (const [key, expected] of Object.entries(assert.variables)) {
89
+ const actual = story.get(key);
90
+ if (actual !== expected) {
91
+ errors.push({
92
+ step: index,
93
+ message: `Variable "${key}": expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
94
+ });
95
+ }
96
+ }
97
+ }
98
+
99
+ if (assert.actionCount !== undefined) {
100
+ const actions = story.getActions();
101
+ if (actions.length !== assert.actionCount) {
102
+ errors.push({
103
+ step: index,
104
+ message: `Expected ${assert.actionCount} actions, got ${actions.length}`,
105
+ });
106
+ }
107
+ }
108
+
109
+ if (assert.actions) {
110
+ const actions = story.getActions();
111
+ for (const matcher of assert.actions) {
112
+ if (!findMatchingAction(actions, matcher)) {
113
+ errors.push({
114
+ step: index,
115
+ message: `No action matching ${JSON.stringify(matcher)}`,
116
+ });
117
+ }
118
+ }
119
+ }
120
+ }
121
+ }
122
+
123
+ function findMatchingAction(
124
+ actions: StoryAction[],
125
+ matcher: ActionMatcher,
126
+ ): StoryAction | undefined {
127
+ return actions.find((action) => {
128
+ if (matcher.id !== undefined && action.id !== matcher.id) return false;
129
+ if (matcher.type !== undefined && action.type !== matcher.type)
130
+ return false;
131
+ if (matcher.target !== undefined && action.target !== matcher.target)
132
+ return false;
133
+ if (matcher.variable !== undefined && action.variable !== matcher.variable)
134
+ return false;
135
+ if (matcher.label !== undefined && action.label !== matcher.label)
136
+ return false;
137
+ return true;
138
+ });
139
+ }
@@ -0,0 +1,38 @@
1
+ export interface ActionMatcher {
2
+ type?: string;
3
+ id?: string;
4
+ target?: string;
5
+ variable?: string;
6
+ label?: string;
7
+ }
8
+
9
+ export interface AssertStep {
10
+ passage?: string;
11
+ variables?: Record<string, unknown>;
12
+ actions?: ActionMatcher[];
13
+ actionCount?: number;
14
+ }
15
+
16
+ export interface AutomationStep {
17
+ action?: string | { id: string; value?: unknown };
18
+ assert?: AssertStep;
19
+ wait?: number;
20
+ set?: Record<string, unknown>;
21
+ }
22
+
23
+ export interface AutomationScript {
24
+ name: string;
25
+ start?: string;
26
+ steps: AutomationStep[];
27
+ }
28
+
29
+ export interface StepError {
30
+ step: number;
31
+ message: string;
32
+ }
33
+
34
+ export interface RunResult {
35
+ success: boolean;
36
+ stepsRun: number;
37
+ errors: StepError[];
38
+ }
@@ -1,4 +1,5 @@
1
1
  import { useStoryStore } from '../store';
2
+ import { useAction } from '../hooks/use-action';
2
3
 
3
4
  interface PassageLinkProps {
4
5
  target: string;
@@ -20,6 +21,15 @@ export function PassageLink({
20
21
  navigate(target);
21
22
  };
22
23
 
24
+ useAction({
25
+ type: 'link',
26
+ key: target,
27
+ authorId: id,
28
+ label: typeof children === 'string' ? children : target,
29
+ target,
30
+ perform: () => navigate(target),
31
+ });
32
+
23
33
  const cls = className ? `passage-link ${className}` : 'passage-link';
24
34
 
25
35
  return (
@@ -1,4 +1,5 @@
1
1
  import { useStoryStore } from '../../store';
2
+ import { useAction } from '../../hooks/use-action';
2
3
 
3
4
  interface BackProps {
4
5
  className?: string;
@@ -10,6 +11,15 @@ export function Back({ className, id }: BackProps) {
10
11
  const canGoBack = useStoryStore((s) => s.historyIndex > 0);
11
12
  const cls = className ? `menubar-button ${className}` : 'menubar-button';
12
13
 
14
+ useAction({
15
+ type: 'back',
16
+ key: 'back',
17
+ authorId: id,
18
+ label: 'Back',
19
+ disabled: !canGoBack,
20
+ perform: () => goBack(),
21
+ });
22
+
13
23
  return (
14
24
  <button
15
25
  id={id}
@@ -2,6 +2,8 @@ import { useStoryStore } from '../../store';
2
2
  import { execute } from '../../expression';
3
3
  import { renderInlineNodes } from '../../markup/render';
4
4
  import { deepClone } from '../../class-registry';
5
+ import { collectText } from '../../utils/extract-text';
6
+ import { useAction } from '../../hooks/use-action';
5
7
  import type { ASTNode } from '../../markup/ast';
6
8
 
7
9
  interface ButtonProps {
@@ -36,6 +38,14 @@ export function Button({ rawArgs, children, className, id }: ButtonProps) {
36
38
  }
37
39
  };
38
40
 
41
+ useAction({
42
+ type: 'button',
43
+ key: rawArgs,
44
+ authorId: id,
45
+ label: collectText(children) || rawArgs,
46
+ perform: handleClick,
47
+ });
48
+
39
49
  const cls = className ? `macro-button ${className}` : 'macro-button';
40
50
 
41
51
  return (
@@ -1,4 +1,5 @@
1
1
  import { useStoryStore } from '../../store';
2
+ import { useAction } from '../../hooks/use-action';
2
3
 
3
4
  interface CheckboxProps {
4
5
  rawArgs: string;
@@ -23,6 +24,16 @@ export function Checkbox({ rawArgs, className, id }: CheckboxProps) {
23
24
  const value = useStoryStore((s) => s.variables[name]);
24
25
  const setVariable = useStoryStore((s) => s.setVariable);
25
26
 
27
+ useAction({
28
+ type: 'checkbox',
29
+ key: `$${name}`,
30
+ authorId: id,
31
+ label: label || name,
32
+ variable: name,
33
+ value: !!value,
34
+ perform: (v) => setVariable(name, v !== undefined ? !!v : !value),
35
+ });
36
+
26
37
  const cls = className ? `macro-checkbox ${className}` : 'macro-checkbox';
27
38
 
28
39
  return (
@@ -1,5 +1,6 @@
1
1
  import { useStoryStore } from '../../store';
2
2
  import { extractOptions } from './option-utils';
3
+ import { useAction } from '../../hooks/use-action';
3
4
  import type { ASTNode } from '../../markup/ast';
4
5
 
5
6
  interface CycleProps {
@@ -25,6 +26,23 @@ export function Cycle({ rawArgs, children, className, id }: CycleProps) {
25
26
  setVariable(name, options[nextIndex]);
26
27
  };
27
28
 
29
+ useAction({
30
+ type: 'cycle',
31
+ key: `$${name}`,
32
+ authorId: id,
33
+ label: value == null ? options[0] || '' : String(value),
34
+ variable: name,
35
+ options,
36
+ value,
37
+ perform: (v) => {
38
+ if (v !== undefined) {
39
+ setVariable(name, v);
40
+ } else {
41
+ handleClick();
42
+ }
43
+ },
44
+ });
45
+
28
46
  const cls = className ? `macro-cycle ${className}` : 'macro-cycle';
29
47
 
30
48
  return (
@@ -1,4 +1,5 @@
1
1
  import { useStoryStore } from '../../store';
2
+ import { useAction } from '../../hooks/use-action';
2
3
 
3
4
  interface ForwardProps {
4
5
  className?: string;
@@ -12,6 +13,15 @@ export function Forward({ className, id }: ForwardProps) {
12
13
  );
13
14
  const cls = className ? `menubar-button ${className}` : 'menubar-button';
14
15
 
16
+ useAction({
17
+ type: 'forward',
18
+ key: 'forward',
19
+ authorId: id,
20
+ label: 'Forward',
21
+ disabled: !canGoForward,
22
+ perform: () => goForward(),
23
+ });
24
+
15
25
  return (
16
26
  <button
17
27
  id={id}
@@ -1,5 +1,6 @@
1
1
  import { useStoryStore } from '../../store';
2
2
  import { extractOptions } from './option-utils';
3
+ import { useAction } from '../../hooks/use-action';
3
4
  import type { ASTNode } from '../../markup/ast';
4
5
 
5
6
  interface ListboxProps {
@@ -18,6 +19,19 @@ export function Listbox({ rawArgs, children, className, id }: ListboxProps) {
18
19
 
19
20
  const options = extractOptions(children);
20
21
 
22
+ useAction({
23
+ type: 'listbox',
24
+ key: `$${name}`,
25
+ authorId: id,
26
+ label: name,
27
+ variable: name,
28
+ options,
29
+ value,
30
+ perform: (v) => {
31
+ if (v !== undefined) setVariable(name, String(v));
32
+ },
33
+ });
34
+
21
35
  const cls = className ? `macro-listbox ${className}` : 'macro-listbox';
22
36
 
23
37
  return (
@@ -31,12 +31,8 @@ function parseArgs(rawArgs: string): {
31
31
  return { display: rawArgs.trim(), passage: null };
32
32
  }
33
33
 
34
- /**
35
- * Collect text from AST nodes for imperative execution (like Do.tsx).
36
- */
37
- function collectText(nodes: ASTNode[]): string {
38
- return nodes.map((n) => (n.type === 'text' ? n.value : '')).join('');
39
- }
34
+ import { collectText } from '../../utils/extract-text';
35
+ import { useAction } from '../../hooks/use-action';
40
36
 
41
37
  /**
42
38
  * Execute the children imperatively: walk AST for {set} and {do} macros.
@@ -93,6 +89,20 @@ export function MacroLink({
93
89
  }
94
90
  };
95
91
 
92
+ useAction({
93
+ type: 'link',
94
+ key: passage || display,
95
+ authorId: id,
96
+ label: display,
97
+ target: passage ?? undefined,
98
+ perform: () => {
99
+ executeChildren(children);
100
+ if (passage) {
101
+ useStoryStore.getState().navigate(passage);
102
+ }
103
+ },
104
+ });
105
+
96
106
  const cls = className ? `macro-link ${className}` : 'macro-link';
97
107
 
98
108
  return (
@@ -1,4 +1,5 @@
1
1
  import { useStoryStore } from '../../store';
2
+ import { useAction } from '../../hooks/use-action';
2
3
 
3
4
  interface NumberboxProps {
4
5
  rawArgs: string;
@@ -23,6 +24,16 @@ export function Numberbox({ rawArgs, className, id }: NumberboxProps) {
23
24
  const value = useStoryStore((s) => s.variables[name]);
24
25
  const setVariable = useStoryStore((s) => s.setVariable);
25
26
 
27
+ useAction({
28
+ type: 'numberbox',
29
+ key: `$${name}`,
30
+ authorId: id,
31
+ label: placeholder || name,
32
+ variable: name,
33
+ value,
34
+ perform: (v) => setVariable(name, v !== undefined ? Number(v) : 0),
35
+ });
36
+
26
37
  const cls = className ? `macro-numberbox ${className}` : 'macro-numberbox';
27
38
 
28
39
  return (
@@ -1,4 +1,5 @@
1
1
  import { useStoryStore } from '../../store';
2
+ import { useAction } from '../../hooks/use-action';
2
3
 
3
4
  interface QuickLoadProps {
4
5
  className?: string;
@@ -19,6 +20,15 @@ export function QuickLoad({ className, id }: QuickLoadProps) {
19
20
  }
20
21
  };
21
22
 
23
+ useAction({
24
+ type: 'load',
25
+ key: 'quickload',
26
+ authorId: id,
27
+ label: 'QuickLoad',
28
+ disabled,
29
+ perform: () => load(),
30
+ });
31
+
22
32
  return (
23
33
  <button
24
34
  id={id}
@@ -1,4 +1,5 @@
1
1
  import { useStoryStore } from '../../store';
2
+ import { useAction } from '../../hooks/use-action';
2
3
 
3
4
  interface QuickSaveProps {
4
5
  className?: string;
@@ -9,6 +10,14 @@ export function QuickSave({ className, id }: QuickSaveProps) {
9
10
  const save = useStoryStore((s) => s.save);
10
11
  const cls = className ? `menubar-button ${className}` : 'menubar-button';
11
12
 
13
+ useAction({
14
+ type: 'save',
15
+ key: 'quicksave',
16
+ authorId: id,
17
+ label: 'QuickSave',
18
+ perform: () => save(),
19
+ });
20
+
12
21
  return (
13
22
  <button
14
23
  id={id}
@@ -1,4 +1,5 @@
1
1
  import { useStoryStore } from '../../store';
2
+ import { useAction } from '../../hooks/use-action';
2
3
 
3
4
  interface RadiobuttonProps {
4
5
  rawArgs: string;
@@ -38,6 +39,16 @@ export function Radiobutton({ rawArgs, className, id }: RadiobuttonProps) {
38
39
  const currentValue = useStoryStore((s) => s.variables[name]);
39
40
  const setVariable = useStoryStore((s) => s.setVariable);
40
41
 
42
+ useAction({
43
+ type: 'radiobutton',
44
+ key: `$${name}:${radioValue}`,
45
+ authorId: id,
46
+ label: label || radioValue,
47
+ variable: name,
48
+ value: currentValue,
49
+ perform: () => setVariable(name, radioValue),
50
+ });
51
+
41
52
  const cls = className
42
53
  ? `macro-radiobutton ${className}`
43
54
  : 'macro-radiobutton';
@@ -1,4 +1,5 @@
1
1
  import { useStoryStore } from '../../store';
2
+ import { useAction } from '../../hooks/use-action';
2
3
 
3
4
  interface RestartProps {
4
5
  className?: string;
@@ -15,6 +16,14 @@ export function Restart({ className, id }: RestartProps) {
15
16
  }
16
17
  };
17
18
 
19
+ useAction({
20
+ type: 'restart',
21
+ key: 'restart',
22
+ authorId: id,
23
+ label: 'Restart',
24
+ perform: () => restart(),
25
+ });
26
+
18
27
  return (
19
28
  <button
20
29
  id={id}
@@ -1,4 +1,5 @@
1
1
  import { useStoryStore } from '../../store';
2
+ import { useAction } from '../../hooks/use-action';
2
3
 
3
4
  interface TextareaProps {
4
5
  rawArgs: string;
@@ -23,6 +24,16 @@ export function Textarea({ rawArgs, className, id }: TextareaProps) {
23
24
  const value = useStoryStore((s) => s.variables[name]);
24
25
  const setVariable = useStoryStore((s) => s.setVariable);
25
26
 
27
+ useAction({
28
+ type: 'textarea',
29
+ key: `$${name}`,
30
+ authorId: id,
31
+ label: placeholder || name,
32
+ variable: name,
33
+ value,
34
+ perform: (v) => setVariable(name, v !== undefined ? String(v) : ''),
35
+ });
36
+
26
37
  const cls = className ? `macro-textarea ${className}` : 'macro-textarea';
27
38
 
28
39
  return (
@@ -1,4 +1,5 @@
1
1
  import { useStoryStore } from '../../store';
2
+ import { useAction } from '../../hooks/use-action';
2
3
 
3
4
  interface TextboxProps {
4
5
  rawArgs: string;
@@ -23,6 +24,16 @@ export function Textbox({ rawArgs, className, id }: TextboxProps) {
23
24
  const value = useStoryStore((s) => s.variables[name]);
24
25
  const setVariable = useStoryStore((s) => s.setVariable);
25
26
 
27
+ useAction({
28
+ type: 'textbox',
29
+ key: `$${name}`,
30
+ authorId: id,
31
+ label: placeholder || name,
32
+ variable: name,
33
+ value,
34
+ perform: (v) => setVariable(name, v !== undefined ? String(v) : ''),
35
+ });
36
+
26
37
  const cls = className ? `macro-textbox ${className}` : 'macro-textbox';
27
38
 
28
39
  return (