@rohal12/spindle 0.38.0 → 0.38.1
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/macros/ExprDisplay.tsx +39 -0
- package/src/interpolation.ts +97 -19
- package/src/markup/ast.ts +24 -1
- package/src/markup/render.tsx +11 -0
- package/src/markup/tokenizer.ts +129 -12
package/package.json
CHANGED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useContext } from 'preact/hooks';
|
|
2
|
+
import { useStoryStore } from '../../store';
|
|
3
|
+
import { evaluate } from '../../expression';
|
|
4
|
+
import { LocalsValuesContext } from '../../markup/render';
|
|
5
|
+
import { useInterpolate } from '../../hooks/use-interpolate';
|
|
6
|
+
|
|
7
|
+
interface ExprDisplayProps {
|
|
8
|
+
expression: string;
|
|
9
|
+
className?: string;
|
|
10
|
+
id?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ExprDisplay({ expression, className, id }: ExprDisplayProps) {
|
|
14
|
+
const resolve = useInterpolate();
|
|
15
|
+
className = resolve(className);
|
|
16
|
+
id = resolve(id);
|
|
17
|
+
const localsValues = useContext(LocalsValuesContext);
|
|
18
|
+
const variables = useStoryStore((s) => s.variables);
|
|
19
|
+
const temporary = useStoryStore((s) => s.temporary);
|
|
20
|
+
|
|
21
|
+
let display: string;
|
|
22
|
+
try {
|
|
23
|
+
const value = evaluate(expression, variables, temporary, localsValues);
|
|
24
|
+
display = value == null ? '' : String(value);
|
|
25
|
+
} catch {
|
|
26
|
+
display = `{error: ${expression}}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (className || id)
|
|
30
|
+
return (
|
|
31
|
+
<span
|
|
32
|
+
id={id}
|
|
33
|
+
class={className}
|
|
34
|
+
>
|
|
35
|
+
{display}
|
|
36
|
+
</span>
|
|
37
|
+
);
|
|
38
|
+
return <>{display}</>;
|
|
39
|
+
}
|
package/src/interpolation.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { evaluate } from './expression';
|
|
2
|
+
|
|
3
|
+
/** Detects any {…} block that starts with a sigil ($, _, @). */
|
|
4
|
+
const INTERP_TEST = /\{[\$_@]\w/;
|
|
3
5
|
|
|
4
6
|
export function hasInterpolation(s: string): boolean {
|
|
5
7
|
return INTERP_TEST.test(s);
|
|
@@ -14,31 +16,107 @@ function resolveDotPath(root: unknown, parts: string[]): unknown {
|
|
|
14
16
|
return value;
|
|
15
17
|
}
|
|
16
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Evaluate an expression string and return its stringified result.
|
|
21
|
+
* Uses the full expression evaluator from expression.ts.
|
|
22
|
+
*/
|
|
23
|
+
export function interpolateExpression(
|
|
24
|
+
expr: string,
|
|
25
|
+
variables: Record<string, unknown>,
|
|
26
|
+
temporary: Record<string, unknown>,
|
|
27
|
+
locals: Record<string, unknown>,
|
|
28
|
+
): string {
|
|
29
|
+
const value = evaluate(expr, variables, temporary, locals);
|
|
30
|
+
return value == null ? '' : String(value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resolveSimple(
|
|
34
|
+
ref: string,
|
|
35
|
+
variables: Record<string, unknown>,
|
|
36
|
+
temporary: Record<string, unknown>,
|
|
37
|
+
locals: Record<string, unknown>,
|
|
38
|
+
): string {
|
|
39
|
+
const prefix = ref[0]!;
|
|
40
|
+
const path = ref.slice(1);
|
|
41
|
+
const parts = path.split('.');
|
|
42
|
+
const root = parts[0]!;
|
|
43
|
+
|
|
44
|
+
let value: unknown;
|
|
45
|
+
if (prefix === '$') {
|
|
46
|
+
value = variables[root];
|
|
47
|
+
} else if (prefix === '_') {
|
|
48
|
+
value = temporary[root];
|
|
49
|
+
} else {
|
|
50
|
+
value = locals[root];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (parts.length > 1) {
|
|
54
|
+
value = resolveDotPath(value, parts);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return value == null ? '' : String(value);
|
|
58
|
+
}
|
|
59
|
+
|
|
17
60
|
export function interpolate(
|
|
18
61
|
template: string,
|
|
19
62
|
variables: Record<string, unknown>,
|
|
20
63
|
temporary: Record<string, unknown>,
|
|
21
64
|
locals: Record<string, unknown>,
|
|
22
65
|
): string {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
66
|
+
// Manual scan: process {…} blocks containing sigils.
|
|
67
|
+
// Simple dot-path refs use the fast resolver; everything else falls back
|
|
68
|
+
// to the full expression evaluator.
|
|
69
|
+
let result = '';
|
|
70
|
+
let i = 0;
|
|
71
|
+
|
|
72
|
+
while (i < template.length) {
|
|
73
|
+
if (template[i] !== '{') {
|
|
74
|
+
// Accumulate plain text
|
|
75
|
+
const nextBrace = template.indexOf('{', i);
|
|
76
|
+
if (nextBrace === -1) {
|
|
77
|
+
result += template.slice(i);
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
result += template.slice(i, nextBrace);
|
|
81
|
+
i = nextBrace;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Found { — check if it starts a sigil expression
|
|
86
|
+
i++; // skip {
|
|
87
|
+
|
|
88
|
+
const sigil = template[i];
|
|
89
|
+
if (sigil !== '$' && sigil !== '_' && sigil !== '@') {
|
|
90
|
+
// Not an interpolation — emit the { as text
|
|
91
|
+
result += '{';
|
|
92
|
+
continue;
|
|
36
93
|
}
|
|
37
94
|
|
|
38
|
-
|
|
39
|
-
|
|
95
|
+
// Scan for balanced closing }
|
|
96
|
+
let depth = 1;
|
|
97
|
+
let j = i;
|
|
98
|
+
while (j < template.length && depth > 0) {
|
|
99
|
+
j++;
|
|
100
|
+
if (template[j] === '{') depth++;
|
|
101
|
+
else if (template[j] === '}') depth--;
|
|
40
102
|
}
|
|
41
103
|
|
|
42
|
-
|
|
43
|
-
|
|
104
|
+
if (depth !== 0) {
|
|
105
|
+
// Unbalanced — emit as text
|
|
106
|
+
result += '{';
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const inner = template.slice(i, j);
|
|
111
|
+
i = j + 1; // skip past closing }
|
|
112
|
+
|
|
113
|
+
// Try simple dot-path match first
|
|
114
|
+
if (/^[\$_@][\w.]+$/.test(inner)) {
|
|
115
|
+
result += resolveSimple(inner, variables, temporary, locals);
|
|
116
|
+
} else {
|
|
117
|
+
result += interpolateExpression(inner, variables, temporary, locals);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return result;
|
|
44
122
|
}
|
package/src/markup/ast.ts
CHANGED
|
@@ -13,6 +13,13 @@ export interface VariableNode {
|
|
|
13
13
|
id?: string;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
export interface ExpressionNode {
|
|
17
|
+
type: 'expression';
|
|
18
|
+
expression: string;
|
|
19
|
+
className?: string;
|
|
20
|
+
id?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
16
23
|
export interface Branch {
|
|
17
24
|
rawArgs: string;
|
|
18
25
|
className?: string;
|
|
@@ -37,7 +44,12 @@ export interface HtmlNode {
|
|
|
37
44
|
children: ASTNode[];
|
|
38
45
|
}
|
|
39
46
|
|
|
40
|
-
export type ASTNode =
|
|
47
|
+
export type ASTNode =
|
|
48
|
+
| TextNode
|
|
49
|
+
| VariableNode
|
|
50
|
+
| ExpressionNode
|
|
51
|
+
| MacroNode
|
|
52
|
+
| HtmlNode;
|
|
41
53
|
|
|
42
54
|
/** Macros that require a closing tag and can contain children */
|
|
43
55
|
const BLOCK_MACROS = new Set([
|
|
@@ -131,6 +143,17 @@ export function buildAST(tokens: Token[]): ASTNode[] {
|
|
|
131
143
|
break;
|
|
132
144
|
}
|
|
133
145
|
|
|
146
|
+
case 'expression': {
|
|
147
|
+
const exprNode: ExpressionNode = {
|
|
148
|
+
type: 'expression',
|
|
149
|
+
expression: token.expression,
|
|
150
|
+
};
|
|
151
|
+
if (token.className) exprNode.className = token.className;
|
|
152
|
+
if (token.id) exprNode.id = token.id;
|
|
153
|
+
current().push(exprNode);
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
|
|
134
157
|
case 'html': {
|
|
135
158
|
if (token.isSelfClose) {
|
|
136
159
|
// Self-closing HTML tag (br, hr, img, etc.)
|
package/src/markup/render.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createContext } from 'preact';
|
|
2
2
|
import { useContext } from 'preact/hooks';
|
|
3
3
|
import { VarDisplay } from '../components/macros/VarDisplay';
|
|
4
|
+
import { ExprDisplay } from '../components/macros/ExprDisplay';
|
|
4
5
|
import { WidgetInvocation } from '../components/macros/WidgetInvocation';
|
|
5
6
|
import { getWidget } from '../widgets/widget-registry';
|
|
6
7
|
import { getMacro, isSubMacro } from '../registry';
|
|
@@ -181,6 +182,16 @@ function renderSingleNode(
|
|
|
181
182
|
/>
|
|
182
183
|
);
|
|
183
184
|
|
|
185
|
+
case 'expression':
|
|
186
|
+
return (
|
|
187
|
+
<ExprDisplay
|
|
188
|
+
key={key}
|
|
189
|
+
expression={node.expression}
|
|
190
|
+
className={node.className}
|
|
191
|
+
id={node.id}
|
|
192
|
+
/>
|
|
193
|
+
);
|
|
194
|
+
|
|
184
195
|
case 'macro':
|
|
185
196
|
return renderMacro(node, key);
|
|
186
197
|
|
package/src/markup/tokenizer.ts
CHANGED
|
@@ -36,6 +36,15 @@ export interface VariableToken {
|
|
|
36
36
|
end: number;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
export interface ExpressionToken {
|
|
40
|
+
type: 'expression';
|
|
41
|
+
expression: string;
|
|
42
|
+
className?: string;
|
|
43
|
+
id?: string;
|
|
44
|
+
start: number;
|
|
45
|
+
end: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
39
48
|
export interface HtmlToken {
|
|
40
49
|
type: 'html';
|
|
41
50
|
tag: string;
|
|
@@ -51,6 +60,7 @@ export type Token =
|
|
|
51
60
|
| LinkToken
|
|
52
61
|
| MacroToken
|
|
53
62
|
| VariableToken
|
|
63
|
+
| ExpressionToken
|
|
54
64
|
| HtmlToken;
|
|
55
65
|
|
|
56
66
|
/** Tag name must start with a letter (covers standard and custom elements). */
|
|
@@ -233,6 +243,20 @@ function parseHtmlAttributes(
|
|
|
233
243
|
return { attributes, endIdx: j };
|
|
234
244
|
}
|
|
235
245
|
|
|
246
|
+
/**
|
|
247
|
+
* Scan for the balanced closing } starting at position i (just past the {).
|
|
248
|
+
* Returns the index of the closing } or -1 if unbalanced.
|
|
249
|
+
*/
|
|
250
|
+
function scanBalancedBrace(input: string, i: number): number {
|
|
251
|
+
let depth = 1;
|
|
252
|
+
while (i < input.length && depth > 0) {
|
|
253
|
+
if (input[i] === '{') depth++;
|
|
254
|
+
else if (input[i] === '}') depth--;
|
|
255
|
+
if (depth > 0) i++;
|
|
256
|
+
}
|
|
257
|
+
return depth === 0 ? i : -1;
|
|
258
|
+
}
|
|
259
|
+
|
|
236
260
|
/**
|
|
237
261
|
* Single-pass tokenizer for Twine passage content.
|
|
238
262
|
* Recognizes: [[links]], {$variable}, {_temporary}, {macroName args}
|
|
@@ -341,7 +365,7 @@ export function tokenize(input: string): Token[] {
|
|
|
341
365
|
const charAfter = input[afterSelectors];
|
|
342
366
|
|
|
343
367
|
if (charAfter === '$') {
|
|
344
|
-
// {.class#id $variable.field}
|
|
368
|
+
// {.class#id $variable.field} or {.class $expr[...]}
|
|
345
369
|
i = afterSelectors + 1;
|
|
346
370
|
const nameStart = i;
|
|
347
371
|
while (i < input.length && /[\w.]/.test(input[i]!)) i++;
|
|
@@ -362,14 +386,31 @@ export function tokenize(input: string): Token[] {
|
|
|
362
386
|
textStart = i;
|
|
363
387
|
continue;
|
|
364
388
|
}
|
|
365
|
-
//
|
|
389
|
+
// Complex expression — scan for balanced closing }
|
|
390
|
+
const closeIdx$ = scanBalancedBrace(input, nameStart);
|
|
391
|
+
if (closeIdx$ !== -1) {
|
|
392
|
+
const expression = input.slice(afterSelectors, closeIdx$);
|
|
393
|
+
i = closeIdx$ + 1;
|
|
394
|
+
const token: ExpressionToken = {
|
|
395
|
+
type: 'expression',
|
|
396
|
+
expression,
|
|
397
|
+
start,
|
|
398
|
+
end: i,
|
|
399
|
+
};
|
|
400
|
+
if (className) token.className = className;
|
|
401
|
+
if (id) token.id = id;
|
|
402
|
+
tokens.push(token);
|
|
403
|
+
textStart = i;
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
// Unbalanced — treat as text
|
|
366
407
|
i = start + 1;
|
|
367
408
|
textStart = start;
|
|
368
409
|
continue;
|
|
369
410
|
}
|
|
370
411
|
|
|
371
412
|
if (charAfter === '_') {
|
|
372
|
-
// {.class#id _temporary.field}
|
|
413
|
+
// {.class#id _temporary.field} or {.class _expr[...]}
|
|
373
414
|
i = afterSelectors + 1;
|
|
374
415
|
const nameStart = i;
|
|
375
416
|
while (i < input.length && /[\w.]/.test(input[i]!)) i++;
|
|
@@ -390,14 +431,31 @@ export function tokenize(input: string): Token[] {
|
|
|
390
431
|
textStart = i;
|
|
391
432
|
continue;
|
|
392
433
|
}
|
|
393
|
-
//
|
|
434
|
+
// Complex expression — scan for balanced closing }
|
|
435
|
+
const closeIdx_ = scanBalancedBrace(input, nameStart);
|
|
436
|
+
if (closeIdx_ !== -1) {
|
|
437
|
+
const expression = input.slice(afterSelectors, closeIdx_);
|
|
438
|
+
i = closeIdx_ + 1;
|
|
439
|
+
const token: ExpressionToken = {
|
|
440
|
+
type: 'expression',
|
|
441
|
+
expression,
|
|
442
|
+
start,
|
|
443
|
+
end: i,
|
|
444
|
+
};
|
|
445
|
+
if (className) token.className = className;
|
|
446
|
+
if (id) token.id = id;
|
|
447
|
+
tokens.push(token);
|
|
448
|
+
textStart = i;
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
// Unbalanced — treat as text
|
|
394
452
|
i = start + 1;
|
|
395
453
|
textStart = start;
|
|
396
454
|
continue;
|
|
397
455
|
}
|
|
398
456
|
|
|
399
457
|
if (charAfter === '@') {
|
|
400
|
-
// {.class#id @local.field}
|
|
458
|
+
// {.class#id @local.field} or {.class @expr[...]}
|
|
401
459
|
i = afterSelectors + 1;
|
|
402
460
|
const nameStart = i;
|
|
403
461
|
while (i < input.length && /[\w.]/.test(input[i]!)) i++;
|
|
@@ -418,7 +476,24 @@ export function tokenize(input: string): Token[] {
|
|
|
418
476
|
textStart = i;
|
|
419
477
|
continue;
|
|
420
478
|
}
|
|
421
|
-
//
|
|
479
|
+
// Complex expression — scan for balanced closing }
|
|
480
|
+
const closeIdx_at = scanBalancedBrace(input, nameStart);
|
|
481
|
+
if (closeIdx_at !== -1) {
|
|
482
|
+
const expression = input.slice(afterSelectors, closeIdx_at);
|
|
483
|
+
i = closeIdx_at + 1;
|
|
484
|
+
const token: ExpressionToken = {
|
|
485
|
+
type: 'expression',
|
|
486
|
+
expression,
|
|
487
|
+
start,
|
|
488
|
+
end: i,
|
|
489
|
+
};
|
|
490
|
+
if (className) token.className = className;
|
|
491
|
+
if (id) token.id = id;
|
|
492
|
+
tokens.push(token);
|
|
493
|
+
textStart = i;
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
// Unbalanced — treat as text
|
|
422
497
|
i = start + 1;
|
|
423
498
|
textStart = start;
|
|
424
499
|
continue;
|
|
@@ -468,7 +543,7 @@ export function tokenize(input: string): Token[] {
|
|
|
468
543
|
continue;
|
|
469
544
|
}
|
|
470
545
|
|
|
471
|
-
// {$variable} or {$variable.field.subfield}
|
|
546
|
+
// {$variable} or {$variable.field.subfield} or {$expr[...]}
|
|
472
547
|
if (nextChar === '$') {
|
|
473
548
|
flushText(i);
|
|
474
549
|
i += 2;
|
|
@@ -488,13 +563,27 @@ export function tokenize(input: string): Token[] {
|
|
|
488
563
|
textStart = i;
|
|
489
564
|
continue;
|
|
490
565
|
}
|
|
491
|
-
//
|
|
566
|
+
// Complex expression — scan for balanced closing }
|
|
567
|
+
const closeIdx = scanBalancedBrace(input, nameStart);
|
|
568
|
+
if (closeIdx !== -1) {
|
|
569
|
+
const expression = input.slice(start + 1, closeIdx);
|
|
570
|
+
i = closeIdx + 1;
|
|
571
|
+
tokens.push({
|
|
572
|
+
type: 'expression',
|
|
573
|
+
expression,
|
|
574
|
+
start,
|
|
575
|
+
end: i,
|
|
576
|
+
});
|
|
577
|
+
textStart = i;
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
// Unbalanced — treat as text
|
|
492
581
|
i = start + 1;
|
|
493
582
|
textStart = start;
|
|
494
583
|
continue;
|
|
495
584
|
}
|
|
496
585
|
|
|
497
|
-
// {_temporary.field}
|
|
586
|
+
// {_temporary.field} or {_expr[...]}
|
|
498
587
|
if (nextChar === '_') {
|
|
499
588
|
flushText(i);
|
|
500
589
|
i += 2;
|
|
@@ -514,13 +603,27 @@ export function tokenize(input: string): Token[] {
|
|
|
514
603
|
textStart = i;
|
|
515
604
|
continue;
|
|
516
605
|
}
|
|
517
|
-
//
|
|
606
|
+
// Complex expression — scan for balanced closing }
|
|
607
|
+
const closeIdx = scanBalancedBrace(input, nameStart);
|
|
608
|
+
if (closeIdx !== -1) {
|
|
609
|
+
const expression = input.slice(start + 1, closeIdx);
|
|
610
|
+
i = closeIdx + 1;
|
|
611
|
+
tokens.push({
|
|
612
|
+
type: 'expression',
|
|
613
|
+
expression,
|
|
614
|
+
start,
|
|
615
|
+
end: i,
|
|
616
|
+
});
|
|
617
|
+
textStart = i;
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
// Unbalanced — treat as text
|
|
518
621
|
i = start + 1;
|
|
519
622
|
textStart = start;
|
|
520
623
|
continue;
|
|
521
624
|
}
|
|
522
625
|
|
|
523
|
-
// {@local.field}
|
|
626
|
+
// {@local.field} or {@expr[...]}
|
|
524
627
|
if (nextChar === '@') {
|
|
525
628
|
flushText(i);
|
|
526
629
|
i += 2;
|
|
@@ -540,7 +643,21 @@ export function tokenize(input: string): Token[] {
|
|
|
540
643
|
textStart = i;
|
|
541
644
|
continue;
|
|
542
645
|
}
|
|
543
|
-
//
|
|
646
|
+
// Complex expression — scan for balanced closing }
|
|
647
|
+
const closeIdx = scanBalancedBrace(input, nameStart);
|
|
648
|
+
if (closeIdx !== -1) {
|
|
649
|
+
const expression = input.slice(start + 1, closeIdx);
|
|
650
|
+
i = closeIdx + 1;
|
|
651
|
+
tokens.push({
|
|
652
|
+
type: 'expression',
|
|
653
|
+
expression,
|
|
654
|
+
start,
|
|
655
|
+
end: i,
|
|
656
|
+
});
|
|
657
|
+
textStart = i;
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
// Unbalanced — treat as text
|
|
544
661
|
i = start + 1;
|
|
545
662
|
textStart = start;
|
|
546
663
|
continue;
|