yarn-spinner-runner-ts 0.1.2 → 0.1.3

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 (70) hide show
  1. package/README.md +97 -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 +6 -4
  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 +10 -1
  23. package/dist/react/DialogueView.js +68 -5
  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/runtime/results.d.ts +3 -0
  32. package/dist/runtime/runner.d.ts +1 -0
  33. package/dist/runtime/runner.js +151 -14
  34. package/dist/runtime/runner.js.map +1 -1
  35. package/dist/tests/markup.test.d.ts +1 -0
  36. package/dist/tests/markup.test.js +46 -0
  37. package/dist/tests/markup.test.js.map +1 -0
  38. package/dist/tests/nodes_lines.test.js +25 -1
  39. package/dist/tests/nodes_lines.test.js.map +1 -1
  40. package/dist/tests/options.test.js +30 -1
  41. package/dist/tests/options.test.js.map +1 -1
  42. package/dist/tests/typing-text.test.d.ts +1 -0
  43. package/dist/tests/typing-text.test.js +12 -0
  44. package/dist/tests/typing-text.test.js.map +1 -0
  45. package/docs/actor-transition.md +34 -0
  46. package/docs/markup.md +34 -19
  47. package/docs/scenes-actors-setup.md +1 -0
  48. package/docs/typing-animation.md +44 -0
  49. package/eslint.config.cjs +3 -0
  50. package/examples/browser/index.html +1 -1
  51. package/examples/browser/main.tsx +0 -2
  52. package/package.json +1 -1
  53. package/src/compile/compiler.ts +4 -4
  54. package/src/compile/ir.ts +3 -2
  55. package/src/index.ts +3 -0
  56. package/src/markup/parser.ts +372 -0
  57. package/src/markup/types.ts +22 -0
  58. package/src/model/ast.ts +17 -13
  59. package/src/parse/parser.ts +60 -8
  60. package/src/react/DialogueExample.tsx +18 -42
  61. package/src/react/DialogueScene.tsx +143 -44
  62. package/src/react/DialogueView.tsx +122 -8
  63. package/src/react/MarkupRenderer.tsx +110 -0
  64. package/src/react/TypingText.tsx +127 -0
  65. package/src/react/dialogue.css +26 -13
  66. package/src/runtime/results.ts +3 -1
  67. package/src/runtime/runner.ts +158 -14
  68. package/src/tests/markup.test.ts +62 -0
  69. package/src/tests/nodes_lines.test.ts +35 -1
  70. package/src/tests/options.test.ts +39 -1
