yarn-spinner-runner-ts 0.1.2 → 0.1.4-a

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 (84) hide show
  1. package/README.md +102 -88
  2. package/dist/compile/compiler.js +4 -4
  3. package/dist/compile/compiler.js.map +1 -1
  4. package/dist/compile/ir.d.ts +3 -0
  5. package/dist/index.d.ts +3 -0
  6. package/dist/index.js +3 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/markup/parser.d.ts +3 -0
  9. package/dist/markup/parser.js +332 -0
  10. package/dist/markup/parser.js.map +1 -0
  11. package/dist/markup/types.d.ts +17 -0
  12. package/dist/markup/types.js +2 -0
  13. package/dist/markup/types.js.map +1 -0
  14. package/dist/model/ast.d.ts +3 -0
  15. package/dist/parse/parser.js +57 -8
  16. package/dist/parse/parser.js.map +1 -1
  17. package/dist/react/DialogueExample.js +13 -10
  18. package/dist/react/DialogueExample.js.map +1 -1
  19. package/dist/react/DialogueScene.d.ts +2 -1
  20. package/dist/react/DialogueScene.js +95 -26
  21. package/dist/react/DialogueScene.js.map +1 -1
  22. package/dist/react/DialogueView.d.ts +18 -4
  23. package/dist/react/DialogueView.js +84 -7
  24. package/dist/react/DialogueView.js.map +1 -1
  25. package/dist/react/MarkupRenderer.d.ts +8 -0
  26. package/dist/react/MarkupRenderer.js +64 -0
  27. package/dist/react/MarkupRenderer.js.map +1 -0
  28. package/dist/react/TypingText.d.ts +14 -0
  29. package/dist/react/TypingText.js +78 -0
  30. package/dist/react/TypingText.js.map +1 -0
  31. package/dist/react/useYarnRunner.js +10 -1
  32. package/dist/react/useYarnRunner.js.map +1 -1
  33. package/dist/runtime/commands.js +12 -1
  34. package/dist/runtime/commands.js.map +1 -1
  35. package/dist/runtime/results.d.ts +3 -0
  36. package/dist/runtime/runner.d.ts +7 -0
  37. package/dist/runtime/runner.js +161 -14
  38. package/dist/runtime/runner.js.map +1 -1
  39. package/dist/tests/custom_functions.test.d.ts +1 -0
  40. package/dist/tests/custom_functions.test.js +129 -0
  41. package/dist/tests/custom_functions.test.js.map +1 -0
  42. package/dist/tests/markup.test.d.ts +1 -0
  43. package/dist/tests/markup.test.js +46 -0
  44. package/dist/tests/markup.test.js.map +1 -0
  45. package/dist/tests/nodes_lines.test.js +25 -1
  46. package/dist/tests/nodes_lines.test.js.map +1 -1
  47. package/dist/tests/options.test.js +30 -1
  48. package/dist/tests/options.test.js.map +1 -1
  49. package/dist/tests/story_end.test.d.ts +1 -0
  50. package/dist/tests/story_end.test.js +37 -0
  51. package/dist/tests/story_end.test.js.map +1 -0
  52. package/dist/tests/typing-text.test.d.ts +1 -0
  53. package/dist/tests/typing-text.test.js +12 -0
  54. package/dist/tests/typing-text.test.js.map +1 -0
  55. package/docs/actor-transition.md +34 -0
  56. package/docs/markup.md +34 -19
  57. package/docs/scenes-actors-setup.md +1 -0
  58. package/docs/typing-animation.md +44 -0
  59. package/eslint.config.cjs +3 -0
  60. package/examples/browser/index.html +1 -1
  61. package/examples/browser/main.tsx +0 -2
  62. package/package.json +1 -1
  63. package/src/compile/compiler.ts +4 -4
  64. package/src/compile/ir.ts +3 -2
  65. package/src/index.ts +3 -0
  66. package/src/markup/parser.ts +372 -0
  67. package/src/markup/types.ts +22 -0
  68. package/src/model/ast.ts +17 -13
  69. package/src/parse/parser.ts +60 -8
  70. package/src/react/DialogueExample.tsx +27 -51
  71. package/src/react/DialogueScene.tsx +143 -44
  72. package/src/react/DialogueView.tsx +150 -14
  73. package/src/react/MarkupRenderer.tsx +110 -0
  74. package/src/react/TypingText.tsx +127 -0
  75. package/src/react/dialogue.css +26 -13
  76. package/src/react/useYarnRunner.tsx +13 -1
  77. package/src/runtime/commands.ts +14 -1
  78. package/src/runtime/results.ts +3 -1
  79. package/src/runtime/runner.ts +170 -14
  80. package/src/tests/custom_functions.test.ts +140 -0
  81. package/src/tests/markup.test.ts +62 -0
  82. package/src/tests/nodes_lines.test.ts +35 -1
  83. package/src/tests/options.test.ts +39 -1
  84. package/src/tests/story_end.test.ts +42 -0
