@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/dist/pkg/format.js +1 -1
- package/package.json +1 -1
- package/src/components/App.tsx +2 -27
- package/src/components/StoryInterface.tsx +12 -17
- package/src/components/macros/PassageDisplay.tsx +33 -0
- package/src/components/macros/Widget.tsx +14 -2
- package/src/components/macros/WidgetInvocation.tsx +97 -0
- package/src/index.tsx +18 -2
- package/src/markup/render.tsx +21 -3
- package/src/widgets/widget-registry.ts +13 -4
package/package.json
CHANGED
package/src/components/App.tsx
CHANGED
|
@@ -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
|
-
|
|
29
|
-
if (!passage) {
|
|
30
|
-
return (
|
|
31
|
-
<div class="error">
|
|
32
|
-
Error: Passage “{currentPassage}” 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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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 “{currentPassage}” 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
|
|
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
|
|
99
|
-
|
|
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.');
|
package/src/markup/render.tsx
CHANGED
|
@@ -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
|
|
419
|
-
if (
|
|
420
|
-
return
|
|
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
|
-
|
|
3
|
+
interface WidgetEntry {
|
|
4
|
+
body: ASTNode[];
|
|
5
|
+
params: string[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const widgets = new Map<string, WidgetEntry>();
|
|
4
9
|
|
|
5
|
-
export function registerWidget(
|
|
6
|
-
|
|
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):
|
|
18
|
+
export function getWidget(name: string): WidgetEntry | undefined {
|
|
10
19
|
return widgets.get(name.toLowerCase());
|
|
11
20
|
}
|
|
12
21
|
|