# 综合实战：用 TypeScript 构建 Markdown 解析器


## 概述

前十一篇我们系统学了 TypeScript 的各个知识点。这一篇把它们串起来。

我们要做一个**轻量 Markdown 解析器**。输入一段 Markdown 文本，输出结构化的语法树（AST）。

为什么选 Markdown 解析器？因为它**小到能在一篇笔记讲完，大到能用上几乎所有 TS 特性**：

| 知识点 | 在本项目中的用法 |
|--------|-----------------|
| interface | 定义各种语法节点类型 |
| 可辨识联合 | 用 `type` 字段区分不同节点 |
| 泛型 | 通用的节点遍历和转换函数 |
| 映射类型 | 从节点类型提取元数据 |
| 类型守卫 | 处理不同类型的节点 |
| 模块 | 按职责拆分文件 |

## 项目结构

```
markdown-parser/
├── src/
│   ├── types.ts        # 所有类型定义
│   ├── parser.ts       # Markdown → AST 解析
│   ├── traverser.ts    # AST 遍历与转换
│   └── index.ts        # 入口，整合所有模块
├── tsconfig.json
└── package.json
```

## 第一步：定义节点类型（types.ts）

Markdown 文档由不同类型的**块**组成。我们先定义每种块的类型。

### 基础类型

```typescript
// src/types.ts

// 文本 —— 最基础的节点
export interface TextNode {
  type: "text"
  value: string
}

// 标题
export interface HeadingNode {
  type: "heading"
  level: 1 | 2 | 3 | 4 | 5 | 6
  children: TextNode[]
}

// 段落
export interface ParagraphNode {
  type: "paragraph"
  children: TextNode[]
}

// 代码块
export interface CodeBlockNode {
  type: "code"
  language: string
  content: string
}

// 列表（无序）
export interface ListNode {
  type: "list"
  items: ListItemNode[]
}

// 列表项
export interface ListItemNode {
  type: "listItem"
  content: TextNode[]
}
```

### 可辨识联合

这些节点都有各自的 `type` 字段。我们把它们组合成一个联合类型：

```typescript
// 所有节点类型的联合
export type BlockNode =
  | HeadingNode
  | ParagraphNode
  | CodeBlockNode
  | ListNode
  | ListItemNode

// 所有内联节点类型（嵌入在块内部的节点）
export type InlineNode = TextNode

// 任意节点 —— 递归场景使用
export type ASTNode = BlockNode | InlineNode
```

> [!info] 说明
> 可辨识联合是 TypeScript 中最实用的模式之一。`type` 字段就是"辨识器"。TypeScript 可以根据 `type` 的值缩小类型范围，精准推断当前分支的类型。

### 文档类型

整个文档就是节点的数组：

```typescript
// 完整的文档 AST
export interface Document {
  type: "document"
  children: BlockNode[]
}
```

## 第二步：解析 Markdown（parser.ts）

解析器把 Markdown 文本转换成 AST。

### 行解析

先按行切分，逐行解析：