@@ -0,0 +1,127 @@
1
+ import React, { useState, useEffect, useRef } from "react";
2
+ import type { MarkupParseResult } from "../markup/types.js";
3
+ import { MarkupRenderer } from "./MarkupRenderer.js";
4
+
5
+ export interface TypingTextProps {
6
+ text: string;
7
+ markup?: MarkupParseResult;
8
+ typingSpeed?: number;
9
+ showCursor?: boolean;
10
+ cursorCharacter?: string;
11
+ cursorBlinkDuration?: number;
12
+ cursorClassName?: string;
13
+ className?: string;
14
+ onComplete?: () => void;
15
+ disabled?: boolean;
16
+ }
17
+
18
+ export function TypingText({
19
+ text,
20
+ markup,
21
+ typingSpeed = 100,
22
+ showCursor = true,
23
+ cursorCharacter = "|",
24
+ cursorBlinkDuration = 530,
25
+ cursorClassName = "",
26
+ className = "",
27
+ onComplete,
28
+ disabled = false,
29
+ }: TypingTextProps) {
30
+ const [displayedLength, setDisplayedLength] = useState(disabled ? text.length : 0);
31
+ const [cursorVisible, setCursorVisible] = useState(true);
32
+ const cursorIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
33
+ const typingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
34
+ const onCompleteRef = useRef(onComplete);
35
+
36
+ useEffect(() => {
37
+ onCompleteRef.current = onComplete;
38
+ }, [onComplete]);
39
+
40
+ // Handle cursor blinking
41
+ useEffect(() => {
42
+ if (!showCursor || disabled) {
43
+ return;
44
+ }
45
+ cursorIntervalRef.current = setInterval(() => {
46
+ setCursorVisible((prev) => !prev);
47
+ }, cursorBlinkDuration);
48
+ return () => {
49
+ if (cursorIntervalRef.current) {
50
+ clearInterval(cursorIntervalRef.current);
51
+ }
52
+ };
53
+ }, [showCursor, cursorBlinkDuration, disabled]);
54
+
55
+ // Handle typing animation
56
+ useEffect(() => {
57
+ if (disabled) {
58
+ setDisplayedLength(text.length);
59
+ if (onCompleteRef.current && text.length > 0) {
60
+ onCompleteRef.current();
61
+ }
62
+ return;
63
+ }
64
+
65
+ // Reset when text changes
66
+ setDisplayedLength(0);
67
+
68
+ if (text.length === 0) {
69
+ if (onCompleteRef.current) {
70
+ onCompleteRef.current();
71
+ }
72
+ return;
73
+ }
74
+
75
+ let index = 0;
76
+ const typeNextCharacter = () => {
77
+ if (index < text.length) {
78
+ index += 1;
79
+ setDisplayedLength(index);
80
+ if (typingSpeed <= 0) {
81
+ requestAnimationFrame(typeNextCharacter);
82
+ } else {
83
+ typingTimeoutRef.current = setTimeout(typeNextCharacter, typingSpeed);
84
+ }
85
+ } else if (onCompleteRef.current) {
86
+ onCompleteRef.current();
87
+ }
88
+ };
89
+
90
+ if (typingSpeed <= 0) {
91
+ requestAnimationFrame(typeNextCharacter);
92
+ } else {
93
+ typingTimeoutRef.current = setTimeout(typeNextCharacter, typingSpeed);
94
+ }
95
+
96
+ return () => {
97
+ if (typingTimeoutRef.current) {
98
+ clearTimeout(typingTimeoutRef.current);
99
+ }
100
+ };
101
+ }, [text, disabled, typingSpeed]);
102
+
103
+ const visibleLength = markup ? Math.min(displayedLength, markup.text.length) : Math.min(displayedLength, text.length);
104
+
105
+ return (
106
+ <span className={className}>
107
+ <span>
108
+ {markup ? (
109
+ <MarkupRenderer text={text} markup={markup} length={visibleLength} />
110
+ ) : (
111
+ text.slice(0, visibleLength)
112
+ )}
113
+ </span>
114
+ {showCursor && !disabled && (
115
+ <span
116
+ className={`yd-typing-cursor ${cursorClassName}`}
117
+ style={{
118
+ opacity: cursorVisible ? 1 : 0,
119
+ transition: `opacity ${cursorBlinkDuration / 2}ms ease-in-out`,
120
+ }}
121
+ >
122
+ {cursorCharacter}
123
+ </span>
124
+ )}
125
+ </span>
126
+ );
127
+ }
@@ -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 {
@@ -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";
@@ -205,15 +206,152 @@ export class YarnRunner {
205
206
  this.step();
206
207
  }
207
208
 
208
- private interpolate(text: string): string {
209
- return text.replace(/\{([^}]+)\}/g, (_m, expr) => {
209
+ private interpolate(text: string, markup?: MarkupParseResult): { text: string; markup?: MarkupParseResult } {
210
+ const evaluateExpression = (expr: string): string => {
210
211
  try {
211
- const val = this.evaluator.evaluateExpression(String(expr));
212
- return String(val ?? "");
212
+ const value = this.evaluator.evaluateExpression(expr.trim());
213
+ if (value === null || value === undefined) {
214
+ return "";
215
+ }
216
+ return String(value);
213
217
  } catch {
214
218
  return "";
215
219
  }
216
- });
220
+ };
221
+
222
+ if (!markup) {
223
+ const interpolated = text.replace(/\{([^}]+)\}/g, (_m, expr) => evaluateExpression(expr));
224
+ return { text: interpolated };
225
+ }
226
+
227
+ const segments = markup.segments.filter((segment) => !segment.selfClosing);
228
+ const getWrappersAt = (index: number): MarkupWrapper[] => {
229
+ for (const segment of segments) {
230
+ if (segment.start <= index && index < segment.end) {
231
+ return segment.wrappers.map((wrapper) => ({
232
+ name: wrapper.name,
233
+ type: wrapper.type,
234
+ properties: { ...wrapper.properties },
235
+ }));
236
+ }
237
+ }
238
+ if (segments.length === 0) {
239
+ return [];
240
+ }
241
+ if (index > 0) {
242
+ return getWrappersAt(index - 1);
243
+ }
244
+ return segments[0].wrappers.map((wrapper) => ({
245
+ name: wrapper.name,
246
+ type: wrapper.type,
247
+ properties: { ...wrapper.properties },
248
+ }));
249
+ };
250
+
251
+ const resultChars: string[] = [];
252
+ const newSegments: MarkupSegment[] = [];
253
+ let currentSegment: MarkupSegment | null = null;
254
+
255
+ const wrappersEqual = (a: MarkupWrapper[], b: MarkupWrapper[]) => {
256
+ if (a.length !== b.length) return false;
257
+ for (let i = 0; i < a.length; i++) {
258
+ const wa = a[i];
259
+ const wb = b[i];
260
+ if (wa.name !== wb.name || wa.type !== wb.type) return false;
261
+ const keysA = Object.keys(wa.properties);
262
+ const keysB = Object.keys(wb.properties);
263
+ if (keysA.length !== keysB.length) return false;
264
+ for (const key of keysA) {
265
+ if (wa.properties[key] !== wb.properties[key]) return false;
266
+ }
267
+ }
268
+ return true;
269
+ };
270
+
271
+ const flushSegment = () => {
272
+ if (currentSegment) {
273
+ newSegments.push(currentSegment);
274
+ currentSegment = null;
275
+ }
276
+ };
277
+
278
+ const appendCharWithWrappers = (char: string, wrappers: MarkupWrapper[]) => {
279
+ const index = resultChars.length;
280
+ resultChars.push(char);
281
+ const wrappersCopy = wrappers.map((wrapper) => ({
282
+ name: wrapper.name,
283
+ type: wrapper.type,
284
+ properties: { ...wrapper.properties },
285
+ }));
286
+ if (currentSegment && wrappersEqual(currentSegment.wrappers, wrappersCopy)) {
287
+ currentSegment.end = index + 1;
288
+ } else {
289
+ flushSegment();
290
+ currentSegment = { start: index, end: index + 1, wrappers: wrappersCopy };
291
+ }
292
+ };
293
+
294
+ const appendStringWithWrappers = (value: string, wrappers: MarkupWrapper[]) => {
295
+ if (!value) {
296
+ flushSegment();
297
+ return;
298
+ }
299
+ for (const ch of value) {
300
+ appendCharWithWrappers(ch, wrappers);
301
+ }
302
+ };
303
+
304
+ let i = 0;
305
+ while (i < text.length) {
306
+ const char = text[i];
307
+ if (char === '{') {
308
+ const close = text.indexOf('}', i + 1);
309
+ if (close === -1) {
310
+ appendCharWithWrappers(char, getWrappersAt(Math.max(0, Math.min(i, text.length - 1))));
311
+ i += 1;
312
+ continue;
313
+ }
314
+ const expr = text.slice(i + 1, close);
315
+ const evaluated = evaluateExpression(expr);
316
+ const wrappers = getWrappersAt(Math.max(0, Math.min(i, text.length - 1)));
317
+ appendStringWithWrappers(evaluated, wrappers);
318
+ i = close + 1;
319
+ continue;
320
+ }
321
+ appendCharWithWrappers(char, getWrappersAt(i));
322
+ i += 1;
323
+ }
324
+
325
+ flushSegment();
326
+ const interpolatedText = resultChars.join('');
327
+ const normalizedMarkup = this.normalizeMarkupResult({ text: interpolatedText, segments: newSegments });
328
+ return { text: interpolatedText, markup: normalizedMarkup };
329
+ }
330
+
331
+ private normalizeMarkupResult(result: MarkupParseResult): MarkupParseResult | undefined {
332
+ if (!result) return undefined;
333
+ if (result.segments.length === 0) {
334
+ return undefined;
335
+ }
336
+ const hasFormatting = result.segments.some(
337
+ (segment) => segment.wrappers.length > 0 || segment.selfClosing
338
+ );
339
+ if (!hasFormatting) {
340
+ return undefined;
341
+ }
342
+ return {
343
+ text: result.text,
344
+ segments: result.segments.map((segment) => ({
345
+ start: segment.start,
346
+ end: segment.end,
347
+ wrappers: segment.wrappers.map((wrapper) => ({
348
+ name: wrapper.name,
349
+ type: wrapper.type,
350
+ properties: { ...wrapper.properties },
351
+ })),
352
+ selfClosing: segment.selfClosing,
353
+ })),
354
+ };
217
355
  }
