Zum Inhalt springen

Einen Markdown-Parser von Grund auf in TypeScript bauen

Baue Schritt für Schritt einen Markdown-Parser: lexikalische Analyse, AST-Konstruktion und HTML-Rendering — so arbeiten Textverarbeitungs-Engines.

5 Min. Lesezeit
Diagramm, das zeigt, wie Markdown-Text über Lexer-Tokens in einen abstrakten Syntaxbaum und gerendertes HTML umgewandelt wird

Parser sind überall – in Compilern, Template-Engines, Konfigurationsdateien und dem Markdown-Renderer, der genau diesen Beitrag anzeigt. Einen Parser von Grund auf zu bauen lehrt dich, wie strukturierter Text zu strukturierten Daten wird – eine Fähigkeit, die sich auf jedes Textverarbeitungsproblem übertragen lässt, dem du begegnest.

Wir bauen einen Parser für eine Markdown-Teilmenge, der Überschriften, Absätze, Fettdruck, Kursivdruck, Codeblöcke und Inline-Code verarbeitet. Die Architektur folgt der klassischen Pipeline: Rohtext → Tokens → abstrakter Syntaxbaum → HTML-Ausgabe.

Token-Typen definieren

Der Lexer zerlegt den rohen Markdown-Text in bedeutungstragende Tokens. Jedes Token trägt einen Typ und den rohen Inhalt, den es repräsentiert.

tstypescript
type TokenType =
  | "heading"
  | "paragraph"
  | "text"
  | "bold"
  | "italic"
  | "code-block"
  | "inline-code"
  | "newline"
  | "eof";
 
interface Token {
  type: TokenType;
  value: string;
  level?: number; // For headings: 1-6
  language?: string; // For code blocks
}

Den Lexer bauen

Der Lexer durchläuft den Eingabestring und erzeugt Tokens. Er verarbeitet das Dokument Zeile für Zeile für Block-Level-Elemente und behandelt dann die Inline-Formatierung innerhalb des Textinhalts.

