yarn-spinner-runner-ts 0.1.4 → 0.1.5-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 (54) hide show
  1. package/README.md +44 -16
  2. package/dist/compile/compiler.js +2 -2
  3. package/dist/compile/compiler.js.map +1 -1
  4. package/dist/compile/ir.d.ts +1 -0
  5. package/dist/markup/parser.js +12 -2
  6. package/dist/markup/parser.js.map +1 -1
  7. package/dist/model/ast.d.ts +1 -0
  8. package/dist/parse/lexer.js +5 -5
  9. package/dist/parse/lexer.js.map +1 -1
  10. package/dist/parse/parser.js +12 -2
  11. package/dist/parse/parser.js.map +1 -1
  12. package/dist/react/DialogueExample.js +12 -10
  13. package/dist/react/DialogueExample.js.map +1 -1
  14. package/dist/react/DialogueView.d.ts +10 -4
  15. package/dist/react/DialogueView.js +38 -12
  16. package/dist/react/DialogueView.js.map +1 -1
  17. package/dist/react/MarkupRenderer.js +1 -1
  18. package/dist/react/MarkupRenderer.js.map +1 -1
  19. package/dist/react/useYarnRunner.js +63 -10
  20. package/dist/react/useYarnRunner.js.map +1 -1
  21. package/dist/runtime/evaluator.d.ts +3 -0
  22. package/dist/runtime/evaluator.js +165 -3
  23. package/dist/runtime/evaluator.js.map +1 -1
  24. package/dist/runtime/runner.d.ts +2 -0
  25. package/dist/runtime/runner.js +66 -10
  26. package/dist/runtime/runner.js.map +1 -1
  27. package/dist/tests/dialogue_view.test.d.ts +1 -0
  28. package/dist/tests/dialogue_view.test.js +18 -0
  29. package/dist/tests/dialogue_view.test.js.map +1 -0
  30. package/dist/tests/markup.test.js +7 -0
  31. package/dist/tests/markup.test.js.map +1 -1
  32. package/dist/tests/options.test.js +164 -9
  33. package/dist/tests/options.test.js.map +1 -1
  34. package/dist/tests/variables_flow_cmds.test.js +117 -10
  35. package/dist/tests/variables_flow_cmds.test.js.map +1 -1
  36. package/docs/markup.md +33 -33
  37. package/eslint.config.cjs +39 -39
  38. package/package.json +6 -6
  39. package/src/compile/compiler.ts +2 -2
  40. package/src/compile/ir.ts +1 -1
  41. package/src/markup/parser.ts +53 -43
  42. package/src/model/ast.ts +1 -0
  43. package/src/parse/lexer.ts +18 -18
  44. package/src/parse/parser.ts +33 -22
  45. package/src/react/DialogueExample.tsx +16 -14
  46. package/src/react/DialogueView.tsx +312 -275
  47. package/src/react/MarkupRenderer.tsx +1 -2
  48. package/src/react/useYarnRunner.tsx +101 -34
  49. package/src/runtime/evaluator.ts +224 -47
  50. package/src/runtime/runner.ts +102 -37
  51. package/src/tests/dialogue_view.test.tsx +26 -0
  52. package/src/tests/markup.test.ts +17 -1
  53. package/src/tests/options.test.ts +206 -36
  54. package/src/tests/variables_flow_cmds.test.ts +139 -28
