yarn-spinner-runner-ts 0.1.2-b → 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 (63) hide show
  1. package/README.md +5 -2
  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 +4 -3
  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 +2 -1
  23. package/dist/react/DialogueView.js +5 -4
  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 +3 -1
  29. package/dist/react/TypingText.js +16 -40
  30. package/dist/react/TypingText.js.map +1 -1
  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/docs/actor-transition.md +34 -0
  43. package/docs/markup.md +34 -19
  44. package/docs/scenes-actors-setup.md +1 -0
  45. package/package.json +1 -1
  46. package/src/compile/compiler.ts +4 -4
  47. package/src/compile/ir.ts +3 -2
  48. package/src/index.ts +3 -0
  49. package/src/markup/parser.ts +372 -0
  50. package/src/markup/types.ts +22 -0
  51. package/src/model/ast.ts +17 -13
  52. package/src/parse/parser.ts +60 -8
  53. package/src/react/DialogueExample.tsx +4 -2
  54. package/src/react/DialogueScene.tsx +143 -44
  55. package/src/react/DialogueView.tsx +19 -5
  56. package/src/react/MarkupRenderer.tsx +110 -0
  57. package/src/react/TypingText.tsx +25 -30
  58. package/src/react/dialogue.css +26 -13
  59. package/src/runtime/results.ts +3 -1
  60. package/src/runtime/runner.ts +158 -14
  61. package/src/tests/markup.test.ts +62 -0
  62. package/src/tests/nodes_lines.test.ts +35 -1
  63. package/src/tests/options.test.ts +39 -1
@@ -0,0 +1,34 @@
1
+ ## Actor Image Transitions (React)
2
+
3
+ `DialogueScene` cross-fades speaker portraits whenever a new line is delivered. The blend speed is configurable so you can match the feel of your UI.
4
+
5
+ ### Configuring the transition
6
+
7
+ - `DialogueScene` accepts an `actorTransitionDuration` prop (milliseconds).
8
+ - `DialogueView` forwards the same prop so you can set it once at the top level.
9
+ - Default is `350` ms; smaller values snap faster, larger values linger.
10
+
11
+ ```tsx
12
+ <DialogueView
13
+ result={result}
14
+ onAdvance={advance}
15
+ scenes={scenes}
16
+ actorTransitionDuration={900}
17
+ />
18
+ ```
19
+
20
+ ### How it works
21
+
22
+ - The duration is exposed to CSS as the `--yd-actor-transition` custom property on the scene container.
23
+ - `dialogue.css` reads that variable in the `transition` for `.yd-actor`, so any value you pass updates both fade-in and fade-out timing while keeping the easing curve.
24
+ - Portraits cross-fade: the outgoing image fades out while the incoming image fades in.
25
+
26
+ ### Tips
27
+
28
+ - Long transitions (e.g., 2000 ms) work best when dialogue advances slowly; otherwise use shorter timings to avoid sluggish portraits.
29
+ - If an actor image fails to load, the component logs a warning and keeps the previous portrait visible.
30
+ - Combine with `enableTypingAnimation` to align text pacing with portrait changes.
31
+
32
+ ### Testing
33
+
34
+ - Animation timing changes are mostly visual. After adjusting durations, run `npm test` and manually confirm the fade still feels right in the browser demo.
package/docs/markup.md CHANGED
@@ -1,19 +1,34 @@
1
- ## Markup (Yarn Spinner)
2
-
3
- Source: [docs.yarnspinner.dev Markup](https://docs.yarnspinner.dev/write-yarn-scripts/advanced-scripting/markup)
4
-
5
- ### What it covers
6
- - Rich text features embedded in lines (emphasis, links, inline commands).
7
- - Parsed and passed to the host for rendering.
8
-
9
- ### Example
10
- ```yarn
11
- title: Start
12
- ---
13
- Narrator: This is *italic*, this is **bold**, and this is a [link](game:codex/entry1).
14
- ===
15
- ```
16
-
17
- Supported markup and rendering vary by integration.
18
-
19
-
1
+ ## Markup (Yarn Spinner)
2
+
3
+ Source: [docs.yarnspinner.dev Markup](https://docs.yarnspinner.dev/write-yarn-scripts/advanced-scripting/markup)
4
+
5
+ ### Supported formatting
6
+
7
+ The runtime now parses Yarn Spinner markup and surfaces it through `TextResult.markup` and option metadata. The React components (`DialogueView`, `TypingText`, and the option buttons) render this markup automatically.
8
+
9
+ - The following tags map directly to native HTML elements: `b`, `strong`, `em`, `small`, `sub`, `sup`, `ins`, `del`, and `mark`.
10
+ - Any other markup tag is rendered as a `<span>` with the class `yd-markup-<tagName>` so you can style or animate it via CSS.
11
+ - Markup attributes are exposed as `data-markup-*` attributes on the rendered element. For example `[wave speed=2]` renders `<span class="yd-markup-wave" data-markup-speed="2">`.
12
+
13
+ ### Example
14
+
15
+ ```yarn
16
+ title: Start
17
+ ---
18
+ Narrator: Plain [b]bold[/b] [wave speed=2]custom[/wave]
19
+ ===
20
+ ```
21
+
22
+ The React renderer produces:
23
+
24
+ ```html
25
+ Plain <b>bold</b> <span class="yd-markup-wave" data-markup-speed="2">custom</span>
26
+ ```
27
+
28
+ ### Integration notes
29
+
30
+ - Markup data is available on `TextResult.markup` and on each option entry (`result.options[i].markup`).
31
+ - `TypingText` respects markup while animating, so formatting stays intact during the typewriter effect.
32
+ - When a markup tag is not recognised, it remains in the output (as a span) rather than being stripped, so you can add custom CSS in your host application.
33
+
34
+ For the full markup vocabulary see the official Yarn Spinner documentation.
@@ -112,6 +112,7 @@ Once a scene is set, the background persists across nodes until a new scene is s
112
112
  - Actor images are matched by name (case-insensitive)
113
113
  - The speaking actor's image appears at the top center of the scene
114
114
  - If no matching actor is found in the scene configuration, only the text is shown
115
+ - The portrait transition duration defaults to 350 ms and can be adjusted by passing `actorTransitionDuration` (in milliseconds) to either `<DialogueScene />` or `<DialogueView />`
115
116
 
116
117
  ### Actor Matching
117
118
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yarn-spinner-runner-ts",
3
- "version": "0.1.2-b",
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";