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
package/eslint.config.cjs CHANGED
@@ -18,6 +18,9 @@ module.exports = [
18
18
  window: "readonly",
19
19
  setTimeout: "readonly",
20
20
  clearTimeout: "readonly",
21
+ setInterval: "readonly",
22
+ clearInterval: "readonly",
23
+ requestAnimationFrame: "readonly",
21
24
  },
22
25
  },
23
26
  plugins: {
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>NarraLeaf-React Dialogue Demo</title>
6
+ <title>yarn-spinner-ts Dialogue Demo</title>
7
7
  <style>
8
8
  * {
9
9
  margin: 0;
@@ -11,8 +11,6 @@ if (!rootEl) {
11
11
 
12
12
  const root = createRoot(rootEl);
13
13
  root.render(
14
- <React.StrictMode>
15
14
  <DialogueExample />
16
- </React.StrictMode>
17
15
  );
18
16
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yarn-spinner-runner-ts",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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",
@@ -44,7 +44,7 @@ export function compile(doc: YarnDocument, opts: CompileOptions = {}): IRProgram
44
44
  case "Line":
45
45
  {
46
46
  const line = s as Line;
47
- block.push({ op: "line", speaker: line.speaker, text: line.text, tags: ensureLineId(line.tags) });
47
+ block.push({ op: "line", speaker: line.speaker, text: line.text, tags: ensureLineId(line.tags), markup: line.markup });
48
48
  }
49
49
  break;
50
50
  case "Command":
@@ -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, 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, block: emitBlock(o.body) })),
76
76
  });
77
77
  break;
78
78
  }
@@ -114,7 +114,7 @@ export function compile(doc: YarnDocument, opts: CompileOptions = {}): IRProgram
114
114
  case "Line":
115
115
  {
116
116
  const line = s as Line;
117
- block.push({ op: "line", speaker: line.speaker, text: line.text, tags: ensureLineId(line.tags) });
117
+ block.push({ op: "line", speaker: line.speaker, text: line.text, tags: ensureLineId(line.tags), markup: line.markup });
118
118
  }
119
119
  break;
120
120
  case "Command":
@@ -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, 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, block: emitBlock(o.body) })),
145
145
  });
146
146
  break;
147
147
  }
