yarn-spinner-runner-ts 0.1.3 → 0.1.4-b

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 (38) hide show
  1. package/README.md +5 -0
  2. package/dist/markup/parser.js +15 -2
  3. package/dist/markup/parser.js.map +1 -1
  4. package/dist/react/DialogueExample.js +9 -8
  5. package/dist/react/DialogueExample.js.map +1 -1
  6. package/dist/react/DialogueView.d.ts +9 -4
  7. package/dist/react/DialogueView.js +22 -8
  8. package/dist/react/DialogueView.js.map +1 -1
  9. package/dist/react/MarkupRenderer.js +1 -1
  10. package/dist/react/MarkupRenderer.js.map +1 -1
  11. package/dist/react/useYarnRunner.js +10 -1
  12. package/dist/react/useYarnRunner.js.map +1 -1
  13. package/dist/runtime/commands.js +12 -1
  14. package/dist/runtime/commands.js.map +1 -1
  15. package/dist/runtime/runner.d.ts +6 -0
  16. package/dist/runtime/runner.js +10 -0
  17. package/dist/runtime/runner.js.map +1 -1
  18. package/dist/tests/custom_functions.test.d.ts +1 -0
  19. package/dist/tests/custom_functions.test.js +129 -0
  20. package/dist/tests/custom_functions.test.js.map +1 -0
  21. package/dist/tests/markup.test.js +7 -0
  22. package/dist/tests/markup.test.js.map +1 -1
  23. package/dist/tests/story_end.test.d.ts +1 -0
  24. package/dist/tests/story_end.test.js +37 -0
  25. package/dist/tests/story_end.test.js.map +1 -0
  26. package/docs/markup.md +33 -33
  27. package/eslint.config.cjs +39 -39
  28. package/package.json +1 -1
  29. package/src/markup/parser.ts +56 -43
  30. package/src/react/DialogueExample.tsx +12 -13
  31. package/src/react/DialogueView.tsx +298 -275
  32. package/src/react/MarkupRenderer.tsx +1 -2
  33. package/src/react/useYarnRunner.tsx +13 -1
  34. package/src/runtime/commands.ts +14 -1
  35. package/src/runtime/runner.ts +12 -0
  36. package/src/tests/custom_functions.test.ts +140 -0
  37. package/src/tests/markup.test.ts +17 -1
  38. package/src/tests/story_end.test.ts +42 -0
@@ -1,7 +1,7 @@
1
1
  import React from "react";
2
2
  import type { MarkupParseResult, MarkupWrapper } from "../markup/types.js";
3
3
 
4
- const DEFAULT_HTML_TAGS = new Set(["b", "em", "small", "strong", "sub", "sup", "ins", "del", "mark"]);
4
+ const DEFAULT_HTML_TAGS = new Set(["b", "em", "small", "strong", "sub", "sup", "ins", "del", "mark", "br"]);
5
5
 