tstypescript
// ❌ Naive approach: regex-replace everything at once
function badParse(markdown: string): string {
  return markdown
    .replace(/^### (.+)$/gm, "<h3>$1</h3>")
    .replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
    .replace(/\*(.+?)\*/g, "<em>$1</em>");
  // Breaks on nested formatting, multi-line elements,
  // code blocks containing markdown syntax, etc.
}
tstypescript
// ✅ Proper lexer with state tracking
class Lexer {
  private input: string;
  private pos: number = 0;
  private tokens: Token[] = [];
 
  constructor(input: string) {
    this.input = input;
  }
 
  tokenize(): Token[] {
    const lines = this.input.split("\n");
    let i = 0;
 
    while (i < lines.length) {
      const line = lines[i];
 
      // Code block (fenced)
      if (line.startsWith("```")) {
        const language = line.slice(3).trim();
        const codeLines: string[] = [];
        i++;
 
        while (i < lines.length && !lines[i].startsWith("```")) {
          codeLines.push(lines[i]);
          i++;
        }
 
        this.tokens.push({
          type: "code-block",
          value: codeLines.join("\n"),
          language: language || undefined,
        });
        i++; // Skip closing ```
        continue;
      }
 
      // Heading
      const headingMatch = line.match(/^(#{2,6})\s+(.+)$/);
      if (headingMatch) {
        this.tokens.push({
          type: "heading",
          value: headingMatch[2],
          level: headingMatch[1].length,
        });
        i++;
        continue;
      }
 
      // Empty line
      if (line.trim() === "") {
        this.tokens.push({ type: "newline", value: "\n" });
        i++;
        continue;
      }
 
      // Paragraph text — collect consecutive non-empty lines
      const paragraphLines: string[] = [];
      while (
        i < lines.length &&
        lines[i].trim() !== "" &&
        !lines[i].startsWith("```") &&
        !lines[i].match(/^#{2,6}\s/)
      ) {
        paragraphLines.push(lines[i]);
        i++;
      }
 
      if (paragraphLines.length > 0) {
        this.tokens.push({
          type: "paragraph",
          value: paragraphLines.join(" "),
        });
      }
    }
 
    this.tokens.push({ type: "eof", value: "" });
    return this.tokens;
  }
}

Der Lexer behandelt Codeblöcke als opaken Inhalt – alles zwischen den Fences wird unverändert erfasst, ohne zu versuchen, Markdown-Syntax darin zu parsen.

Der abstrakte Syntaxbaum

Der AST liefert uns eine strukturierte Repräsentation, die sich leicht in jedes beliebige Ausgabeformat transformieren lässt.

tstypescript
type ASTNode =
  | HeadingNode
  | ParagraphNode
  | TextNode
  | BoldNode
  | ItalicNode
  | CodeBlockNode
  | InlineCodeNode
  | DocumentNode;
 
interface DocumentNode {
  type: "document";
  children: ASTNode[];
}
 
interface HeadingNode {
  type: "heading";
  level: number;
  children: ASTNode[];
}
 
interface ParagraphNode {
  type: "paragraph";
  children: ASTNode[];
}
 
interface TextNode {
  type: "text";
  value: string;
}
 
interface BoldNode {
  type: "bold";
  children: ASTNode[];
}
 
interface ItalicNode {
  type: "italic";
  children: ASTNode[];
}
 
interface CodeBlockNode {
  type: "code-block";
  language?: string;
  value: string;
}
 
interface InlineCodeNode {
  type: "inline-code";
  value: string;
}

Inline-Inhalte parsen

Das Inline-Parsing behandelt Fettdruck, Kursivdruck und Inline-Code innerhalb von Text. Das ist der kniffligste Teil, weil sich diese Formate verschachteln können.

tstypescript
class InlineParser {
  private input: string;
  private pos: number = 0;
 
  constructor(input: string) {
    this.input = input;
  }
 
  parse(): ASTNode[] {
    const nodes: ASTNode[] = [];
    let textBuffer = "";
 
    while (this.pos < this.input.length) {
      const char = this.input[this.pos];
      const next = this.input[this.pos + 1];
 
      // Inline code
      if (char === "`") {
        if (textBuffer) {
          nodes.push({ type: "text", value: textBuffer });
          textBuffer = "";
        }
        nodes.push(this.parseInlineCode());
        continue;
      }
 
      // Bold: **text**
      if (char === "*" && next === "*") {
        if (textBuffer) {
          nodes.push({ type: "text", value: textBuffer });
          textBuffer = "";
        }
        nodes.push(this.parseBold());
        continue;
      }
 
      // Italic: *text*
      if (char === "*" && next !== "*") {
        if (textBuffer) {
          nodes.push({ type: "text", value: textBuffer });
          textBuffer = "";
        }
        nodes.push(this.parseItalic());
        continue;
      }
 
      textBuffer += char;
      this.pos++;
    }
 
    if (textBuffer) {
      nodes.push({ type: "text", value: textBuffer });
    }
 
    return nodes;
  }
 
  private parseInlineCode(): InlineCodeNode {
    this.pos++; // Skip opening `
    let value = "";
 
    while (this.pos < this.input.length && this.input[this.pos] !== "`") {
      value += this.input[this.pos];
      this.pos++;
    }
 
    this.pos++; // Skip closing `
    return { type: "inline-code", value };
  }
 
  private parseBold(): BoldNode {
    this.pos += 2; // Skip opening **
    let content = "";
 
    while (
      this.pos < this.input.length - 1 &&
      !(this.input[this.pos] === "*" && this.input[this.pos + 1] === "*")
    ) {
      content += this.input[this.pos];
      this.pos++;
    }
 
    this.pos += 2; // Skip closing **
    const innerParser = new InlineParser(content);
    return { type: "bold", children: innerParser.parse() };
  }
 
  private parseItalic(): ItalicNode {
    this.pos++; // Skip opening *
    let content = "";
 
    while (this.pos < this.input.length && this.input[this.pos] !== "*") {
      content += this.input[this.pos];
      this.pos++;
    }
 
    this.pos++; // Skip closing *
    const innerParser = new InlineParser(content);
    return { type: "italic", children: innerParser.parse() };
  }
}

Beachte, wie parseBold rekursiv einen neuen InlineParser für seinen Inhalt erzeugt. Dadurch lässt sich verschachtelte Formatierung wie **bold with *italic* inside** korrekt parsen.

Den Dokument-Parser bauen

Der Dokument-Parser nimmt den Token-Strom und baut den vollständigen AST, indem er Block-Level-Tokens mit dem Inline-Parsing kombiniert.

tstypescript
class Parser {
  private tokens: Token[];
  private pos: number = 0;
 
  constructor(tokens: Token[]) {
    this.tokens = tokens;
  }
 
  parse(): DocumentNode {
    const children: ASTNode[] = [];
 
    while (this.current().type !== "eof") {
      const token = this.current();
 
      if (token.type === "newline") {
        this.pos++;
        continue;
      }
 
      if (token.type === "heading") {
        const inlineParser = new InlineParser(token.value);
        children.push({
          type: "heading",
          level: token.level!,
          children: inlineParser.parse(),
        });
        this.pos++;
        continue;
      }
 
      if (token.type === "code-block") {
        children.push({
          type: "code-block",
          language: token.language,
          value: token.value,
        });
        this.pos++;
        continue;
      }
 
      if (token.type === "paragraph") {
        const inlineParser = new InlineParser(token.value);
        children.push({
          type: "paragraph",
          children: inlineParser.parse(),
        });
        this.pos++;
        continue;
      }
 
      this.pos++;
    }
 
    return { type: "document", children };
  }
 
  private current(): Token {
    return this.tokens[this.pos] ?? { type: "eof", value: "" };
  }
}

Rendering nach HTML

Der Renderer durchläuft den AST und erzeugt HTML. Da der Baum gut strukturiert ist, ist das Rendering eine einfache rekursive Traversierung.

tstypescript
function escapeHtml(text: string): string {
  return text
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}
 
function renderNode(node: ASTNode): string {
  switch (node.type) {
    case "document":
      return node.children.map(renderNode).join("\n");
 
    case "heading":
      const tag = `h${node.level}`;
      const headingContent = node.children.map(renderNode).join("");
      return `<${tag}>${headingContent}</${tag}>`;
 
    case "paragraph":
      const paraContent = node.children.map(renderNode).join("");
      return `<p>${paraContent}</p>`;
 
    case "text":
      return escapeHtml(node.value);
 
    case "bold":
      const boldContent = node.children.map(renderNode).join("");
      return `<strong>${boldContent}</strong>`;
 
    case "italic":
      const italicContent = node.children.map(renderNode).join("");
      return `<em>${italicContent}</em>`;
 
    case "code-block":
      const langAttr = node.language
        ? ` class="language-${escapeHtml(node.language)}"`
        : "";
      return `<pre><code${langAttr}>${escapeHtml(node.value)}</code></pre>`;
 
    case "inline-code":
      return `<code>${escapeHtml(node.value)}</code>`;
 
    default:
      return "";
  }
}

Die Funktion escapeHtml ist entscheidend – ohne sie würde von Nutzern erzeugtes Markdown mit <script>-Tags ausführbares HTML produzieren.

Alles zusammenfügen

tstypescript
function parseMarkdown(input: string): string {
  const lexer = new Lexer(input);
  const tokens = lexer.tokenize();
 
  const parser = new Parser(tokens);
  const ast = parser.parse();
 
  return renderNode(ast);
}
 
// Usage
const markdown = `## Hello World
 
This is a **bold** and *italic* paragraph with \`inline code\`.
 
\`\`\`typescript
const x = 42;
console.log(x);
\`\`\`
 
Another paragraph with **nested *formatting* here**.`;
 
console.log(parseMarkdown(markdown));

Die wichtigsten Erkenntnisse

Einen Parser von Grund auf zu bauen festigt ein fundamentales Muster, das überall in der Software auftaucht: Roheingabe → strukturierte Tokens → abstrakter Baum → Ausgabeformat. Dieselbe Pipeline treibt Compiler, Template-Engines, Abfragesprachen und Konfigurationsparser an. Die konkreten Techniken – Lexer mit Zustandsverfolgung, Recursive-Descent-Parsing, AST-basiertes Rendering – sind Werkzeuge, zu denen du greifen wirst, wann immer du strukturierten Text verarbeiten musst. Fang einfach an, bring die Pipeline von Ende zu Ende zum Laufen und füge dann schrittweise Features hinzu. Ein Parser, der fünf Konstrukte korrekt verarbeitet, ist wertvoller als einer, der zwanzig Konstrukte mit subtilen Bugs behandelt.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX