@rohal12/spindle 0.6.0 → 0.7.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.6.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -1,11 +1,10 @@
1
1
  import { useEffect } from 'preact/hooks';
2
2
  import { useStoryStore } from '../store';
3
- import { Passage } from './Passage';
4
3
  import { StoryInterface } from './StoryInterface';
5
4
 
6
5
  export function App() {
7
- const currentPassage = useStoryStore((s) => s.currentPassage);
8
6
  const storyData = useStoryStore((s) => s.storyData);
7
+ const currentPassage = useStoryStore((s) => s.currentPassage);
9
8
 
10
9
  useEffect(() => {
11
10
  const onKeyDown = (e: KeyboardEvent) => {
@@ -25,29 +24,5 @@ export function App() {
25
24
  return <div class="loading">Loading...</div>;
26
25
  }
27
26
 
28
- const passage = storyData.passages.get(currentPassage);
29
- if (!passage) {
30
- return (
31
- <div class="error">
32
- Error: Passage &ldquo;{currentPassage}&rdquo; not found.
33
- </div>
34
- );
35
- }
36
-
37
- return (
38
- <>
39
- <header class="story-menubar">
40
- <StoryInterface />
41
- </header>
42
- <div
43
- id="story"
44
- class="story"
45
- >
46
- <Passage
47
- passage={passage}
48
- key={currentPassage}
49
- />
50
- </div>
51
- </>
52
- );
27
+ return <StoryInterface />;
53
28
  }
@@ -1,11 +1,10 @@
1
- import { useMemo } from 'preact/hooks';
2
1
  import { useStoryStore } from '../store';
3
2
  import { tokenize } from '../markup/tokenizer';
4
3
  import { buildAST } from '../markup/ast';
5
4
  import { renderNodes } from '../markup/render';
6
5
 
7
6
  const DEFAULT_MARKUP =
8
- '{story-title}{back}{forward}{restart}{quicksave}{quickload}{saves}{settings}';
7
+ '<header class="story-menubar">{story-title}{back}{forward}{restart}{quicksave}{quickload}{saves}{settings}</header>\n{passage}';
9
8
 
10
9
  export function StoryInterface() {
11
10
  const storyData = useStoryStore((s) => s.storyData);
@@ -14,19 +13,15 @@ export function StoryInterface() {
14
13
  const markup =
15
14
  overridePassage !== undefined ? overridePassage.content : DEFAULT_MARKUP;
16
15
 
17
- const content = useMemo(() => {
18
- try {
19
- const tokens = tokenize(markup);
20
- const ast = buildAST(tokens);
21
- return renderNodes(ast);
22
- } catch (err) {
23
- return (
24
- <span class="error">
25
- Error in StoryInterface: {(err as Error).message}
26
- </span>
27
- );
28
- }
29
- }, [markup]);
30
-
31
- return <>{content}</>;
16
+ try {
17
+ const tokens = tokenize(markup);
18
+ const ast = buildAST(tokens);
19
+ return <>{renderNodes(ast)}</>;
20
+ } catch (err) {
21
+ return (
22
+ <span class="error">
23
+ Error in StoryInterface: {(err as Error).message}
24
+ </span>
25
+ );
26
+ }
32
27
  }
@@ -0,0 +1,33 @@
1
+ import { useStoryStore } from '../../store';
2
+ import { Passage } from '../Passage';
3
+
4
+ interface PassageDisplayProps {
5
+ className?: string;
6
+ id?: string;
7
+ }
8
+
9
+ export function PassageDisplay({ className, id }: PassageDisplayProps) {
10
+ const currentPassage = useStoryStore((s) => s.currentPassage);
11
+ const storyData = useStoryStore((s) => s.storyData);
12
+
13
+ const passage = storyData?.passages.get(currentPassage);
14
+ if (!passage) {
15
+ return (
16
+ <div class="error">
17
+ Error: Passage &ldquo;{currentPassage}&rdquo; not found.
18
+ </div>
19
+ );
20
+ }
21
+
22
+ return (
23
+ <div
24
+ id={id ?? 'story'}
25
+ class={className ?? 'story'}
26
+ >
27
+ <Passage
28
+ passage={passage}
29
+ key={currentPassage}
30
+ />
31
+ </div>
32
+ );
33
+ }
@@ -7,11 +7,23 @@ interface WidgetProps {
7
7
  children: ASTNode[];
8
8
  }
9
9
 
10
+ /**
11
+ * Parse widget definition args: "WidgetName" or "WidgetName" $param1 $param2
12
+ */
13
+ function parseWidgetDef(rawArgs: string): { name: string; params: string[] } {
14
+ const tokens = rawArgs.trim().split(/\s+/);
15
+ const name = tokens[0]!.replace(/["']/g, '');
16
+ const params = tokens
17
+ .slice(1)
18
+ .filter((t) => t.startsWith('$') || t.startsWith('_'));
19
+ return { name, params };
20
+ }
21
+
10
22
  export function Widget({ rawArgs, children }: WidgetProps) {
11
- const name = rawArgs.trim().replace(/["']/g, '');
23
+ const { name, params } = parseWidgetDef(rawArgs);
12
24
 
13
25
  useLayoutEffect(() => {
14
- registerWidget(name, children);
26
+ registerWidget(name, children, params);
15
27
  }, []);
16
28
 
17
29
  return null;
@@ -0,0 +1,97 @@
1
+ import { useContext } from 'preact/hooks';
2
+ import { LocalsContext, renderNodes } from '../../markup/render';
3
+ import { useMergedLocals } from '../../hooks/use-merged-locals';
4
+ import { evaluate } from '../../expression';
5
+ import type { ASTNode } from '../../markup/ast';
6
+
7
+ interface WidgetInvocationProps {
8
+ body: ASTNode[];
9
+ params: string[];
10
+ rawArgs?: string;
11
+ }
12
+
13
+ /**
14
+ * Split rawArgs by commas, respecting parentheses, brackets, braces, and strings.
15
+ */
16
+ function splitArgs(raw: string): string[] {
17
+ const args: string[] = [];
18
+ let current = '';
19
+ let depth = 0;
20
+ let inString: string | null = null;
21
+
22
+ for (let i = 0; i < raw.length; i++) {
23
+ const ch = raw[i]!;
24
+
25
+ if (inString) {
26
+ current += ch;
27
+ if (ch === inString && raw[i - 1] !== '\\') inString = null;
28
+ continue;
29
+ }
30
+
31
+ if (ch === '"' || ch === "'" || ch === '`') {
32
+ inString = ch;
33
+ current += ch;
34
+ continue;
35
+ }
36
+
37
+ if (ch === '(' || ch === '[' || ch === '{') {
38
+ depth++;
39
+ current += ch;
40
+ continue;
41
+ }
42
+
43
+ if (ch === ')' || ch === ']' || ch === '}') {
44
+ depth--;
45
+ current += ch;
46
+ continue;
47
+ }
48
+
49
+ if (ch === ',' && depth === 0) {
50
+ args.push(current.trim());
51
+ current = '';
52
+ continue;
53
+ }
54
+
55
+ current += ch;
56
+ }
57
+
58
+ const last = current.trim();
59
+ if (last) args.push(last);
60
+ return args;
61
+ }
62
+
63
+ export function WidgetInvocation({
64
+ body,
65
+ params,
66
+ rawArgs,
67
+ }: WidgetInvocationProps) {
68
+ const parentLocals = useContext(LocalsContext);
69
+ const [mergedVars, mergedTemps] = useMergedLocals();
70
+
71
+ if (params.length === 0 || !rawArgs) {
72
+ return <>{renderNodes(body)}</>;
73
+ }
74
+
75
+ const argExprs = splitArgs(rawArgs);
76
+ const locals: Record<string, unknown> = { ...parentLocals };
77
+
78
+ for (let i = 0; i < params.length; i++) {
79
+ const param = params[i]!;
80
+ const expr = argExprs[i];
81
+ let value: unknown;
82
+ if (expr !== undefined) {
83
+ try {
84
+ value = evaluate(expr, mergedVars, mergedTemps);
85
+ } catch {
86
+ value = undefined;
87
+ }
88
+ }
89
+ locals[param] = value;
90
+ }
91
+
92
+ return (
93
+ <LocalsContext.Provider value={locals}>
94
+ {renderNodes(body)}
95
+ </LocalsContext.Provider>
96
+ );
97
+ }
package/src/index.tsx CHANGED
@@ -95,8 +95,12 @@ function boot() {
95
95
  const widgetAST = buildAST(widgetTokens);
96
96
  for (const node of widgetAST) {
97
97
  if (node.type === 'macro' && node.name === 'widget' && node.rawArgs) {
98
- const widgetName = node.rawArgs.trim().replace(/["']/g, '');
99
- registerWidget(widgetName, node.children as ASTNode[]);
98
+ const tokens2 = node.rawArgs.trim().split(/\s+/);
99
+ const widgetName = tokens2[0]!.replace(/["']/g, '');
100
+ const params = tokens2
101
+ .slice(1)
102
+ .filter((t) => t.startsWith('$') || t.startsWith('_'));
103
+ registerWidget(widgetName, node.children as ASTNode[], params);
100
104
  }
101
105
  }
102
106
  }
@@ -111,6 +115,18 @@ function boot() {
111
115
  }
112
116
  });
113
117
 
118
+ // Warn if StoryInterface passage exists but doesn't contain {passage}
119
+ const storyInterfacePassage = storyData.passages.get('StoryInterface');
120
+ if (
121
+ storyInterfacePassage &&
122
+ !storyInterfacePassage.content.includes('{passage}')
123
+ ) {
124
+ console.warn(
125
+ 'spindle: StoryInterface passage does not contain {passage}. ' +
126
+ 'The current passage will not be displayed.',
127
+ );
128
+ }
129
+
114
130
  const root = document.getElementById('root');
115
131
  if (!root) {
116
132
  throw new Error('spindle: No <div id="root"> element found.');
@@ -32,8 +32,10 @@ import { Repeat } from '../components/macros/Repeat';
32
32
  import { Stop } from '../components/macros/Stop';
33
33
  import { Type } from '../components/macros/Type';
34
34
  import { Widget } from '../components/macros/Widget';
35
+ import { WidgetInvocation } from '../components/macros/WidgetInvocation';
35
36
  import { Computed } from '../components/macros/Computed';
36
37
  import { Meter } from '../components/macros/Meter';
38
+ import { PassageDisplay } from '../components/macros/PassageDisplay';
37
39
  import { getWidget } from '../widgets/widget-registry';
38
40
  import { getMacro } from '../registry';
39
41
  import { markdownToHtml } from './markdown';
@@ -132,6 +134,15 @@ function renderMacro(node: MacroNode, key: number) {
132
134
  />
133
135
  );
134
136
 
137
+ case 'passage':
138
+ return (
139
+ <PassageDisplay
140
+ key={key}
141
+ className={node.className}
142
+ id={node.id}
143
+ />
144
+ );
145
+
135
146
  case 'if':
136
147
  return (
137
148
  <If
@@ -415,9 +426,16 @@ function renderMacro(node: MacroNode, key: number) {
415
426
 
416
427
  default: {
417
428
  // Check widget registry for user-defined widgets
418
- const widgetAST = getWidget(node.name);
419
- if (widgetAST) {
420
- return <>{renderNodes(widgetAST)}</>;
429
+ const widget = getWidget(node.name);
430
+ if (widget) {
431
+ return (
432
+ <WidgetInvocation
433
+ key={key}
434
+ body={widget.body}
435
+ params={widget.params}
436
+ rawArgs={node.rawArgs}
437
+ />
438
+ );
421
439
  }
422
440
 
423
441
  // Check component registry for custom macros
@@ -1,12 +1,21 @@
1
1
  import type { ASTNode } from '../markup/ast';
2
2
 
3
- const widgets = new Map<string, ASTNode[]>();
3
+ interface WidgetEntry {
4
+ body: ASTNode[];
5
+ params: string[];
6
+ }
7
+
8
+ const widgets = new Map<string, WidgetEntry>();
4
9
 
5
- export function registerWidget(name: string, bodyAST: ASTNode[]): void {
6
- widgets.set(name.toLowerCase(), bodyAST);
10
+ export function registerWidget(
11
+ name: string,
12
+ bodyAST: ASTNode[],
13
+ params: string[],
14
+ ): void {
15
+ widgets.set(name.toLowerCase(), { body: bodyAST, params });
7
16
  }
8
17
 
9
- export function getWidget(name: string): ASTNode[] | undefined {
18
+ export function getWidget(name: string): WidgetEntry | undefined {
10
19
  return widgets.get(name.toLowerCase());
11
20
  }
12
21