@rohal12/spindle 0.10.0 → 0.11.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.10.0",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -1,5 +1,6 @@
1
1
  import { useStoryStore } from '../store';
2
2
  import { useAction } from '../hooks/use-action';
3
+ import { useInterpolate } from '../hooks/use-interpolate';
3
4
 
4
5
  interface PassageLinkProps {
5
6
  target: string;
@@ -15,6 +16,9 @@ export function PassageLink({
15
16
  children,
16
17
  }: PassageLinkProps) {
17
18
  const navigate = useStoryStore((s) => s.navigate);
19
+ const resolve = useInterpolate();
20
+ className = resolve(className);
21
+ id = resolve(id);
18
22
 
19
23
  const handleClick = (e: MouseEvent) => {
20
24
  e.preventDefault();
@@ -6,6 +6,7 @@ import { deepClone } from '../../class-registry';
6
6
  import { collectText } from '../../utils/extract-text';
7
7
  import { useAction } from '../../hooks/use-action';
8
8
  import { useMergedLocals } from '../../hooks/use-merged-locals';
9
+ import { useInterpolate } from '../../hooks/use-interpolate';
9
10
  import { currentSourceLocation } from '../../utils/source-location';
10
11
  import type { ASTNode } from '../../markup/ast';
11
12
 
@@ -17,6 +18,9 @@ interface ButtonProps {
17
18
  }
18
19
 
19
20
  export function Button({ rawArgs, children, className, id }: ButtonProps) {
21
+ const resolve = useInterpolate();
22
+ className = resolve(className);
23
+ id = resolve(id);
20
24
  const scope = useContext(LocalsContext);
21
25
  const [, , mergedLocals] = useMergedLocals();
22
26
 
@@ -1,4 +1,4 @@
1
- import { useLayoutEffect } from 'preact/hooks';
1
+ import { useLayoutEffect, useRef } from 'preact/hooks';
2
2
  import { useStoryStore } from '../../store';
3
3
  import { evaluate } from '../../expression';
4
4
  import { useMergedLocals } from '../../hooks/use-merged-locals';
@@ -59,6 +59,34 @@ function valuesEqual(a: unknown, b: unknown): boolean {
59
59
  return false;
60
60
  }
61
61
 
62
+ function computeAndApply(
63
+ expr: string,
64
+ name: string,
65
+ isTemp: boolean,
66
+ variables: Record<string, unknown>,
67
+ temporary: Record<string, unknown>,
68
+ locals: Record<string, unknown>,
69
+ rawArgs: string,
70
+ ): void {
71
+ const state = useStoryStore.getState();
72
+ let newValue: unknown;
73
+ try {
74
+ newValue = evaluate(expr, variables, temporary, locals);
75
+ } catch (err) {
76
+ console.error(
77
+ `spindle: Error in {computed ${rawArgs}}${currentSourceLocation()}:`,
78
+ err,
79
+ );
80
+ return;
81
+ }
82
+
83
+ const current = isTemp ? state.temporary[name] : state.variables[name];
84
+ if (!valuesEqual(current, newValue)) {
85
+ if (isTemp) state.setTemporary(name, newValue);
86
+ else state.setVariable(name, newValue);
87
+ }
88
+ }
89
+
62
90
  export function Computed({ rawArgs }: ComputedProps) {
63
91
  const [mergedVars, mergedTemps, mergedLocals] = useMergedLocals();
64
92
 
@@ -79,31 +107,34 @@ export function Computed({ rawArgs }: ComputedProps) {
79
107
  const isTemp = target.startsWith('_');
80
108
  const name = target.slice(1);
81
109
 
82
- // Evaluate in useLayoutEffect so preceding {set} effects have already run
83
- useLayoutEffect(() => {
110
+ // Synchronous first evaluation — sees preceding synchronous {set} mutations
111
+ const ran = useRef(false);
112
+ if (!ran.current) {
113
+ ran.current = true;
84
114
  const state = useStoryStore.getState();
115
+ computeAndApply(
116
+ expr,
117
+ name,
118
+ isTemp,
119
+ state.variables,
120
+ state.temporary,
121
+ mergedLocals,
122
+ rawArgs,
123
+ );
124
+ }
85
125
 
86
- let newValue: unknown;
87
- try {
88
- newValue = evaluate(expr, mergedVars, mergedTemps, mergedLocals);
89
- } catch (err) {
90
- console.error(
91
- `spindle: Error in {computed ${rawArgs}}${currentSourceLocation()}:`,
92
- err,
93
- );
94
- return;
95
- }
96
-
97
- const current = isTemp ? state.temporary[name] : state.variables[name];
98
-
99
- if (!valuesEqual(current, newValue)) {
100
- if (isTemp) {
101
- state.setTemporary(name, newValue);
102
- } else {
103
- state.setVariable(name, newValue);
104
- }
105
- }
106
- });
126
+ // Reactive re-evaluation when dependencies change (via useMergedLocals subscription)
127
+ useLayoutEffect(() => {
128
+ computeAndApply(
129
+ expr,
130
+ name,
131
+ isTemp,
132
+ mergedVars,
133
+ mergedTemps,
134
+ mergedLocals,
135
+ rawArgs,
136
+ );
137
+ }, [mergedVars, mergedTemps, mergedLocals]);
107
138
 
108
139
  return null;
109
140
  }
@@ -3,6 +3,7 @@ import { evaluate } from '../../expression';
3
3
  import { LocalsContext, renderNodes } from '../../markup/render';
4
4
  import type { LocalsScope } from '../../markup/render';
5
5
  import { useMergedLocals } from '../../hooks/use-merged-locals';
6
+ import { useInterpolate } from '../../hooks/use-interpolate';
6
7
  import { currentSourceLocation } from '../../utils/source-location';
7
8
  import type { ASTNode } from '../../markup/ast';
8
9
 
@@ -76,6 +77,9 @@ function ForIteration({
76
77
  }
77
78
 
78
79
  export function For({ rawArgs, children, className, id }: ForProps) {
80
+ const resolve = useInterpolate();
81
+ className = resolve(className);
82
+ id = resolve(id);
79
83
  const parentScope = useContext(LocalsContext);
80
84
  const [mergedVars, mergedTemps, mergedLocals] = useMergedLocals();
81
85
 
@@ -1,6 +1,7 @@
1
1
  import { evaluate } from '../../expression';
2
2
  import { renderNodes } from '../../markup/render';
3
3
  import { useMergedLocals } from '../../hooks/use-merged-locals';
4
+ import { useInterpolate } from '../../hooks/use-interpolate';
4
5
  import { currentSourceLocation } from '../../utils/source-location';
5
6
  import type { Branch } from '../../markup/ast';
6
7
 
@@ -9,15 +10,18 @@ interface IfProps {
9
10
  }
10
11
 
11
12
  export function If({ branches }: IfProps) {
13
+ const resolve = useInterpolate();
12
14
  const [mergedVars, mergedTemps, mergedLocals] = useMergedLocals();
13
15
 
14
16
  function renderBranch(branch: Branch) {
15
17
  const children = renderNodes(branch.children);
16
- if (branch.className || branch.id)
18
+ const cls = resolve(branch.className);
19
+ const branchId = resolve(branch.id);
20
+ if (cls || branchId)
17
21
  return (
18
22
  <span
19
- id={branch.id}
20
- class={branch.className}
23
+ id={branchId}
24
+ class={cls}
21
25
  >
22
26
  {children}
23
27
  </span>
@@ -4,6 +4,7 @@ import { tokenize } from '../../markup/tokenizer';
4
4
  import { buildAST } from '../../markup/ast';
5
5
  import { renderNodes } from '../../markup/render';
6
6
  import { useMergedLocals } from '../../hooks/use-merged-locals';
7
+ import { useInterpolate } from '../../hooks/use-interpolate';
7
8
  import { currentSourceLocation } from '../../utils/source-location';
8
9
 
9
10
  interface IncludeProps {
@@ -13,6 +14,9 @@ interface IncludeProps {
13
14
  }
14
15
 
15
16
  export function Include({ rawArgs, className, id }: IncludeProps) {
17
+ const resolve = useInterpolate();
18
+ className = resolve(className);
19
+ id = resolve(id);
16
20
  const storyData = useStoryStore((s) => s.storyData);
17
21
  const [variables, temporary, locals] = useMergedLocals();
18
22
 
@@ -5,6 +5,7 @@ import type { ASTNode } from '../../markup/ast';
5
5
  import { deepClone } from '../../class-registry';
6
6
  import { LocalsContext } from '../../markup/render';
7
7
  import { useMergedLocals } from '../../hooks/use-merged-locals';
8
+ import { useInterpolate } from '../../hooks/use-interpolate';
8
9
  import { currentSourceLocation } from '../../utils/source-location';
9
10
 
10
11
  interface MacroLinkProps {
@@ -99,6 +100,9 @@ export function MacroLink({
99
100
  className,
100
101
  id,
101
102
  }: MacroLinkProps) {
103
+ const resolve = useInterpolate();
104
+ className = resolve(className);
105
+ id = resolve(id);
102
106
  const { display, passage } = parseArgs(rawArgs);
103
107
  const scope = useContext(LocalsContext);
104
108
  const [, , mergedLocals] = useMergedLocals();
@@ -1,5 +1,6 @@
1
1
  import { evaluate } from '../../expression';
2
2
  import { useMergedLocals } from '../../hooks/use-merged-locals';
3
+ import { useInterpolate } from '../../hooks/use-interpolate';
3
4
 
4
5
  interface MeterProps {
5
6
  rawArgs: string;
@@ -83,6 +84,9 @@ function formatLabel(
83
84
  }
84
85
 
85
86
  export function Meter({ rawArgs, className, id }: MeterProps) {
87
+ const resolve = useInterpolate();
88
+ className = resolve(className);
89
+ id = resolve(id);
86
90
  const [mergedVars, mergedTemps, mergedLocals] = useMergedLocals();
87
91
 
88
92
  try {
@@ -1,5 +1,6 @@
1
1
  import { useStoryStore } from '../../store';
2
2
  import { Passage } from '../Passage';
3
+ import { useInterpolate } from '../../hooks/use-interpolate';
3
4
 
4
5
  interface PassageDisplayProps {
5
6
  className?: string;
@@ -7,6 +8,9 @@ interface PassageDisplayProps {
7
8
  }
8
9
 
9
10
  export function PassageDisplay({ className, id }: PassageDisplayProps) {
11
+ const resolve = useInterpolate();
12
+ className = resolve(className);
13
+ id = resolve(id);
10
14
  const currentPassage = useStoryStore((s) => s.currentPassage);
11
15
  const storyData = useStoryStore((s) => s.storyData);
12
16
 
@@ -1,5 +1,6 @@
1
1
  import { evaluate } from '../../expression';
2
2
  import { useMergedLocals } from '../../hooks/use-merged-locals';
3
+ import { useInterpolate } from '../../hooks/use-interpolate';
3
4
  import { currentSourceLocation } from '../../utils/source-location';
4
5
 
5
6
  interface PrintProps {
@@ -9,6 +10,9 @@ interface PrintProps {
9
10
  }
10
11
 
11
12
  export function Print({ rawArgs, className, id }: PrintProps) {
13
+ const resolve = useInterpolate();
14
+ className = resolve(className);
15
+ id = resolve(id);
12
16
  const [mergedVars, mergedTemps, mergedLocals] = useMergedLocals();
13
17
 
14
18
  try {
@@ -1,7 +1,9 @@
1
1
  import { createContext } from 'preact';
2
- import { useState, useEffect, useCallback } from 'preact/hooks';
2
+ import { useState, useEffect, useCallback, useMemo } from 'preact/hooks';
3
3
  import { renderNodes } from '../../markup/render';
4
4
  import { parseDelay } from '../../utils/parse-delay';
5
+ import { hasInterpolation, interpolate } from '../../interpolation';
6
+ import { useStoryStore } from '../../store';
5
7
  import type { ASTNode } from '../../markup/ast';
6
8
 
7
9
  export const RepeatContext = createContext<{ stop: () => void }>({
@@ -15,7 +17,21 @@ interface RepeatProps {
15
17
  id?: string;
16
18
  }
17
19
 
18
- export function Repeat({ rawArgs, children, className, id }: RepeatProps) {
20
+ export function Repeat({
21
+ rawArgs,
22
+ children,
23
+ className: rawClassName,
24
+ id: rawId,
25
+ }: RepeatProps) {
26
+ const [className, id] = useMemo(() => {
27
+ const resolveOnce = (s: string | undefined) => {
28
+ if (!s || !hasInterpolation(s)) return s;
29
+ const st = useStoryStore.getState();
30
+ return interpolate(s, st.variables, st.temporary, {});
31
+ };
32
+ return [resolveOnce(rawClassName), resolveOnce(rawId)];
33
+ }, [rawClassName, rawId]);
34
+
19
35
  const delay = parseDelay(rawArgs);
20
36
  const [count, setCount] = useState(0);
21
37
  const [stopped, setStopped] = useState(false);
@@ -36,7 +52,7 @@ export function Repeat({ rawArgs, children, className, id }: RepeatProps) {
36
52
 
37
53
  const content = (
38
54
  <RepeatContext.Provider value={{ stop }}>
39
- {renderNodes(children)}
55
+ <span key={count}>{renderNodes(children)}</span>
40
56
  </RepeatContext.Provider>
41
57
  );
42
58
 
@@ -1,4 +1,4 @@
1
- import { useLayoutEffect, useContext } from 'preact/hooks';
1
+ import { useRef, useContext } from 'preact/hooks';
2
2
  import { useStoryStore } from '../../store';
3
3
  import { execute } from '../../expression';
4
4
  import { deepClone } from '../../class-registry';
@@ -13,8 +13,10 @@ interface SetProps {
13
13
  export function Set({ rawArgs }: SetProps) {
14
14
  const scope = useContext(LocalsContext);
15
15
  const [, , mergedLocals] = useMergedLocals();
16
+ const ran = useRef(false);
16
17
 
17
- useLayoutEffect(() => {
18
+ if (!ran.current) {
19
+ ran.current = true;
18
20
  const state = useStoryStore.getState();
19
21
  const vars = deepClone(state.variables);
20
22
  const temps = deepClone(state.temporary);
@@ -27,7 +29,7 @@ export function Set({ rawArgs }: SetProps) {
27
29
  `spindle: Error in {set ${rawArgs}}${currentSourceLocation()}:`,
28
30
  err,
29
31
  );
30
- return;
32
+ return null;
31
33
  }
32
34
 
33
35
  // Diff and apply store changes
@@ -48,7 +50,7 @@ export function Set({ rawArgs }: SetProps) {
48
50
  scope.update(`@${key}`, localsClone[key]);
49
51
  }
50
52
  }
51
- }, []);
53
+ }
52
54
 
53
55
  return null;
54
56
  }
@@ -1,6 +1,7 @@
1
1
  import { useState, useEffect, useMemo } from 'preact/hooks';
2
2
  import { renderNodes } from '../../markup/render';
3
3
  import { parseDelay } from '../../utils/parse-delay';
4
+ import { useInterpolate } from '../../hooks/use-interpolate';
4
5
  import type { ASTNode, Branch } from '../../markup/ast';
5
6
 
6
7
  interface TimedProps {
@@ -18,6 +19,9 @@ export function Timed({
18
19
  className,
19
20
  id,
20
21
  }: TimedProps) {
22
+ const resolve = useInterpolate();
23
+ className = resolve(className);
24
+ id = resolve(id);
21
25
  // Section 0 = initial children, sections 1..N = {next} branches
22
26
  // Each section has its own delay
23
27
  const sections = useMemo(() => {
@@ -1,6 +1,7 @@
1
1
  import { useState, useEffect, useRef } from 'preact/hooks';
2
2
  import { renderInlineNodes } from '../../markup/render';
3
3
  import { parseDelay } from '../../utils/parse-delay';
4
+ import { useInterpolate } from '../../hooks/use-interpolate';
4
5
  import type { ASTNode } from '../../markup/ast';
5
6
 
6
7
  interface TypeProps {
@@ -11,6 +12,9 @@ interface TypeProps {
11
12
  }
12
13
 
13
14
  export function Type({ rawArgs, children, className, id }: TypeProps) {
15
+ const resolve = useInterpolate();
16
+ className = resolve(className);
17
+ id = resolve(id);
14
18
  const speed = parseDelay(rawArgs);
15
19
  const containerRef = useRef<HTMLSpanElement>(null);
16
20
  const [totalChars, setTotalChars] = useState(0);
@@ -1,4 +1,4 @@
1
- import { useLayoutEffect } from 'preact/hooks';
1
+ import { useRef } from 'preact/hooks';
2
2
  import { useStoryStore } from '../../store';
3
3
 
4
4
  interface UnsetProps {
@@ -6,7 +6,10 @@ interface UnsetProps {
6
6
  }
7
7
 
8
8
  export function Unset({ rawArgs }: UnsetProps) {
9
- useLayoutEffect(() => {
9
+ const ran = useRef(false);
10
+
11
+ if (!ran.current) {
12
+ ran.current = true;
10
13
  const state = useStoryStore.getState();
11
14
  const name = rawArgs.trim();
12
15
 
@@ -19,7 +22,7 @@ export function Unset({ rawArgs }: UnsetProps) {
19
22
  `spindle: {unset} expects a variable ($name or _name), got "${name}"`,
20
23
  );
21
24
  }
22
- }, []);
25
+ }
23
26
 
24
27
  return null;
25
28
  }
@@ -1,6 +1,7 @@
1
1
  import { useStoryStore } from '../../store';
2
2
  import { useContext } from 'preact/hooks';
3
3
  import { LocalsContext } from '../../markup/render';
4
+ import { useInterpolate } from '../../hooks/use-interpolate';
4
5
 
5
6
  interface VarDisplayProps {
6
7
  name: string;
@@ -10,6 +11,9 @@ interface VarDisplayProps {
10
11
  }
11
12
 
12
13
  export function VarDisplay({ name, scope, className, id }: VarDisplayProps) {
14
+ const resolve = useInterpolate();
15
+ className = resolve(className);
16
+ id = resolve(id);
13
17
  const localsScope = useContext(LocalsContext);
14
18
  const parts = name.split('.');
15
19
  const root = parts[0]!;
@@ -7,7 +7,10 @@ export function extractOptions(children: ASTNode[]): string[] {
7
7
  const options: string[] = [];
8
8
  for (const node of children) {
9
9
  if (node.type === 'macro' && node.name === 'option') {
10
- options.push(node.rawArgs.trim());
10
+ const raw = node.rawArgs.trim();
11
+ // Strip surrounding quotes so {option "Long Sword"} gives "Long Sword"
12
+ const stripped = raw.replace(/^(["'])(.+)\1$/, '$2');
13
+ options.push(stripped);
11
14
  }
12
15
  }
13
16
  return options;
package/src/expression.ts CHANGED
@@ -37,13 +37,117 @@ const VAR_RE = /\$(\w+)/g;
37
37
  const TEMP_RE = /\b_(\w+)/g;
38
38
  const LOCAL_RE = /@(\w+)/g;
39
39
 
40
- function transform(expr: string): string {
41
- return expr
40
+ function transformSegment(segment: string): string {
41
+ return segment
42
42
  .replace(VAR_RE, 'variables["$1"]')
43
43
  .replace(TEMP_RE, 'temporary["$1"]')
44
44
  .replace(LOCAL_RE, 'locals["$1"]');
45
45
  }
46
46
 
47
+ /**
48
+ * String-aware expression transformer. Walks the expression character by
49
+ * character so that variable sigils ($, _, @) inside string literals are
50
+ * left untouched while code — including expressions inside template-literal
51
+ * `${…}` interpolations — is transformed.
52
+ */
53
+ function transform(expr: string): string {
54
+ let result = '';
55
+ let code = ''; // accumulates code characters to be transformed
56
+ let i = 0;
57
+
58
+ function flushCode() {
59
+ if (code) {
60
+ result += transformSegment(code);
61
+ code = '';
62
+ }
63
+ }
64
+
65
+ while (i < expr.length) {
66
+ const ch = expr.charAt(i);
67
+
68
+ // Single or double quoted string — skip entirely
69
+ if (ch === '"' || ch === "'") {
70
+ flushCode();
71
+ const quote = ch;
72
+ let str = quote;
73
+ i++;
74
+ while (i < expr.length) {
75
+ const c = expr.charAt(i);
76
+ if (c === '\\' && i + 1 < expr.length) {
77
+ str += c + expr.charAt(i + 1);
78
+ i += 2;
79
+ } else if (c === quote) {
80
+ str += quote;
81
+ i++;
82
+ break;
83
+ } else {
84
+ str += c;
85
+ i++;
86
+ }
87
+ }
88
+ result += str;
89
+ continue;
90
+ }
91
+
92
+ // Template literal — preserve literal parts, transform interpolations
93
+ if (ch === '`') {
94
+ flushCode();
95
+ result += '`';
96
+ i++;
97
+ while (i < expr.length) {
98
+ const c = expr.charAt(i);
99
+ if (c === '\\' && i + 1 < expr.length) {
100
+ result += c + expr.charAt(i + 1);
101
+ i += 2;
102
+ } else if (c === '$' && expr.charAt(i + 1) === '{') {
103
+ // Template interpolation — collect the inner expression and
104
+ // recursively transform it
105
+ result += '${';
106
+ i += 2;
107
+ let depth = 1;
108
+ let inner = '';
109
+ while (i < expr.length && depth > 0) {
110
+ const ic = expr.charAt(i);
111
+ if (ic === '{') {
112
+ depth++;
113
+ inner += ic;
114
+ } else if (ic === '}') {
115
+ depth--;
116
+ if (depth === 0) break;
117
+ inner += ic;
118
+ } else if (ic === '\\' && i + 1 < expr.length) {
119
+ inner += ic + expr.charAt(i + 1);
120
+ i++;
121
+ } else {
122
+ inner += ic;
123
+ }
124
+ i++;
125
+ }
126
+ result += transform(inner); // recursive transform
127
+ if (i < expr.length && expr.charAt(i) === '}') {
128
+ result += '}';
129
+ i++;
130
+ }
131
+ } else if (c === '`') {
132
+ result += '`';
133
+ i++;
134
+ break;
135
+ } else {
136
+ result += c;
137
+ i++;
138
+ }
139
+ }
140
+ continue;
141
+ }
142
+
143
+ // Regular code character
144
+ code += ch;
145
+ i++;
146
+ }
147
+ flushCode();
148
+ return result;
149
+ }
150
+
47
151
  const preamble =
48
152
  'const {currentPassage,previousPassage,visited,hasVisited,hasVisitedAny,hasVisitedAll,rendered,hasRendered,hasRenderedAny,hasRenderedAll,random,randomInt}=__fns;';
49
153
 
@@ -0,0 +1,17 @@
1
+ import { useCallback } from 'preact/hooks';
2
+ import { useMergedLocals } from './use-merged-locals';
3
+ import { hasInterpolation, interpolate } from '../interpolation';
4
+
5
+ export function useInterpolate(): (
6
+ s: string | undefined,
7
+ ) => string | undefined {
8
+ const [variables, temporary, locals] = useMergedLocals();
9
+
10
+ return useCallback(
11
+ (s: string | undefined): string | undefined => {
12
+ if (s === undefined || !hasInterpolation(s)) return s;
13
+ return interpolate(s, variables, temporary, locals);
14
+ },
15
+ [variables, temporary, locals],
16
+ );
17
+ }
package/src/index.tsx CHANGED
@@ -99,7 +99,10 @@ function boot() {
99
99
  const widgetName = tokens2[0]!.replace(/["']/g, '');
100
100
  const params = tokens2
101
101
  .slice(1)
102
- .filter((t) => t.startsWith('$') || t.startsWith('_'));
102
+ .filter(
103
+ (t) =>
104
+ t.startsWith('$') || t.startsWith('_') || t.startsWith('@'),
105
+ );
103
106
  registerWidget(widgetName, node.children as ASTNode[], params);
104
107
  }
105
108
  }
@@ -0,0 +1,44 @@
1
+ const INTERP_RE = /\{(\$[\w.]+|_[\w.]+|@[\w.]+)\}/g;
2
+ const INTERP_TEST = /\{[\$_@][\w.]+\}/;
3
+
4
+ export function hasInterpolation(s: string): boolean {
5
+ return INTERP_TEST.test(s);
6
+ }
7
+
8
+ function resolveDotPath(root: unknown, parts: string[]): unknown {
9
+ let value = root;
10
+ for (let i = 1; i < parts.length; i++) {
11
+ if (value == null || typeof value !== 'object') return undefined;
12
+ value = (value as Record<string, unknown>)[parts[i]!];
13
+ }
14
+ return value;
15
+ }
16
+
17
+ export function interpolate(
18
+ template: string,
19
+ variables: Record<string, unknown>,
20
+ temporary: Record<string, unknown>,
21
+ locals: Record<string, unknown>,
22
+ ): 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];
36
+ }
37
+
38
+ if (parts.length > 1) {
39
+ value = resolveDotPath(value, parts);
40
+ }
41
+
42
+ return value == null ? '' : String(value);
43
+ });
44
+ }