yarn-spinner-runner-ts
Version:
TypeScript parser, compiler, and runtime for Yarn Spinner 3.x with React adapter [NPM package](https://www.npmjs.com/package/yarn-spinner-runner-ts)
495 lines • 18.9 kB
JavaScript
import { lex } from "./lexer.js";
import { parseMarkup, sliceMarkup } from "../markup/parser.js";
export class ParseError extends Error {
}
export function parseYarn(text) {
const tokens = lex(text);
const p = new Parser(tokens);
return p.parseDocument();
}
class Parser {
constructor(tokens) {
this.tokens = tokens;
this.i = 0;
}
peek(offset = 0) {
return this.tokens[this.i + offset];
}
at(type) {
return this.peek()?.type === type;
}
take(type, err) {
const t = this.peek();
if (!t || t.type !== type)
throw new ParseError(err ?? `Expected ${type}, got ${t?.type}`);
this.i++;
return t;
}
takeIf(type) {
if (this.at(type))
return this.take(type);
return null;
}
parseDocument() {
const enums = [];
const nodes = [];
while (!this.at("EOF")) {
// Skip empties
while (this.at("EMPTY"))
this.i++;
if (this.at("EOF"))
break;
// Check if this is an enum definition (top-level)
if (this.at("COMMAND")) {
const cmd = this.peek().text.trim();
if (cmd.startsWith("enum ")) {
const enumCmd = this.take("COMMAND").text; // consume the enum command
const enumName = enumCmd.slice(5).trim();
const enumDef = this.parseEnumBlock(enumName);
enums.push(enumDef);
continue;
}
}
nodes.push(this.parseNode());
}
return { type: "Document", enums, nodes };
}
parseNode() {
const headers = {};
let title = null;
let nodeTags;
let whenConditions = [];
let nodeCss;
// headers
while (!this.at("NODE_START")) {
const keyTok = this.take("HEADER_KEY", "Expected node header before '---'");
const valTok = this.take("HEADER_VALUE", "Expected header value");
if (keyTok.text === "title")
title = valTok.text.trim();
if (keyTok.text === "tags") {
const raw = valTok.text.trim();
nodeTags = raw.split(/\s+/).filter(Boolean);
}
if (keyTok.text === "when") {
// Each when: header adds one condition (can have multiple when: headers)
const raw = valTok.text.trim();
whenConditions.push(raw);
}
// Capture &css{ ... } styles in any header value
const rawVal = valTok.text.trim();
if (rawVal.startsWith("&css{")) {
// Collect until closing '}' possibly spanning multiple lines before '---'
let cssContent = rawVal.replace(/^&css\{/, "");
let closed = cssContent.includes("}");
if (closed) {
cssContent = cssContent.split("}")[0];
}
else {
// Consume subsequent TEXT or HEADER_VALUE tokens until we find a '}'
while (!this.at("NODE_START") && !this.at("EOF")) {
const next = this.peek();
if (next.type === "TEXT" || next.type === "HEADER_VALUE") {
const t = this.take(next.type).text;
if (t.includes("}")) {
cssContent += (cssContent ? "\n" : "") + t.split("}")[0];
closed = true;
break;
}
else {
cssContent += (cssContent ? "\n" : "") + t;
}
}
else if (next.type === "EMPTY") {
this.i++;
}
else {
break;
}
}
}
nodeCss = (cssContent || "").trim();
}
headers[keyTok.text] = valTok.text;
// allow empty lines
while (this.at("EMPTY"))
this.i++;
}
if (!title)
throw new ParseError("Every node must have a title header");
this.take("NODE_START");
// allow optional empties after ---
while (this.at("EMPTY"))
this.i++;
const body = this.parseStatementsUntil("NODE_END");
this.take("NODE_END", "Expected node end '==='");
return {
type: "Node",
title,
headers,
nodeTags,
when: whenConditions.length > 0 ? whenConditions : undefined,
css: nodeCss,
body
};
}
parseStatementsUntil(endType) {
const out = [];
while (!this.at(endType) && !this.at("EOF")) {
// skip extra empties
while (this.at("EMPTY"))
this.i++;
if (this.at(endType) || this.at("EOF"))
break;
if (this.at("OPTION")) {
out.push(this.parseOptionGroup());
continue;
}
const stmt = this.parseStatement();
out.push(stmt);
}
return out;
}
parseStatement() {
const t = this.peek();
if (!t)
throw new ParseError("Unexpected EOF");
if (t.type === "COMMAND") {
const cmd = this.take("COMMAND").text;
if (cmd.startsWith("jump "))
return { type: "Jump", target: cmd.slice(5).trim() };
if (cmd.startsWith("detour "))
return { type: "Detour", target: cmd.slice(7).trim() };
if (cmd.startsWith("if "))
return this.parseIfCommandBlock(cmd);
if (cmd === "once")
return this.parseOnceBlock();
if (cmd.startsWith("enum ")) {
const enumName = cmd.slice(5).trim();
return this.parseEnumBlock(enumName);
}
return { type: "Command", content: cmd };
}
if (t.type === "TEXT") {
const raw = this.take("TEXT").text;
const { cleanText: textWithoutTags, tags } = this.extractTags(raw);
const markup = parseMarkup(textWithoutTags);
const speakerMatch = markup.text.match(/^([^:\s][^:]*)\s*:\s*(.*)$/);
if (speakerMatch) {
const messageText = speakerMatch[2];
const messageOffset = markup.text.length - messageText.length;
const slicedMarkup = sliceMarkup(markup, messageOffset);
const normalizedMarkup = this.normalizeMarkup(slicedMarkup);
return {
type: "Line",
speaker: speakerMatch[1].trim(),
text: messageText,
tags,
markup: normalizedMarkup,
};
}
// If/Else blocks use inline markup {if ...}
const trimmed = markup.text.trim();
if (trimmed.startsWith("{if ") || trimmed === "{else}" || trimmed.startsWith("{else if ") || trimmed === "{endif}") {
return this.parseIfFromText(markup.text);
}
return {
type: "Line",
text: markup.text,
tags,
markup: this.normalizeMarkup(markup),
};
}
throw new ParseError(`Unexpected token ${t.type}`);
}
parseOptionGroup() {
const options = [];
// One or more OPTION lines, with bodies under INDENT
while (this.at("OPTION")) {
const raw = this.take("OPTION").text;
const { cleanText: textWithAttrs, tags } = this.extractTags(raw);
const { text: textWithCondition, css } = this.extractCss(textWithAttrs);
const { text: optionText, condition } = this.extractOptionCondition(textWithCondition);
const markup = parseMarkup(optionText);
let body = [];
if (this.at("INDENT")) {
this.take("INDENT");
body = this.parseStatementsUntil("DEDENT");
this.take("DEDENT");
while (this.at("EMPTY"))
this.i++;
}
options.push({
type: "Option",
text: markup.text,
body,
tags,
css,
markup: this.normalizeMarkup(markup),
condition,
});
// Consecutive options belong to the same group; break on non-OPTION
while (this.at("EMPTY"))
this.i++;
}
return { type: "OptionGroup", options };
}
normalizeMarkup(result) {
if (!result)
return undefined;
if (result.segments.length === 0) {
return undefined;
}
const hasFormatting = result.segments.some((segment) => segment.wrappers.length > 0 || segment.selfClosing);
if (!hasFormatting) {
return undefined;
}
return {
text: result.text,
segments: result.segments.map((segment) => ({
start: segment.start,
end: segment.end,
wrappers: segment.wrappers.map((wrapper) => ({
name: wrapper.name,
type: wrapper.type,
properties: { ...wrapper.properties },
})),
selfClosing: segment.selfClosing,
})),
};
}
extractTags(input) {
const tags = [];
// Match tags that are space-separated and not part of hex colors or CSS
// Tags are like "#tag" preceded by whitespace and not followed by hex digits
const re = /\s#([a-zA-Z_][a-zA-Z0-9_]*)(?!\w)/g;
let text = input;
let m;
while ((m = re.exec(input))) {
tags.push(m[1]);
}
if (tags.length > 0) {
// Only remove tags that match the pattern (not hex colors in CSS)
text = input.replace(/\s#([a-zA-Z_][a-zA-Z0-9_]*)(?!\w)/g, "").trimEnd();
return { cleanText: text, tags };
}
return { cleanText: input };
}
extractCss(input) {
const cssMatch = input.match(/\s*&css\{([^}]*)\}\s*$/);
if (cssMatch) {
const css = cssMatch[1].trim();
const text = input.replace(cssMatch[0], "").trimEnd();
return { text, css };
}
return { text: input };
}
extractOptionCondition(input) {
const match = input.match(/\s\[\s*if\s+([^\]]+)\]\s*$/i);
if (match) {
const text = input.slice(0, match.index).trimEnd();
return { text, condition: match[1].trim() };
}
return { text: input };
}
parseStatementsUntilStop(shouldStop) {
const out = [];
while (!this.at("EOF")) {
// Check stop condition at root level only
if (shouldStop())
break;
while (this.at("EMPTY"))
this.i++;
if (this.at("EOF") || shouldStop())
break;
// Handle indentation - if we see INDENT, parse the indented block
if (this.at("INDENT")) {
this.take("INDENT");
// Parse statements at this indent level until DEDENT (don't check stop condition inside)
while (!this.at("DEDENT") && !this.at("EOF")) {
while (this.at("EMPTY"))
this.i++;
if (this.at("DEDENT") || this.at("EOF"))
break;
if (this.at("OPTION")) {
out.push(this.parseOptionGroup());
continue;
}
out.push(this.parseStatement());
}
if (this.at("DEDENT")) {
this.take("DEDENT");
while (this.at("EMPTY"))
this.i++;
}
continue;
}
if (this.at("OPTION")) {
out.push(this.parseOptionGroup());
continue;
}
out.push(this.parseStatement());
}
return out;
}
parseOnceBlock() {
// Already consumed <<once>>; expect body under INDENT then <<endonce>> as COMMAND
let body = [];
if (this.at("INDENT")) {
this.take("INDENT");
body = this.parseStatementsUntil("DEDENT");
this.take("DEDENT");
}
else {
// Alternatively, body until explicit <<endonce>> command on single line
body = [];
}
// consume closing command if present on own line
if (this.at("COMMAND") && this.peek().text === "endonce") {
this.take("COMMAND");
}
return { type: "Once", body };
}
parseIfFromText(firstLine) {
const branches = [];
// expecting state not required in current implementation
let cursor = firstLine.trim();
function parseCond(text) {
const mIf = text.match(/^\{if\s+(.+?)\}$/);
if (mIf)
return mIf[1];
const mElIf = text.match(/^\{else\s+if\s+(.+?)\}$/);
if (mElIf)
return mElIf[1];
return null;
}
while (true) {
const cond = parseCond(cursor);
if (cursor === "{else}") {
branches.push({ condition: null, body: this.parseIfBlockBody() });
// next must be {endif}
const endLine = this.take("TEXT", "Expected {endif}").text.trim();
if (endLine !== "{endif}")
throw new ParseError("Expected {endif}");
break;
}
else if (cond) {
branches.push({ condition: cond, body: this.parseIfBlockBody() });
// next control line
const next = this.take("TEXT", "Expected {else}, {else if}, or {endif}").text.trim();
if (next === "{endif}")
break;
cursor = next;
continue;
}
else if (cursor === "{endif}") {
break;
}
else {
throw new ParseError("Invalid if/else control line");
}
}
return { type: "If", branches };
}
parseEnumBlock(enumName) {
const cases = [];
// Parse cases until <<endenum>>
while (!this.at("EOF")) {
while (this.at("EMPTY"))
this.i++;
if (this.at("COMMAND")) {
const cmd = this.peek().text.trim();
if (cmd === "endenum") {
this.take("COMMAND");
break;
}
if (cmd.startsWith("case ")) {
this.take("COMMAND");
const caseName = cmd.slice(5).trim();
cases.push(caseName);
}
else {
// Unknown command, might be inside enum block - skip or break?
break;
}
}
else {
// Skip non-command lines
if (this.at("TEXT"))
this.take("TEXT");
}
}
return { type: "Enum", name: enumName, cases };
}
parseIfCommandBlock(firstCmd) {
const branches = [];
const firstCond = firstCmd.slice(3).trim();
// Body until next elseif/else/endif command (check at root level, not inside indented blocks)
const firstBody = this.parseStatementsUntilStop(() => {
// Only stop at root level commands, not inside indented blocks
return this.at("COMMAND") && /^(elseif\s|else$|endif$)/.test(this.peek().text);
});
branches.push({ condition: firstCond, body: firstBody });
while (!this.at("EOF")) {
if (!this.at("COMMAND"))
break;
const t = this.peek();
const txt = t.text.trim();
if (txt.startsWith("elseif ")) {
this.take("COMMAND");
const cond = txt.slice(7).trim();
const body = this.parseStatementsUntilStop(() => this.at("COMMAND") && /^(elseif\s|else$|endif$)/.test(this.peek().text));
branches.push({ condition: cond, body });
continue;
}
if (txt === "else") {
this.take("COMMAND");
const body = this.parseStatementsUntilStop(() => this.at("COMMAND") && /^(endif$)/.test(this.peek().text));
branches.push({ condition: null, body });
// require endif after else body
if (this.at("COMMAND") && this.peek().text.trim() === "endif") {
this.take("COMMAND");
}
break;
}
if (txt === "endif") {
this.take("COMMAND");
break;
}
break;
}
return { type: "If", branches };
}
parseIfBlockBody() {
// Body is indented lines until next control line or DEDENT boundary; to keep this simple
// we consume subsequent lines until encountering a control TEXT or EOF/OPTION/NODE_END.
const body = [];
while (!this.at("EOF") && !this.at("NODE_END")) {
// Stop when next TEXT is a control or when OPTION starts (new group)
if (this.at("TEXT")) {
const look = this.peek().text.trim();
if (look === "{else}" || look === "{endif}" || look.startsWith("{else if ") || look.startsWith("{if "))
break;
}
if (this.at("OPTION"))
break;
// Support indented bodies inside if-branches
if (this.at("INDENT")) {
this.take("INDENT");
const nested = this.parseStatementsUntil("DEDENT");
this.take("DEDENT");
body.push(...nested);
// continue scanning after dedent
while (this.at("EMPTY"))
this.i++;
continue;
}
if (this.at("EMPTY")) {
this.i++;
continue;
}
body.push(this.parseStatement());
}
return body;
}
}
//# sourceMappingURL=parser.js.map