@@ -43,19 +43,32 @@
43
43
  }
44
44
 
45
45
  /* Actor image */
46
- .yd-actor {
47
- position: absolute;
48
- top: 0;
49
- left: 50%;
50
- transform: translateX(-50%);
51
- max-height: 70%;
52
- max-width: 40%;
53
- object-fit: contain;
54
- z-index: 1;
55
- transition: opacity 0.3s ease-in-out;
56
- opacity: 1;
57
- pointer-events: none;
58
- }
46
+ .yd-actor {
47
+ position: absolute;
48
+ top: 0;
49
+ left: 50%;
50
+ transform: translateX(-50%);
51
+ max-height: 70%;
52
+ max-width: 40%;
53
+ object-fit: contain;
54
+ opacity: 0;
55
+ transition: opacity var(--yd-actor-transition, 0.35s) ease-in-out;
56
+ pointer-events: none;
57
+ z-index: 1;
58
+ will-change: opacity;
59
+ }
60
+
61
+ .yd-actor--current {
62
+ z-index: 2;
63
+ }
64
+
65
+ .yd-actor--previous {
66
+ z-index: 1;
67
+ }
68
+
69
+ .yd-actor--visible {
70
+ opacity: 1;
71
+ }
59
72
 
60
73
  /* Dialogue box container */
