UNPKG

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
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