package/src/compile/ir.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { MarkupParseResult } from "../markup/types.js";
1
2
  export type IRProgram = {
2
3
  enums: Record<string, string[]>; // enum name -> cases
3
4
  nodes: Record<string, IRNode | IRNodeGroup>; // can be single node or group
@@ -17,11 +18,11 @@ export type IRNodeGroup = {
17
18
  };
18
19
 
19
20
  export type IRInstruction =
20
- | { op: "line"; speaker?: string; text: string; tags?: string[] }
21
+ | { op: "line"; speaker?: string; text: string; tags?: string[]; markup?: MarkupParseResult }
21
22
  | { op: "command"; content: string }
22
23
  | { op: "jump"; target: string }
23
24
  | { op: "detour"; target: string }
24
- | { op: "options"; options: Array<{ text: string; tags?: string[]; css?: string; block: IRInstruction[] }> }
25
+ | { op: "options"; options: Array<{ text: string; tags?: string[]; css?: string; markup?: MarkupParseResult; block: IRInstruction[] }> }
25
26
  | { op: "if"; branches: Array<{ condition: string | null; block: IRInstruction[] }> }
26
27
  | { op: "once"; id: string; block: IRInstruction[] };
27
28
 
package/src/index.ts CHANGED
@@ -3,6 +3,8 @@ export * from "./parse/lexer.js";
3
3
  export * from "./parse/parser.js";
4
4
  export * from "./compile/ir.js";
5
5
  export * from "./compile/compiler.js";
6
+ export * from "./markup/types.js";
7
+ export * from "./markup/parser.js";
6
8
  export * from "./runtime/results.js";
7
9
  export * from "./runtime/evaluator.js";
8
10
  export * from "./runtime/commands.js";
@@ -14,4 +16,5 @@ export * from "./react/useYarnRunner.js";
14
16
  export * from "./react/DialogueView.js";
15
17
  export * from "./react/DialogueExample.js";
16
18
  export * from "./react/DialogueScene.js";
19
+ export * from "./react/MarkupRenderer.js";
17
20
 
@@ -0,0 +1,372 @@
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"]);
4
+
5
+ interface StackEntry {
6
+ name: string;
7
+ type: MarkupWrapper["type"];
8
+ properties: Record<string, MarkupValue>;
9
+ originalText: string;
10
+ }
11
+
12
+ interface ParsedTag {
13
+ kind: "open" | "close" | "self";
14
+ name: string;
15
+ properties: Record<string, MarkupValue>;
16
+ }
17
+
18
+ const SELF_CLOSING_SPACE_REGEX = /\s+\/$/;
19
+ const ATTRIBUTE_REGEX =
20
+ /^([a-zA-Z_][a-zA-Z0-9_-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"']+)))?/;
21
+
22
+ export function parseMarkup(input: string): MarkupParseResult {
23
+ const segments: MarkupSegment[] = [];
24
+ const stack: StackEntry[] = [];
25
+ const chars: string[] = [];
26
+ let currentSegment: MarkupSegment | null = null;
27
+ let nomarkupDepth = 0;
28
+
29
+ const pushSegment = (segment: MarkupSegment) => {
30
+ if (segment.selfClosing || segment.end > segment.start) {
31
+ segments.push(segment);
32
+ }
33
+ };
34
+
35
+ const wrappersEqual = (a: MarkupWrapper[], b: MarkupWrapper[]) => {
36
+ if (a.length !== b.length) return false;
37
+ for (let i = 0; i < a.length; i++) {
38
+ const wa = a[i];
39
+ const wb = b[i];
40
+ if (wa.name !== wb.name || wa.type !== wb.type) return false;
41
+ const keysA = Object.keys(wa.properties);
42
+ const keysB = Object.keys(wb.properties);
43
+ if (keysA.length !== keysB.length) return false;
44
+ for (const key of keysA) {
45
+ if (wa.properties[key] !== wb.properties[key]) return false;
46
+ }
47
+ }
48
+ return true;
49
+ };
50
+
51
+ const flushCurrentSegment = () => {
52
+ if (currentSegment) {
53
+ segments.push(currentSegment);
54
+ currentSegment = null;
55
+ }
56
+ };
57
+
58
+ const cloneWrappers = (): MarkupWrapper[] =>
59
+ stack.map((entry) => ({
60
+ name: entry.name,
61
+ type: entry.type,
62
+ properties: { ...entry.properties },
63
+ }));
64
+
65
+ const appendChar = (char: string) => {
66
+ const index = chars.length;
67
+ chars.push(char);
68
+ const wrappers = cloneWrappers();
69
+ if (currentSegment && wrappersEqual(currentSegment.wrappers, wrappers)) {
70
+ currentSegment.end = index + 1;
71
+ } else {
72
+ flushCurrentSegment();
73
+ currentSegment = {
74
+ start: index,
75
+ end: index + 1,
76
+ wrappers,
77
+ };
78
+ }
79
+ };
80
+
81
+ const appendLiteral = (literal: string) => {
82
+ for (const ch of literal) {
83
+ appendChar(ch);
84
+ }
85
+ };
86
+
87
+ const parseTag = (contentRaw: string): ParsedTag | null => {
88
+ let content = contentRaw.trim();
89
+ if (!content) return null;
90
+
91
+ if (content.startsWith("/")) {
92
+ const name = content.slice(1).trim().toLowerCase();
93
+ if (!name) return null;
94
+ return { kind: "close", name, properties: {} };
95
+ }
96
+
97
+ let kind: ParsedTag["kind"] = "open";
98
+ if (content.endsWith("/")) {
99
+ content = content.replace(SELF_CLOSING_SPACE_REGEX, "").trim();
100
+ if (content.endsWith("/")) {
101
+ content = content.slice(0, -1).trim();
102
+ }
103
+ kind = "self";
104
+ }
105
+
106
+ const nameMatch = content.match(/^([a-zA-Z_][a-zA-Z0-9_-]*)/);
107
+ if (!nameMatch) return null;
108
+ const name = nameMatch[1].toLowerCase();
109
+ let rest = content.slice(nameMatch[0].length).trim();
110
+
111
+ const properties: Record<string, MarkupValue> = {};
112
+ while (rest.length > 0) {
113
+ const attrMatch = rest.match(ATTRIBUTE_REGEX);
114
+ if (!attrMatch) {
115
+ break;
116
+ }
117
+ const [, keyRaw, doubleQuoted, singleQuoted, bare] = attrMatch;
118
+ const key = keyRaw.toLowerCase();
119
+ let value: MarkupValue = true;
120
+ const rawValue = doubleQuoted ?? singleQuoted ?? bare;
121
+ if (rawValue !== undefined) {
122
+ value = parseAttributeValue(rawValue);
123
+ }
124
+ properties[key] = value;
125
+ rest = rest.slice(attrMatch[0].length).trim();
126
+ }
127
+
128
+ return { kind, name, properties };
129
+ };
130
+
131
+ const parseAttributeValue = (raw: string): MarkupValue => {
132
+ const trimmed = raw.trim();
133
+ if (/^(true|false)$/i.test(trimmed)) {
134
+ return /^true$/i.test(trimmed);
135
+ }
136
+ if (/^[+-]?\d+(\.\d+)?$/.test(trimmed)) {
137
+ const num = Number(trimmed);
138
+ if (!Number.isNaN(num)) {
139
+ return num;
140
+ }
141
+ }
142
+ return trimmed;
143
+ };
144
+
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
+ };
159
+
160
+ let i = 0;
161
+ while (i < input.length) {
162
+ const char = input[i];
163
+ if (char === "\\" && i + 1 < input.length) {
164
+ const next = input[i + 1];
165
+ if (next === "[" || next === "]" || next === "\\") {
166
+ appendChar(next);
167
+ i += 2;
168
+ continue;
169
+ }
170
+ }
171
+
172
+ if (char === "[") {
173
+ const closeIndex = findClosingBracket(input, i + 1);
174
+ if (closeIndex === -1) {
175
+ appendChar(char);
176
+ i += 1;
177
+ continue;
178
+ }
179
+ const content = input.slice(i + 1, closeIndex);
180
+ const originalText = input.slice(i, closeIndex + 1);
181
+
182
+ const parsed = parseTag(content);
183
+ if (!parsed) {
184
+ appendLiteral(originalText);
185
+ i = closeIndex + 1;
186
+ continue;
187
+ }
188
+
189
+ if (parsed.name === "nomarkup") {
190
+ if (parsed.kind === "open") {
191
+ nomarkupDepth += 1;
192
+ } else if (parsed.kind === "close" && nomarkupDepth > 0) {
193
+ nomarkupDepth -= 1;
194
+ }
195
+ i = closeIndex + 1;
196
+ continue;
197
+ }
198
+
199
+ if (nomarkupDepth > 0) {
200
+ appendLiteral(originalText);
201
+ i = closeIndex + 1;
202
+ continue;
203
+ }
204
+
205
+ if (parsed.kind === "open") {
206
+ const entry: StackEntry = {
207
+ name: parsed.name,
208
+ type: DEFAULT_HTML_TAGS.has(parsed.name) ? "default" : "custom",
209
+ properties: parsed.properties,
210
+ originalText,
211
+ };
212
+ stack.push(entry);
213
+ flushCurrentSegment();
214
+ i = closeIndex + 1;
215
+ continue;
216
+ }
217
+
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
+ }
242
+
243
+ appendChar(char);
244
+ i += 1;
245
+ }
246
+
247
+ flushCurrentSegment();
248
+
249
+ // If any tags remain open, treat them as literal text appended at end
250
+ while (stack.length > 0) {
251
+ const entry = stack.pop()!;
252
+ appendLiteral(entry.originalText);
253
+ }
254
+ flushCurrentSegment();
255
+
256
+ const text = chars.join("");
257
+ return {
258
+ text,
259
+ segments: mergeSegments(segments, text.length),
260
+ };
261
+ }
262
+
263
+ function mergeSegments(segments: MarkupSegment[], textLength: number): MarkupSegment[] {
264
+ const sorted = [...segments].sort((a, b) => a.start - b.start || a.end - b.end);
265
+ const merged: MarkupSegment[] = [];
266
+ let last: MarkupSegment | null = null;
267
+
268
+ for (const seg of sorted) {
269
+ if (seg.start === seg.end && !seg.selfClosing) {
270
+ continue;
271
+ }
272
+ if (last && !seg.selfClosing && last.end === seg.start && wrappersMatch(last.wrappers, seg.wrappers)) {
273
+ last.end = seg.end;
274
+ } else {
275
+ last = {
276
+ start: seg.start,
277
+ end: seg.end,
278
+ wrappers: seg.wrappers,
279
+ selfClosing: seg.selfClosing,
280
+ };
281
+ merged.push(last);
282
+ }
283
+ }
284
+
285
+ if (merged.length === 0 && textLength > 0) {
286
+ merged.push({
287
+ start: 0,
288
+ end: textLength,
289
+ wrappers: [],
290
+ });
291
+ }
292
+
293
+ return merged;
294
+ }
295
+
296
+ function wrappersMatch(a: MarkupWrapper[], b: MarkupWrapper[]): boolean {
297
+ if (a.length !== b.length) return false;
298
+ for (let i = 0; i < a.length; i++) {
299
+ if (a[i].name !== b[i].name || a[i].type !== b[i].type) return false;
300
+ const keysA = Object.keys(a[i].properties);
301
+ const keysB = Object.keys(b[i].properties);
302
+ if (keysA.length !== keysB.length) return false;
303
+ for (const key of keysA) {
304
+ if (a[i].properties[key] !== b[i].properties[key]) return false;
305
+ }
306
+ }
307
+ return true;
308
+ }
309
+
310
+ function findClosingBracket(text: string, start: number): number {
311
+ for (let i = start; i < text.length; i++) {
312
+ if (text[i] === "]") {
313
+ let backslashCount = 0;
314
+ let j = i - 1;
315
+ while (j >= 0 && text[j] === "\\") {
316
+ backslashCount++;
317
+ j--;
318
+ }
319
+ if (backslashCount % 2 === 0) {
320
+ return i;
321
+ }
322
+ }
323
+ }
324
+ return -1;
325
+ }
326
+
327
+ export function sliceMarkup(result: MarkupParseResult, start: number, end?: number): MarkupParseResult {
328
+ const textLength = result.text.length;
329
+ const sliceStart = Math.max(0, Math.min(start, textLength));
330
+ const sliceEnd = end === undefined ? textLength : Math.max(sliceStart, Math.min(end, textLength));
331
+ const slicedSegments: MarkupSegment[] = [];
332
+
333
+ for (const seg of result.segments) {
334
+ const segStart = Math.max(seg.start, sliceStart);
335
+ const segEnd = Math.min(seg.end, sliceEnd);
336
+ if (seg.selfClosing) {
337
+ if (segStart >= sliceStart && segStart <= sliceEnd) {
338
+ slicedSegments.push({
339
+ start: segStart - sliceStart,
340
+ end: segStart - sliceStart,
341
+ wrappers: seg.wrappers,
342
+ selfClosing: true,
343
+ });
344
+ }
345
+ continue;
346
+ }
347
+ if (segEnd <= segStart) continue;
348
+ slicedSegments.push({
349
+ start: segStart - sliceStart,
350
+ end: segEnd - sliceStart,
351
+ wrappers: seg.wrappers.map((wrapper) => ({
352
+ name: wrapper.name,
353
+ type: wrapper.type,
354
+ properties: { ...wrapper.properties },
355
+ })),
356
+ });
357
+ }
358
+
359
+ if (slicedSegments.length === 0 && sliceEnd - sliceStart > 0) {
360
+ slicedSegments.push({
361
+ start: 0,
362
+ end: sliceEnd - sliceStart,
363
+ wrappers: [],
364
+ });
365
+ }
366
+
367
+ return {
368
+ text: result.text.slice(sliceStart, sliceEnd),
369
+ segments: mergeSegments(slicedSegments, sliceEnd - sliceStart),
370
+ };
371
+ }
372
+
@@ -0,0 +1,22 @@
1
+ export type MarkupValue = string | number | boolean;
2
+
3
+ export type MarkupWrapperType = "default" | "custom";
4
+
5
+ export interface MarkupWrapper {
6
+ name: string;
7
+ type: MarkupWrapperType;
8
+ properties: Record<string, MarkupValue>;
9
+ }
10
+
11
+ export interface MarkupSegment {
12
+ start: number;
13
+ end: number;
14
+ wrappers: MarkupWrapper[];
15
+ selfClosing?: boolean;
16
+ }
17
+
18
+ export interface MarkupParseResult {
19
+ text: string;
20
+ segments: MarkupSegment[];
21
+ }
22
+
package/src/model/ast.ts CHANGED
@@ -36,12 +36,15 @@ export type Statement =
36
36
  | Detour
37
37
  | EnumBlock;
38
38
 
39
- export interface Line {
40
- type: "Line";
41
- speaker?: string;
42
- text: string;
43
- tags?: string[];
44
- }
39
+ import type { MarkupParseResult } from "../markup/types.js";
40
+
41
+ export interface Line {
42
+ type: "Line";
43
+ speaker?: string;
44
+ text: string;
45
+ tags?: string[];
46
+ markup?: MarkupParseResult;
47
+ }
45
48
 
46
49
  export interface Command {
47
50
  type: "Command";
@@ -63,13 +66,14 @@ export interface OptionGroup {
63
66
  options: Option[];
64
67
  }
65
68
 
66
- export interface Option {
67
- type: "Option";
68
- text: string;
69
- body: Statement[]; // executed if chosen
70
- tags?: string[];
71
- css?: string; // Custom CSS style for option
72
- }
69
+ export interface Option {
70
+ type: "Option";
71
+ text: string;
72
+ body: Statement[]; // executed if chosen
73
+ tags?: string[];
74
+ css?: string; // Custom CSS style for option
75
+ markup?: MarkupParseResult;
76
+ }
73
77
 
74
78
  export interface IfBlock {
75
79
  type: "If";
@@ -1,4 +1,6 @@
1
1
  import { lex, Token } from "./lexer.js";
2
+ import { parseMarkup, sliceMarkup } from "../markup/parser.js";
3
+ import type { MarkupParseResult } from "../markup/types.js";
2
4
  import type {
3
5
  YarnDocument,
4
6
  YarnNode,
@@ -177,17 +179,33 @@ class Parser {
177
179
  }
178
180
  if (t.type === "TEXT") {
179
181
  const raw = this.take("TEXT").text;
180
- const { cleanText: text, tags } = this.extractTags(raw);
181
- const speakerMatch = text.match(/^([^:\s][^:]*)\s*:\s*(.*)$/);
182
+ const { cleanText: textWithoutTags, tags } = this.extractTags(raw);
183
+ const markup = parseMarkup(textWithoutTags);
184
+ const speakerMatch = markup.text.match(/^([^:\s][^:]*)\s*:\s*(.*)$/);
182
185
  if (speakerMatch) {
183
- return { type: "Line", speaker: speakerMatch[1].trim(), text: speakerMatch[2], tags } as Line;
186
+ const messageText = speakerMatch[2];
187
+ const messageOffset = markup.text.length - messageText.length;
188
+ const slicedMarkup = sliceMarkup(markup, messageOffset);
189
+ const normalizedMarkup = this.normalizeMarkup(slicedMarkup);
190
+ return {
191
+ type: "Line",
192
+ speaker: speakerMatch[1].trim(),
193
+ text: messageText,
194
+ tags,
195
+ markup: normalizedMarkup,
196
+ } as Line;
184
197
  }
185
198
  // If/Else blocks use inline markup {if ...}
186
- const trimmed = text.trim();
199
+ const trimmed = markup.text.trim();
187
200
  if (trimmed.startsWith("{if ") || trimmed === "{else}" || trimmed.startsWith("{else if ") || trimmed === "{endif}") {
188
- return this.parseIfFromText(text);
201
+ return this.parseIfFromText(markup.text);
189
202
  }
190
- return { type: "Line", text, tags } as Line;
203
+ return {
204
+ type: "Line",
205
+ text: markup.text,
206
+ tags,
207
+ markup: this.normalizeMarkup(markup),
208
+ } as Line;
191
209
  }
192
210
  throw new ParseError(`Unexpected token ${t.type}`);
193
211
  }
@@ -198,7 +216,8 @@ class Parser {
198
216
  while (this.at("OPTION")) {
199
217
  const raw = this.take("OPTION").text;
200
218
  const { cleanText: textWithAttrs, tags } = this.extractTags(raw);
201
- const { text, css } = this.extractCss(textWithAttrs);
219
+ const { text: textWithoutCss, css } = this.extractCss(textWithAttrs);
220
+ const markup = parseMarkup(textWithoutCss);
202
221
  let body: Statement[] = [];
203
222
  if (this.at("INDENT")) {
204
223
  this.take("INDENT");
@@ -206,13 +225,46 @@ class Parser {
206
225
  this.take("DEDENT");
207
226
  while (this.at("EMPTY")) this.i++;
208
227
  }
209
- options.push({ type: "Option", text, body, tags, css });
228
+ options.push({
229
+ type: "Option",
230
+ text: markup.text,
231
+ body,
232
+ tags,
233
+ css,
234
+ markup: this.normalizeMarkup(markup),
235
+ });
210
236
  // Consecutive options belong to the same group; break on non-OPTION
211
237
  while (this.at("EMPTY")) this.i++;
212
238
  }
213
239
  return { type: "OptionGroup", options };
214
240
  }
215
241
 
242
+ private normalizeMarkup(result: MarkupParseResult): MarkupParseResult | undefined {
243
+ if (!result) return undefined;
244
+ if (result.segments.length === 0) {
245
+ return undefined;
246
+ }
247
+ const hasFormatting = result.segments.some(
248
+ (segment) => segment.wrappers.length > 0 || segment.selfClosing
249
+ );
250
+ if (!hasFormatting) {
251
+ return undefined;
252
+ }
253
+ return {
254
+ text: result.text,
255
+ segments: result.segments.map((segment) => ({
256
+ start: segment.start,
257
+ end: segment.end,
258
+ wrappers: segment.wrappers.map((wrapper) => ({
259
+ name: wrapper.name,
260
+ type: wrapper.type,
261
+ properties: { ...wrapper.properties },
262
+ })),
263
+ selfClosing: segment.selfClosing,
264
+ })),
265
+ };
266
+ }
267
+
216
268
  private extractTags(input: string): { cleanText: string; tags?: string[] } {
217
269
  const tags: string[] = [];
218
270
  // Match tags that are space-separated and not part of hex colors or CSS