@rohal12/spindle 0.1.0 → 0.2.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.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -40,6 +40,7 @@
40
40
  "test": "vitest run",
41
41
  "test:watch": "vitest",
42
42
  "test:e2e": "vitest run --config vitest.e2e.config.ts",
43
+ "typecheck": "tsc --noEmit",
43
44
  "format": "prettier --write .",
44
45
  "format:check": "prettier --check .",
45
46
  "docs:dev": "vitepress dev docs",
@@ -61,7 +62,7 @@
61
62
  "happy-dom": "^20.7.0",
62
63
  "playwright": "^1.58.2",
63
64
  "prettier": "^3.8.1",
64
- "tweenode": "^0.3.0",
65
+ "@rohal12/twee-ts": "^1.0.0",
65
66
  "typescript": "^5.7.0",
66
67
  "vite": "^6.0.0",
67
68
  "vite-plugin-singlefile": "^2.0.0",
@@ -0,0 +1,189 @@
1
+ // Class registry for preserving class instances across clone/save/load cycles.
2
+
3
+ type Constructor = new (...args: any[]) => any;
4
+
5
+ const registry = new Map<string, Constructor>();
6
+ const ctorToName = new Map<Constructor, string>();
7
+
8
+ export function registerClass(name: string, ctor: Constructor): void {
9
+ registry.set(name, ctor);
10
+ ctorToName.set(ctor, name);
11
+ }
12
+
13
+ export function getClassName(ctor: Constructor): string | undefined {
14
+ return ctorToName.get(ctor);
15
+ }
16
+
17
+ export function clearRegistry(): void {
18
+ registry.clear();
19
+ ctorToName.clear();
20
+ }
21
+
22
+ // --- Deep Clone ---
23
+
24
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
25
+ if (typeof value !== 'object' || value === null) return false;
26
+ const proto = Object.getPrototypeOf(value);
27
+ return proto === Object.prototype || proto === null;
28
+ }
29
+
30
+ export function deepClone<T>(value: T): T {
31
+ const seen = new Map<object, object>();
32
+
33
+ function clone(val: unknown): unknown {
34
+ if (val === null || typeof val !== 'object') return val;
35
+
36
+ const obj = val as object;
37
+ if (seen.has(obj)) return seen.get(obj);
38
+
39
+ if (val instanceof Date) return new Date(val.getTime()) as unknown;
40
+ if (val instanceof RegExp)
41
+ return new RegExp(val.source, val.flags) as unknown;
42
+
43
+ if (Array.isArray(val)) {
44
+ const arr: unknown[] = [];
45
+ seen.set(obj, arr);
46
+ for (let i = 0; i < val.length; i++) {
47
+ arr[i] = clone(val[i]);
48
+ }
49
+ return arr;
50
+ }
51
+
52
+ // Registered class instance
53
+ const ctor = obj.constructor as Constructor;
54
+ const name = ctorToName.get(ctor);
55
+ if (name !== undefined) {
56
+ const copy = Object.create(Object.getPrototypeOf(obj)) as Record<
57
+ string,
58
+ unknown
59
+ >;
60
+ seen.set(obj, copy);
61
+ for (const key of Object.keys(obj)) {
62
+ copy[key] = clone((obj as Record<string, unknown>)[key]);
63
+ }
64
+ return copy;
65
+ }
66
+
67
+ // Plain object (or unregistered class — treat as plain)
68
+ if (isPlainObject(val) || typeof val === 'object') {
69
+ const copy: Record<string, unknown> = {};
70
+ seen.set(obj, copy);
71
+ for (const key of Object.keys(obj)) {
72
+ copy[key] = clone((obj as Record<string, unknown>)[key]);
73
+ }
74
+ return copy;
75
+ }
76
+
77
+ return val;
78
+ }
79
+
80
+ return clone(value) as T;
81
+ }
82
+
83
+ // --- Serialize ---
84
+
85
+ const CLASS_TAG = '__spindle_class__';
86
+ const DATA_TAG = '__spindle_data__';
87
+
88
+ export function serialize<T>(value: T): T {
89
+ const seen = new Set<object>();
90
+
91
+ function ser(val: unknown): unknown {
92
+ if (val === null || typeof val !== 'object') return val;
93
+
94
+ const obj = val as object;
95
+ if (seen.has(obj)) {
96
+ throw new Error('spindle: Cannot serialize circular references');
97
+ }
98
+ seen.add(obj);
99
+
100
+ if (val instanceof Date) {
101
+ seen.delete(obj);
102
+ return val.toISOString();
103
+ }
104
+
105
+ if (val instanceof RegExp) {
106
+ seen.delete(obj);
107
+ return val.toString();
108
+ }
109
+
110
+ if (Array.isArray(val)) {
111
+ const result = val.map((item) => ser(item));
112
+ seen.delete(obj);
113
+ return result;
114
+ }
115
+
116
+ // Registered class instance
117
+ const ctor = obj.constructor as Constructor;
118
+ const name = ctorToName.get(ctor);
119
+ if (name !== undefined) {
120
+ const data: Record<string, unknown> = {};
121
+ for (const key of Object.keys(obj)) {
122
+ data[key] = ser((obj as Record<string, unknown>)[key]);
123
+ }
124
+ seen.delete(obj);
125
+ return { [CLASS_TAG]: name, [DATA_TAG]: data };
126
+ }
127
+
128
+ // Plain object
129
+ const result: Record<string, unknown> = {};
130
+ for (const key of Object.keys(obj)) {
131
+ result[key] = ser((obj as Record<string, unknown>)[key]);
132
+ }
133
+ seen.delete(obj);
134
+ return result;
135
+ }
136
+
137
+ return ser(value) as T;
138
+ }
139
+
140
+ // --- Deserialize ---
141
+
142
+ export function deserialize<T>(value: T): T {
143
+ function deser(val: unknown): unknown {
144
+ if (val === null || typeof val !== 'object') return val;
145
+
146
+ if (Array.isArray(val)) {
147
+ return val.map((item) => deser(item));
148
+ }
149
+
150
+ const obj = val as Record<string, unknown>;
151
+
152
+ // Tagged class instance (from serialized data)
153
+ if (CLASS_TAG in obj && DATA_TAG in obj) {
154
+ const name = obj[CLASS_TAG] as string;
155
+ const data = obj[DATA_TAG] as Record<string, unknown>;
156
+ const ctor = registry.get(name);
157
+ if (!ctor) {
158
+ console.warn(
159
+ `spindle: Class "${name}" not registered. Falling back to plain object.`,
160
+ );
161
+ const plain: Record<string, unknown> = {};
162
+ for (const key of Object.keys(data)) {
163
+ plain[key] = deser(data[key]);
164
+ }
165
+ return plain;
166
+ }
167
+ const instance = Object.create(ctor.prototype) as Record<string, unknown>;
168
+ for (const key of Object.keys(data)) {
169
+ instance[key] = deser(data[key]);
170
+ }
171
+ return instance;
172
+ }
173
+
174
+ // Already-live registered class instance — pass through as-is
175
+ const ctor = (obj as object).constructor as Constructor;
176
+ if (ctorToName.has(ctor)) {
177
+ return val;
178
+ }
179
+
180
+ // Plain object
181
+ const result: Record<string, unknown> = {};
182
+ for (const key of Object.keys(obj)) {
183
+ result[key] = deser(obj[key]);
184
+ }
185
+ return result;
186
+ }
187
+
188
+ return deser(value) as T;
189
+ }
@@ -4,7 +4,8 @@ import { tokenize } from '../markup/tokenizer';
4
4
  import { buildAST } from '../markup/ast';
5
5
  import { renderNodes } from '../markup/render';
6
6
 
7
- const DEFAULT_MARKUP = '{story-title}{back}{forward}{restart}{quicksave}{quickload}{saves}{settings}';
7
+ const DEFAULT_MARKUP =
8
+ '{story-title}{back}{forward}{restart}{quicksave}{quickload}{saves}{settings}';
8
9
 
9
10
  export function StoryInterface() {
10
11
  const storyData = useStoryStore((s) => s.storyData);
@@ -1,6 +1,7 @@
1
1
  import { useStoryStore } from '../../store';
2
2
  import { execute } from '../../expression';
3
3
  import { renderInlineNodes } from '../../markup/render';
4
+ import { deepClone } from '../../class-registry';
4
5
  import type { ASTNode } from '../../markup/ast';
5
6
 
6
7
  interface ButtonProps {
@@ -13,8 +14,8 @@ interface ButtonProps {
13
14
  export function Button({ rawArgs, children, className, id }: ButtonProps) {
14
15
  const handleClick = () => {
15
16
  const state = useStoryStore.getState();
16
- const vars = structuredClone(state.variables);
17
- const temps = structuredClone(state.temporary);
17
+ const vars = deepClone(state.variables);
18
+ const temps = deepClone(state.temporary);
18
19
 
19
20
  try {
20
21
  execute(rawArgs, vars, temps);
@@ -33,7 +33,7 @@ export function Cycle({ rawArgs, children, className, id }: CycleProps) {
33
33
  class={cls}
34
34
  onClick={handleClick}
35
35
  >
36
- {value == null ? (options[0] || '') : String(value)}
36
+ {value == null ? options[0] || '' : String(value)}
37
37
  </button>
38
38
  );
39
39
  }
@@ -2,6 +2,7 @@ import { useLayoutEffect } from 'preact/hooks';
2
2
  import { useStoryStore } from '../../store';
3
3
  import { execute } from '../../expression';
4
4
  import type { ASTNode } from '../../markup/ast';
5
+ import { deepClone } from '../../class-registry';
5
6
 
6
7
  interface DoProps {
7
8
  children: ASTNode[];
@@ -19,8 +20,8 @@ export function Do({ children }: DoProps) {
19
20
 
20
21
  useLayoutEffect(() => {
21
22
  const state = useStoryStore.getState();
22
- const vars = { ...state.variables };
23
- const temps = { ...state.temporary };
23
+ const vars = deepClone(state.variables);
24
+ const temps = deepClone(state.temporary);
24
25
 
25
26
  try {
26
27
  execute(code, vars, temps);
@@ -25,9 +25,7 @@ export function Listbox({ rawArgs, children, className, id }: ListboxProps) {
25
25
  id={id}
26
26
  class={cls}
27
27
  value={value == null ? '' : String(value)}
28
- onChange={(e) =>
29
- setVariable(name, (e.target as HTMLSelectElement).value)
30
- }
28
+ onChange={(e) => setVariable(name, (e.target as HTMLSelectElement).value)}
31
29
  >
32
30
  {options.map((opt) => (
33
31
  <option
@@ -1,6 +1,7 @@
1
1
  import { useStoryStore } from '../../store';
2
2
  import { execute } from '../../expression';
3
3
  import type { ASTNode } from '../../markup/ast';
4
+ import { deepClone } from '../../class-registry';
4
5
 
5
6
  interface MacroLinkProps {
6
7
  rawArgs: string;
@@ -42,8 +43,8 @@ function collectText(nodes: ASTNode[]): string {
42
43
  */
43
44
  function executeChildren(children: ASTNode[]) {
44
45
  const state = useStoryStore.getState();
45
- const vars = structuredClone(state.variables);
46
- const temps = structuredClone(state.temporary);
46
+ const vars = deepClone(state.variables);
47
+ const temps = deepClone(state.temporary);
47
48
 
48
49
  for (const node of children) {
49
50
  if (node.type !== 'macro') continue;
@@ -0,0 +1,130 @@
1
+ import { useStoryStore } from '../../store';
2
+ import { useContext } from 'preact/hooks';
3
+ import { evaluate } from '../../expression';
4
+ import { LocalsContext } from '../../markup/render';
5
+
6
+ interface MeterProps {
7
+ rawArgs: string;
8
+ className?: string;
9
+ id?: string;
10
+ }
11
+
12
+ /**
13
+ * Parse rawArgs into currentExpr, maxExpr, and optional labelMode.
14
+ * Supports: {meter $current $max}, {meter $current $max "%"}, etc.
15
+ */
16
+ function parseArgs(rawArgs: string): {
17
+ currentExpr: string;
18
+ maxExpr: string;
19
+ labelMode: string;
20
+ } {
21
+ const trimmed = rawArgs.trim();
22
+
23
+ // Extract quoted label mode from the end if present
24
+ let labelMode = '';
25
+ let rest = trimmed;
26
+ const quoteMatch = rest.match(/\s+(?:"([^"]*)"|'([^']*)')$/);
27
+ if (quoteMatch) {
28
+ labelMode = quoteMatch[1] ?? quoteMatch[2];
29
+ rest = rest.slice(0, quoteMatch.index!).trim();
30
+ }
31
+
32
+ // Split remaining into two expressions.
33
+ // Expressions can contain dots, brackets, parens — we need to find the split point.
34
+ // Strategy: walk tokens, splitting on whitespace that isn't inside parens/brackets.
35
+ const exprs = splitExpressions(rest);
36
+ if (exprs.length < 2) {
37
+ throw new Error(
38
+ 'meter requires two arguments: {meter currentExpr maxExpr}',
39
+ );
40
+ }
41
+
42
+ return {
43
+ currentExpr: exprs[0],
44
+ maxExpr: exprs.slice(1).join(' '),
45
+ labelMode,
46
+ };
47
+ }
48
+
49
+ function splitExpressions(input: string): string[] {
50
+ const result: string[] = [];
51
+ let current = '';
52
+ let depth = 0;
53
+
54
+ for (let i = 0; i < input.length; i++) {
55
+ const ch = input[i];
56
+ if (ch === '(' || ch === '[') {
57
+ depth++;
58
+ current += ch;
59
+ } else if (ch === ')' || ch === ']') {
60
+ depth--;
61
+ current += ch;
62
+ } else if (/\s/.test(ch) && depth === 0 && current.length > 0) {
63
+ result.push(current);
64
+ current = '';
65
+ // Skip additional whitespace
66
+ while (i + 1 < input.length && /\s/.test(input[i + 1])) i++;
67
+ } else {
68
+ current += ch;
69
+ }
70
+ }
71
+ if (current.length > 0) result.push(current);
72
+ return result;
73
+ }
74
+
75
+ function formatLabel(
76
+ current: number,
77
+ max: number,
78
+ labelMode: string,
79
+ ): string | null {
80
+ if (labelMode === 'none') return null;
81
+ if (labelMode === '%') return `${Math.round((current / max) * 100)}%`;
82
+ if (labelMode) return `${current} ${labelMode} / ${max} ${labelMode}`;
83
+ return `${current} / ${max}`;
84
+ }
85
+
86
+ export function Meter({ rawArgs, className, id }: MeterProps) {
87
+ const variables = useStoryStore((s) => s.variables);
88
+ const temporary = useStoryStore((s) => s.temporary);
89
+ const locals = useContext(LocalsContext);
90
+
91
+ // Merge locals into variables for expression evaluation
92
+ const mergedVars = { ...variables };
93
+ const mergedTemps = { ...temporary };
94
+ for (const [key, val] of Object.entries(locals)) {
95
+ if (key.startsWith('$')) mergedVars[key.slice(1)] = val;
96
+ else if (key.startsWith('_')) mergedTemps[key.slice(1)] = val;
97
+ }
98
+
99
+ try {
100
+ const { currentExpr, maxExpr, labelMode } = parseArgs(rawArgs);
101
+ const current = Number(evaluate(currentExpr, mergedVars, mergedTemps));
102
+ const max = Number(evaluate(maxExpr, mergedVars, mergedTemps));
103
+ const pct = Math.max(0, Math.min(100, (current / max) * 100));
104
+ const label = formatLabel(current, max, labelMode);
105
+
106
+ const classes = ['macro-meter', className].filter(Boolean).join(' ');
107
+
108
+ return (
109
+ <div
110
+ class={classes}
111
+ id={id}
112
+ >
113
+ <div
114
+ class="macro-meter-fill"
115
+ style={`width: ${pct}%`}
116
+ />
117
+ {label != null && <span class="macro-meter-label">{label}</span>}
118
+ </div>
119
+ );
120
+ } catch (err) {
121
+ return (
122
+ <span
123
+ class="error"
124
+ title={String(err)}
125
+ >
126
+ {`{meter error: ${(err as Error).message}}`}
127
+ </span>
128
+ );
129
+ }
130
+ }
@@ -7,9 +7,7 @@ interface NumberboxProps {
7
7
  }
8
8
 
9
9
  function parseArgs(rawArgs: string): { varName: string; placeholder: string } {
10
- const match = rawArgs.match(
11
- /^\s*(["']?\$\w+["']?)\s*(?:["'](.*)["'])?\s*$/,
12
- );
10
+ const match = rawArgs.match(/^\s*(["']?\$\w+["']?)\s*(?:["'](.*)["'])?\s*$/);
13
11
  if (!match) {
14
12
  return { varName: rawArgs.trim(), placeholder: '' };
15
13
  }
@@ -1,6 +1,7 @@
1
1
  import { useLayoutEffect } from 'preact/hooks';
2
2
  import { useStoryStore } from '../../store';
3
3
  import { execute } from '../../expression';
4
+ import { deepClone } from '../../class-registry';
4
5
 
5
6
  interface SetProps {
6
7
  rawArgs: string;
@@ -9,8 +10,8 @@ interface SetProps {
9
10
  export function Set({ rawArgs }: SetProps) {
10
11
  useLayoutEffect(() => {
11
12
  const state = useStoryStore.getState();
12
- const vars = structuredClone(state.variables);
13
- const temps = structuredClone(state.temporary);
13
+ const vars = deepClone(state.variables);
14
+ const temps = deepClone(state.temporary);
14
15
 
15
16
  try {
16
17
  execute(rawArgs, vars, temps);
@@ -7,9 +7,7 @@ interface TextareaProps {
7
7
  }
8
8
 
9
9
  function parseArgs(rawArgs: string): { varName: string; placeholder: string } {
10
- const match = rawArgs.match(
11
- /^\s*(["']?\$\w+["']?)\s*(?:["'](.*)["'])?\s*$/,
12
- );
10
+ const match = rawArgs.match(/^\s*(["']?\$\w+["']?)\s*(?:["'](.*)["'])?\s*$/);
13
11
  if (!match) {
14
12
  return { varName: rawArgs.trim(), placeholder: '' };
15
13
  }
@@ -7,9 +7,7 @@ interface TextboxProps {
7
7
  }
8
8
 
9
9
  function parseArgs(rawArgs: string): { varName: string; placeholder: string } {
10
- const match = rawArgs.match(
11
- /^\s*(["']?\$\w+["']?)\s*(?:["'](.*)["'])?\s*$/,
12
- );
10
+ const match = rawArgs.match(/^\s*(["']?\$\w+["']?)\s*(?:["'](.*)["'])?\s*$/);
13
11
  if (!match) {
14
12
  return { varName: rawArgs.trim(), placeholder: '' };
15
13
  }
@@ -44,11 +44,7 @@ export function Type({ rawArgs, children, className, id }: TypeProps) {
44
44
 
45
45
  const done = visibleChars >= totalChars && totalChars > 0;
46
46
 
47
- const cls = [
48
- 'macro-type',
49
- done ? 'macro-type-done' : '',
50
- className || '',
51
- ]
47
+ const cls = ['macro-type', done ? 'macro-type-done' : '', className || '']
52
48
  .filter(Boolean)
53
49
  .join(' ');
54
50
 
@@ -58,10 +54,7 @@ export function Type({ rawArgs, children, className, id }: TypeProps) {
58
54
  class={cls}
59
55
  ref={containerRef}
60
56
  style={{
61
- clipPath:
62
- totalChars > 0 && !done
63
- ? undefined
64
- : undefined,
57
+ clipPath: totalChars > 0 && !done ? undefined : undefined,
65
58
  }}
66
59
  >
67
60
  <span
package/src/index.tsx CHANGED
@@ -93,11 +93,7 @@ function boot() {
93
93
  const widgetTokens = tokenize(passage.content);
94
94
  const widgetAST = buildAST(widgetTokens);
95
95
  for (const node of widgetAST) {
96
- if (
97
- node.type === 'macro' &&
98
- node.name === 'widget' &&
99
- node.rawArgs
100
- ) {
96
+ if (node.type === 'macro' && node.name === 'widget' && node.rawArgs) {
101
97
  const widgetName = node.rawArgs.trim().replace(/["']/g, '');
102
98
  registerWidget(widgetName, node.children as ASTNode[]);
103
99
  }
package/src/markup/ast.ts CHANGED
@@ -89,11 +89,7 @@ export function buildAST(tokens: Token[]): ASTNode[] {
89
89
  if (stack.length === 0) return root;
90
90
  const top = stack[stack.length - 1].node;
91
91
  // For if-blocks, append to the last branch's children
92
- if (
93
- top.type === 'macro' &&
94
- top.branches &&
95
- top.branches.length > 0
96
- ) {
92
+ if (top.type === 'macro' && top.branches && top.branches.length > 0) {
97
93
  return top.branches[top.branches.length - 1].children;
98
94
  }
99
95
  return top.children;
@@ -204,7 +200,8 @@ export function buildAST(tokens: Token[]): ASTNode[] {
204
200
  // Handle branch macros (elseif/else, case/default, next)
205
201
  if (BRANCH_PARENT[token.name]) {
206
202
  const expectedParent = BRANCH_PARENT[token.name];
207
- const topNode = stack.length > 0 ? stack[stack.length - 1].node : null;
203
+ const topNode =
204
+ stack.length > 0 ? stack[stack.length - 1].node : null;
208
205
  if (
209
206
  !topNode ||
210
207
  topNode.type !== 'macro' ||
@@ -1,8 +1,5 @@
1
1
  import { micromark } from 'micromark';
2
- import {
3
- gfmTable,
4
- gfmTableHtml,
5
- } from 'micromark-extension-gfm-table';
2
+ import { gfmTable, gfmTableHtml } from 'micromark-extension-gfm-table';
6
3
  import {
7
4
  gfmStrikethrough,
8
5
  gfmStrikethroughHtml,
@@ -33,6 +33,7 @@ import { Stop } from '../components/macros/Stop';
33
33
  import { Type } from '../components/macros/Type';
34
34
  import { Widget } from '../components/macros/Widget';
35
35
  import { Computed } from '../components/macros/Computed';
36
+ import { Meter } from '../components/macros/Meter';
36
37
  import { getWidget } from '../widgets/widget-registry';
37
38
  import { getMacro } from '../registry';
38
39
  import { markdownToHtml } from './markdown';
@@ -119,6 +120,16 @@ function renderMacro(node: MacroNode, key: number) {
119
120
  />
120
121
  );
121
122
 
123
+ case 'meter':
124
+ return (
125
+ <Meter
126
+ key={key}
127
+ rawArgs={node.rawArgs}
128
+ className={node.className}
129
+ id={node.id}
130
+ />
131
+ );
132
+
122
133
  case 'if':
123
134
  return (
124
135
  <If
@@ -371,9 +382,7 @@ function renderMacro(node: MacroNode, key: number) {
371
382
  );
372
383
 
373
384
  case 'stop':
374
- return (
375
- <Stop key={key} />
376
- );
385
+ return <Stop key={key} />;
377
386
 
378
387
  case 'type':
379
388
  return (
@@ -487,9 +496,7 @@ function renderSingleNode(
487
496
  * Used for inline containers (button labels, link text) where block-level
488
497
  * markdown (lists, headers) would misinterpret content like "-" or "+".
489
498
  */
490
- export function renderInlineNodes(
491
- nodes: ASTNode[],
492
- ): preact.ComponentChildren {
499
+ export function renderInlineNodes(nodes: ASTNode[]): preact.ComponentChildren {
493
500
  if (nodes.length === 0) return null;
494
501
  return nodes.map((node, i) => renderSingleNode(node, i));
495
502
  }