@rohal12/spindle 0.51.4 → 0.52.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.
Files changed (64) hide show
  1. package/dist/pkg/format.js +1 -1
  2. package/dist/pkg/headless.js +4313 -1640
  3. package/dist/pkg/macro-registry.json +7 -7
  4. package/dist/pkg/story-variables.js +1636 -177
  5. package/package.json +5 -2
  6. package/src/automation/runner.ts +2 -1
  7. package/src/class-registry.ts +214 -103
  8. package/src/components/Passage.tsx +2 -2
  9. package/src/components/PassageDialog.tsx +2 -5
  10. package/src/components/StoryInterface.tsx +2 -4
  11. package/src/components/macros/Button.tsx +5 -31
  12. package/src/components/macros/Checkbox.tsx +7 -4
  13. package/src/components/macros/Computed.tsx +19 -13
  14. package/src/components/macros/For.tsx +29 -3
  15. package/src/components/macros/If.tsx +8 -0
  16. package/src/components/macros/Include.tsx +7 -6
  17. package/src/components/macros/MacroError.tsx +2 -1
  18. package/src/components/macros/MacroLink.tsx +12 -45
  19. package/src/components/macros/Meter.tsx +11 -3
  20. package/src/components/macros/Nobr.tsx +1 -0
  21. package/src/components/macros/PassageDisplay.tsx +3 -0
  22. package/src/components/macros/Print.tsx +4 -0
  23. package/src/components/macros/Radiobutton.tsx +5 -2
  24. package/src/components/macros/SaveManager.tsx +25 -8
  25. package/src/components/macros/Span.tsx +1 -0
  26. package/src/components/macros/StoryTitle.tsx +1 -0
  27. package/src/components/macros/Switch.tsx +13 -0
  28. package/src/components/macros/Unset.tsx +30 -10
  29. package/src/components/macros/VarDisplay.tsx +21 -4
  30. package/src/components/macros/Widget.tsx +20 -1
  31. package/src/components/macros/WidgetInvocation.tsx +17 -75
  32. package/src/components/macros/arg-utils.ts +107 -1
  33. package/src/components/macros/detached-body.tsx +68 -0
  34. package/src/components/macros/option-utils.ts +3 -2
  35. package/src/define-macro.ts +32 -5
  36. package/src/execute-mutation.ts +270 -67
  37. package/src/expression.ts +86 -55
  38. package/src/hooks/use-action.ts +18 -3
  39. package/src/hooks/use-interpolate.ts +36 -5
  40. package/src/index.tsx +10 -1
  41. package/src/interpolation.ts +394 -96
  42. package/src/js-lexer.ts +1231 -97
  43. package/src/markup/code-attributes.ts +64 -0
  44. package/src/markup/markdown.ts +188 -9
  45. package/src/markup/render.tsx +430 -49
  46. package/src/markup/tokenizer.ts +578 -110
  47. package/src/prng.ts +8 -8
  48. package/src/registry.ts +35 -0
  49. package/src/saves/save-manager.ts +339 -158
  50. package/src/saves/storage.ts +16 -7
  51. package/src/saves/types.ts +2 -1
  52. package/src/store.ts +521 -154
  53. package/src/story-api.ts +31 -67
  54. package/src/story-init.ts +1 -1
  55. package/src/story-variables.ts +98 -102
  56. package/src/triggers.ts +6 -5
  57. package/src/utils/error-message.ts +12 -0
  58. package/src/utils/live-locals.ts +10 -3
  59. package/src/utils/namespace.ts +71 -0
  60. package/src/utils/object-path.ts +99 -14
  61. package/src/utils/stable-key.ts +14 -9
  62. package/src/widgets/widget-registry.ts +9 -0
  63. package/types/index.d.ts +43 -7
  64. package/types/tooling.d.ts +1 -0
@@ -1,17 +1,48 @@
1
- import { useCallback } from 'preact/hooks';
1
+ import { useCallback, useContext, useMemo } from 'preact/hooks';
2
2
  import { useMergedLocals } from './use-merged-locals';
3
- import { hasInterpolation, interpolate } from '../interpolation';
3
+ import { WidgetChildrenContext } from '../markup/render';
4
+ import {
5
+ hasInterpolation,
6
+ interpolateText,
7
+ type TextScope,
8
+ } from '../interpolation';
9
+ import { errorMessage } from '../utils/error-message';
4
10
 
11
+ /**
12
+ * The scope text-only markup (attribute values, labels) is evaluated in:
13
+ * store variables, locals and the enclosing block widget's children. The
14
+ * caller re-renders whenever one of them changes.
15
+ */
16
+ export function useTextScope(): TextScope {
17
+ const [variables, temporary, locals, transient] = useMergedLocals();
18
+ const widgetChildren = useContext(WidgetChildrenContext);
19
+ return useMemo(
20
+ () => ({ variables, temporary, locals, transient, widgetChildren }),
21
+ [variables, temporary, locals, transient, widgetChildren],
22
+ );
23
+ }
24
+
25
+ /**
26
+ * Return a function resolving the markup in a string (a macro label, class
27
+ * or id) to text. Errors (a macro with no text form, a failing expression)
28
+ * are logged, and their part of the string is left empty.
29
+ */
5
30
  export function useInterpolate(): (
6
31
  s: string | undefined,
7
32
  ) => string | undefined {
8
- const [variables, temporary, locals, transient] = useMergedLocals();
33
+ const scope = useTextScope();
9
34
 
10
35
  return useCallback(
11
36
  (s: string | undefined): string | undefined => {
12
37
  if (s === undefined || !hasInterpolation(s)) return s;
13
- return interpolate(s, variables, temporary, locals, transient);
38
+ const { text, errors } = interpolateText(s, scope);
39
+ for (const { macro, error } of errors) {
40
+ console.error(
41
+ `spindle: {${macro}} error in "${s}": ${errorMessage(error)}`,
42
+ );
43
+ }
44
+ return text;
14
45
  },
15
- [variables, temporary, locals, transient],
46
+ [scope],
16
47
  );
17
48
  }
package/src/index.tsx CHANGED
@@ -21,6 +21,7 @@ import { tokenize } from './markup/tokenizer';
21
21
  import { buildAST, registerBlockMacro } from './markup/ast';
22
22
  import { registerWidget } from './widgets/widget-registry';
23
23
  import { astContainsChildren } from './widgets/ast-scanner';
24
+ import { errorMessage } from './utils/error-message';
24
25
  import type { ASTNode } from './markup/ast';
25
26
  import './macros/register-builtins';
26
27
  import builtinCSS from './styles.css?inline';
@@ -182,7 +183,15 @@ export function boot() {
182
183
  );
183
184
  const children = node.children as ASTNode[];
184
185
  const isBlock = astContainsChildren(children);
185
- registerWidget(widgetName, children, params, isBlock);
186
+ try {
187
+ registerWidget(widgetName, children, params, isBlock);
188
+ } catch (err) {
189
+ // As the {widget} macro refuses it: the others still register
190
+ console.error(
191
+ `spindle: widget "${widgetName}" in passage "${passage.name}" was not registered: ${errorMessage(err)}`,
192
+ );
193
+ continue;
194
+ }
186
195
  if (isBlock) {
187
196
  registerBlockMacro(widgetName);
188
197
  }