package/eslint.config.cjs CHANGED
@@ -1,39 +1,39 @@
1
- // ESLint v9 flat config
2
- const js = require("@eslint/js");
3
- const tsParser = require("@typescript-eslint/parser");
4
- const tsPlugin = require("@typescript-eslint/eslint-plugin");
5
-
6
- module.exports = [
7
- {
8
- ignores: ["dist/**", "node_modules/**", "examples/**", "src/examples/**", "src/tests/**"],
9
- },
10
- {
11
- files: ["src/**/*.ts", "src/**/*.tsx"],
12
- languageOptions: {
13
- parser: tsParser,
14
- ecmaVersion: "latest",
15
- sourceType: "module",
16
- globals: {
17
- console: "readonly",
18
- window: "readonly",
19
- setTimeout: "readonly",
20
- clearTimeout: "readonly",
21
- setInterval: "readonly",
22
- clearInterval: "readonly",
23
- requestAnimationFrame: "readonly",
24
- },
25
- },
26
- plugins: {
27
- "@typescript-eslint": tsPlugin,
28
- },
29
- rules: {
30
- ...js.configs.recommended.rules,
31
- ...tsPlugin.configs.recommended.rules,
32
- "@typescript-eslint/explicit-module-boundary-types": "off",
33
- "@typescript-eslint/no-explicit-any": "off",
34
- "no-console": ["warn", { allow: ["warn", "error", "assert", "log"] }],
35
- },
36
- },
37
- ];
38
-
39
-
1
+ // ESLint v9 flat config
2
+ const js = require("@eslint/js");
3
+ const tsParser = require("@typescript-eslint/parser");
4
+ const tsPlugin = require("@typescript-eslint/eslint-plugin");
5
+
6
+ module.exports = [
7
+ {
8
+ ignores: ["dist/**", "node_modules/**", "examples/**", "src/examples/**", "src/tests/**"],
9
+ },
10
+ {
11
+ files: ["src/**/*.ts", "src/**/*.tsx"],
12
+ languageOptions: {
13
+ parser: tsParser,
14
+ ecmaVersion: "latest",
15
+ sourceType: "module",
16
+ globals: {
17
+ console: "readonly",
18
+ window: "readonly",
19
+ setTimeout: "readonly",
20
+ clearTimeout: "readonly",
21
+ setInterval: "readonly",
22
+ clearInterval: "readonly",
23
+ requestAnimationFrame: "readonly",
24
+ },
25
+ },
26
+ plugins: {
27
+ "@typescript-eslint": tsPlugin,
28
+ },
29
+ rules: {
30
+ ...js.configs.recommended.rules,
31
+ ...tsPlugin.configs.recommended.rules,
32
+ "@typescript-eslint/explicit-module-boundary-types": "off",
33
+ "@typescript-eslint/no-explicit-any": "off",
34
+ "no-console": ["warn", { allow: ["warn", "error", "assert", "log"] }],
35
+ },
36
+ },
37
+ ];
38
+
39
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yarn-spinner-runner-ts",
3
- "version": "0.1.4",
3
+ "version": "0.1.5-a",
4
4
  "private": false,
5
5
  "description": "TypeScript parser, compiler, and runtime for Yarn Spinner 3.x with React adapter [NPM package](https://www.npmjs.com/package/yarn-spinner-runner-ts)",
6
6
  "license": "MIT",
@@ -37,20 +37,20 @@
37
37
  "js-yaml": "^4.1.0"
38
38
  },
39
39
  "devDependencies": {
40
- "@eslint/js": "^9.12.0",
41
- "@types/js-yaml": "^4.0.9",
42
40
  "@types/node": "^22.7.4",
41
+ "@types/js-yaml": "^4.0.9",
43
42
  "@types/react": "^18.3.3",
44
43
  "@types/react-dom": "^18.3.0",
45
- "@typescript-eslint/eslint-plugin": "^8.8.1",
44
+ "@eslint/js": "^9.12.0",
46
45
  "@typescript-eslint/parser": "^8.8.1",
46
+ "@typescript-eslint/eslint-plugin": "^8.8.1",
47
47
  "@vitejs/plugin-react": "^4.3.1",
48
48
  "eslint": "^9.12.0",
49
49
  "react": "^18.3.1",
50
50
  "react-dom": "^18.3.1",
51
51
  "rimraf": "^6.0.1",
52
52
  "typescript": "^5.6.3",
53
- "vite": "^5.4.3",
54
- "vitest": "^4.0.7"
53
+ "vite": "^5.4.3"
55
54
  }
