yarn-spinner-runner-ts 0.1.0
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 +180 -0
- package/dist/compile/compiler.d.ts +9 -0
- package/dist/compile/compiler.js +172 -0
- package/dist/compile/compiler.js.map +1 -0
- package/dist/compile/ir.d.ts +47 -0
- package/dist/compile/ir.js +2 -0
- package/dist/compile/ir.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/model/ast.d.ts +72 -0
- package/dist/model/ast.js +2 -0
- package/dist/model/ast.js.map +1 -0
- package/dist/parse/lexer.d.ts +7 -0
- package/dist/parse/lexer.js +78 -0
- package/dist/parse/lexer.js.map +1 -0
- package/dist/parse/parser.d.ts +4 -0
- package/dist/parse/parser.js +433 -0
- package/dist/parse/parser.js.map +1 -0
- package/dist/runtime/commands.d.ts +31 -0
- package/dist/runtime/commands.js +157 -0
- package/dist/runtime/commands.js.map +1 -0
- package/dist/runtime/evaluator.d.ts +52 -0
- package/dist/runtime/evaluator.js +309 -0
- package/dist/runtime/evaluator.js.map +1 -0
- package/dist/runtime/results.d.ts +22 -0
- package/dist/runtime/results.js +2 -0
- package/dist/runtime/results.js.map +1 -0
- package/dist/runtime/runner.d.ts +63 -0
- package/dist/runtime/runner.js +456 -0
- package/dist/runtime/runner.js.map +1 -0
- package/dist/tests/full_featured.test.d.ts +1 -0
- package/dist/tests/full_featured.test.js +130 -0
- package/dist/tests/full_featured.test.js.map +1 -0
- package/dist/tests/index.test.d.ts +1 -0
- package/dist/tests/index.test.js +30 -0
- package/dist/tests/index.test.js.map +1 -0
- package/dist/tests/jump_detour.test.d.ts +1 -0
- package/dist/tests/jump_detour.test.js +47 -0
- package/dist/tests/jump_detour.test.js.map +1 -0
- package/dist/tests/nodes_lines.test.d.ts +1 -0
- package/dist/tests/nodes_lines.test.js +23 -0
- package/dist/tests/nodes_lines.test.js.map +1 -0
- package/dist/tests/once.test.d.ts +1 -0
- package/dist/tests/once.test.js +29 -0
- package/dist/tests/once.test.js.map +1 -0
- package/dist/tests/options.test.d.ts +1 -0
- package/dist/tests/options.test.js +32 -0
- package/dist/tests/options.test.js.map +1 -0
- package/dist/tests/variables_flow_cmds.test.d.ts +1 -0
- package/dist/tests/variables_flow_cmds.test.js +30 -0
- package/dist/tests/variables_flow_cmds.test.js.map +1 -0
- package/dist/types.d.ts +3 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/docs/commands.md +21 -0
- package/docs/compatibility-checklist.md +77 -0
- package/docs/css-attribute.md +47 -0
- package/docs/detour.md +24 -0
- package/docs/enums.md +25 -0
- package/docs/flow-control.md +25 -0
- package/docs/functions.md +20 -0
- package/docs/jumps.md +24 -0
- package/docs/line-groups.md +21 -0
- package/docs/lines-nodes-and-options.md +30 -0
- package/docs/logic-and-variables.md +25 -0
- package/docs/markup.md +19 -0
- package/docs/node-groups.md +13 -0
- package/docs/once.md +21 -0
- package/docs/options.md +45 -0
- package/docs/saliency.md +25 -0
- package/docs/scenes-actors-setup.md +195 -0
- package/docs/scenes.md +64 -0
- package/docs/shadow-lines.md +18 -0
- package/docs/smart-variables.md +19 -0
- package/docs/storylets-and-saliency-a-primer.md +14 -0
- package/docs/tags-metadata.md +18 -0
- package/eslint.config.cjs +33 -0
- package/examples/browser/README.md +40 -0
- package/examples/browser/index.html +23 -0
- package/examples/browser/main.tsx +16 -0
- package/examples/browser/vite.config.ts +22 -0
- package/examples/react/DialogueExample.tsx +2 -0
- package/examples/react/DialogueView.tsx +2 -0
- package/examples/react/useYarnRunner.tsx +2 -0
- package/examples/scenes/scenes.yaml +10 -0
- package/examples/yarn/full_featured.yarn +43 -0
- package/package.json +55 -0
- package/src/compile/compiler.ts +183 -0
- package/src/compile/ir.ts +28 -0
- package/src/index.ts +17 -0
- package/src/model/ast.ts +93 -0
- package/src/parse/lexer.ts +108 -0
- package/src/parse/parser.ts +435 -0
- package/src/react/DialogueExample.tsx +149 -0
- package/src/react/DialogueScene.tsx +107 -0
- package/src/react/DialogueView.tsx +160 -0
- package/src/react/dialogue.css +181 -0
- package/src/react/useYarnRunner.tsx +33 -0
- package/src/runtime/commands.ts +183 -0
- package/src/runtime/evaluator.ts +327 -0
- package/src/runtime/results.ts +27 -0
- package/src/runtime/runner.ts +480 -0
- package/src/scene/parser.ts +83 -0
- package/src/scene/types.ts +17 -0
- package/src/tests/full_featured.test.ts +131 -0
- package/src/tests/index.test.ts +34 -0
- package/src/tests/jump_detour.test.ts +47 -0
- package/src/tests/nodes_lines.test.ts +27 -0
- package/src/tests/once.test.ts +32 -0
- package/src/tests/options.test.ts +34 -0
- package/src/tests/variables_flow_cmds.test.ts +33 -0
- package/src/types.ts +4 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import type { YarnDocument, Statement, Line, Option } from "../model/ast";
|
|
2
|
+
import type { IRProgram, IRNode, IRNodeGroup, IRInstruction } from "./ir";
|
|
3
|
+
|
|
4
|
+
export interface CompileOptions {
|
|
5
|
+
generateOnceIds?: (ctx: { node: string; index: number }) => string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function compile(doc: YarnDocument, opts: CompileOptions = {}): IRProgram {
|
|
9
|
+
const program: IRProgram = { enums: {}, nodes: {} };
|
|
10
|
+
// Store enum definitions
|
|
11
|
+
for (const enumDef of doc.enums) {
|
|
12
|
+
program.enums[enumDef.name] = enumDef.cases;
|
|
13
|
+
}
|
|
14
|
+
const genOnce = opts.generateOnceIds ?? ((x) => `${x.node}#once#${x.index}`);
|
|
15
|
+
let globalLineCounter = 0;
|
|
16
|
+
|
|
17
|
+
function ensureLineId(tags?: string[]): string[] | undefined {
|
|
18
|
+
const t = tags ? [...tags] : [];
|
|
19
|
+
if (!t.some((x) => x.startsWith("line:"))) {
|
|
20
|
+
t.push(`line:${(globalLineCounter++).toString(16)}`);
|
|
21
|
+
}
|
|
22
|
+
return t;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Group nodes by title to handle node groups
|
|
26
|
+
const nodesByTitle = new Map<string, typeof doc.nodes>();
|
|
27
|
+
for (const node of doc.nodes) {
|
|
28
|
+
if (!nodesByTitle.has(node.title)) {
|
|
29
|
+
nodesByTitle.set(node.title, []);
|
|
30
|
+
}
|
|
31
|
+
nodesByTitle.get(node.title)!.push(node);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (const [title, nodesWithSameTitle] of nodesByTitle) {
|
|
35
|
+
// If only one node with this title, treat as regular node
|
|
36
|
+
if (nodesWithSameTitle.length === 1) {
|
|
37
|
+
const node = nodesWithSameTitle[0];
|
|
38
|
+
const instructions: IRInstruction[] = [];
|
|
39
|
+
let onceCounter = 0;
|
|
40
|
+
function emitBlock(stmts: Statement[]): IRInstruction[] {
|
|
41
|
+
const block: IRInstruction[] = [];
|
|
42
|
+
for (const s of stmts) {
|
|
43
|
+
switch (s.type) {
|
|
44
|
+
case "Line":
|
|
45
|
+
{
|
|
46
|
+
const line = s as Line;
|
|
47
|
+
block.push({ op: "line", speaker: line.speaker, text: line.text, tags: ensureLineId(line.tags) });
|
|
48
|
+
}
|
|
49
|
+
break;
|
|
50
|
+
case "Command":
|
|
51
|
+
block.push({ op: "command", content: s.content });
|
|
52
|
+
break;
|
|
53
|
+
case "Jump":
|
|
54
|
+
block.push({ op: "jump", target: s.target });
|
|
55
|
+
break;
|
|
56
|
+
case "Detour":
|
|
57
|
+
block.push({ op: "detour", target: s.target });
|
|
58
|
+
break;
|
|
59
|
+
case "OptionGroup": {
|
|
60
|
+
// Add #lastline tag to the most recent line, if present
|
|
61
|
+
for (let i = block.length - 1; i >= 0; i--) {
|
|
62
|
+
const ins = block[i];
|
|
63
|
+
if (ins.op === "line") {
|
|
64
|
+
const tags = new Set(ins.tags ?? []);
|
|
65
|
+
if (![...tags].some((x) => x === "lastline" || x === "#lastline")) {
|
|
66
|
+
tags.add("lastline");
|
|
67
|
+
}
|
|
68
|
+
ins.tags = Array.from(tags);
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
if (ins.op !== "command") break; // stop if non-line non-command before options
|
|
72
|
+
}
|
|
73
|
+
block.push({
|
|
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) })),
|
|
76
|
+
});
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
case "If":
|
|
80
|
+
block.push({
|
|
81
|
+
op: "if",
|
|
82
|
+
branches: s.branches.map((b) => ({ condition: b.condition, block: emitBlock(b.body) })),
|
|
83
|
+
});
|
|
84
|
+
break;
|
|
85
|
+
case "Once":
|
|
86
|
+
block.push({ op: "once", id: genOnce({ node: node.title, index: onceCounter++ }), block: emitBlock(s.body) });
|
|
87
|
+
break;
|
|
88
|
+
case "Enum":
|
|
89
|
+
// Enums are metadata, skip during compilation (already stored in program.enums)
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return block;
|
|
94
|
+
}
|
|
95
|
+
instructions.push(...emitBlock(node.body));
|
|
96
|
+
const irNode: IRNode = {
|
|
97
|
+
title: node.title,
|
|
98
|
+
instructions,
|
|
99
|
+
when: node.when,
|
|
100
|
+
css: (node as any).css,
|
|
101
|
+
scene: node.headers.scene?.trim() || undefined
|
|
102
|
+
};
|
|
103
|
+
program.nodes[node.title] = irNode;
|
|
104
|
+
} else {
|
|
105
|
+
// Multiple nodes with same title - create node group
|
|
106
|
+
const groupNodes: IRNode[] = [];
|
|
107
|
+
for (const node of nodesWithSameTitle) {
|
|
108
|
+
const instructions: IRInstruction[] = [];
|
|
109
|
+
let onceCounter = 0;
|
|
110
|
+
function emitBlock(stmts: Statement[]): IRInstruction[] {
|
|
111
|
+
const block: IRInstruction[] = [];
|
|
112
|
+
for (const s of stmts) {
|
|
113
|
+
switch (s.type) {
|
|
114
|
+
case "Line":
|
|
115
|
+
{
|
|
116
|
+
const line = s as Line;
|
|
117
|
+
block.push({ op: "line", speaker: line.speaker, text: line.text, tags: ensureLineId(line.tags) });
|
|
118
|
+
}
|
|
119
|
+
break;
|
|
120
|
+
case "Command":
|
|
121
|
+
block.push({ op: "command", content: s.content });
|
|
122
|
+
break;
|
|
123
|
+
case "Jump":
|
|
124
|
+
block.push({ op: "jump", target: s.target });
|
|
125
|
+
break;
|
|
126
|
+
case "Detour":
|
|
127
|
+
block.push({ op: "detour", target: s.target });
|
|
128
|
+
break;
|
|
129
|
+
case "OptionGroup": {
|
|
130
|
+
for (let i = block.length - 1; i >= 0; i--) {
|
|
131
|
+
const ins = block[i];
|
|
132
|
+
if (ins.op === "line") {
|
|
133
|
+
const tags = new Set(ins.tags ?? []);
|
|
134
|
+
if (![...tags].some((x) => x === "lastline" || x === "#lastline")) {
|
|
135
|
+
tags.add("lastline");
|
|
136
|
+
}
|
|
137
|
+
ins.tags = Array.from(tags);
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
if (ins.op !== "command") break;
|
|
141
|
+
}
|
|
142
|
+
block.push({
|
|
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) })),
|
|
145
|
+
});
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
case "If":
|
|
149
|
+
block.push({
|
|
150
|
+
op: "if",
|
|
151
|
+
branches: s.branches.map((b) => ({ condition: b.condition, block: emitBlock(b.body) })),
|
|
152
|
+
});
|
|
153
|
+
break;
|
|
154
|
+
case "Once":
|
|
155
|
+
block.push({ op: "once", id: genOnce({ node: node.title, index: onceCounter++ }), block: emitBlock(s.body) });
|
|
156
|
+
break;
|
|
157
|
+
case "Enum":
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return block;
|
|
162
|
+
}
|
|
163
|
+
instructions.push(...emitBlock(node.body));
|
|
164
|
+
groupNodes.push({
|
|
165
|
+
title: node.title,
|
|
166
|
+
instructions,
|
|
167
|
+
when: node.when,
|
|
168
|
+
css: (node as any).css,
|
|
169
|
+
scene: node.headers.scene?.trim() || undefined
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
const group: IRNodeGroup = {
|
|
173
|
+
title,
|
|
174
|
+
nodes: groupNodes
|
|
175
|
+
};
|
|
176
|
+
program.nodes[title] = group;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return program;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type IRProgram = {
|
|
2
|
+
enums: Record<string, string[]>; // enum name -> cases
|
|
3
|
+
nodes: Record<string, IRNode | IRNodeGroup>; // can be single node or group
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type IRNode = {
|
|
7
|
+
title: string;
|
|
8
|
+
instructions: IRInstruction[];
|
|
9
|
+
when?: string[]; // Array of when conditions
|
|
10
|
+
css?: string;
|
|
11
|
+
scene?: string; // Scene name from node header
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type IRNodeGroup = {
|
|
15
|
+
title: string;
|
|
16
|
+
nodes: IRNode[]; // Multiple nodes with same title, different when conditions
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type IRInstruction =
|
|
20
|
+
| { op: "line"; speaker?: string; text: string; tags?: string[] }
|
|
21
|
+
| { op: "command"; content: string }
|
|
22
|
+
| { op: "jump"; target: string }
|
|
23
|
+
| { op: "detour"; target: string }
|
|
24
|
+
| { op: "options"; options: Array<{ text: string; tags?: string[]; css?: string; block: IRInstruction[] }> }
|
|
25
|
+
| { op: "if"; branches: Array<{ condition: string | null; block: IRInstruction[] }> }
|
|
26
|
+
| { op: "once"; id: string; block: IRInstruction[] };
|
|
27
|
+
|
|
28
|
+
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export * from "./model/ast.js";
|
|
2
|
+
export * from "./parse/lexer.js";
|
|
3
|
+
export * from "./parse/parser.js";
|
|
4
|
+
export * from "./compile/ir.js";
|
|
5
|
+
export * from "./compile/compiler.js";
|
|
6
|
+
export * from "./runtime/results.js";
|
|
7
|
+
export * from "./runtime/evaluator.js";
|
|
8
|
+
export * from "./runtime/commands.js";
|
|
9
|
+
export * from "./runtime/runner.js";
|
|
10
|
+
export * from "./types.js";
|
|
11
|
+
export * from "./scene/types.js";
|
|
12
|
+
export * from "./scene/parser.js";
|
|
13
|
+
export * from "./react/useYarnRunner.js";
|
|
14
|
+
export * from "./react/DialogueView.js";
|
|
15
|
+
export * from "./react/DialogueExample.js";
|
|
16
|
+
export * from "./react/DialogueScene.js";
|
|
17
|
+
|
package/src/model/ast.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export type Position = { line: number; column: number };
|
|
2
|
+
|
|
3
|
+
export interface NodeHeaderMap {
|
|
4
|
+
[key: string]: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface YarnDocument {
|
|
8
|
+
type: "Document";
|
|
9
|
+
enums: EnumDefinition[];
|
|
10
|
+
nodes: YarnNode[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface EnumDefinition {
|
|
14
|
+
type: "Enum";
|
|
15
|
+
name: string;
|
|
16
|
+
cases: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface YarnNode {
|
|
20
|
+
type: "Node";
|
|
21
|
+
title: string;
|
|
22
|
+
headers: NodeHeaderMap;
|
|
23
|
+
nodeTags?: string[];
|
|
24
|
+
when?: string[]; // Array of when conditions (can be "once", "always", or expression like "$has_sword")
|
|
25
|
+
css?: string; // Custom CSS style for node
|
|
26
|
+
body: Statement[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type Statement =
|
|
30
|
+
| Line
|
|
31
|
+
| Command
|
|
32
|
+
| OptionGroup
|
|
33
|
+
| IfBlock
|
|
34
|
+
| OnceBlock
|
|
35
|
+
| Jump
|
|
36
|
+
| Detour
|
|
37
|
+
| EnumBlock;
|
|
38
|
+
|
|
39
|
+
export interface Line {
|
|
40
|
+
type: "Line";
|
|
41
|
+
speaker?: string;
|
|
42
|
+
text: string;
|
|
43
|
+
tags?: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface Command {
|
|
47
|
+
type: "Command";
|
|
48
|
+
content: string; // inside << >>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface Jump {
|
|
52
|
+
type: "Jump";
|
|
53
|
+
target: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface Detour {
|
|
57
|
+
type: "Detour";
|
|
58
|
+
target: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface OptionGroup {
|
|
62
|
+
type: "OptionGroup";
|
|
63
|
+
options: Option[];
|
|
64
|
+
}
|
|
65
|
+
|
|
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
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface IfBlock {
|
|
75
|
+
type: "If";
|
|
76
|
+
branches: Array<{
|
|
77
|
+
condition: string | null; // null for else
|
|
78
|
+
body: Statement[];
|
|
79
|
+
}>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface OnceBlock {
|
|
83
|
+
type: "Once";
|
|
84
|
+
body: Statement[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface EnumBlock {
|
|
88
|
+
type: "Enum";
|
|
89
|
+
name: string;
|
|
90
|
+
cases: string[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
export interface Token {
|
|
2
|
+
type:
|
|
3
|
+
| "HEADER_KEY"
|
|
4
|
+
| "HEADER_VALUE"
|
|
5
|
+
| "NODE_START" // ---
|
|
6
|
+
| "NODE_END" // ===
|
|
7
|
+
| "OPTION" // ->
|
|
8
|
+
| "COMMAND" // <<...>> (single-line)
|
|
9
|
+
| "TEXT" // any non-empty content line
|
|
10
|
+
| "EMPTY"
|
|
11
|
+
| "INDENT"
|
|
12
|
+
| "DEDENT"
|
|
13
|
+
| "EOF";
|
|
14
|
+
text: string;
|
|
15
|
+
line: number;
|
|
16
|
+
column: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Minimal indentation-sensitive lexer to support options and their bodies.
|
|
20
|
+
export function lex(input: string): Token[] {
|
|
21
|
+
const lines = input.replace(/\r\n?/g, "\n").split("\n");
|
|
22
|
+
const tokens: Token[] = [];
|
|
23
|
+
const indentStack: number[] = [0];
|
|
24
|
+
|
|
25
|
+
let inHeaders = true;
|
|
26
|
+
|
|
27
|
+
function push(type: Token["type"], text: string, line: number, column: number) {
|
|
28
|
+
tokens.push({ type, text, line, column });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for (let i = 0; i < lines.length; i++) {
|
|
32
|
+
const raw = lines[i];
|
|
33
|
+
const lineNum = i + 1;
|
|
34
|
+
const indent = raw.match(/^[ \t]*/)?.[0] ?? "";
|
|
35
|
+
const content = raw.slice(indent.length);
|
|
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
|
+
}
|
|
55
|
+
|
|
56
|
+
if (content === "---") {
|
|
57
|
+
inHeaders = false;
|
|
58
|
+
push("NODE_START", content, lineNum, indent.length + 1);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (content === "===") {
|
|
62
|
+
inHeaders = true;
|
|
63
|
+
// flush indentation to root
|
|
64
|
+
while (indentStack.length > 1) {
|
|
65
|
+
indentStack.pop();
|
|
66
|
+
push("DEDENT", "", lineNum, 1);
|
|
67
|
+
}
|
|
68
|
+
push("NODE_END", content, lineNum, indent.length + 1);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Header: key: value (only valid while inHeaders)
|
|
73
|
+
if (inHeaders) {
|
|
74
|
+
const m = content.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$/);
|
|
75
|
+
if (m) {
|
|
76
|
+
push("HEADER_KEY", m[1], lineNum, indent.length + 1);
|
|
77
|
+
push("HEADER_VALUE", m[2], lineNum, indent.length + 1 + m[0].indexOf(m[2]));
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (content.startsWith("->")) {
|
|
83
|
+
push("OPTION", content.slice(2).trim(), lineNum, indent.length + 1);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Commands like <<...>> (single line)
|
|
88
|
+
const cmd = content.match(/^<<(.+?)>>\s*$/);
|
|
89
|
+
if (cmd) {
|
|
90
|
+
push("COMMAND", cmd[1].trim(), lineNum, indent.length + 1);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Plain text line
|
|
95
|
+
push("TEXT", content, lineNum, indent.length + 1);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// close remaining indentation at EOF
|
|
99
|
+
while (indentStack.length > 1) {
|
|
100
|
+
indentStack.pop();
|
|
101
|
+
tokens.push({ type: "DEDENT", text: "", line: lines.length, column: 1 });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
tokens.push({ type: "EOF", text: "", line: lines.length + 1, column: 1 });
|
|
105
|
+
return tokens;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|