@@ -1,129 +1,427 @@
1
+ /**
2
+ * Text-only markup: HTML attribute values, image alt text and link titles,
3
+ * and macro labels resolve the inline markup of passage text (variables,
4
+ * expressions, macros, widgets) to a string.
5
+ *
6
+ * Such a value is parsed with the passage tokenizer in text mode, so `{…}`
7
+ * means the same as in passage text, and its AST is evaluated directly to a
8
+ * string rather than rendered: the result is ready during the render that
9
+ * needs it (so it goes through the usual attribute handling, boolean
10
+ * attributes and live properties included), costs no DOM work, and is
11
+ * recomputed whenever the variables or locals the caller subscribes to
12
+ * change. Macros take part through their text form (MacroDefinition.text);
13
+ * one without a text form (inputs, links, {set}, …) has nothing to put in a
14
+ * string, so it is reported instead of being run or shown as source.
15
+ */
1
16
  import { evaluate } from './expression';
2
- import { scanBalancedBrace } from './markup/tokenizer';
3
-
4
- /** Detects any {…} block that starts with a sigil ($, _, @, %). */
5
- const INTERP_TEST = /\{[\$_@%]\w/;
17
+ import { tokenize } from './markup/tokenizer';
18
+ import { buildAST } from './markup/ast';
19
+ import type { ASTNode, MacroNode, TextNode } from './markup/ast';
20
+ import { getMacro, getMacroText, isSubMacro } from './registry';
21
+ import type { MacroTextContext } from './registry';
22
+ import { getWidget } from './widgets/widget-registry';
23
+ import { splitArgs } from './components/macros/arg-utils';
24
+ import { splitSigilTemplate } from './markup/code-attributes';
25
+ import {
26
+ checkVariableName,
27
+ createNamespace,
28
+ ownValue,
29
+ type Namespace,
30
+ } from './utils/namespace';
6
31
 
32
+ /** Whether a string may contain markup (anything but plain text). */
7
33
  export function hasInterpolation(s: string): boolean {
8
- return INTERP_TEST.test(s);
34
+ return s.includes('{') || s.includes('}');
9
35
  }
10
36
 
11
- function resolveDotPath(root: unknown, parts: string[]): unknown {
12
- let value = root;
13
- for (let i = 1; i < parts.length; i++) {
14
- // Primitives box on access, so `{$name.length}` works too.
15
- if (value == null) return undefined;
16
- value = (value as Record<string, unknown>)[parts[i]!];
17
- }
18
- return value;
37
+ /** The scope text-only markup is evaluated in. */
38
+ export interface TextScope {
39
+ variables: Record<string, unknown>;
40
+ temporary: Record<string, unknown>;
41
+ locals: Record<string, unknown>;
42
+ transient: Record<string, unknown>;
43
+ /** Invocation children of the enclosing block widget, for `{@children}`. */
44
+ widgetChildren?: ASTNode[] | null;
45
+ }
46
+
47
+ /** A macro, expression or markup error met while evaluating text. */
48
+ export interface TextError {
49
+ /** The macro name, or `expression` / `markup`. */
50
+ macro: string;
51
+ error: unknown;
19
52
  }
20
53
 
54
+ export interface TextResult {
55
+ text: string;
56
+ errors: TextError[];
57
+ }
58
+
59
+ export type ParsedText = { nodes: ASTNode[] } | { error: unknown };
60
+
61
+ const parseCache = new Map<string, ParsedText>();
62
+ const PARSE_CACHE_LIMIT = 2000;
63
+
21
64
  /**
22
- * Evaluate an expression string and return its stringified result.
23
- * Uses the full expression evaluator from expression.ts.
65
+ * Parse text-only markup into AST nodes, or the error the AST builder
66
+ * reports for it (an unclosed or mismatched macro). Results are cached.
24
67
  */
25
- export function interpolateExpression(
26
- expr: string,
27
- variables: Record<string, unknown>,
28
- temporary: Record<string, unknown>,
29
- locals: Record<string, unknown>,
30
- transient: Record<string, unknown> = {},
31
- ): string {
32
- const value = evaluate(expr, variables, temporary, locals, transient);
68
+ export function parseText(template: string): ParsedText {
69
+ let parsed = parseCache.get(template);
70
+ if (parsed === undefined) {
71
+ try {
72
+ parsed = { nodes: buildAST(tokenize(template, { text: true })) };
73
+ } catch (error) {
74
+ parsed = { error };
75
+ }
76
+ if (parseCache.size >= PARSE_CACHE_LIMIT) parseCache.clear();
77
+ parseCache.set(template, parsed);
78
+ }
79
+ return parsed;
80
+ }
81
+
82
+ /** Map the text nodes of an AST (macro bodies and branches included). */
83
+ export function mapTextNodes(
84
+ nodes: ASTNode[],
85
+ fn: (value: string) => string,
86
+ ): ASTNode[] {
87
+ return nodes.map((node): ASTNode => {
88
+ switch (node.type) {
89
+ case 'text':
90
+ return { type: 'text', value: fn(node.value) } satisfies TextNode;
91
+ case 'html':
92
+ return { ...node, children: mapTextNodes(node.children, fn) };
93
+ case 'macro': {
94
+ const copy: MacroNode = {
95
+ ...node,
96
+ children: mapTextNodes(node.children, fn),
97
+ };
98
+ if (node.branches) {
99
+ copy.branches = node.branches.map((b) => ({
100
+ ...b,
101
+ children: mapTextNodes(b.children, fn),
102
+ }));
103
+ }
104
+ return copy;
105
+ }
106
+ default:
107
+ return node;
108
+ }
109
+ });
110
+ }
111
+
112
+ /** Deep enough for any real nesting; stops a widget that includes itself. */
113
+ const MAX_DEPTH = 100;
114
+
115
+ function display(value: unknown): string {
33
116
  return value == null ? '' : String(value);
34
117
  }
35
118
 
36
- function resolveSimple(
37
- ref: string,
38
- variables: Record<string, unknown>,
39
- temporary: Record<string, unknown>,
40
- locals: Record<string, unknown>,
41
- transient: Record<string, unknown>,
42
- ): string {
43
- const prefix = ref[0]!;
44
- const path = ref.slice(1);
45
- const parts = path.split('.');
46
- const root = parts[0]!;
119
+ type VariableKind = 'variable' | 'temporary' | 'local' | 'transient';
47
120
 
48
- let value: unknown;
49
- if (prefix === '$') {
50
- value = variables[root];
51
- } else if (prefix === '_') {
52
- value = temporary[root];
53
- } else if (prefix === '%') {
54
- value = transient[root];
55
- } else {
56
- value = locals[root];
121
+ const KIND_SIGILS = {
122
+ variable: '$',
123
+ temporary: '_',
124
+ local: '@',
125
+ transient: '%',
126
+ } as const;
127
+
128
+ /**
129
+ * The value of a variable reference (`name` without its sigil, with an
130
+ * optional dot path), as `{$name}` shows it in passage text (see
131
+ * VarDisplay): the variable is read from its namespace's own entries only,
132
+ * so an unset `$toString` is undefined, and one named `__proto__` throws.
133
+ */
134
+ function resolveVariable(
135
+ scope: TextScope,
136
+ kind: VariableKind,
137
+ name: string,
138
+ ): unknown {
139
+ const parts = name.split('.');
140
+ const root = parts[0]!;
141
+ checkVariableName(root, KIND_SIGILS[kind] + root);
142
+ const store =
143
+ kind === 'variable'
144
+ ? scope.variables
145
+ : kind === 'temporary'
146
+ ? scope.temporary
147
+ : kind === 'transient'
148
+ ? scope.transient
149
+ : scope.locals;
150
+ // Own entries only, as the expression engine reads them
151
+ let value = ownValue(store, root);
152
+ // Primitives box on access, so `{$name.length}` works too.
153
+ for (let i = 1; i < parts.length; i++) {
154
+ if (value == null) return undefined;
155
+ value = (value as Record<string, unknown>)[parts[i]!];
57
156
  }
157
+ return value;
158
+ }
58
159
 
59
- if (parts.length > 1) {
60
- value = resolveDotPath(value, parts);
160
+ class TextEvaluator {
161
+ readonly errors: TextError[] = [];
162
+
163
+ nodes(nodes: ASTNode[], scope: TextScope, depth: number): string {
164
+ let text = '';
165
+ for (const node of nodes) text += this.node(node, scope, depth);
166
+ return text;
61
167
  }
62
168
 
63
- return value == null ? '' : String(value);
64
- }
169
+ private evaluate(expr: string, scope: TextScope): unknown {
170
+ return evaluate(
171
+ expr,
172
+ scope.variables,
173
+ scope.temporary,
174
+ scope.locals,
175
+ scope.transient,
176
+ );
177
+ }
65
178
 
66
- export function interpolate(
67
- template: string,
68
- variables: Record<string, unknown>,
69
- temporary: Record<string, unknown>,
70
- locals: Record<string, unknown>,
71
- transient: Record<string, unknown> = {},
72
- ): string {
73
- // Manual scan: process {…} blocks containing sigils.
74
- // Simple dot-path refs use the fast resolver; everything else falls back
75
- // to the full expression evaluator.
76
- let result = '';
77
- let i = 0;
78
-
79
- while (i < template.length) {
80
- if (template[i] !== '{') {
81
- // Accumulate plain text
82
- const nextBrace = template.indexOf('{', i);
83
- if (nextBrace === -1) {
84
- result += template.slice(i);
85
- break;
179
+ private node(node: ASTNode, scope: TextScope, depth: number): string {
180
+ switch (node.type) {
181
+ case 'text':
182
+ return node.value;
183
+ case 'variable':
184
+ if (node.scope === 'local' && node.name === 'children') {
185
+ // As {@children} in a widget body: the invocation's children, in
186
+ // the scope of the body.
187
+ const children = scope.widgetChildren;
188
+ if (!children) return '';
189
+ return this.nested('children', children, scope, depth, {
190
+ widgetChildren: null,
191
+ });
192
+ }
193
+ return this.variable(node.scope, node.name, scope);
194
+ case 'expression':
195
+ try {
196
+ return display(this.evaluate(node.expression, scope));
197
+ } catch (error) {
198
+ this.errors.push({ macro: 'expression', error });
199
+ return '';
200
+ }
201
+ case 'html':
202
+ return this.nodes(node.children, scope, depth);
203
+ case 'macro':
204
+ return this.macro(node, scope, depth);
205
+ default: {
206
+ const _exhaustive: never = node;
207
+ return _exhaustive;
86
208
  }
87
- result += template.slice(i, nextBrace);
88
- i = nextBrace;
89
- continue;
90
209
  }
210
+ }
91
211
 
92
- // Found { — check if it starts a sigil expression
93
- i++; // skip {
212
+ /** A variable reference, or '' and an error if it can't be one. */
213
+ private variable(kind: VariableKind, name: string, scope: TextScope): string {
214
+ try {
215
+ return display(resolveVariable(scope, kind, name));
216
+ } catch (error) {
217
+ // Reported as passage text reports it: `{$__proto__ error: …}`
218
+ this.errors.push({ macro: KIND_SIGILS[kind] + name, error });
219
+ return '';
220
+ }
221
+ }
94
222
 
95
- const sigil = template[i];
96
- if (sigil !== '$' && sigil !== '_' && sigil !== '@' && sigil !== '%') {
97
- // Not an interpolation — emit the { as text
98
- result += '{';
99
- continue;
223
+ private nested(
224
+ macro: string,
225
+ nodes: ASTNode[],
226
+ scope: TextScope,
227
+ depth: number,
228
+ changes: Partial<TextScope> = {},
229
+ ): string {
230
+ if (depth >= MAX_DEPTH) {
231
+ this.errors.push({
232
+ macro,
233
+ error: new Error('markup nested too deeply (a widget using itself?)'),
234
+ });
235
+ return '';
100
236
  }
237
+ return this.nodes(nodes, { ...scope, ...changes }, depth + 1);
238
+ }
101
239
 
102
- // Scan for balanced closing }, ignoring braces inside strings
103
- const j = scanBalancedBrace(template, i);
240
+ private macro(node: MacroNode, scope: TextScope, depth: number): string {
241
+ // Rendering ignores a stray sub-macro too (see renderMacro).
242
+ if (isSubMacro(node.name)) return '';
104
243
 
105
- if (j === -1) {
106
- // Unbalanced — emit as text
107
- result += '{';
108
- continue;
244
+ const widget = getWidget(node.name);
245
+ if (widget) {
246
+ let locals = scope.locals;
247
+ // Parameterized widgets get their own scope (see WidgetInvocation):
248
+ // a missing or failing argument is undefined.
249
+ if (widget.params.length > 0) {
250
+ const argExprs = node.rawArgs ? splitArgs(node.rawArgs) : [];
251
+ locals = createNamespace(scope.locals);
252
+ widget.params.forEach((param, i) => {
253
+ let value: unknown;
254
+ const expr = argExprs[i];
255
+ if (expr !== undefined) {
256
+ try {
257
+ value = this.evaluate(expr, scope);
258
+ } catch {
259
+ value = undefined;
260
+ }
261
+ }
262
+ locals[param.startsWith('@') ? param.slice(1) : param] = value;
263
+ });
264
+ }
265
+ return this.nested(node.name, widget.body, scope, depth, {
266
+ locals,
267
+ widgetChildren: node.children.length > 0 ? node.children : null,
268
+ });
109
269
  }
110
270
 
111
- const inner = template.slice(i, j);
112
- i = j + 1; // skip past closing }
271
+ const text = getMacroText(node.name);
272
+ if (!text) {
273
+ const message = getMacro(node.name)
274
+ ? `{${node.name}} has no text form, so it can't be used in an attribute value or label`
275
+ : `unknown macro {${node.name}}`;
276
+ this.errors.push({ macro: node.name, error: new Error(message) });
277
+ return '';
278
+ }
113
279
 
114
- // Try simple dot-path match first
115
- if (/^[\$_@%][\w.]+$/.test(inner)) {
116
- result += resolveSimple(inner, variables, temporary, locals, transient);
117
- } else {
118
- result += interpolateExpression(
119
- inner,
120
- variables,
121
- temporary,
122
- locals,
123
- transient,
280
+ const ctx: MacroTextContext = {
281
+ evaluate: (expr) => this.evaluate(expr, scope),
282
+ renderText: (nodes, locals) =>
283
+ this.nested(
284
+ node.name,
285
+ nodes,
286
+ scope,
287
+ depth,
288
+ locals ? { locals: withLocals(scope.locals, locals) } : {},
289
+ ),
290
+ };
291
+ try {
292
+ return text(
293
+ {
294
+ rawArgs: node.rawArgs,
295
+ className: node.className,
296
+ id: node.id,
297
+ children: node.children,
298
+ branches: node.branches,
299
+ },
300
+ ctx,
124
301
  );
302
+ } catch (error) {
303
+ this.errors.push({ macro: node.name, error });
304
+ return '';
305
+ }
306
+ }
307
+ }
308
+
309
+ /**
310
+ * The locals `ns` with `added` (keys without `@`) on top, as a namespace
311
+ * (see utils/namespace.ts). A name no namespace can hold throws.
312
+ */
313
+ function withLocals(ns: Namespace, added: Record<string, unknown>): Namespace {
314
+ for (const key of Object.keys(added)) checkVariableName(key, `@${key}`);
315
+ return createNamespace(ns, added);
316
+ }
317
+
318
+ /** Evaluate parsed text-only markup to a string. */
319
+ export function renderText(nodes: ASTNode[], scope: TextScope): TextResult {
320
+ const evaluator = new TextEvaluator();
321
+ const text = evaluator.nodes(nodes, scope, 0);
322
+ return { text, errors: evaluator.errors };
323
+ }
324
+
325
+ /**
326
+ * Evaluate text-only markup. Markup that doesn't parse (an unclosed
327
+ * `{if}`) is reported and kept as written.
328
+ */
329
+ export function interpolateText(
330
+ template: string,
331
+ scope: TextScope,
332
+ ): TextResult {
333
+ if (!hasInterpolation(template)) return { text: template, errors: [] };
334
+ const parsed = parseText(template);
335
+ if ('error' in parsed) {
336
+ return {
337
+ text: template,
338
+ errors: [{ macro: 'markup', error: parsed.error }],
339
+ };
340
+ }
341
+ return renderText(parsed.nodes, scope);
342
+ }
343
+
344
+ const SIGIL_KINDS = {
345
+ $: 'variable',
346
+ _: 'temporary',
347
+ '@': 'local',
348
+ '%': 'transient',
349
+ } as const;
350
+
351
+ /**
352
+ * Resolve the sigil references in the value of a code attribute (see
353
+ * isCodeAttribute): `{$x}`, `{_x.y}`, `{$n + 1}`. Everything else, other
354
+ * braces included, is literal text, passed through `decode` when given.
355
+ * A reference whose expression fails is reported and left empty.
356
+ */
357
+ export function interpolateCode(
358
+ template: string,
359
+ scope: TextScope,
360
+ decode?: (text: string) => string,
361
+ ): TextResult {
362
+ const errors: TextError[] = [];
363
+ let text = '';
364
+ for (const part of splitSigilTemplate(template)) {
365
+ if ('text' in part) {
366
+ text += decode ? decode(part.text) : part.text;
367
+ } else if ('verbatim' in part) {
368
+ text += part.verbatim;
369
+ } else if (/^[$_@%][\w.]+$/.test(part.expr)) {
370
+ const kind = SIGIL_KINDS[part.expr[0] as keyof typeof SIGIL_KINDS];
371
+ try {
372
+ text += display(resolveVariable(scope, kind, part.expr.slice(1)));
373
+ } catch (error) {
374
+ errors.push({ macro: part.expr, error });
375
+ }
376
+ } else {
377
+ try {
378
+ text += display(
379
+ evaluate(
380
+ part.expr,
381
+ scope.variables,
382
+ scope.temporary,
383
+ scope.locals,
384
+ scope.transient,
385
+ ),
386
+ );
387
+ } catch (error) {
388
+ errors.push({ macro: 'expression', error });
389
+ }
125
390
  }
126
391
  }
392
+ return { text, errors };
393
+ }
394
+
395
+ /**
396
+ * Evaluate text-only markup to a string, throwing the first error met.
397
+ */
398
+ export function interpolate(
399
+ template: string,
400
+ variables: Record<string, unknown>,
401
+ temporary: Record<string, unknown>,
402
+ locals: Record<string, unknown>,
403
+ transient: Record<string, unknown> = {},
404
+ ): string {
405
+ const { text, errors } = interpolateText(template, {
406
+ variables,
407
+ temporary,
408
+ locals,
409
+ transient,
410
+ });
411
+ if (errors.length > 0) throw errors[0]!.error;
412
+ return text;
413
+ }
127
414
 
128
- return result;
415
+ /**
416
+ * Evaluate an expression string and return its stringified result.
417
+ * Uses the full expression evaluator from expression.ts.
418
+ */
419
+ export function interpolateExpression(
420
+ expr: string,
421
+ variables: Record<string, unknown>,
422
+ temporary: Record<string, unknown>,
423
+ locals: Record<string, unknown>,
424
+ transient: Record<string, unknown> = {},
425
+ ): string {
426
+ return display(evaluate(expr, variables, temporary, locals, transient));
129
427
  }