6
6
  interface RenderPiece {
7
7
  text: string;
@@ -107,4 +107,3 @@ function createWrapperElement(
107
107
  function sanitizeClassName(name: string): string {
108
108
  return name.toLowerCase().replace(/[^a-z0-9_-]/g, "-");
109
109
  }
110
-
@@ -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)
@@ -10,6 +10,7 @@ export interface RunnerOptions {
10
10
  functions?: Record<string, (...args: unknown[]) => unknown>;
11
11
  handleCommand?: (command: string, parsed?: ReturnType<typeof parseCommand>) => void;
12
12
  commandHandler?: CommandHandler;
13
+ onStoryEnd?: (payload: { variables: Readonly<Record<string, unknown>>; storyEnd: true }) => void;
13
14
  }
14
15
 
15
16
  const globalOnceSeen = new Set<string>();
@@ -23,6 +24,8 @@ export class YarnRunner {
23
24
  private readonly commandHandler: CommandHandler;
24
25
  private readonly evaluator: ExpressionEvaluator;
25
26
  private readonly onceSeen = globalOnceSeen;
27
+ private readonly onStoryEnd?: RunnerOptions["onStoryEnd"];
28
+ private storyEnded = false;
26
29
  private readonly nodeGroupOnceSeen = globalNodeGroupOnceSeen;
27
30
  private readonly visitCounts: Record<string, number> = {};
28
31
 
@@ -95,6 +98,7 @@ export class YarnRunner {
95
98
  ...(opts.functions ?? {}),
96
99
  } as Record<string, (...args: unknown[]) => unknown>;
97
100
  this.handleCommand = opts.handleCommand;
101
+ this.onStoryEnd = opts.onStoryEnd;
98
102
  this.evaluator = new ExpressionEvaluator(this.variables, this.functions, this.program.enums);
99
103
  this.commandHandler = opts.commandHandler ?? new CommandHandler(this.variables);
100
104
  this.nodeTitle = opts.startAt;
@@ -589,6 +593,14 @@ export class YarnRunner {
589
593
  private emit(res: RuntimeResult) {
590
594
  this.currentResult = res;
591
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
+ }
592
604
  // If we ended a detour node, return to caller after emitting last result
593
605
  // Position is restored here, but we wait for next advance() to continue
594
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
+ });
@@ -43,6 +43,23 @@ test("parseMarkup handles self-closing tags", () => {
43
43
  );
44
44
  });
45
45
 
46
+ test("parseMarkup handles br line breaks", () => {
47
+ const result = parseMarkup("Line one[br]Line two[/br][br/]Line three");
48
+ strictEqual(result.text, "Line one\nLine two\nLine three");
49
+
50
+ const brSegments = result.segments.filter(
51
+ (segment) => segment.selfClosing && segment.wrappers.some((wrapper) => wrapper.name === "br")
52
+ );
53
+
54
+ strictEqual(brSegments.length, 2);
55
+ ok(
56
+ brSegments.every((segment) =>
57
+ segment.wrappers.some((wrapper) => wrapper.name === "br" && wrapper.type === "default")
58
+ ),
59
+ "Expected br wrappers to use default HTML type"
60
+ );
61
+ });
62
+
46
63
  test("parseMarkup respects nomarkup blocks and escaping", () => {
47
64
  const result = parseMarkup(`[nomarkup][b] raw [/b][/nomarkup] and \\[escaped\\]`);
48
65
  strictEqual(result.text, "[b] raw [/b] and [escaped]");
@@ -59,4 +76,3 @@ function findSegment(result: MarkupParseResult, target: string) {
59
76
  return text === target;
60
77
  });
61
78
  }
62
-
@@ -0,0 +1,42 @@
1
+ import { test } from "node:test";
2
+ import { ok, strictEqual } from "node:assert";
3
+ import { parseYarn, compile, YarnRunner } from "../index.js";
4
+
5
+ test("onStoryEnd receives variables snapshot", () => {
6
+ const script = `
7
+ title: Start
8
+ ---
9
+ Narrator: Beginning
10
+ <<set $score = 42>>
11
+ Narrator: Done
12
+ ===
13
+ `;
14
+ let payload: { variables: Readonly<Record<string, unknown>>; storyEnd: true } | undefined;
15
+ const doc = parseYarn(script);
16
+ const ir = compile(doc);
17
+ const runner = new YarnRunner(ir, {
18
+ startAt: "Start",
19
+ onStoryEnd: (info) => {
20
+ payload = info;
21
+ },
22
+ });
23
+
24
+ let result = runner.currentResult;
25
+ ok(result && result.type === "text");
26
+
27
+ runner.advance();
28
+ result = runner.currentResult;
29
+ ok(result && result.type === "command");
30
+
31
+ runner.advance();
32
+ result = runner.currentResult;
33
+ ok(result && result.type === "text");
34
+
35
+ runner.advance();
36
+ result = runner.currentResult;
37
+ ok(result && result.isDialogueEnd === true);
38
+
39
+ strictEqual(payload?.storyEnd, true);
40
+ const variables = payload?.variables ?? {};
41
+ strictEqual((variables as Record<string, unknown>)["score"], 42);
42
+ });