56
55
  }
56
+
@@ -72,7 +72,7 @@ export function compile(doc: YarnDocument, opts: CompileOptions = {}): IRProgram
72
72
  }
73
73
  block.push({
74
74
  op: "options",
75
- options: s.options.map((o: Option) => ({ text: o.text, tags: ensureLineId(o.tags), css: (o as any).css, markup: o.markup, block: emitBlock(o.body) })),
75
+ options: s.options.map((o: Option) => ({ text: o.text, tags: ensureLineId(o.tags), css: (o as any).css, markup: o.markup, condition: o.condition, block: emitBlock(o.body) })),
76
76
  });
77
77
  break;
78
78
  }
@@ -141,7 +141,7 @@ export function compile(doc: YarnDocument, opts: CompileOptions = {}): IRProgram
141
141
  }
142
142
  block.push({
143
143
  op: "options",
144
- options: s.options.map((o: Option) => ({ text: o.text, tags: ensureLineId(o.tags), css: (o as any).css, markup: o.markup, block: emitBlock(o.body) })),
144
+ options: s.options.map((o: Option) => ({ text: o.text, tags: ensureLineId(o.tags), css: (o as any).css, markup: o.markup, condition: o.condition, block: emitBlock(o.body) })),
145
145
  });
146
146
  break;
147
147
  }
package/src/compile/ir.ts CHANGED
@@ -22,7 +22,7 @@ export type IRInstruction =
22
22
  | { op: "command"; content: string }
23
23
  | { op: "jump"; target: string }
24
24
  | { op: "detour"; target: string }
25
- | { op: "options"; options: Array<{ text: string; tags?: string[]; css?: string; markup?: MarkupParseResult; block: IRInstruction[] }> }
25
+ | { op: "options"; options: Array<{ text: string; tags?: string[]; css?: string; markup?: MarkupParseResult; condition?: string; block: IRInstruction[] }> }
26
26
  | { op: "if"; branches: Array<{ condition: string | null; block: IRInstruction[] }> }
27
27
  | { op: "once"; id: string; block: IRInstruction[] };
28
28
 
@@ -1,6 +1,7 @@
1
- import type { MarkupParseResult, MarkupSegment, MarkupValue, MarkupWrapper } from "./types.js";
2
-
3
- const DEFAULT_HTML_TAGS = new Set(["b", "em", "small", "strong", "sub", "sup", "ins", "del", "mark"]);
1
+ import type { MarkupParseResult, MarkupSegment, MarkupValue, MarkupWrapper } from "./types.js";
2
+
3
+ const DEFAULT_HTML_TAGS = new Set(["b", "em", "small", "strong", "sub", "sup", "ins", "del", "mark", "br"]);
4
+ const SELF_CLOSING_TAGS = new Set(["br"]);
4
5
 
5
6
  interface StackEntry {
6
7
  name: string;
@@ -125,8 +126,9 @@ export function parseMarkup(input: string): MarkupParseResult {
125
126
  rest = rest.slice(attrMatch[0].length).trim();
126
127
  }
127
128
 
128
- return { kind, name, properties };
129
- };
129
+ const finalKind: ParsedTag["kind"] = kind === "self" || SELF_CLOSING_TAGS.has(name) ? "self" : kind;
130
+ return { kind: finalKind, name, properties };
131
+ };
130
132
 
131
133
  const parseAttributeValue = (raw: string): MarkupValue => {
132
134
  const trimmed = raw.trim();
@@ -142,20 +144,20 @@ export function parseMarkup(input: string): MarkupParseResult {
142
144
  return trimmed;
143
145
  };
144
146
 
145
- const handleSelfClosing = (tag: ParsedTag) => {
146
- const wrapper: MarkupWrapper = {
147
- name: tag.name,
148
- type: DEFAULT_HTML_TAGS.has(tag.name) ? "default" : "custom",
149
- properties: tag.properties,
150
- };
151
- const position = chars.length;
152
- pushSegment({
153
- start: position,
154
- end: position,
155
- wrappers: [wrapper],
156
- selfClosing: true,
157
- });
158
- };
147
+ const handleSelfClosing = (tag: ParsedTag) => {
148
+ const wrapper: MarkupWrapper = {
149
+ name: tag.name,
150
+ type: DEFAULT_HTML_TAGS.has(tag.name) ? "default" : "custom",
151
+ properties: tag.properties,
152
+ };
153
+ const position = chars.length;
154
+ pushSegment({
155
+ start: position,
156
+ end: position,
157
+ wrappers: [wrapper],
158
+ selfClosing: true,
159
+ });
160
+ };
159
161
 
160
162
  let i = 0;
161
163
  while (i < input.length) {
@@ -215,30 +217,38 @@ export function parseMarkup(input: string): MarkupParseResult {
215
217
  continue;
216
218
  }
217
219
 
218
- if (parsed.kind === "self") {
219
- handleSelfClosing(parsed);
220
- i = closeIndex + 1;
221
- continue;
222
- }
223
-
224
- // closing tag
225
- if (stack.length === 0) {
226
- appendLiteral(originalText);
227
- i = closeIndex + 1;
228
- continue;
229
- }
230
- const top = stack[stack.length - 1];
231
- if (top.name === parsed.name) {
232
- flushCurrentSegment();
233
- stack.pop();
234
- i = closeIndex + 1;
235
- continue;
236
- }
237
- // mismatched closing; treat as literal
238
- appendLiteral(originalText);
239
- i = closeIndex + 1;
240
- continue;
241
- }
220
+ if (parsed.kind === "self") {
221
+ handleSelfClosing(parsed);
222
+ i = closeIndex + 1;
223
+ continue;
224
+ }
225
+
226
+ // closing tag
227
+ if (stack.length === 0) {
228
+ if (SELF_CLOSING_TAGS.has(parsed.name)) {
229
+ i = closeIndex + 1;
230
+ continue;
231
+ }
232
+ appendLiteral(originalText);
233
+ i = closeIndex + 1;
234
+ continue;
235
+ }
236
+ const top = stack[stack.length - 1];
237
+ if (top.name === parsed.name) {
238
+ flushCurrentSegment();
239
+ stack.pop();
240
+ i = closeIndex + 1;
241
+ continue;
242
+ }
243
+ if (SELF_CLOSING_TAGS.has(parsed.name)) {
244
+ i = closeIndex + 1;
245
+ continue;
246
+ }
247
+ // mismatched closing; treat as literal
248
+ appendLiteral(originalText);
249
+ i = closeIndex + 1;
250
+ continue;
251
+ }
242
252
 
243
253
  appendChar(char);
244
254
  i += 1;
package/src/model/ast.ts CHANGED
@@ -73,6 +73,7 @@ export interface Option {
73
73
  tags?: string[];
74
74
  css?: string; // Custom CSS style for option
75
75
  markup?: MarkupParseResult;
76
+ condition?: string;
76
77
  }
77
78
 
78
79
  export interface IfBlock {
@@ -34,24 +34,24 @@ export function lex(input: string): Token[] {
34
34
  const indent = raw.match(/^[ \t]*/)?.[0] ?? "";
35
35
  const content = raw.slice(indent.length);
36
36
 
37
- // Manage indentation tokens only within node bodies
38
- if (!inHeaders) {
39
- const prev = indentStack[indentStack.length - 1];
40
- if (indent.length > prev) {
41
- indentStack.push(indent.length);
42
- push("INDENT", "", lineNum, 1);
43
- } else if (indent.length < prev) {
44
- while (indentStack.length && indent.length < indentStack[indentStack.length - 1]) {
45
- indentStack.pop();
46
- push("DEDENT", "", lineNum, 1);
47
- }
48
- }
49
- }
50
-
51
- if (content.trim() === "") {
52
- push("EMPTY", "", lineNum, 1);
53
- continue;
54
- }
37
+ if (content.trim() === "") {
38
+ push("EMPTY", "", lineNum, 1);
39
+ continue;
40
+ }
41
+
42
+ // Manage indentation tokens only within node bodies and on non-empty lines
43
+ if (!inHeaders) {
44
+ const prev = indentStack[indentStack.length - 1];
45
+ if (indent.length > prev) {
46
+ indentStack.push(indent.length);
47
+ push("INDENT", "", lineNum, 1);
48
+ } else if (indent.length < prev) {
49
+ while (indentStack.length && indent.length < indentStack[indentStack.length - 1]) {
50
+ indentStack.pop();
51
+ push("DEDENT", "", lineNum, 1);
52
+ }
53
+ }
54
+ }
55
55
 
56
56
  if (content === "---") {
57
57
  inHeaders = false;
@@ -215,24 +215,26 @@ class Parser {
215
215
  // One or more OPTION lines, with bodies under INDENT
216
216
  while (this.at("OPTION")) {
217
217
  const raw = this.take("OPTION").text;
218
- const { cleanText: textWithAttrs, tags } = this.extractTags(raw);
219
- const { text: textWithoutCss, css } = this.extractCss(textWithAttrs);
220
- const markup = parseMarkup(textWithoutCss);
221
- let body: Statement[] = [];
222
- if (this.at("INDENT")) {
223
- this.take("INDENT");
224
- body = this.parseStatementsUntil("DEDENT");
218
+ const { cleanText: textWithAttrs, tags } = this.extractTags(raw);
219
+ const { text: textWithCondition, css } = this.extractCss(textWithAttrs);
220
+ const { text: optionText, condition } = this.extractOptionCondition(textWithCondition);
221
+ const markup = parseMarkup(optionText);
222
+ let body: Statement[] = [];
223
+ if (this.at("INDENT")) {
224
+ this.take("INDENT");
225
+ body = this.parseStatementsUntil("DEDENT");
225
226
  this.take("DEDENT");
226
227
  while (this.at("EMPTY")) this.i++;
227
228
  }
228
- options.push({
229
- type: "Option",
230
- text: markup.text,
231
- body,
232
- tags,
233
- css,
234
- markup: this.normalizeMarkup(markup),
235
- });
229
+ options.push({
230
+ type: "Option",
231
+ text: markup.text,
232
+ body,
233
+ tags,
234
+ css,
235
+ markup: this.normalizeMarkup(markup),
236
+ condition,
237
+ });
236
238
  // Consecutive options belong to the same group; break on non-OPTION
237
239
  while (this.at("EMPTY")) this.i++;
238
240
  }
@@ -283,15 +285,24 @@ class Parser {
283
285
  return { cleanText: input };
284
286
  }
285
287
 
286
- private extractCss(input: string): { text: string; css?: string } {
287
- const cssMatch = input.match(/\s*&css\{([^}]*)\}\s*$/);
288
- if (cssMatch) {
289
- const css = cssMatch[1].trim();
290
- const text = input.replace(cssMatch[0], "").trimEnd();
288
+ private extractCss(input: string): { text: string; css?: string } {
289
+ const cssMatch = input.match(/\s*&css\{([^}]*)\}\s*$/);
290
+ if (cssMatch) {
291
+ const css = cssMatch[1].trim();
292
+ const text = input.replace(cssMatch[0], "").trimEnd();
291
293
  return { text, css };
292
294
  }
293
- return { text: input };
294
- }
295
+ return { text: input };
296
+ }
297
+
298
+ private extractOptionCondition(input: string): { text: string; condition?: string } {
299
+ const match = input.match(/\s\[\s*if\s+([^\]]+)\]\s*$/i);
300
+ if (match) {
301
+ const text = input.slice(0, match.index).trimEnd();
302
+ return { text, condition: match[1].trim() };
303
+ }
304
+ return { text: input };
305
+ }
295
306
 
296
307
  private parseStatementsUntilStop(shouldStop: () => boolean): Statement[] {
297
308
  const out: Statement[] = [];
@@ -1,7 +1,6 @@
1
1
  import React, { useState, useMemo } from "react";
2
2
  import { parseYarn } from "../parse/parser.js";
3
3
  import { compile } from "../compile/compiler.js";
4
- import { useYarnRunner } from "./useYarnRunner.js";
5
4
  import { DialogueView } from "./DialogueView.js";
6
5
  import { parseScenes } from "../scene/parser.js";
7
6
  import type { SceneCollection } from "../scene/types.js";
@@ -9,11 +8,12 @@ import type { SceneCollection } from "../scene/types.js";
9
8
  const DEFAULT_YARN = `title: Start
10
9
  scene: scene1
11
10
  ---
12
- Narrator: [wave]hello[/wave] [b]hello[/b] baarter
13
- Narrator: Welcome to yarn-spinner-ts!
11
+ << declare $hasBadge = false >>
12
+ Narrator: Welcome to [b]yarn-spinner-ts[/b], {$playerName}!
13
+ Narrator: Current street cred: {$reputation}
14
14
  npc: This is a dialogue system powered by Yarn Spinner.
15
15
  Narrator: Click anywhere to continue, or choose an option below.
16
- -> Start the adventure &css{backgroundColor: #4a9eff; color: white;}
16
+ -> Start the adventure &css{backgroundColor: #4a9eff; color: white;} [if $hasBadge]
17
17
  Narrator: Great! Let's begin your journey.
18
18
  <<jump NextScene>>
19
19
  -> Learn more &css{backgroundColor: #2ecc71; color: red;}
@@ -47,7 +47,7 @@ actors:
47
47
  export function DialogueExample() {
48
48
  const [yarnText] = useState(DEFAULT_YARN);
49
49
  const [error, setError] = useState<string | null>(null);
50
- const enableTypingAnimation = true;
50
+ const enableTypingAnimation = false;
51
51
 
52
52
  const scenes: SceneCollection = useMemo(() => {
53
53
  try {
@@ -69,13 +69,10 @@ export function DialogueExample() {
69
69
  }
70
70
  }, [yarnText]);
71
71
 
72
- const { result, advance } = useYarnRunner(
73
- program || { nodes: {}, enums: {} },
74
- {
75
- startAt: "Start",
76
- variables: {},
77
- }
78
- );
72
+ const customFunctions = useMemo(() => ({
73
+ greet: () => {console.log('test')},
74
+ double: (num: unknown) => Number(num) * 2
75
+ }), []);
79
76
 
80
77
  return (
81
78
  <div
@@ -106,9 +103,10 @@ export function DialogueExample() {
106
103
  )}
107
104
 
108
105
  <DialogueView
109
- result={result}
110
- onAdvance={advance}
106
+ program={program || { nodes: {}, enums: {} }}
107
+ startNode="Start"
111
108
  scenes={scenes}
109
+ variables={{ playerName: "V", reputation: 3 }}
112
110
  enableTypingAnimation={enableTypingAnimation}
113
111
  showTypingCursor={true}
114
112
  typingSpeed={20}
@@ -117,6 +115,10 @@ export function DialogueExample() {
117
115
  autoAdvanceDelay={2000}
118
116
  actorTransitionDuration={1000}
119
117
  pauseBeforeAdvance={enableTypingAnimation ? 1000 : 0}
118
+ onStoryEnd={(info) => {
119
+ console.log('Story ended with variables:', info.variables);
120
+ }}
121
+ functions={customFunctions}
120
122
  />
121
123
  </div>
122
124
  </div>