218
356
 
219
357
  private resumeBlock(): boolean {
@@ -229,9 +367,11 @@ export class YarnRunner {
229
367
  return true;
230
368
  }
231
369
  switch (ins.op) {
232
- case "line":
233
- this.emit({ type: "text", text: this.interpolate(ins.text), speaker: ins.speaker, tags: ins.tags, isDialogueEnd: false });
370
+ case "line": {
371
+ const { text: interpolatedText, markup: interpolatedMarkup } = this.interpolate(ins.text, ins.markup);
372
+ this.emit({ type: "text", text: interpolatedText, speaker: ins.speaker, tags: ins.tags, markup: interpolatedMarkup, isDialogueEnd: false });
234
373
  return true;
374
+ }
235
375
  case "command": {
236
376
  try {
237
377
  const parsed = parseCommand(ins.content);
@@ -244,7 +384,7 @@ export class YarnRunner {
244
384
  return true;
245
385
  }
246
386
  case "options": {
247
- this.emit({ type: "options", options: ins.options.map((o) => ({ text: o.text, tags: o.tags })), isDialogueEnd: false });
387
+ this.emit({ type: "options", options: ins.options.map((o) => ({ text: o.text, tags: o.tags, markup: o.markup })), isDialogueEnd: false });
248
388
  return true;
249
389
  }
250
390
  case "if": {
@@ -294,9 +434,11 @@ export class YarnRunner {
294
434
  }
295
435
  this.ip++;
296
436
  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() });
437
+ case "line": {
438
+ const { text: interpolatedText, markup: interpolatedMarkup } = this.interpolate(ins.text, ins.markup);
439
+ this.emit({ type: "text", text: interpolatedText, speaker: ins.speaker, tags: ins.tags, markup: interpolatedMarkup, nodeCss: resolved.css, scene: resolved.scene, isDialogueEnd: this.lookaheadIsEnd() });
299
440
  return;
441
+ }
300
442
  case "command": {
301
443
  try {
302
444
  const parsed = parseCommand(ins.content);
@@ -327,7 +469,7 @@ export class YarnRunner {
327
469
  continue;
328
470
  }
329
471
  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() });
472
+ 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
473
  return;
332
474
  }
333
475
  case "if": {
@@ -368,10 +510,12 @@ export class YarnRunner {
368
510
  const ins = tempNode.instructions[idx++];
369
511
  if (!ins) break;
370
512
  switch (ins.op) {
371
- case "line":
372
- this.emit({ type: "text", text: ins.text, speaker: ins.speaker, isDialogueEnd: false });
513
+ case "line": {
514
+ const { text: interpolatedText, markup: interpolatedMarkup } = this.interpolate(ins.text, ins.markup);
515
+ this.emit({ type: "text", text: interpolatedText, speaker: ins.speaker, markup: interpolatedMarkup, isDialogueEnd: false });
373
516
  restore();
374
517
  return;
518
+ }
375
519
  case "command":
376
520
  try {
377
521
  const parsed = parseCommand(ins.content);
@@ -384,7 +528,7 @@ export class YarnRunner {
384
528
  restore();
385
529
  return;
386
530
  case "options":
387
- this.emit({ type: "options", options: ins.options.map((o) => ({ text: o.text })), isDialogueEnd: false });
531
+ this.emit({ type: "options", options: ins.options.map((o) => ({ text: o.text, markup: o.markup })), isDialogueEnd: false });
388
532
  // Maintain context that options belong to main node at ip-1
389
533
  restore();
390
534
  return;
@@ -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
+ });
@@ -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("options selection", () => {
@@ -32,3 +32,41 @@ Narrator: Choose one
32
32
  });
33
33
 
34
34
 
35
+
36
+
37
+ test("option markup is exposed", () => {
38
+ const script = `
39
+ title: Start
40
+ ---
41
+ Narrator: Choose
42
+ -> [b]Bold[/b]
43
+ Narrator: Bold
44
+ -> [wave intensity=5]Custom[/wave]
45
+ Narrator: Custom
46
+ ===
47
+ `;
48
+
49
+ const doc = parseYarn(script);
50
+ const ir = compile(doc);
51
+ const runner = new YarnRunner(ir, { startAt: "Start" });
52
+
53
+ runner.advance(); // move to options
54
+ const result = runner.currentResult;
55
+ ok(result && result.type === "options", "Expected options result");
56
+ const options = result!.options;
57
+ ok(options[0].markup, "Expected markup on first option");
58
+ ok(options[1].markup, "Expected markup on second option");
59
+ const boldMarkup = options[0].markup!;
60
+ strictEqual(boldMarkup.text, "Bold");
61
+ ok(
62
+ boldMarkup.segments.some((segment) =>
63
+ segment.wrappers.some((wrapper) => wrapper.name === "b" && wrapper.type === "default")
64
+ ),
65
+ "Expected bold wrapper"
66
+ );
67
+ const customWrapper = options[1].markup!.segments
68
+ .flatMap((segment) => segment.wrappers)
69
+ .find((wrapper) => wrapper.name === "wave");
70
+ ok(customWrapper, "Expected custom wrapper on second option");
71
+ strictEqual(customWrapper!.properties.intensity, 5);
72
+ });