@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.38.0",
3
+ "version": "0.38.1",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -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
+ }
@@ -1,5 +1,7 @@
1
- const INTERP_RE = /\{(\$[\w.]+|_[\w.]+|@[\w.]+)\}/g;
2
- const INTERP_TEST = /\{[\$_@][\w.]+\}/;
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
- return template.replace(INTERP_RE, (_match, ref: string) => {
24
- const prefix = ref[0]!;
25
- const path = ref.slice(1);
26
- const parts = path.split('.');
27
- const root = parts[0]!;
28
-
29
- let value: unknown;
30
- if (prefix === '$') {
31
- value = variables[root];
32
- } else if (prefix === '_') {
33
- value = temporary[root];
34
- } else {
35
- value = locals[root];
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
- if (parts.length > 1) {
39
- value = resolveDotPath(value, parts);
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
- return value == null ? '' : String(value);
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 = TextNode | VariableNode | MacroNode | HtmlNode;
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.)
@@ -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
 
@@ -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
- // Not validtreat as text
389
+ // Complex expressionscan 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
- // Not validtreat as text
434
+ // Complex expressionscan 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
- // Not validtreat as text
479
+ // Complex expressionscan 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
- // Not a valid variable token treat as text
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
- // Not a valid temporary token treat as text
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
- // Not a valid local token treat as text
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;