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.

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.
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.
// ❌ 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.
}// ✅ 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.
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.
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.
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.
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
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
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.


