Saltar al contenido

Construyendo un parser de Markdown desde cero en TypeScript

Construye un parser de Markdown paso a paso: análisis léxico, construcción del AST y renderizado a HTML, para entender los motores de texto por dentro.

5 min de lectura
Diagrama que muestra texto Markdown transformándose a través de los tokens del lexer en un árbol de sintaxis abstracta y HTML renderizado

Los parsers están en todas partes: en compiladores, motores de plantillas, archivos de configuración y el renderizador de Markdown que muestra esta misma publicación. Construir uno desde cero te enseña cómo el texto estructurado se convierte en datos estructurados, una habilidad que se traslada a cada problema de procesamiento de texto que encuentres.

Construiremos un parser de un subconjunto de Markdown que maneja encabezados, párrafos, negritas, cursivas, bloques de código y código en línea. La arquitectura sigue el pipeline clásico: texto crudo → tokens → árbol de sintaxis abstracta → salida HTML.

Definiendo los tipos de tokens

El lexer divide el texto Markdown crudo en tokens significativos. Cada token lleva un tipo y el contenido crudo que representa.

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
}

Construyendo el lexer

El lexer recorre la cadena de entrada y produce tokens. Procesa el documento línea por línea para los elementos de nivel de bloque, y luego maneja el formato en línea dentro del contenido de texto.

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

El lexer maneja los bloques de código como contenido opaco: todo lo que está entre las cercas se captura tal cual, sin intentar parsear sintaxis Markdown en su interior.

El árbol de sintaxis abstracta

El AST nos da una representación estructurada que es fácil de transformar en cualquier formato de salida.

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

Parseando contenido en línea

El parseo en línea maneja negritas, cursivas y código en línea dentro del texto. Esta es la parte más delicada porque estos formatos pueden anidarse.

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() };
  }
}

Observa cómo parseBold crea recursivamente un nuevo InlineParser para su contenido. Esto permite que el formato anidado como **bold with *italic* inside** se parsee correctamente.

Construyendo el parser del documento

El parser del documento toma el flujo de tokens y construye el AST completo combinando los tokens de nivel de bloque con el parseo en línea.

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: "" };
  }
}

Renderizando a HTML

El renderizador recorre el AST y produce HTML. Como el árbol está bien estructurado, el renderizado es un recorrido recursivo directo.

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 "";
  }
}

La función escapeHtml es crítica: sin ella, el Markdown generado por usuarios que contenga etiquetas <script> produciría HTML ejecutable.

Juntándolo todo

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

Puntos clave

Construir un parser desde cero refuerza un patrón fundamental que aparece en todas partes del software: entrada cruda → tokens estructurados → árbol abstracto → formato de salida. El mismo pipeline impulsa compiladores, motores de plantillas, lenguajes de consulta y parsers de configuración. Las técnicas específicas —lexers con seguimiento de estado, parseo descendente recursivo, renderizado basado en AST— son herramientas a las que recurrirás siempre que necesites procesar texto estructurado. Empieza simple, haz que el pipeline funcione de punta a punta y luego agrega funcionalidades de forma incremental. Un parser que maneja cinco constructos correctamente vale más que uno que maneja veinte con errores sutiles.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX