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.
- package/README.md +97 -88
- package/dist/compile/compiler.js +4 -4
- package/dist/compile/compiler.js.map +1 -1
- package/dist/compile/ir.d.ts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/markup/parser.d.ts +3 -0
- package/dist/markup/parser.js +332 -0
- package/dist/markup/parser.js.map +1 -0
- package/dist/markup/types.d.ts +17 -0
- package/dist/markup/types.js +2 -0
- package/dist/markup/types.js.map +1 -0
- package/dist/model/ast.d.ts +3 -0
- package/dist/parse/parser.js +57 -8
- package/dist/parse/parser.js.map +1 -1
- package/dist/react/DialogueExample.js +6 -4
- package/dist/react/DialogueExample.js.map +1 -1
- package/dist/react/DialogueScene.d.ts +2 -1
- package/dist/react/DialogueScene.js +95 -26
- package/dist/react/DialogueScene.js.map +1 -1
- package/dist/react/DialogueView.d.ts +10 -1
- package/dist/react/DialogueView.js +68 -5
- package/dist/react/DialogueView.js.map +1 -1
- package/dist/react/MarkupRenderer.d.ts +8 -0
- package/dist/react/MarkupRenderer.js +64 -0
- package/dist/react/MarkupRenderer.js.map +1 -0
- package/dist/react/TypingText.d.ts +14 -0
- package/dist/react/TypingText.js +78 -0
- package/dist/react/TypingText.js.map +1 -0
- package/dist/runtime/results.d.ts +3 -0
- package/dist/runtime/runner.d.ts +1 -0
- package/dist/runtime/runner.js +151 -14
- package/dist/runtime/runner.js.map +1 -1
- package/dist/tests/markup.test.d.ts +1 -0
- package/dist/tests/markup.test.js +46 -0
- package/dist/tests/markup.test.js.map +1 -0
- package/dist/tests/nodes_lines.test.js +25 -1
- package/dist/tests/nodes_lines.test.js.map +1 -1
- package/dist/tests/options.test.js +30 -1
- package/dist/tests/options.test.js.map +1 -1
- package/dist/tests/typing-text.test.d.ts +1 -0
- package/dist/tests/typing-text.test.js +12 -0
- package/dist/tests/typing-text.test.js.map +1 -0
- package/docs/actor-transition.md +34 -0
- package/docs/markup.md +34 -19
- package/docs/scenes-actors-setup.md +1 -0
- package/docs/typing-animation.md +44 -0
- package/eslint.config.cjs +3 -0
- package/examples/browser/index.html +1 -1
- package/examples/browser/main.tsx +0 -2
- package/package.json +1 -1
- package/src/compile/compiler.ts +4 -4
- package/src/compile/ir.ts +3 -2
- package/src/index.ts +3 -0
- package/src/markup/parser.ts +372 -0
- package/src/markup/types.ts +22 -0
- package/src/model/ast.ts +17 -13
- package/src/parse/parser.ts +60 -8
- package/src/react/DialogueExample.tsx +18 -42
- package/src/react/DialogueScene.tsx +143 -44
- package/src/react/DialogueView.tsx +122 -8
- package/src/react/MarkupRenderer.tsx +110 -0
- package/src/react/TypingText.tsx +127 -0
- package/src/react/dialogue.css +26 -13
- package/src/runtime/results.ts +3 -1
- package/src/runtime/runner.ts +158 -14
- package/src/tests/markup.test.ts +62 -0
- package/src/tests/nodes_lines.test.ts +35 -1
- package/src/tests/options.test.ts +39 -1
package/eslint.config.cjs
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yarn-spinner-runner-ts",
|
|
3
|
-
"version": "0.1.
|
|
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",
|
package/src/compile/compiler.ts
CHANGED
|
@@ -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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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";
|
package/src/parse/parser.ts
CHANGED
|
@@ -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:
|
|
181
|
-
const
|
|
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
|
-
|
|
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 {
|
|
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({
|
|
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
|