```typescript
// src/parser.ts
import type {
  Document,
  BlockNode,
  HeadingNode,
  ParagraphNode,
  CodeBlockNode,
  ListNode,
  ListItemNode,
  TextNode,
} from "./types"

// 解析入口
export function parse(markdown: string): Document {
  const lines = markdown.split("\n")
  const blocks = parseBlocks(lines)

  return {
    type: "document",
    children: blocks,
  }
}

// 按行解析块
function parseBlocks(lines: string[]): BlockNode[] {
  const blocks: BlockNode[] = []
  let i = 0

  while (i < lines.length) {
    const line = lines[i]

    if (line.startsWith("### ")) {
      blocks.push(parseHeading(line, 3))
    } else if (line.startsWith("## ")) {
      blocks.push(parseHeading(line, 2))
    } else if (line.startsWith("# ")) {
      blocks.push(parseHeading(line, 1))
    } else if (line.startsWith("```")) {
      const result = parseCodeBlock(lines, i)
      blocks.push(result.node)
      i = result.nextLine
      continue
    } else if (line.startsWith("- ")) {
      const result = parseList(lines, i)
      blocks.push(result.node)
      i = result.nextLine
      continue
    } else if (line.trim() !== "") {
      blocks.push(parseParagraph(line))
    }
    // 空行跳过

    i++
  }

  return blocks
}
```

### 具体解析函数

**标题解析：**

```typescript
function parseHeading(line: string, level: number): HeadingNode {
  const text = line.replace(/^#{1,6}\s+/, "")
  return {
    type: "heading",
    level,
    children: parseInline(text),
  }
}
```

**代码块解析：**

```typescript
function parseCodeBlock(lines: string[], start: number): { node: CodeBlockNode; nextLine: number } {
  const firstLine = lines[start]
  const language = firstLine.replace("```", "").trim()
  const contentLines: string[] = []
  let i = start + 1

  while (i < lines.length && !lines[i].startsWith("```")) {
    contentLines.push(lines[i])
    i++
  }

  return {
    node: {
      type: "code",
      language,
      content: contentLines.join("\n"),
    },
    nextLine: i + 1, // 跳过结束的 ```
  }
}
```

**列表解析：**

```typescript
function parseList(lines: string[], start: number): { node: ListNode; nextLine: number } {
  const items: ListItemNode[] = []
  let i = start

  while (i < lines.length && lines[i].startsWith("- ")) {
    const content = lines[i].replace(/^- /, "")
    items.push({
      type: "listItem",
      content: parseInline(content),
    })
    i++
  }

  return {
    node: {
      type: "list",
      items,
    },
    nextLine: i,
  }
}
```

**段落与内联解析：**

```typescript
function parseParagraph(line: string): ParagraphNode {
  return {
    type: "paragraph",
    children: parseInline(line),
  }
}

// 内联解析 —— 现在只处理纯文本
function parseInline(text: string): TextNode[] {
  return [
    {
      type: "text",
      value: text,
    },
  ]
}
```

> [!tip] 技巧
> 当前的内联解析函数很简单。你可以扩展它，支持 `**加粗**`、`*斜体*`、`` `代码` `` 等内联语法——这就是不同的 `InlineNode` 子类型。

## 第三步：遍历与转换（traverser.ts）

AST 建好了，现在要对它做操作。用泛型写一个通用的遍历函数：

### 通用遍历器

```typescript
// src/traverser.ts
import type { Document, BlockNode, InlineNode, ASTNode }

// 遍历回调类型 —— 使用者决定做什么
type Visitor<T> = (node: ASTNode) => T

// 通用的遍历函数 —— 泛型让它可以用于任何场景
export function traverse<T>(doc: Document, visitor: Visitor<T>): T[] {
  const results: T[] = []

  function visit(nodes: ASTNode[]) {
    for (const node of nodes) {
      results.push(visitor(node))

      // 根据类型访问子节点
      switch (node.type) {
        case "document":
          visit(node.children)
          break
        case "heading":
        case "paragraph":
          visit(node.children)
          break
        case "list":
          visit(node.items)
          break
        case "listItem":
          visit(node.content)
          break
        // code 和 text 没有子节点，不处理
      }
    }
  }

  visit(doc.children)
  return results
}
```

### 用遍历器实现功能

**统计节点数量：**

```typescript
// 统计每种节点的数量
function countNodes(doc: Document): Record<string, number> {
  const counts: Record<string, number> = {}

  traverse(doc, (node) => {
    const type = node.type
    counts[type] = (counts[type] || 0) + 1
  })

  return counts
}

// 使用
const doc = parse("# 标题\n\n一段文字\n\n- 列表项1\n- 列表项2")
const counts = countNodes(doc)
// { document: 1, heading: 1, paragraph: 1, list: 1, listItem: 2, text: 4 }
```

**提取所有标题：**

```typescript
function extractHeadings(doc: Document) {
  return traverse(doc, (node) => {
    if (node.type === "heading") {
      return `H${node.level}: ${node.children.map(t => t.value).join("")}`
    }
    return null
  }).filter(Boolean)
}
```

### 节点转换器

`traverse` 只读访问。如果需要**转换**（修改）AST，可以写一个 transform 函数：

```typescript
// 通用的节点转换函数 —— 用泛型保持类型安全
type Transformer<T extends ASTNode> = (node: T) => T

// 转换文档中的所有标题级别
function transformHeadings(doc: Document, delta: number): Document {
  function transformNode(node: ASTNode): ASTNode {
    if (node.type === "heading") {
      const newLevel = Math.min(6, Math.max(1, node.level + delta)) as 1 | 2 | 3 | 4 | 5 | 6
      return { ...node, level: newLevel }
    }

    if (node.type === "document") {
      return {
        ...node,
        children: node.children.map(child => transformNode(child)) as BlockNode[],
      }
    }

    if (node.type === "list") {
      return {
        ...node,
        items: node.items.map(item => transformNode(item)) as ListItemNode[],
      }
    }

    return node
  }

  return transformNode(doc) as Document
}
```

## 第四步：用映射类型提取信息

定义一些工具类型，从现有的节点类型中提取元信息：

```typescript
// src/types.ts — 追加

// 提取所有节点类型名称的联合
type NodeTypeName = ASTNode["type"]
// "document" | "heading" | "paragraph" | "code" | "list" | "listItem" | "text"

// 映射：节点类型名 → 对应的节点类型
type NodeTypeMap = {
  [K in ASTNode["type"]]: Extract<ASTNode, { type: K }>
}
// {
//   document: Document
//   heading: HeadingNode
//   paragraph: ParagraphNode
//   code: CodeBlockNode
//   list: ListNode
//   listItem: ListItemNode
//   text: TextNode
// }

// 提取所有有 "children" 属性的节点
type ParentNode = Extract<ASTNode, { children: ASTNode[] }>
// HeadingNode | ParagraphNode | Document

// 提取所有块级节点名称（不含 inline）
type BlockType = BlockNode["type"]
// "heading" | "paragraph" | "code" | "list" | "listItem"
```

## 第五步：串联运行（index.ts）

把所有模块整合到入口文件：

```typescript
// src/index.ts
import { parse } from "./parser"
import { traverse } from "./traverser"
import type { Document, ASTNode } from "./types"

// 完整的解析 + 处理流程
function processMarkdown(input: string) {
  // 1. 解析为 AST
  const doc = parse(input)

  // 2. 遍历统计
  const counts: Record<string, number> = {}
  traverse(doc, (node) => {
    const type = node.type
    counts[type] = (counts[type] || 0) + 1
  })

  // 3. 提取目录
  const toc = traverse(doc, (node) => {
    if (node.type === "heading") {
      return {
        level: node.level,
        text: node.children.map(t => t.value).join(""),
      }
    }
    return null
  }).filter(Boolean)

  return { doc, stats: counts, toc }
}

// 测试
const markdown = `
# TypeScript 笔记

TypeScript 是 JavaScript 的超集。

## 安装

\`\`\`shell
npm install typescript -g
\`\`\`

## 基础类型

- string
- number
- boolean

## 结语

开始使用 TypeScript 吧！
`

const result = processMarkdown(markdown)
console.log("节点统计：", result.stats)
console.log("\n目录：")
result.toc.forEach((item, i) => {
  const indent = "  ".repeat(item.level - 1)
  console.log(`${indent}H${item.level}. ${item.text}`)
})
```

运行输出：

```
节点统计： { document: 1, heading: 4, paragraph: 2, code: 1, list: 1, listItem: 3, text: 10 }

目录：
H1. TypeScript 笔记
  H2. 安装
  H2. 基础类型
  H2. 结语
```

## 扩展方向

这个解析器是一个**最小可行产品**。你可以沿着几个方向扩展：

**内联语法支持**：为 `**加粗**`、`*斜体*`、`` `行内代码` `` 添加对应的 `InlineNode` 子类型。

**嵌套列表**：当前列表只有一层。可以在 `ListItemNode` 中添加可选的子列表字段。

**行内代码高亮**：将代码块的语言映射到语法高亮规则上。

**HTML 输出**：写一个 `renderer.ts`，将 AST 渲染为 HTML 字符串。

```typescript
// 扩展后的渲染函数签名
function render(node: ASTNode): string {
  switch (node.type) {
    case "document":
      return node.children.map(render).join("\n")
    case "heading":
      const tag = `h${node.level}`
      return `<${tag}>${renderInline(node.children)}</${tag}>`
    case "paragraph":
      return `<p>${renderInline(node.children)}</p>`
    case "code":
      return `<pre><code class="language-${node.language}">${escapeHtml(node.content)}</code></pre>`
    case "list":
      return `<ul>${node.items.map(render).join("\n")}</ul>`
    case "listItem":
      return `<li>${renderInline(node.content)}</li>`
    case "text":
      return escapeHtml(node.value)
  }
}
```



---

> 作者: Aphros  
> URL: https://blog.papergate.top/posts/12.%E7%BB%BC%E5%90%88%E5%AE%9E%E6%88%98%E7%94%A8-typescript-%E6%9E%84%E5%BB%BA-markdown-%E8%A7%A3%E6%9E%90%E5%99%A8/  