61
74
  .yd-dialogue-box {
@@ -13,11 +13,23 @@ export function useYarnRunner(
13
13
  } {
14
14
  const runnerRef = useRef<YarnRunner | null>(null);
15
15
  const [result, setResult] = useState<RuntimeResult | null>(null);
16
+ const optionsRef = useRef(options);
16
17
 
17
- // Initialize runner only once
18
+ // Update runner if functions change
19
+ if (
20
+ runnerRef.current &&
21
+ JSON.stringify(optionsRef.current.functions) !== JSON.stringify(options.functions)
22
+ ) {
23
+ runnerRef.current = new YarnRunner(program, options);
24
+ setResult(runnerRef.current.currentResult);
25
+ optionsRef.current = options;
26
+ }
27
+
28
+ // Initialize runner if not exists
18
29
  if (!runnerRef.current) {
19
30
  runnerRef.current = new YarnRunner(program, options);
20
31
  setResult(runnerRef.current.currentResult);
32
+ optionsRef.current = options;
21
33
  }
22
34
 
23
35
  const runner = runnerRef.current;
@@ -29,6 +29,14 @@ export function parseCommand(content: string): ParsedCommand {
29
29
  const char = trimmed[i];
30
30
 
31
31
  if ((char === '"' || char === "'") && !inQuotes) {
32
+ // If we have accumulated non-quoted content (e.g. a function name and "(")
33
+ // push it as its own part before entering quoted mode. This prevents the
34
+ // surrounding text from being merged into the quoted content when we
35
+ // later push the quoted value.
36
+ if (current.trim()) {
37
+ parts.push(current.trim());
38
+ current = "";
39
+ }
32
40
  inQuotes = true;
33
41
  quoteChar = char;
34
42
  continue;
@@ -36,8 +44,11 @@ export function parseCommand(content: string): ParsedCommand {
36
44
 
37
45
  if (char === quoteChar && inQuotes) {
38
46
  inQuotes = false;
47
+ // Preserve the surrounding quotes in the parsed part so callers that
48
+ // reassemble the expression (e.g. declare handlers) keep string literals
49
+ // intact instead of losing quote characters.
50
+ parts.push(quoteChar + current + quoteChar);
39
51
  quoteChar = "";
40
- parts.push(current);
41
52
  current = "";
42
53
  continue;
43
54
  }
@@ -129,11 +140,13 @@ export class CommandHandler {
129
140
  this.register("declare", (args, evaluator) => {
130
141
  if (!evaluator) return;
131
142
  if (args.length < 3) return; // name, '=', expr
143
+
132
144
  const varNameRaw = args[0];
133
145
  let exprParts = args.slice(1);
134
146
  if (exprParts[0] === "=") exprParts = exprParts.slice(1);
135
147
  const expr = exprParts.join(" ");
136
148
 
149
+
137
150
  const key = varNameRaw.startsWith("$") ? varNameRaw.slice(1) : varNameRaw;
138
151
 
139
152
  // Check if expression is "smart" (contains operators, comparisons, or variable references)
@@ -1,8 +1,10 @@
1
+ import type { MarkupParseResult } from "../markup/types.js";
1
2
  export type TextResult = {
2
3
  type: "text";
3
4
  text: string;
4
5
  speaker?: string;
5
6
  tags?: string[];
7
+ markup?: MarkupParseResult;
6
8
  nodeCss?: string; // Node-level CSS from &css{} header
7
9
  scene?: string; // Scene name from node header
8
10
  isDialogueEnd: boolean;
@@ -10,7 +12,7 @@ export type TextResult = {
10
12
 
11
13
  export type OptionsResult = {
12
14
  type: "options";
13
- options: { text: string; tags?: string[]; css?: string }[];
15
+ options: { text: string; tags?: string[]; css?: string; markup?: MarkupParseResult }[];
14
16
  nodeCss?: string; // Node-level CSS from &css{} header
15
17
  scene?: string; // Scene name from node header
16
18
  isDialogueEnd: boolean;
@@ -1,4 +1,5 @@
1
1
  import type { IRProgram, IRInstruction, IRNode, IRNodeGroup } from "../compile/ir";
2
+ import type { MarkupParseResult, MarkupSegment, MarkupWrapper } from "../markup/types.js";
2
3
  import type { RuntimeResult } from "./results.js";
3
4
  import { ExpressionEvaluator } from "./evaluator.js";
4
5
  import { CommandHandler, parseCommand } from "./commands.js";
@@ -9,6 +10,7 @@ export interface RunnerOptions {
9
10
  functions?: Record<string, (...args: unknown[]) => unknown>;
10
11
  handleCommand?: (command: string, parsed?: ReturnType<typeof parseCommand>) => void;
11
12
  commandHandler?: CommandHandler;
13
+ onStoryEnd?: (payload: { variables: Readonly<Record<string, unknown>>; storyEnd: true }) => void;
12
14
  }
13
15
 
14
16
  const globalOnceSeen = new Set<string>();
@@ -22,6 +24,8 @@ export class YarnRunner {
22
24
  private readonly commandHandler: CommandHandler;
23
25
  private readonly evaluator: ExpressionEvaluator;
24
26
  private readonly onceSeen = globalOnceSeen;
27
+ private readonly onStoryEnd?: RunnerOptions["onStoryEnd"];
28
+ private storyEnded = false;
25
29
  private readonly nodeGroupOnceSeen = globalNodeGroupOnceSeen;
26
30
  private readonly visitCounts: Record<string, number> = {};
27
31
 
@@ -94,6 +98,7 @@ export class YarnRunner {
94
98
  ...(opts.functions ?? {}),
95
99
  } as Record<string, (...args: unknown[]) => unknown>;
96
100
  this.handleCommand = opts.handleCommand;
101
+ this.onStoryEnd = opts.onStoryEnd;
97
102
  this.evaluator = new ExpressionEvaluator(this.variables, this.functions, this.program.enums);
98
103
  this.commandHandler = opts.commandHandler ?? new CommandHandler(this.variables);
99
104
  this.nodeTitle = opts.startAt;
@@ -205,15 +210,152 @@ export class YarnRunner {
205
210
  this.step();
206
211
  }
207
212
 
208
- private interpolate(text: string): string {
209
- return text.replace(/\{([^}]+)\}/g, (_m, expr) => {
213
+ private interpolate(text: string, markup?: MarkupParseResult): { text: string; markup?: MarkupParseResult } {
214
+ const evaluateExpression = (expr: string): string => {
210
215
  try {
211
- const val = this.evaluator.evaluateExpression(String(expr));
212
- return String(val ?? "");
216
+ const value = this.evaluator.evaluateExpression(expr.trim());
217
+ if (value === null || value === undefined) {
218
+ return "";
219
+ }
220
+ return String(value);
213
221
  } catch {
214
222
  return "";
215
223
  }
216
- });
224
+ };
225
+
226
+ if (!markup) {
227
+ const interpolated = text.replace(/\{([^}]+)\}/g, (_m, expr) => evaluateExpression(expr));
228
+ return { text: interpolated };
229
+ }
230
+
231
+ const segments = markup.segments.filter((segment) => !segment.selfClosing);
232
+ const getWrappersAt = (index: number): MarkupWrapper[] => {
233
+ for (const segment of segments) {
234
+ if (segment.start <= index && index < segment.end) {
235
+ return segment.wrappers.map((wrapper) => ({
236
+ name: wrapper.name,
237
+ type: wrapper.type,
238
+ properties: { ...wrapper.properties },
239
+ }));
240
+ }
241
+ }
242
+ if (segments.length === 0) {
243
+ return [];
244
+ }
245
+ if (index > 0) {
246
+ return getWrappersAt(index - 1);
247
+ }
248
+ return segments[0].wrappers.map((wrapper) => ({
249
+ name: wrapper.name,
250
+ type: wrapper.type,
251
+ properties: { ...wrapper.properties },
252
+ }));
253
+ };
254
+
255
+ const resultChars: string[] = [];
256
+ const newSegments: MarkupSegment[] = [];
257
+ let currentSegment: MarkupSegment | null = null;
258
+
259
+ const wrappersEqual = (a: MarkupWrapper[], b: MarkupWrapper[]) => {
260
+ if (a.length !== b.length) return false;
261
+ for (let i = 0; i < a.length; i++) {
262
+ const wa = a[i];
263
+ const wb = b[i];
264
+ if (wa.name !== wb.name || wa.type !== wb.type) return false;
265
+ const keysA = Object.keys(wa.properties);
266
+ const keysB = Object.keys(wb.properties);
267
+ if (keysA.length !== keysB.length) return false;
268
+ for (const key of keysA) {
269
+ if (wa.properties[key] !== wb.properties[key]) return false;
270
+ }
271
+ }
272
+ return true;
273
+ };
274
+
275
+ const flushSegment = () => {
276
+ if (currentSegment) {
277
+ newSegments.push(currentSegment);
278
+ currentSegment = null;
279
+ }
280
+ };
281
+
282
+ const appendCharWithWrappers = (char: string, wrappers: MarkupWrapper[]) => {
283
+ const index = resultChars.length;
284
+ resultChars.push(char);
285
+ const wrappersCopy = wrappers.map((wrapper) => ({
286
+ name: wrapper.name,
287
+ type: wrapper.type,
288
+ properties: { ...wrapper.properties },
289
+ }));
290
+ if (currentSegment && wrappersEqual(currentSegment.wrappers, wrappersCopy)) {
291
+ currentSegment.end = index + 1;
292
+ } else {
293
+ flushSegment();
294
+ currentSegment = { start: index, end: index + 1, wrappers: wrappersCopy };
295
+ }
296
+ };
297
+
298
+ const appendStringWithWrappers = (value: string, wrappers: MarkupWrapper[]) => {
299
+ if (!value) {
300
+ flushSegment();
301
+ return;
302
+ }
303
+ for (const ch of value) {
304
+ appendCharWithWrappers(ch, wrappers);
305
+ }
306
+ };
307
+
308
+ let i = 0;
309
+ while (i < text.length) {
310
+ const char = text[i];
311
+ if (char === '{') {
312
+ const close = text.indexOf('}', i + 1);
313
+ if (close === -1) {
314
+ appendCharWithWrappers(char, getWrappersAt(Math.max(0, Math.min(i, text.length - 1))));
315
+ i += 1;
316
+ continue;
317
+ }
318
+ const expr = text.slice(i + 1, close);
319
+ const evaluated = evaluateExpression(expr);
320
+ const wrappers = getWrappersAt(Math.max(0, Math.min(i, text.length - 1)));
321
+ appendStringWithWrappers(evaluated, wrappers);
322
+ i = close + 1;
323
+ continue;
324
+ }
325
+ appendCharWithWrappers(char, getWrappersAt(i));
326
+ i += 1;
327
+ }
328
+
329
+ flushSegment();
330
+ const interpolatedText = resultChars.join('');
331
+ const normalizedMarkup = this.normalizeMarkupResult({ text: interpolatedText, segments: newSegments });
332
+ return { text: interpolatedText, markup: normalizedMarkup };
333
+ }
334
+
335
+ private normalizeMarkupResult(result: MarkupParseResult): MarkupParseResult | undefined {
336
+ if (!result) return undefined;
337
+ if (result.segments.length === 0) {
338
+ return undefined;
339
+ }
340
+ const hasFormatting = result.segments.some(
341
+ (segment) => segment.wrappers.length > 0 || segment.selfClosing
342
+ );
343
+ if (!hasFormatting) {
344
+ return undefined;
345
+ }
346
+ return {
347
+ text: result.text,
348
+ segments: result.segments.map((segment) => ({
349
+ start: segment.start,
350
+ end: segment.end,
351
+ wrappers: segment.wrappers.map((wrapper) => ({
352
+ name: wrapper.name,
353
+ type: wrapper.type,
354
+ properties: { ...wrapper.properties },
355
+ })),
356
+ selfClosing: segment.selfClosing,
357
+ })),
358
+ };
217
359
  }
218
360
 
219
361
  private resumeBlock(): boolean {
@@ -229,9 +371,11 @@ export class YarnRunner {
229
371
  return true;
230
372
  }
231
373
  switch (ins.op) {
232
- case "line":
233
- this.emit({ type: "text", text: this.interpolate(ins.text), speaker: ins.speaker, tags: ins.tags, isDialogueEnd: false });
374
+ case "line": {
375
+ const { text: interpolatedText, markup: interpolatedMarkup } = this.interpolate(ins.text, ins.markup);
376
+ this.emit({ type: "text", text: interpolatedText, speaker: ins.speaker, tags: ins.tags, markup: interpolatedMarkup, isDialogueEnd: false });
234
377
  return true;
378
+ }
235
379
  case "command": {
236
380
  try {
237
381
  const parsed = parseCommand(ins.content);
@@ -244,7 +388,7 @@ export class YarnRunner {
244
388
  return true;
245
389
  }
246
390
  case "options": {
247
- this.emit({ type: "options", options: ins.options.map((o) => ({ text: o.text, tags: o.tags })), isDialogueEnd: false });
391
+ this.emit({ type: "options", options: ins.options.map((o) => ({ text: o.text, tags: o.tags, markup: o.markup })), isDialogueEnd: false });
248
392
  return true;
249
393
  }
250
394
  case "if": {
@@ -294,9 +438,11 @@ export class YarnRunner {
294
438
  }
295
439
  this.ip++;
296
440
  switch (ins.op) {
297
- case "line":
298
- this.emit({ type: "text", text: this.interpolate(ins.text), speaker: ins.speaker, tags: ins.tags, nodeCss: resolved.css, scene: resolved.scene, isDialogueEnd: this.lookaheadIsEnd() });
441
+ case "line": {
442
+ const { text: interpolatedText, markup: interpolatedMarkup } = this.interpolate(ins.text, ins.markup);
443
+ this.emit({ type: "text", text: interpolatedText, speaker: ins.speaker, tags: ins.tags, markup: interpolatedMarkup, nodeCss: resolved.css, scene: resolved.scene, isDialogueEnd: this.lookaheadIsEnd() });
299
444
  return;
445
+ }
300
446
  case "command": {
301
447
  try {
302
448
  const parsed = parseCommand(ins.content);
@@ -327,7 +473,7 @@ export class YarnRunner {
327
473
  continue;
328
474
  }
329
475
  case "options": {
330
- this.emit({ type: "options", options: ins.options.map((o: { text: string; tags?: string[]; css?: string }) => ({ text: o.text, tags: o.tags, css: o.css })), nodeCss: resolved.css, scene: resolved.scene, isDialogueEnd: this.lookaheadIsEnd() });
476
+ this.emit({ type: "options", options: ins.options.map((o: { text: string; tags?: string[]; css?: string; markup?: MarkupParseResult }) => ({ text: o.text, tags: o.tags, css: o.css, markup: o.markup })), nodeCss: resolved.css, scene: resolved.scene, isDialogueEnd: this.lookaheadIsEnd() });
331
477
  return;
332
478
  }
333
479
  case "if": {
@@ -368,10 +514,12 @@ export class YarnRunner {
368
514
  const ins = tempNode.instructions[idx++];
369
515
  if (!ins) break;
370
516
  switch (ins.op) {
371
- case "line":
372
- this.emit({ type: "text", text: ins.text, speaker: ins.speaker, isDialogueEnd: false });
517
+ case "line": {
518
+ const { text: interpolatedText, markup: interpolatedMarkup } = this.interpolate(ins.text, ins.markup);
519
+ this.emit({ type: "text", text: interpolatedText, speaker: ins.speaker, markup: interpolatedMarkup, isDialogueEnd: false });
373
520
  restore();
374
521
  return;
522
+ }
375
523
  case "command":
376
524
  try {
377
525
  const parsed = parseCommand(ins.content);
@@ -384,7 +532,7 @@ export class YarnRunner {
384
532
  restore();
385
533
  return;
386
534
  case "options":
387
- this.emit({ type: "options", options: ins.options.map((o) => ({ text: o.text })), isDialogueEnd: false });
535
+ this.emit({ type: "options", options: ins.options.map((o) => ({ text: o.text, markup: o.markup })), isDialogueEnd: false });
388
536
  // Maintain context that options belong to main node at ip-1
389
537
  restore();
390
538
  return;
@@ -445,6 +593,14 @@ export class YarnRunner {
445
593
  private emit(res: RuntimeResult) {
446
594
  this.currentResult = res;
447
595
  this.history.push(res);
596
+ if (res.isDialogueEnd && !this.storyEnded && this.callStack.length === 0) {
597
+ this.storyEnded = true;
598
+ if (this.onStoryEnd) {
599
+ // Create a readonly copy of the variables
600
+ const variablesCopy = Object.freeze({ ...this.variables });
601
+ this.onStoryEnd({ storyEnd: true, variables: variablesCopy });
602
+ }
603
+ }
448
604
  // If we ended a detour node, return to caller after emitting last result
449
605
  // Position is restored here, but we wait for next advance() to continue
450
606
  if (res.isDialogueEnd && this.callStack.length > 0) {
@@ -0,0 +1,140 @@
1
+ import { test } from "node:test";
2
+ import { strictEqual, ok, match } from "node:assert";
3
+ import { parseYarn, compile } from "../index.js";
4
+ import { YarnRunner } from "../runtime/runner.js";
5
+
6
+ test("custom functions", () => {
7
+ const yarnText = `
8
+ title: CustomFuncs
9
+ ---
10
+ <<declare $doubled = multiply(2, 3)>>
11
+ <<declare $concatenated = concat("Hello", " World")>>
12
+ <<declare $power = pow(2, 3)>>
13
+ <<declare $conditionalValue = ifThen(true, "yes", "no")>>
14
+ Result: {$doubled}, {$concatenated}, {$power}, {$conditionalValue}
15
+ ===
16
+ `;
17
+
18
+ const ast = parseYarn(yarnText);
19
+ const program = compile(ast);
20
+ const runner = new YarnRunner(program, {
21
+ startAt: "CustomFuncs",
22
+ functions: {
23
+ multiply: (a: unknown, b: unknown) => Number(a) * Number(b),
24
+ concat: (a: unknown, b: unknown) => String(a) + String(b),
25
+ pow: (base: unknown, exp: unknown) => Math.pow(Number(base), Number(exp)),
26
+ ifThen: (cond: unknown, yes: unknown, no: unknown) => Boolean(cond) ? yes : no,
27
+ },
28
+ });
29
+
30
+ // Need to advance past declare commands and get to the text line
31
+ for (let i = 0; i < 4; i++) {
32
+ runner.advance();
33
+ }
34
+ strictEqual(runner.currentResult?.type, "text");
35
+ if (runner.currentResult?.type === "text") {
36
+ const fullText = (runner.currentResult.speaker ? `${runner.currentResult.speaker}: ` : "") + runner.currentResult.text;
37
+ strictEqual(fullText, "Result: 6, Hello World, 8, yes");
38
+ }
39
+ });
40
+
41
+ test("custom functions with type coercion", () => {
42
+ const yarnText = `
43
+ title: TypeCoercion
44
+ ---
45
+ <<declare $numFromStr = multiply("2", "3")>>
46
+ <<declare $concatNums = concat(123, 456)>>
47
+ <<declare $boolStr = ifThen("true", 1, 0)>>
48
+ Result: {$numFromStr}, {$concatNums}, {$boolStr}
49
+ ===
50
+ `;
51
+
52
+ const ast = parseYarn(yarnText);
53
+ const program = compile(ast);
54
+ const runner = new YarnRunner(program, {
55
+ startAt: "TypeCoercion",
56
+ functions: {
57
+ multiply: (a: unknown, b: unknown) => Number(a) * Number(b),
58
+ concat: (a: unknown, b: unknown) => String(a) + String(b),
59
+ ifThen: (cond: unknown, yes: unknown, no: unknown) => Boolean(cond) ? yes : no,
60
+ },
61
+ });
62
+
63
+ // Need to advance past declare commands and get to the text line
64
+ for (let i = 0; i < 3; i++) {
65
+ runner.advance();
66
+ }
67
+ strictEqual(runner.currentResult?.type, "text");
68
+ if (runner.currentResult?.type === "text") {
69
+ const fullText = (runner.currentResult.speaker ? `${runner.currentResult.speaker}: ` : "") + runner.currentResult.text;
70
+ strictEqual(fullText, "Result: 6, 123456, 1");
71
+ }
72
+ });
73
+
74
+ test("custom functions error handling", () => {
75
+ const yarnText = `
76
+ title: ErrorHandling
77
+ ---
78
+ <<declare $result = safeDivide(10, 0)>>
79
+ Result: {$result}
80
+ ===
81
+ `;
82
+
83
+ const ast = parseYarn(yarnText);
84
+ const program = compile(ast);
85
+ const runner = new YarnRunner(program, {
86
+ startAt: "ErrorHandling",
87
+ functions: {
88
+ safeDivide: (a: unknown, b: unknown) => {
89
+ const numerator = Number(a);
90
+ const denominator = Number(b);
91
+ return denominator === 0 ? "Cannot divide by zero" : numerator / denominator;
92
+ },
93
+ },
94
+ });
95
+
96
+ // Advance until we reach a text result (some commands emit immediately)
97
+ for (let i = 0; i < 10 && runner.currentResult?.type !== "text"; i++) {
98
+ runner.advance();
99
+ }
100
+ strictEqual(runner.currentResult?.type, "text");
101
+ if (runner.currentResult?.type === "text") {
102
+ const fullText = (runner.currentResult.speaker ? `${runner.currentResult.speaker}: ` : "") + runner.currentResult.text;
103
+ strictEqual(fullText, "Result: Cannot divide by zero");
104
+ }
105
+ });
106
+
107
+ test("custom functions alongside built-in functions", () => {
108
+ const yarnText = `
109
+ title: MixedFunctions
110
+ ---
111
+ <<declare $random = random()>>
112
+ <<declare $doubled = multiply($random, 2)>>
113
+ <<declare $formatted = format_number($doubled)>>
114
+ Result: {$formatted}
115
+ ===
116
+ `;
117
+
118
+ const ast = parseYarn(yarnText);
119
+ const program = compile(ast);
120
+ const runner = new YarnRunner(program, {
121
+ startAt: "MixedFunctions",
122
+ functions: {
123
+ multiply: (a: unknown, b: unknown) => Number(a) * Number(b),
124
+ format_number: (n: unknown) => Number(n).toFixed(2),
125
+ },
126
+ });
127
+
128
+ // Need to advance past declare commands and get to the text line
129
+ for (let i = 0; i < 3; i++) {
130
+ runner.advance();
131
+ }
132
+ strictEqual(runner.currentResult?.type, "text");
133
+ if (runner.currentResult?.type === "text") {
134
+ const fullText = (runner.currentResult.speaker ? `${runner.currentResult.speaker}: ` : "") + runner.currentResult.text;
135
+ const resultNumber = parseFloat(fullText.replace("Result: ", ""));
136
+ ok(resultNumber >= 0);
137
+ ok(resultNumber <= 2);
138
+ match(fullText, /Result: \d+\.\d{2}/);
139
+ }
140
+ });
@@ -0,0 +1,62 @@
1
+ import { test } from "node:test";
2
+ import { strictEqual, ok } from "node:assert";
3
+ import { parseMarkup } from "../markup/parser.js";
4
+ import type { MarkupParseResult } from "../markup/types.js";
5
+
6
+ test("parseMarkup handles default HTML tags", () => {
7
+ const result = parseMarkup("This is [b]bold[/b] and [em]emphasized[/em].");
8
+
9
+ strictEqual(result.text, "This is bold and emphasized.");
10
+ const bold = findSegment(result, "bold");
11
+ ok(bold, "Expected bold segment");
12
+ strictEqual(bold.wrappers.length, 1);
13
+ strictEqual(bold.wrappers[0].name, "b");
14
+ strictEqual(bold.wrappers[0].type, "default");
15
+
16
+ const em = findSegment(result, "emphasized");
17
+ ok(em, "Expected em segment");
18
+ strictEqual(em.wrappers[0].name, "em");
19
+ strictEqual(em.wrappers[0].type, "default");
20
+ });
21
+
22
+ test("parseMarkup exposes custom tags with properties", () => {
23
+ const result = parseMarkup("Say [wave speed=2 tone=\"high\"]hello[/wave]!");
24
+ strictEqual(result.text, "Say hello!");
25
+
26
+ const segment = findSegment(result, "hello");
27
+ ok(segment, "Expected wave segment");
28
+ const wrapper = segment.wrappers.find((w) => w.name === "wave");
29
+ ok(wrapper, "Expected wave wrapper");
30
+ strictEqual(wrapper.type, "custom");
31
+ strictEqual(wrapper.properties.speed, 2);
32
+ strictEqual(wrapper.properties.tone, "high");
33
+ });
34
+
35
+ test("parseMarkup handles self-closing tags", () => {
36
+ const result = parseMarkup("[pause length=500/] Ready.");
37
+ strictEqual(result.text, " Ready.");
38
+ ok(
39
+ result.segments.some(
40
+ (seg) => seg.selfClosing && seg.wrappers.some((wrapper) => wrapper.name === "pause")
41
+ ),
42
+ "Expected self closing pause marker"
43
+ );
44
+ });
45
+
46
+ test("parseMarkup respects nomarkup blocks and escaping", () => {
47
+ const result = parseMarkup(`[nomarkup][b] raw [/b][/nomarkup] and \\[escaped\\]`);
48
+ strictEqual(result.text, "[b] raw [/b] and [escaped]");
49
+ ok(
50
+ result.segments.every((segment) => segment.wrappers.length === 0),
51
+ "Expected no wrappers when using nomarkup and escaping"
52
+ );
53
+ });
54
+
55
+ function findSegment(result: MarkupParseResult, target: string) {
56
+ return result.segments.find((segment) => {
57
+ if (segment.selfClosing) return false;
58
+ const text = result.text.slice(segment.start, segment.end);
59
+ return text === target;
60
+ });
61
+ }
62
+
@@ -1,5 +1,5 @@
1
1
  import { test } from "node:test";
2
- import { strictEqual } from "node:assert";
2
+ import { strictEqual, ok } from "node:assert";
3
3
  import { parseYarn, compile, YarnRunner } from "../index.js";
4
4
 
5
5
  test("nodes and lines delivery", () => {
@@ -25,3 +25,37 @@ Narrator: Line two
25
25
  });
26
26
 
27
27
 
28
+
29
+
30
+ test("markup parsing propagates to runtime", () => {
31
+ const script = `
32
+ title: Start
33
+ ---
34
+ Narrator: Plain [b]bold[/b] [wave speed=2]custom[/wave]
35
+ ===
36
+ `;
37
+
38
+ const doc = parseYarn(script);
39
+ const ir = compile(doc);
40
+ const runner = new YarnRunner(ir, { startAt: "Start" });
41
+
42
+ const result = runner.currentResult;
43
+ ok(result && result.type === "text", "Expected text result with markup");
44
+ ok(result?.markup, "Expected markup data to be present");
45
+ const markup = result!.markup!;
46
+ strictEqual(markup.text, "Plain bold custom");
47
+
48
+ const boldSegment = markup.segments.find((segment) =>
49
+ segment.wrappers.some((wrapper) => wrapper.name === "b" && wrapper.type === "default")
50
+ );
51
+ ok(boldSegment, "Expected bold segment");
52
+ strictEqual(markup.text.slice(boldSegment!.start, boldSegment!.end), "bold");
53
+
54
+ const customSegment = markup.segments.find((segment) =>
55
+ segment.wrappers.some((wrapper) => wrapper.name === "wave" && wrapper.type === "custom")
56
+ );
57
+ ok(customSegment, "Expected custom segment");
58
+ const waveWrapper = customSegment!.wrappers.find((wrapper) => wrapper.name === "wave");
59
+ ok(waveWrapper);
60
+ strictEqual(waveWrapper!.properties.speed, 2);
61
+ });