@rohal12/spindle 0.6.0 → 0.8.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/Button.tsx +13 -2
- package/src/components/macros/Computed.tsx +2 -2
- package/src/components/macros/Do.tsx +12 -2
- package/src/components/macros/For.tsx +52 -12
- package/src/components/macros/Goto.tsx +5 -3
- package/src/components/macros/If.tsx +7 -2
- package/src/components/macros/Include.tsx +3 -3
- package/src/components/macros/MacroLink.tsx +20 -5
- package/src/components/macros/Meter.tsx +7 -3
- package/src/components/macros/PassageDisplay.tsx +33 -0
- package/src/components/macros/Print.tsx +2 -2
- package/src/components/macros/Set.tsx +16 -3
- package/src/components/macros/Switch.tsx +8 -3
- package/src/components/macros/VarDisplay.tsx +14 -6
- package/src/components/macros/Widget.tsx +12 -2
- package/src/components/macros/WidgetInvocation.tsx +127 -0
- package/src/expression.ts +10 -4
- package/src/hooks/use-merged-locals.ts +12 -10
- package/src/index.tsx +18 -2
- package/src/markup/ast.ts +1 -1
- package/src/markup/render.tsx +32 -4
- package/src/markup/tokenizer.ts +55 -1
- package/src/story-variables.ts +5 -5
- package/src/widgets/widget-registry.ts +13 -4
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { useContext, useState, useCallback } from 'preact/hooks';
|
|
2
|
+
import { LocalsContext, renderNodes } from '../../markup/render';
|
|
3
|
+
import type { LocalsScope } from '../../markup/render';
|
|
4
|
+
import { useMergedLocals } from '../../hooks/use-merged-locals';
|
|
5
|
+
import { evaluate } from '../../expression';
|
|
6
|
+
import type { ASTNode } from '../../markup/ast';
|
|
7
|
+
|
|
8
|
+
interface WidgetInvocationProps {
|
|
9
|
+
body: ASTNode[];
|
|
10
|
+
params: string[];
|
|
11
|
+
rawArgs?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Split rawArgs by commas, respecting parentheses, brackets, braces, and strings.
|
|
16
|
+
*/
|
|
17
|
+
function splitArgs(raw: string): string[] {
|
|
18
|
+
const args: string[] = [];
|
|
19
|
+
let current = '';
|
|
20
|
+
let depth = 0;
|
|
21
|
+
let inString: string | null = null;
|
|
22
|
+
|
|
23
|
+
for (let i = 0; i < raw.length; i++) {
|
|
24
|
+
const ch = raw[i]!;
|
|
25
|
+
|
|
26
|
+
if (inString) {
|
|
27
|
+
current += ch;
|
|
28
|
+
if (ch === inString && raw[i - 1] !== '\\') inString = null;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (ch === '"' || ch === "'" || ch === '`') {
|
|
33
|
+
inString = ch;
|
|
34
|
+
current += ch;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (ch === '(' || ch === '[' || ch === '{') {
|
|
39
|
+
depth++;
|
|
40
|
+
current += ch;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (ch === ')' || ch === ']' || ch === '}') {
|
|
45
|
+
depth--;
|
|
46
|
+
current += ch;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (ch === ',' && depth === 0) {
|
|
51
|
+
args.push(current.trim());
|
|
52
|
+
current = '';
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
current += ch;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const last = current.trim();
|
|
60
|
+
if (last) args.push(last);
|
|
61
|
+
return args;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function WidgetBody({
|
|
65
|
+
body,
|
|
66
|
+
parentValues,
|
|
67
|
+
ownKeys,
|
|
68
|
+
}: {
|
|
69
|
+
body: ASTNode[];
|
|
70
|
+
parentValues: Record<string, unknown>;
|
|
71
|
+
ownKeys: Record<string, unknown>;
|
|
72
|
+
}) {
|
|
73
|
+
const [localState, setLocalState] = useState<Record<string, unknown>>(() => ({
|
|
74
|
+
...parentValues,
|
|
75
|
+
...ownKeys,
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
const update = useCallback((key: string, value: unknown) => {
|
|
79
|
+
setLocalState((prev) => ({ ...prev, [key]: value }));
|
|
80
|
+
}, []);
|
|
81
|
+
|
|
82
|
+
const scope: LocalsScope = { values: localState, update };
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<LocalsContext.Provider value={scope}>
|
|
86
|
+
{renderNodes(body)}
|
|
87
|
+
</LocalsContext.Provider>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function WidgetInvocation({
|
|
92
|
+
body,
|
|
93
|
+
params,
|
|
94
|
+
rawArgs,
|
|
95
|
+
}: WidgetInvocationProps) {
|
|
96
|
+
const parentScope = useContext(LocalsContext);
|
|
97
|
+
const [mergedVars, mergedTemps, mergedLocals] = useMergedLocals();
|
|
98
|
+
|
|
99
|
+
if (params.length === 0 || !rawArgs) {
|
|
100
|
+
return <>{renderNodes(body)}</>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const argExprs = splitArgs(rawArgs);
|
|
104
|
+
const ownKeys: Record<string, unknown> = {};
|
|
105
|
+
|
|
106
|
+
for (let i = 0; i < params.length; i++) {
|
|
107
|
+
const param = params[i]!;
|
|
108
|
+
const expr = argExprs[i];
|
|
109
|
+
let value: unknown;
|
|
110
|
+
if (expr !== undefined) {
|
|
111
|
+
try {
|
|
112
|
+
value = evaluate(expr, mergedVars, mergedTemps, mergedLocals);
|
|
113
|
+
} catch {
|
|
114
|
+
value = undefined;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
ownKeys[param] = value;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<WidgetBody
|
|
122
|
+
body={body}
|
|
123
|
+
parentValues={parentScope.values}
|
|
124
|
+
ownKeys={ownKeys}
|
|
125
|
+
/>
|
|
126
|
+
);
|
|
127
|
+
}
|
package/src/expression.ts
CHANGED
|
@@ -21,6 +21,7 @@ interface ExpressionFns {
|
|
|
21
21
|
type CompiledExpression = (
|
|
22
22
|
variables: Record<string, unknown>,
|
|
23
23
|
temporary: Record<string, unknown>,
|
|
24
|
+
locals: Record<string, unknown>,
|
|
24
25
|
__fns: ExpressionFns,
|
|
25
26
|
) => unknown;
|
|
26
27
|
|
|
@@ -34,11 +35,13 @@ const fnCache = new Map<string, CompiledExpression>();
|
|
|
34
35
|
*/
|
|
35
36
|
const VAR_RE = /\$(\w+)/g;
|
|
36
37
|
const TEMP_RE = /\b_(\w+)/g;
|
|
38
|
+
const LOCAL_RE = /@(\w+)/g;
|
|
37
39
|
|
|
38
40
|
function transform(expr: string): string {
|
|
39
41
|
return expr
|
|
40
42
|
.replace(VAR_RE, 'variables["$1"]')
|
|
41
|
-
.replace(TEMP_RE, 'temporary["$1"]')
|
|
43
|
+
.replace(TEMP_RE, 'temporary["$1"]')
|
|
44
|
+
.replace(LOCAL_RE, 'locals["$1"]');
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
const preamble =
|
|
@@ -55,6 +58,7 @@ function getOrCompile(key: string, body: string): CompiledExpression {
|
|
|
55
58
|
const fn = new Function(
|
|
56
59
|
'variables',
|
|
57
60
|
'temporary',
|
|
61
|
+
'locals',
|
|
58
62
|
'__fns',
|
|
59
63
|
preamble + body,
|
|
60
64
|
) as CompiledExpression;
|
|
@@ -136,11 +140,12 @@ export function evaluate(
|
|
|
136
140
|
expr: string,
|
|
137
141
|
variables: Record<string, unknown>,
|
|
138
142
|
temporary: Record<string, unknown>,
|
|
143
|
+
locals: Record<string, unknown> = {},
|
|
139
144
|
): unknown {
|
|
140
145
|
const transformed = transform(expr);
|
|
141
146
|
const body = `return (${transformed});`;
|
|
142
147
|
const fn = getOrCompile(body, body);
|
|
143
|
-
return fn(variables, temporary, buildExpressionFns());
|
|
148
|
+
return fn(variables, temporary, locals, buildExpressionFns());
|
|
144
149
|
}
|
|
145
150
|
|
|
146
151
|
/**
|
|
@@ -151,15 +156,16 @@ export function execute(
|
|
|
151
156
|
code: string,
|
|
152
157
|
variables: Record<string, unknown>,
|
|
153
158
|
temporary: Record<string, unknown>,
|
|
159
|
+
locals: Record<string, unknown> = {},
|
|
154
160
|
): void {
|
|
155
161
|
const transformed = transform(code);
|
|
156
162
|
const fn = getOrCompile('exec:' + transformed, transformed);
|
|
157
|
-
fn(variables, temporary, buildExpressionFns());
|
|
163
|
+
fn(variables, temporary, locals, buildExpressionFns());
|
|
158
164
|
}
|
|
159
165
|
|
|
160
166
|
/**
|
|
161
167
|
* Convenience: evaluate using store state directly.
|
|
162
168
|
*/
|
|
163
169
|
export function evaluateWithState(expr: string, state: StoryState): unknown {
|
|
164
|
-
return evaluate(expr, state.variables, state.temporary);
|
|
170
|
+
return evaluate(expr, state.variables, state.temporary, {});
|
|
165
171
|
}
|
|
@@ -3,24 +3,26 @@ import { useStoryStore } from '../store';
|
|
|
3
3
|
import { LocalsContext } from '../markup/render';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
* Locals
|
|
6
|
+
* Return store variables, temporary, and @-prefixed locals from context.
|
|
7
|
+
* Locals use `@` prefix keys internally; the returned locals dict has
|
|
8
|
+
* the prefix stripped so it can be passed directly to evaluate/execute.
|
|
8
9
|
*/
|
|
9
10
|
export function useMergedLocals(): readonly [
|
|
10
11
|
Record<string, unknown>,
|
|
11
12
|
Record<string, unknown>,
|
|
13
|
+
Record<string, unknown>,
|
|
12
14
|
] {
|
|
13
15
|
const variables = useStoryStore((s) => s.variables);
|
|
14
16
|
const temporary = useStoryStore((s) => s.temporary);
|
|
15
|
-
const
|
|
17
|
+
const scope = useContext(LocalsContext);
|
|
16
18
|
|
|
17
19
|
return useMemo(() => {
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
const locals: Record<string, unknown> = {};
|
|
21
|
+
for (const [key, val] of Object.entries(scope.values)) {
|
|
22
|
+
if (key.startsWith('@')) {
|
|
23
|
+
locals[key.slice(1)] = val;
|
|
24
|
+
}
|
|
23
25
|
}
|
|
24
|
-
return [
|
|
25
|
-
}, [variables, temporary,
|
|
26
|
+
return [variables, temporary, locals] as const;
|
|
27
|
+
}, [variables, temporary, scope.values]);
|
|
26
28
|
}
|
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/ast.ts
CHANGED
package/src/markup/render.tsx
CHANGED
|
@@ -32,15 +32,27 @@ 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';
|
|
40
42
|
import { h } from 'preact';
|
|
41
43
|
import type { ASTNode, Branch, MacroNode } from './ast';
|
|
42
44
|
|
|
43
|
-
export
|
|
45
|
+
export interface LocalsScope {
|
|
46
|
+
values: Record<string, unknown>;
|
|
47
|
+
update: (key: string, value: unknown) => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const defaultLocalsScope: LocalsScope = {
|
|
51
|
+
values: {},
|
|
52
|
+
update: () => {},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const LocalsContext = createContext<LocalsScope>(defaultLocalsScope);
|
|
44
56
|
|
|
45
57
|
const EMPTY_BRANCHES: Branch[] = [];
|
|
46
58
|
|
|
@@ -132,6 +144,15 @@ function renderMacro(node: MacroNode, key: number) {
|
|
|
132
144
|
/>
|
|
133
145
|
);
|
|
134
146
|
|
|
147
|
+
case 'passage':
|
|
148
|
+
return (
|
|
149
|
+
<PassageDisplay
|
|
150
|
+
key={key}
|
|
151
|
+
className={node.className}
|
|
152
|
+
id={node.id}
|
|
153
|
+
/>
|
|
154
|
+
);
|
|
155
|
+
|
|
135
156
|
case 'if':
|
|
136
157
|
return (
|
|
137
158
|
<If
|
|
@@ -415,9 +436,16 @@ function renderMacro(node: MacroNode, key: number) {
|
|
|
415
436
|
|
|
416
437
|
default: {
|
|
417
438
|
// Check widget registry for user-defined widgets
|
|
418
|
-
const
|
|
419
|
-
if (
|
|
420
|
-
return
|
|
439
|
+
const widget = getWidget(node.name);
|
|
440
|
+
if (widget) {
|
|
441
|
+
return (
|
|
442
|
+
<WidgetInvocation
|
|
443
|
+
key={key}
|
|
444
|
+
body={widget.body}
|
|
445
|
+
params={widget.params}
|
|
446
|
+
rawArgs={node.rawArgs}
|
|
447
|
+
/>
|
|
448
|
+
);
|
|
421
449
|
}
|
|
422
450
|
|
|
423
451
|
// Check component registry for custom macros
|
package/src/markup/tokenizer.ts
CHANGED
|
@@ -29,7 +29,7 @@ export interface MacroToken {
|
|
|
29
29
|
export interface VariableToken {
|
|
30
30
|
type: 'variable';
|
|
31
31
|
name: string;
|
|
32
|
-
scope: 'variable' | 'temporary';
|
|
32
|
+
scope: 'variable' | 'temporary' | 'local';
|
|
33
33
|
className?: string;
|
|
34
34
|
id?: string;
|
|
35
35
|
start: number;
|
|
@@ -419,6 +419,34 @@ export function tokenize(input: string): Token[] {
|
|
|
419
419
|
continue;
|
|
420
420
|
}
|
|
421
421
|
|
|
422
|
+
if (charAfter === '@') {
|
|
423
|
+
// {.class#id @local.field}
|
|
424
|
+
i = afterSelectors + 1;
|
|
425
|
+
const nameStart = i;
|
|
426
|
+
while (i < input.length && /[\w.]/.test(input[i]!)) i++;
|
|
427
|
+
const name = input.slice(nameStart, i);
|
|
428
|
+
|
|
429
|
+
if (input[i] === '}') {
|
|
430
|
+
i++; // skip }
|
|
431
|
+
const token: VariableToken = {
|
|
432
|
+
type: 'variable',
|
|
433
|
+
name,
|
|
434
|
+
scope: 'local',
|
|
435
|
+
start,
|
|
436
|
+
end: i,
|
|
437
|
+
};
|
|
438
|
+
if (className) token.className = className;
|
|
439
|
+
if (id) token.id = id;
|
|
440
|
+
tokens.push(token);
|
|
441
|
+
textStart = i;
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
// Not valid — treat as text
|
|
445
|
+
i = start + 1;
|
|
446
|
+
textStart = start;
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
|
|
422
450
|
if (charAfter !== undefined && /[a-zA-Z]/.test(charAfter)) {
|
|
423
451
|
// {.class#id macroName args}
|
|
424
452
|
i = afterSelectors;
|
|
@@ -515,6 +543,32 @@ export function tokenize(input: string): Token[] {
|
|
|
515
543
|
continue;
|
|
516
544
|
}
|
|
517
545
|
|
|
546
|
+
// {@local.field}
|
|
547
|
+
if (nextChar === '@') {
|
|
548
|
+
flushText(i);
|
|
549
|
+
i += 2;
|
|
550
|
+
const nameStart = i;
|
|
551
|
+
while (i < input.length && /[\w.]/.test(input[i]!)) i++;
|
|
552
|
+
const name = input.slice(nameStart, i);
|
|
553
|
+
|
|
554
|
+
if (input[i] === '}') {
|
|
555
|
+
i++; // skip }
|
|
556
|
+
tokens.push({
|
|
557
|
+
type: 'variable',
|
|
558
|
+
name,
|
|
559
|
+
scope: 'local',
|
|
560
|
+
start,
|
|
561
|
+
end: i,
|
|
562
|
+
});
|
|
563
|
+
textStart = i;
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
// Not a valid local token — treat as text
|
|
567
|
+
i = start + 1;
|
|
568
|
+
textStart = start;
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
|
|
518
572
|
// {macro ...} or {/macro} — but not bare { that's just text
|
|
519
573
|
// Must start with a letter or /
|
|
520
574
|
if (
|
package/src/story-variables.ts
CHANGED
|
@@ -14,7 +14,7 @@ export interface VariableSchema extends FieldSchema {
|
|
|
14
14
|
|
|
15
15
|
const DECLARATION_RE = /^\$(\w+)\s*=\s*(.+)$/;
|
|
16
16
|
const VAR_REF_RE = /\$(\w+(?:\.\w+)*)/g;
|
|
17
|
-
const FOR_LOCAL_RE = /\{for\s
|
|
17
|
+
const FOR_LOCAL_RE = /\{for\s+@(\w+)(?:\s*,\s*@(\w+))?\s+of\b/g;
|
|
18
18
|
|
|
19
19
|
const VALID_VAR_TYPES = new Set<string>(['number', 'string', 'boolean']);
|
|
20
20
|
|
|
@@ -77,16 +77,16 @@ export function parseStoryVariables(
|
|
|
77
77
|
|
|
78
78
|
/**
|
|
79
79
|
* Extract for-loop local variable names from passage content.
|
|
80
|
-
* `{for
|
|
81
|
-
* `{for
|
|
80
|
+
* `{for @item of ...}` → "item"
|
|
81
|
+
* `{for @index, @item of ...}` → "index", "item"
|
|
82
82
|
*/
|
|
83
83
|
function extractForLocals(content: string): Set<string> {
|
|
84
84
|
const locals = new Set<string>();
|
|
85
85
|
let match: RegExpExecArray | null;
|
|
86
86
|
FOR_LOCAL_RE.lastIndex = 0;
|
|
87
87
|
while ((match = FOR_LOCAL_RE.exec(content)) !== null) {
|
|
88
|
-
locals.add(match[1]
|
|
89
|
-
if (match[2]) locals.add(match[2]
|
|
88
|
+
locals.add(match[1]!);
|
|
89
|
+
if (match[2]) locals.add(match[2]!);
|
|
90
90
|
}
|
|
91
91
|
return locals;
|
|
92
92
|
}
|
|
@@ -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
|
|