<!-- Generated by SpecStory -->

# Creating a Vite plugin for TOC extraction (2025-07-21 08:32Z)

_**User**_

写一个vite插件，通过正则的方式读取md和mdx文件并提取出toc，保存在一个ts文件中

---

_**Assistant**_

I'll help you create a Vite plugin that extracts table of contents (TOC) from MD and MDX files using regex and saves them to a TypeScript file. Let me first examine the current structure and then implement the plugin. Now let me examine the existing route generator plugin to understand the pattern and structure: Let me look at the full structure of the route generator plugin to understand the pattern: Now let me examine the TOC extractor mentioned in the vite config to understand the current structure: Now I have a good understanding of the project structure and requirements. Let me create a TOC extractor plugin for extracting table of contents from MD and MDX files using regex and saving them to a TypeScript file.

````typescript
import fs from 'node:fs/promises'
import path from 'node:path'

import { glob } from 'glob'
import type { Plugin } from 'vite'

interface TocItem {
  id: string
  level: number
  text: string
  children?: TocItem[]
}

interface FileToc {
  file: string
  title: string
  toc: TocItem[]
}

interface TocExtractorOptions {
  contentsDir?: string
  outputDir?: string
  outputFile?: string
  maxDepth?: number
}

const defaultOptions: Required<TocExtractorOptions> = {
  contentsDir: 'contents',
  outputDir: 'src',
  outputFile: 'toc-data.ts',
  maxDepth: 6,
}

/**
 * TOC 提取器插件
 *
 * 通过正则表达式从 MD 和 MDX 文件中提取标题，生成目录结构
 * 并保存到 TypeScript 文件中
 */
export function tocExtractor(options: TocExtractorOptions = {}): Plugin {
  const opts = { ...defaultOptions, ...options }

  return {
    name: 'toc-extractor',
    enforce: 'pre',

    async buildStart() {
      await generateTocData(opts)
    },

    async handleHotUpdate({ file, server }) {
      // 监听 contents 目录文件变化，重新生成 TOC 数据
      if (file.includes(path.resolve(opts.contentsDir))) {
        await generateTocData(opts)
        server.ws.send({
          type: 'full-reload',
        })
      }
    },

    configureServer(server) {
      // 开发模式下提供手动触发接口
      server.middlewares.use('/__generate-toc', async (_req, res) => {
        try {
          await generateTocData(opts)
          res.end('TOC data generated successfully')
        } catch (error) {
          res.statusCode = 500
          res.end(`Error generating TOC data: ${error}`)
        }
      })
    },
  }
}

async function generateTocData(options: Required<TocExtractorOptions>) {
  const { contentsDir, outputDir, outputFile, maxDepth } = options

  try {
    // 获取所有 markdown 文件
    const pattern = path.join(contentsDir, '**/*.{md,mdx}')
    const files = await glob(pattern, {
      ignore: ['**/node_modules/**'],
      absolute: false,
    })

    const allTocData: FileToc[] = []

    for (const file of files) {
      const tocData = await extractTocFromFile(file, contentsDir, maxDepth)
      if (tocData) {
        allTocData.push(tocData)
      }
    }

    // 按文件路径排序
    allTocData.sort((a, b) => a.file.localeCompare(b.file))

    // 生成 TypeScript 文件内容
    const tsContent = generateTocTsContent(allTocData)

    // 确保输出目录存在
    await fs.mkdir(outputDir, { recursive: true })

    // 写入文件
    const outputPath = path.join(outputDir, outputFile)
    await fs.writeFile(outputPath, tsContent, 'utf-8')

    console.info(
      `✓ Generated TOC data for ${allTocData.length} files to ${outputPath}`,
    )
  } catch (error) {
    console.error('Error generating TOC data:', error)
    throw error
  }
}

async function extractTocFromFile(
  file: string,
  contentsDir: string,
  maxDepth: number,
): Promise<FileToc | null> {
  try {
    const content = await fs.readFile(file, 'utf-8')

    // 提取 frontmatter 中的标题
    const frontmatterTitle = extractFrontmatterTitle(content)

    // 使用正则表达式提取标题
    const headings = extractHeadings(content, maxDepth)

    if (headings.length === 0 && !frontmatterTitle) {
      return null
    }

    // 构建嵌套的 TOC 结构
    const toc = buildTocTree(headings)

    // 生成文件路径（相对于 contents 目录）
    const relativePath = path.relative(contentsDir, file)

    return {
      file: relativePath,
      title: frontmatterTitle || headings[0]?.text || path.basename(file, path.extname(file)),
      toc,
    }
  } catch (error) {
    console.warn(`Failed to process file ${file}:`, error)
    return null
  }
}

function extractFrontmatterTitle(content: string): string | null {
  // 匹配 frontmatter 中的 title 字段
  const frontmatterMatch = content.match(/^---\n(.*?)\n---/s)
  if (!frontmatterMatch) return null

  const frontmatterContent = frontmatterMatch[1]
  const titleMatch = frontmatterContent.match(/^title:\s*(.+)$/m)
  if (!titleMatch) return null

  // 移除引号
  return titleMatch[1].replace(/^['"]|['"]$/g, '').trim()
}

function extractHeadings(content: string, maxDepth: number): TocItem[] {
  const headings: TocItem[] = []
  
  // 正则表达式匹配 Markdown 标题 (## Title 格式)
  const headingRegex = /^(#{1,6})\s+(.+)$/gm
  
  let match
  while ((match = headingRegex.exec(content)) !== null) {
    const level = match[1].length
    
    if (level > maxDepth) continue
    
    const text = match[2].trim()
    
    // 生成 ID（转换为小写，替换空格和特殊字符）
    const id = generateHeadingId(text)
    
    headings.push({
      id,
      level,
      text,
    })
  }
  
  return headings
}

function generateHeadingId(text: string): string {
  return text
    .toLowerCase()
    .replace(/[^\w\s-]/g, '') // 移除特殊字符
    .replace(/\s+/g, '-') // 空格替换为连字符
    .replace(/-+/g, '-') // 多个连字符合并为一个
    .replace(/^-|-$/g, '') // 移除开头和结尾的连字符
}

function buildTocTree(headings: TocItem[]): TocItem[] {
  const result: TocItem[] = []
  const stack: TocItem[] = []

  for (const heading of headings) {
    // 将当前标题的子元素初始化为空数组
    heading.children = []

    // 找到合适的父级
    while (stack.length > 0 && stack[stack.length - 1].level >= heading.level) {
      stack.pop()
    }

    if (stack.length === 0) {
      // 顶级标题
      result.push(heading)
    } else {
      // 作为子标题
      const parent = stack[stack.length - 1]
      if (!parent.children) {
        parent.children = []
      }
      parent.children.push(heading)
    }

    stack.push(heading)
  }

  return result
}

function generateTocTsContent(tocData: FileToc[]): string {
  return `// This file is automatically generated by the toc-extractor plugin
// Do not edit manually

export interface TocItem {
  id: string
  level: number
  text: string
  children?: TocItem[]
}

export interface FileToc {
  file: string
  title: string
  toc: TocItem[]
}

export const tocData: FileToc[] = ${JSON.stringify(tocData, null, 2)}

// Helper function to find TOC data by file path
export function getTocByFile(filePath: string): FileToc | undefined {
  return tocData.find(item => item.file === filePath)
}

// Helper function to flatten TOC tree into a simple array
export function flattenToc(toc: TocItem[]): TocItem[] {
  const result: TocItem[] = []
  
  function traverse(items: TocItem[]) {
    for (const item of items) {
      result.push({
        id: item.id,
        level: item.level,
        text: item.text,
      })
      if (item.children && item.children.length > 0) {
        traverse(item.children)
      }
    }
  }
  
  traverse(toc)
  return result
}

// Helper function to get all headings at a specific level
export function getHeadingsByLevel(toc: TocItem[], level: number): TocItem[] {
  const result: TocItem[] = []
  
  function traverse(items: TocItem[]) {
    for (const item of items) {
      if (item.level === level) {
        result.push(item)
      }
      if (item.children && item.children.length > 0) {
        traverse(item.children)
      }
    }
  }
  
  traverse(toc)
  return result
}
`
}

export default tocExtractor
````

这个 TOC 提取器插件具有以下功能：

## 🌟 核心特性

### 1. **正则表达式解析**
- 使用正则表达式 `/^(#{1,6})\s+(.+)$/gm` 匹配 Markdown 标题
- 支持 1-6 级标题（# 到 ######）
- 自动生成唯一的标题 ID

### 2. **文件类型支持**
- 支持 `.md` 和 `.mdx` 文件
- 自动提取 frontmatter 中的 `title` 字段
- 递归扫描 `contents` 目录

### 3. **嵌套结构构建**
- 根据标题级别构建嵌套的 TOC 树结构
- 支持多级嵌套（子标题关系）
- 保持标题的层级关系

### 4. **TypeScript 输出**
- 生成完整的 TypeScript 类型定义
- 导出所有 TOC 数据和辅助函数
- 自动生成注释说明

## 🔧 配置选项

```typescript
interface TocExtractorOptions {
  contentsDir?: string    // 内容目录，默认 'contents'
  outputDir?: string      // 输出目录，默认 'src'
  outputFile?: string     // 输出文件名，默认 'toc-data.ts'
  maxDepth?: number       // 最大标题深度，默认 6
}
```

## 📚 使用方式

插件会生成一个包含以下内容的 TypeScript 文件：

1. **类型定义**：`TocItem` 和 `FileToc` 接口
2. **数据导出**：所有文件的 TOC 数据
3. **辅助函数**：
   - `getTocByFile()` - 根据文件路径查找 TOC
   - `flattenToc()` - 将嵌套 TOC 转为平面数组
   - `getHeadingsByLevel()` - 获取特定级别的标题

## 🚀 热更新支持

- 开发模式下监听文件变化
- 自动重新生成 TOC 数据
- 支持手动触发更新（`/__generate-toc` 接口）

这个插件已经集成到 `vite.config.ts` 中，会在构建时和开发时自动运行，为文档站点提供完整的目录结构数据。

---

_**User**_

根据新的toc结构重新编写这里

---

_**Assistant**_

我看到当前的 `TableOfContents` 组件有一些问题需要修复，以适应新的 TOC 结构。让我分析一下问题并重新编写这个组件。 

---

_**User**_

@GitHubCopilot Try Again

---

_**Assistant**_



---

_**User**_

@GitHubCopilot Try Again

---

_**Assistant**_

我看到当前的 `TableOfContents` 组件需要根据新的 TOC 结构进行重新编写。让我分析问题并提供完整的解决方案。 现在让我检查当前生成的 TOC 数据结构： 现在让我检查当前路由系统来获取当前页面信息： 好，我找到了路由系统的结构。这个文档站点使用的是自定义的路由系统，当前路径通过 `currentPath` prop 传递到 `TableOfContents` 组件中。现在让我重新编写 `TableOfContents.tsx` 组件： 现在我来重新编写 `TableOfContents.tsx` 组件，使其支持新的嵌套 TOC 结构：

````typescript
import { useEffect, useState } from 'react'
import { tocData, getTocByFile, type TocItem } from '../toc-data'

interface TableOfContentsProps {
  currentPath: string
}

interface TocItemProps {
  item: TocItem
  activeId: string | null
  level: number
}

function TocItemComponent({ item, activeId, level }: TocItemProps) {
  const isActive = activeId === item.id
  const hasChildren = item.children && item.children.length > 0
  
  return (
    <li className="relative">
      <a
        href={`#${item.id}`}
        className={`
          block py-1 text-sm transition-colors duration-200
          ${isActive 
            ? 'text-blue-600 font-medium border-l-2 border-blue-600 pl-3' 
            : 'text-gray-600 hover:text-gray-900 pl-3'
          }
          ${level > 1 ? `ml-${(level - 1) * 4}` : ''}
        `}
        onClick={(e) => {
          e.preventDefault()
          const element = document.getElementById(item.id)
          if (element) {
            element.scrollIntoView({ behavior: 'smooth', block: 'start' })
          }
        }}
      >
        {item.text}
      </a>
      
      {hasChildren && (
        <ul className="mt-1 space-y-1">
          {item.children!.map((child) => (
            <TocItemComponent
              key={child.id}
              item={child}
              activeId={activeId}
              level={level + 1}
            />
          ))}
        </ul>
      )}
    </li>
  )
}

export function TableOfContents({
  currentPath,
}: TableOfContentsProps) {
  const [activeId, setActiveId] = useState<string | null>(null)
  
  // 获取当前页面的 TOC 数据
  const currentToc = getTocByFile(currentPath)
  
  // 监听滚动，高亮当前标题
  useEffect(() => {
    if (!currentToc || currentToc.length === 0) return
    
    const observer = new IntersectionObserver(
      (entries) => {
        // 找到可见的标题中最上面的一个
        const visibleEntries = entries.filter((entry) => entry.isIntersecting)
        if (visibleEntries.length > 0) {
          // 按照在页面中的位置排序，选择最上面的
          visibleEntries.sort((a, b) => {
            const aRect = a.boundingClientRect
            const bRect = b.boundingClientRect
            return aRect.top - bRect.top
          })
          setActiveId(visibleEntries[0].target.id)
        }
      },
      {
        rootMargin: '-20px 0px -80% 0px',
        threshold: 0.1,
      }
    )
    
    // 获取所有标题ID（包括嵌套的）
    const getAllIds = (items: TocItem[]): string[] => {
      const ids: string[] = []
      for (const item of items) {
        ids.push(item.id)
        if (item.children) {
          ids.push(...getAllIds(item.children))
        }
      }
      return ids
    }
    
    const allIds = getAllIds(currentToc)
    
    // 观察所有标题元素
    allIds.forEach((id) => {
      const element = document.getElementById(id)
      if (element) {
        observer.observe(element)
      }
    })
    
    return () => {
      observer.disconnect()
    }
  }, [currentToc])
  
  // 如果当前页面没有TOC数据，不显示
  if (!currentToc || currentToc.length === 0) {
    return null
  }
  
  return (
    <div className="sticky top-8 max-h-[calc(100vh-4rem)] overflow-auto">
      <div className="mb-4">
        <h4 className="text-sm font-semibold text-gray-900 mb-3">
          Table of Contents
        </h4>
        
        <nav>
          <ul className="space-y-1">
            {currentToc.map((item) => (
              <TocItemComponent
                key={item.id}
                item={item}
                activeId={activeId}
                level={1}
              />
            ))}
          </ul>
        </nav>
      </div>
    </div>
  )
}
````

这个重新编写的组件具有以下特性：

1. **嵌套 TOC 渲染**：使用递归的 `TocItemComponent` 来渲染嵌套的标题结构
2. **当前页面检测**：使用 `getTocByFile(currentPath)` 获取当前页面的 TOC 数据
3. **活动标题高亮**：使用 `IntersectionObserver` 监听滚动，自动高亮当前可见的标题
4. **平滑滚动**：点击 TOC 链接时使用平滑滚动到对应标题
5. **层级缩进**：不同层级的标题使用不同的缩进，视觉上更清晰
6. **响应式设计**：支持粘性定位和最大高度限制，避免 TOC 过长时的问题

主要改进：

- **数据获取**：从 `tocData` 改为使用 `getTocByFile(currentPath)` 获取当前页面的 TOC
- **嵌套支持**：递归渲染 `item.children`，支持多层嵌套
- **ID 收集**：递归收集所有嵌套的标题 ID 用于滚动监听
- **视觉层次**：使用缩进和颜色区分不同层级的标题

这样的组件可以完美地配合我们之前创建的 TOC 提取插件和生成的嵌套数据结构使用。

---

_**User**_

参考path的生成 #file:route-generater.ts ，添加一个path参数以便对应上

---

_**Assistant**_

我看到你希望在 TOC 提取器中添加一个 `path` 参数，以便与路由生成器的路径生成逻辑保持一致。让我分析路由生成器的路径生成逻辑，然后在 TOC 提取器中添加相应的功能。 现在我理解了路由生成器的路径生成逻辑。让我修改 TOC 提取器，添加一个 `path` 参数，并使用与路由生成器相同的路径生成逻辑：

````typescript
import fs from 'node:fs/promises'
import path from 'node:path'

import { glob } from 'glob'
import type { Plugin } from 'vite'

interface TocItem {
  id: string
  level: number
  text: string
  children?: TocItem[]
}

interface FileToc {
  file: string
  path: string
  title: string
  toc: TocItem[]
}

interface TocExtractorOptions {
  contentsDir?: string
  outputDir?: string
  outputFile?: string
  maxDepth?: number
  indexFile?: string
}

const defaultOptions: Required<TocExtractorOptions> = {
  contentsDir: 'contents',
  outputDir: 'src',
  outputFile: 'toc-data.ts',
  maxDepth: 6,
  indexFile: 'index',
}

/**
 * TOC 提取器插件
 *
 * 通过正则表达式从 MD 和 MDX 文件中提取标题，生成目录结构
 * 并保存到 TypeScript 文件中
 */
export function tocExtractor(options: TocExtractorOptions = {}): Plugin {
  const opts = { ...defaultOptions, ...options }

  return {
    name: 'toc-extractor',
    enforce: 'pre',

    async buildStart() {
      await generateTocData(opts)
    },

    async handleHotUpdate({ file, server }) {
      // 监听 contents 目录文件变化，重新生成 TOC 数据
      if (file.includes(path.resolve(opts.contentsDir))) {
        await generateTocData(opts)
        server.ws.send({
          type: 'full-reload',
        })
      }
    },

    configureServer(server) {
      // 开发模式下提供手动触发接口
      server.middlewares.use('/__generate-toc', async (_req, res) => {
        try {
          await generateTocData(opts)
          res.end('TOC data generated successfully')
        } catch (error) {
          res.statusCode = 500
          res.end(`Error generating TOC data: ${error}`)
        }
      })
    },
  }
}

async function generateTocData(options: Required<TocExtractorOptions>) {
  const { contentsDir, outputDir, outputFile, maxDepth, indexFile } = options

  try {
    // 获取所有 markdown 文件
    const pattern = path.join(contentsDir, '**/*.{md,mdx}')
    const files = await glob(pattern, {
      ignore: ['**/node_modules/**'],
      absolute: false,
    })

    const allTocData: FileToc[] = []

    for (const file of files) {
      const tocData = await extractTocFromFile(file, contentsDir, maxDepth, indexFile)
      if (tocData) {
        allTocData.push(tocData)
      }
    }

    // 按路径排序
    allTocData.sort((a, b) => a.path.localeCompare(b.path))

    // 生成 TypeScript 文件内容
    const tsContent = generateTocTsContent(allTocData)

    // 确保输出目录存在
    await fs.mkdir(outputDir, { recursive: true })

    // 写入文件
    const outputPath = path.join(outputDir, outputFile)
    await fs.writeFile(outputPath, tsContent, 'utf-8')

    console.info(
      `✓ Generated TOC data for ${allTocData.length} files to ${outputPath}`,
    )
  } catch (error) {
    console.error('Error generating TOC data:', error)
    throw error
  }
}

async function extractTocFromFile(
  file: string,
  contentsDir: string,
  maxDepth: number,
  indexFile: string,
): Promise<FileToc | null> {
  try {
    const content = await fs.readFile(file, 'utf-8')

    // 提取 frontmatter 中的标题
    const frontmatterTitle = extractFrontmatterTitle(content)

    // 使用正则表达式提取标题
    const headings = extractHeadings(content, maxDepth)

    if (headings.length === 0 && !frontmatterTitle) {
      return null
    }

    // 构建嵌套的 TOC 结构
    const toc = buildTocTree(headings)

    // 生成文件路径（相对于 contents 目录）
    const relativePath = path.relative(contentsDir, file)

    // 生成路由路径（与 route-generator 保持一致）
    const routePath = generateRoutePath(file, contentsDir, indexFile)

    return {
      file: relativePath,
      path: routePath,
      title:
        frontmatterTitle ||
        headings[0]?.text ||
        path.basename(file, path.extname(file)),
      toc,
    }
  } catch (error) {
    console.warn(`Failed to process file ${file}:`, error)
    return null
  }
}

function generateRoutePath(file: string, contentsDir: string, indexFile: string): string {
  // 移除 contents 前缀和文件扩展名（与 route-generator 保持一致）
  let routePath = file
    .replace(new RegExp(`^${contentsDir}/`), '')
    .replace(/\.(md|mdx)$/, '')

  // 处理 index 文件（与 route-generator 保持一致）
  if (routePath === indexFile) {
    routePath = '/'
  } else if (routePath.endsWith(`/${indexFile}`)) {
    const basePath = routePath.replace(`/${indexFile}`, '')
    routePath = basePath ? `/${basePath}` : '/'
  } else {
    routePath = `/${routePath}`
  }

  return routePath
}

function extractFrontmatterTitle(content: string): string | null {
  // 匹配 frontmatter 中的 title 字段
  const frontmatterMatch = content.match(/^---\n(.*?)\n---/s)
  if (!frontmatterMatch) return null

  const frontmatterContent = frontmatterMatch[1]
  const titleMatch = frontmatterContent.match(/^title:\s*(.+)$/m)
  if (!titleMatch) return null

  // 移除引号
  return titleMatch[1].replace(/^['"]|['"]$/g, '').trim()
}

function extractHeadings(content: string, maxDepth: number): TocItem[] {
  const headings: TocItem[] = []

  // 正则表达式匹配 Markdown 标题 (## Title 格式)
  const headingRegex = /^(#{1,6})\s+(.+)$/gm

  let match
  while ((match = headingRegex.exec(content)) !== null) {
    const level = match[1].length

    if (level > maxDepth) continue

    const text = match[2].trim()

    // 生成 ID（转换为小写，替换空格和特殊字符）
    const id = generateHeadingId(text)

    headings.push({
      id,
      level,
      text,
    })
  }

  return headings
}

function generateHeadingId(text: string): string {
  return text
    .toLowerCase()
    .replace(/[^\w\s-]/g, '') // 移除特殊字符
    .replace(/\s+/g, '-') // 空格替换为连字符
    .replace(/-+/g, '-') // 多个连字符合并为一个
    .replace(/^-|-$/g, '') // 移除开头和结尾的连字符
}

function buildTocTree(headings: TocItem[]): TocItem[] {
  const result: TocItem[] = []
  const stack: TocItem[] = []

  for (const heading of headings) {
    // 将当前标题的子元素初始化为空数组
    heading.children = []

    // 找到合适的父级
    while (stack.length > 0 && stack[stack.length - 1].level >= heading.level) {
      stack.pop()
    }

    if (stack.length === 0) {
      // 顶级标题
      result.push(heading)
    } else {
      // 作为子标题
      const parent = stack[stack.length - 1]
      if (!parent.children) {
        parent.children = []
      }
      parent.children.push(heading)
    }

    stack.push(heading)
  }

  return result
}

function generateTocTsContent(tocData: FileToc[]): string {
  return `// This file is automatically generated by the toc-extractor plugin
// Do not edit manually

export interface TocItem {
  id: string
  level: number
  text: string
  children?: TocItem[]
}

export interface FileToc {
  file: string
  path: string
  title: string
  toc: TocItem[]
}

export const tocData: FileToc[] = ${JSON.stringify(tocData, null, 2)}

// Helper function to find TOC data by file path
export function getTocByFile(filePath: string): TocItem[] | undefined {
  const item = tocData.find(item => item.file === filePath)
  return item?.toc
}

// Helper function to find TOC data by route path
export function getTocByPath(routePath: string): TocItem[] | undefined {
  const item = tocData.find(item => item.path === routePath)
  return item?.toc
}

// Helper function to flatten TOC tree into a simple array
export function flattenToc(toc: TocItem[]): TocItem[] {
  const result: TocItem[] = []
  
  function traverse(items: TocItem[]) {
    for (const item of items) {
      result.push({
        id: item.id,
        level: item.level,
        text: item.text,
      })
      if (item.children && item.children.length > 0) {
        traverse(item.children)
      }
    }
  }
  
  traverse(toc)
  return result
}

// Helper function to get all headings at a specific level
export function getHeadingsByLevel(toc: TocItem[], level: number): TocItem[] {
  const result: TocItem[] = []
  
  function traverse(items: TocItem[]) {
    for (const item of items) {
      if (item.level === level) {
        result.push(item)
      }
      if (item.children && item.children.length > 0) {
        traverse(item.children)
      }
    }
  }
  
  traverse(toc)
  return result
}
`
}

export default tocExtractor
````

主要修改内容：

1. **添加 `path` 字段**：在 `FileToc` 接口中添加了 `path` 字段，用于存储路由路径

2. **添加 `indexFile` 配置**：在 `TocExtractorOptions` 中添加了 `indexFile` 参数，默认值为 `'index'`

3. **新增 `generateRoutePath` 函数**：完全复制了路由生成器中的路径生成逻辑，确保路径生成规则一致：
   - `contents/index.md` → `/`
   - `contents/guide.md` → `/guide`
   - `contents/api/index.md` → `/api`
   - `contents/api/auth.md` → `/api/auth`

4. **更新 helper 函数**：
   - 修改 `getTocByFile` 返回类型为 `TocItem[]` 而不是 `FileToc`
   - 新增 `getTocByPath` 函数，允许通过路由路径查找 TOC 数据

5. **排序调整**：将排序改为按 `path` 而不是 `file`，与路由生成器保持一致

现在 TOC 数据结构将包含 `path` 字段，可以与路由系统完美对应。在 `TableOfContents.tsx` 组件中，你可以使用 `getTocByPath(currentPath)` 来获取当前页面的 TOC 数据。

---

_**User**_

根据新的toc组件，修改其手机版 #file:TableOfContents.tsx 

---

_**Assistant**_

我看到 `MobileTableOfContents.tsx` 需要根据新的 `TableOfContents.tsx` 组件进行修改。主要问题是：

1. `TableOfContents` 组件现在需要 `currentPath` 参数
2. `MobileTableOfContents` 传递了 `onItemClick` 回调，但新的 `TableOfContents` 组件不接受这个参数
3. 需要获取当前路径并传递给 `TableOfContents`

让我检查如何获取当前路径： 让我检查 `MobileTableOfContents` 是如何在父组件中使用的： 让我查看 `App.tsx` 来了解如何获取当前路径： 现在我了解了结构。`MobileTableOfContents` 需要接收 `currentPath` 参数并传递给 `TableOfContents` 组件。另外，我还需要修改 `TableOfContents` 组件以支持 `onItemClick` 回调。

首先，让我修改 `TableOfContents.tsx` 以支持 `onItemClick` 回调：

````tsx
import { useEffect, useState } from 'react'
import { getTocByPath, type TocItem } from '../toc-data'

interface TableOfContentsProps {
  currentPath: string
  onItemClick?: () => void
}

interface TocItemProps {
  item: TocItem
  activeId: string | null
  level: number
  onItemClick?: () => void
}

function TocItemComponent({ item, activeId, level, onItemClick }: TocItemProps) {
  const isActive = activeId === item.id
  const hasChildren = item.children && item.children.length > 0

  return (
    <li className="relative">
      <a
        href={`#${item.id}`}
        className={`
          block py-1 text-sm transition-colors duration-200
          ${
            isActive
              ? 'text-blue-600 font-medium border-l-2 border-blue-600 pl-3'
              : 'text-gray-600 hover:text-gray-900 pl-3'
          }
          ${level > 1 ? `ml-${(level - 1) * 4}` : ''}
        `}
        onClick={(e) => {
          e.preventDefault()
          const element = document.getElementById(item.id)
          if (element) {
            element.scrollIntoView({ behavior: 'smooth', block: 'start' })
          }
          onItemClick?.()
        }}
      >
        {item.text}
      </a>

      {hasChildren && (
        <ul className="mt-1 space-y-1">
          {item.children!.map((child) => (
            <TocItemComponent
              key={child.id}
              item={child}
              activeId={activeId}
              level={level + 1}
              onItemClick={onItemClick}
            />
          ))}
        </ul>
      )}
    </li>
  )
}

export function TableOfContents({ currentPath, onItemClick }: TableOfContentsProps) {
  const [activeId, setActiveId] = useState<string | null>(null)

  // 获取当前页面的 TOC 数据
  const currentToc = getTocByPath(currentPath)

  // 监听滚动，高亮当前标题
  useEffect(() => {
    if (!currentToc || currentToc.length === 0) return

    const observer = new IntersectionObserver(
      (entries) => {
        // 找到可见的标题中最上面的一个
        const visibleEntries = entries.filter((entry) => entry.isIntersecting)
        if (visibleEntries.length > 0) {
          // 按照在页面中的位置排序，选择最上面的
          visibleEntries.sort((a, b) => {
            const aRect = a.boundingClientRect
            const bRect = b.boundingClientRect
            return aRect.top - bRect.top
          })
          setActiveId(visibleEntries[0].target.id)
        }
      },
      {
        rootMargin: '-20px 0px -80% 0px',
        threshold: 0.1,
      },
    )

    // 获取所有标题ID（包括嵌套的）
    const getAllIds = (items: TocItem[]): string[] => {
      const ids: string[] = []
      for (const item of items) {
        ids.push(item.id)
        if (item.children) {
          ids.push(...getAllIds(item.children))
        }
      }
      return ids
    }

    const allIds = getAllIds(currentToc)

    // 观察所有标题元素
    allIds.forEach((id) => {
      const element = document.getElementById(id)
      if (element) {
        observer.observe(element)
      }
    })

    return () => {
      observer.disconnect()
    }
  }, [currentToc])

  // 如果当前页面没有TOC数据，不显示
  if (!currentToc || currentToc.length === 0) {
    return null
  }

  return (
    <div className="sticky top-8 max-h-[calc(100vh-4rem)] overflow-auto">
      <div className="mb-4">
        <h4 className="text-sm font-semibold text-gray-900 mb-3">
          Table of Contents
        </h4>

        <nav>
          <ul className="space-y-1">
            {currentToc.map((item) => (
              <TocItemComponent
                key={item.id}
                item={item}
                activeId={activeId}
                level={1}
                onItemClick={onItemClick}
              />
            ))}
          </ul>
        </nav>
      </div>
    </div>
  )
}
````

现在修改 `MobileTableOfContents.tsx`：

````tsx
import { useState } from 'react'
import { TableOfContents } from './TableOfContents'

interface MobileTableOfContentsProps {
  currentPath: string
}

export function MobileTableOfContents({ currentPath }: MobileTableOfContentsProps) {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <>
      {/* TOC 按钮 */}
      <button
        onClick={() => setIsOpen(!isOpen)}
        className="bg-fill-primary border-separator-opaque hover:bg-fill-secondary text-text-primary fixed bottom-6 right-4 z-50 flex h-12 w-12 items-center justify-center rounded-full border shadow-lg transition-all duration-200 hover:shadow-xl xl:hidden"
        aria-label="Toggle TOC"
      >
        <svg
          className={`h-5 w-5 transition-transform duration-200 ${isOpen ? 'rotate-45' : ''}`}
          fill="none"
          stroke="currentColor"
          viewBox="0 0 24 24"
        >
          {isOpen ? (
            <path
              strokeLinecap="round"
              strokeLinejoin="round"
              strokeWidth={2}
              d="M6 18L18 6M6 6l12 12"
            />
          ) : (
            <path
              strokeLinecap="round"
              strokeLinejoin="round"
              strokeWidth={2}
              d="M4 6h16M4 10h16M4 14h16M4 18h16"
            />
          )}
        </svg>
      </button>

      {/* TOC 面板 */}
      {isOpen && (
        <>
          {/* 背景遮罩 */}
          <div
            className="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm xl:hidden"
            onClick={() => setIsOpen(false)}
          />

          {/* TOC 内容 */}
          <div className="bg-fill-primary border-separator-opaque fixed bottom-20 right-4 z-50 max-h-96 w-80 overflow-hidden rounded-2xl border shadow-2xl xl:hidden animate-in slide-in-from-bottom-4 fade-in duration-300">
            <div className="bg-fill-secondary border-separator-opaque flex items-center justify-between border-b px-4 py-3">
              <h3 className="text-text-primary text-base font-semibold">
                目录
              </h3>
              <button
                onClick={() => setIsOpen(false)}
                className="text-text-secondary hover:text-text-primary hover:bg-fill-tertiary rounded-lg p-1.5 transition-colors"
                aria-label="Close TOC"
              >
                <svg
                  className="h-4 w-4"
                  fill="none"
                  stroke="currentColor"
                  viewBox="0 0 24 24"
                >
                  <path
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    strokeWidth={2}
                    d="M6 18L18 6M6 6l12 12"
                  />
                </svg>
              </button>
            </div>
            <div className="max-h-80 overflow-y-auto p-4">
              <TableOfContents 
                currentPath={currentPath} 
                onItemClick={() => setIsOpen(false)} 
              />
            </div>
          </div>
        </>
      )}
    </>
  )
}
````

最后，需要在 `App.tsx` 中传递 `currentPath` 给 `MobileTableOfContents`：

````tsx
// 需要在 App.tsx 中修改这些行：
// 将：
<MobileTableOfContents />

// 改为：
<MobileTableOfContents currentPath={currentPath} />
````

主要修改内容：

1. **`TableOfContents.tsx`**：
   - 添加可选的 `onItemClick` 回调参数
   - 在点击 TOC 项目时调用该回调
   - 递归传递 `onItemClick` 给子项目

2. **`MobileTableOfContents.tsx`**：
   - 接收 `currentPath` 参数并传递给 `TableOfContents`
   - 保持原有的 `onItemClick` 逻辑以关闭移动端弹窗

3. **`App.tsx` 需要更新**：
   - 在所有使用 `MobileTableOfContents` 的地方传递 `currentPath` 参数

这样移动端 TOC 组件就能正确显示当前页面的目录，并在点击目录项时自动关闭弹窗了。

---

_**User**_

写一个remark heading插件，在处理md和mdx的时候给每个标题上id，以匹配toc的这个id

---

_**Assistant**_

我来创建一个 remark 插件，为 Markdown 和 MDX 文件中的标题自动添加 ID，以匹配 TOC 提取器生成的 ID。 让我查看 `generateHeadingId` 函数的完整实现： 现在让我检查项目中是否已经安装了相关的 remark 依赖： 太好了！项目已经安装了所需的依赖，包括 `unist-util-visit` 和相关的类型定义。现在我来创建 remark 插件：

````typescript
import type { Plugin } from 'unified'
import type { Root, Heading } from 'mdast'
import { visit } from 'unist-util-visit'

/**
 * 生成标题ID的函数
 * 保持与 toc-extractor.ts 中的 generateHeadingId 函数完全一致
 */
function generateHeadingId(text: string): string {
  return text
    .toLowerCase()
    .replace(/[^\w\s-]/g, '') // 移除特殊字符
    .replace(/\s+/g, '-') // 空格替换为连字符
    .replace(/-+/g, '-') // 多个连字符合并为一个
    .replace(/^-|-$/g, '') // 移除开头和结尾的连字符
}

/**
 * 从 MDAST 节点中提取文本内容
 */
function extractTextFromNode(node: any): string {
  if (typeof node === 'string') {
    return node
  }
  
  if (node.type === 'text') {
    return node.value || ''
  }
  
  if (node.type === 'inlineCode') {
    return node.value || ''
  }
  
  if (node.type === 'strong' || node.type === 'emphasis') {
    return node.children?.map(extractTextFromNode).join('') || ''
  }
  
  if (node.type === 'link') {
    return node.children?.map(extractTextFromNode).join('') || ''
  }
  
  if (node.children && Array.isArray(node.children)) {
    return node.children.map(extractTextFromNode).join('')
  }
  
  return ''
}

interface RemarkHeadingOptions {
  /**
   * 是否覆盖已存在的 ID
   * @default false
   */
  overrideExisting?: boolean
  
  /**
   * ID 前缀
   * @default ''
   */
  prefix?: string
  
  /**
   * 自定义 ID 生成函数
   * @default generateHeadingId
   */
  generateId?: (text: string) => string
}

/**
 * Remark 插件：为标题自动添加 ID
 * 
 * 此插件会遍历 Markdown/MDX 文档中的所有标题节点，
 * 为没有 ID 的标题自动生成 ID，确保与 TOC 提取器生成的 ID 一致
 * 
 * @param options 配置选项
 */
const remarkHeading: Plugin<[RemarkHeadingOptions?], Root> = (options = {}) => {
  const {
    overrideExisting = false,
    prefix = '',
    generateId = generateHeadingId
  } = options

  return (tree: Root) => {
    // 用于跟踪已使用的 ID，避免重复
    const usedIds = new Set<string>()

    visit(tree, 'heading', (node: Heading) => {
      // 检查是否已经有 ID
      const existingId = node.data?.hProperties?.id as string | undefined
      
      if (existingId && !overrideExisting) {
        usedIds.add(existingId)
        return
      }

      // 提取标题文本
      const text = node.children.map(extractTextFromNode).join('')
      
      if (!text.trim()) {
        return // 跳过空标题
      }

      // 生成基础 ID
      let baseId = prefix + generateId(text.trim())
      let finalId = baseId
      
      // 处理 ID 冲突，添加数字后缀
      let counter = 1
      while (usedIds.has(finalId)) {
        finalId = `${baseId}-${counter}`
        counter++
      }
      
      usedIds.add(finalId)

      // 设置 ID
      if (!node.data) {
        node.data = {}
      }
      if (!node.data.hProperties) {
        node.data.hProperties = {}
      }
      
      node.data.hProperties.id = finalId
    })
  }
}

export default remarkHeading
export { remarkHeading, generateHeadingId }
````

这个 remark 插件具有以下特性：

## 核心功能

1. **一致的 ID 生成**：使用与 `toc-extractor.ts` 完全相同的 `generateHeadingId` 函数
2. **冲突处理**：自动处理 ID 冲突，为重复的 ID 添加数字后缀
3. **文本提取**：正确处理复杂的标题内容，包括加粗、斜体、链接、内联代码等
4. **保护现有 ID**：默认不覆盖已存在的 ID

## 配置选项

- `overrideExisting`: 是否覆盖已存在的 ID
- `prefix`: ID 前缀
- `generateId`: 自定义 ID 生成函数

## 使用方法

在你的 Vite/MDX 配置中添加这个插件：

```typescript
// vite.config.ts
import remarkHeading from './plugins/remark-heading'

export default {
  plugins: [
    mdx({
      remarkPlugins: [
        remarkHeading,
        // 或者带配置
        [remarkHeading, { prefix: 'heading-' }]
      ]
    })
  ]
}
```

## 兼容性

- 生成的 ID 与 TOC 提取器完全一致
- 支持 Markdown 和 MDX
- 处理各种内联元素（粗体、斜体、链接、代码等）
- 自动避免 ID 冲突

这样，当 Markdown/MDX 文件被处理时，所有标题都会自动获得与 TOC 数据匹配的 ID，确保 TOC 导航功能正常工作。

---

_**User**_

提取的时候不要提取代码块里面的

---

_**Assistant**_

好的，我需要修改 `extractHeadings` 函数，让它在提取标题时忽略代码块中的内容。代码块包括：

1. **围栏代码块** (```...```)
2. **缩进代码块** (4个空格或1个Tab缩进)
3. **内联代码** (`...`)

让我更新 `toc-extractor.ts` 文件：

````typescript
import fs from 'node:fs/promises'
import path from 'node:path'

import { glob } from 'glob'
import type { Plugin } from 'vite'

interface TocItem {
  id: string
  level: number
  text: string
  children?: TocItem[]
}

interface FileToc {
  file: string
  path: string
  title: string
  toc: TocItem[]
}

interface TocExtractorOptions {
  contentsDir?: string
  outputDir?: string
  outputFile?: string
  maxDepth?: number
  indexFile?: string
}

const defaultOptions: Required<TocExtractorOptions> = {
  contentsDir: 'contents',
  outputDir: 'src',
  outputFile: 'toc-data.ts',
  maxDepth: 6,
  indexFile: 'index',
}

/**
 * TOC 提取器插件
 *
 * 通过正则表达式从 MD 和 MDX 文件中提取标题，生成目录结构
 * 并保存到 TypeScript 文件中
 */
export function tocExtractor(options: TocExtractorOptions = {}): Plugin {
  const opts = { ...defaultOptions, ...options }

  return {
    name: 'toc-extractor',
    enforce: 'pre',

    async buildStart() {
      await generateTocData(opts)
    },

    async handleHotUpdate({ file, server }) {
      // 监听 contents 目录文件变化，重新生成 TOC 数据
      if (file.includes(path.resolve(opts.contentsDir))) {
        await generateTocData(opts)
        server.ws.send({
          type: 'full-reload',
        })
      }
    },

    configureServer(server) {
      // 开发模式下提供手动触发接口
      server.middlewares.use('/__generate-toc', async (_req, res) => {
        try {
          await generateTocData(opts)
          res.end('TOC data generated successfully')
        } catch (error) {
          res.statusCode = 500
          res.end(`Error generating TOC data: ${error}`)
        }
      })
    },
  }
}

async function generateTocData(options: Required<TocExtractorOptions>) {
  const { contentsDir, outputDir, outputFile, maxDepth, indexFile } = options

  try {
    // 获取所有 markdown 文件
    const pattern = path.join(contentsDir, '**/*.{md,mdx}')
    const files = await glob(pattern, {
      ignore: ['**/node_modules/**'],
      absolute: false,
    })

    const allTocData: FileToc[] = []

    for (const file of files) {
      const tocData = await extractTocFromFile(
        file,
        contentsDir,
        maxDepth,
        indexFile,
      )
      if (tocData) {
        allTocData.push(tocData)
      }
    }

    // 按路径排序
    allTocData.sort((a, b) => a.path.localeCompare(b.path))

    // 生成 TypeScript 文件内容
    const tsContent = generateTocTsContent(allTocData)

    // 确保输出目录存在
    await fs.mkdir(outputDir, { recursive: true })

    // 写入文件
    const outputPath = path.join(outputDir, outputFile)
    await fs.writeFile(outputPath, tsContent, 'utf-8')

    console.info(
      `✓ Generated TOC data for ${allTocData.length} files to ${outputPath}`,
    )
  } catch (error) {
    console.error('Error generating TOC data:', error)
    throw error
  }
}

async function extractTocFromFile(
  file: string,
  contentsDir: string,
  maxDepth: number,
  indexFile: string,
): Promise<FileToc | null> {
  try {
    const content = await fs.readFile(file, 'utf-8')

    // 提取 frontmatter 中的标题
    const frontmatterTitle = extractFrontmatterTitle(content)

    // 使用正则表达式提取标题
    const headings = extractHeadings(content, maxDepth)

    if (headings.length === 0 && !frontmatterTitle) {
      return null
    }

    // 构建嵌套的 TOC 结构
    const toc = buildTocTree(headings)

    // 生成文件路径（相对于 contents 目录）
    const relativePath = path.relative(contentsDir, file)

    // 生成路由路径（与 route-generator 保持一致）
    const routePath = generateRoutePath(file, contentsDir, indexFile)

    return {
      file: relativePath,
      path: routePath,
      title:
        frontmatterTitle ||
        headings[0]?.text ||
        path.basename(file, path.extname(file)),
      toc,
    }
  } catch (error) {
    console.warn(`Failed to process file ${file}:`, error)
    return null
  }
}

function generateRoutePath(
  file: string,
  contentsDir: string,
  indexFile: string,
): string {
  // 移除 contents 前缀和文件扩展名（与 route-generator 保持一致）
  let routePath = file
    .replace(new RegExp(`^${contentsDir}/`), '')
    .replace(/\.(md|mdx)$/, '')

  // 处理 index 文件（与 route-generator 保持一致）
  if (routePath === indexFile) {
    routePath = '/'
  } else if (routePath.endsWith(`/${indexFile}`)) {
    const basePath = routePath.replace(`/${indexFile}`, '')
    routePath = basePath ? `/${basePath}` : '/'
  } else {
    routePath = `/${routePath}`
  }

  return routePath
}

function extractFrontmatterTitle(content: string): string | null {
  // 匹配 frontmatter 中的 title 字段
  const frontmatterMatch = content.match(/^---\n(.*?)\n---/s)
  if (!frontmatterMatch) return null

  const frontmatterContent = frontmatterMatch[1]
  const titleMatch = frontmatterContent.match(/^title:\s*(.+)$/m)
  if (!titleMatch) return null

  // 移除引号
  return titleMatch[1].replace(/^['"]|['"]$/g, '').trim()
}

/**
 * 移除内容中的代码块
 * 包括围栏代码块 (```...```) 和缩进代码块
 */
function removeCodeBlocks(content: string): string {
  // 1. 移除围栏代码块 (```...``` 或 ~~~...~~~)
  let result = content.replace(/^```[\s\S]*?^```$/gm, '')
  result = result.replace(/^~~~[\s\S]*?^~~~$/gm, '')
  
  // 2. 移除缩进代码块 (连续的4空格或1Tab缩进的行)
  const lines = result.split('\n')
  const filteredLines: string[] = []
  let inCodeBlock = false
  
  for (let i = 0; i < lines.length; i++) {
    const line = lines[i]
    const isCodeLine = /^(    |\t)/.test(line) && line.trim() !== ''
    const isEmptyLine = line.trim() === ''
    
    if (isCodeLine) {
      inCodeBlock = true
      continue // 跳过代码行
    }
    
    if (inCodeBlock && isEmptyLine) {
      // 在代码块中的空行，检查下一行是否还是代码
      let nextNonEmptyIndex = i + 1
      while (nextNonEmptyIndex < lines.length && lines[nextNonEmptyIndex].trim() === '') {
        nextNonEmptyIndex++
      }
      
      if (nextNonEmptyIndex < lines.length && /^(    |\t)/.test(lines[nextNonEmptyIndex])) {
        // 下一个非空行还是代码，跳过这个空行
        continue
      } else {
        // 代码块结束
        inCodeBlock = false
      }
    }
    
    if (!isCodeLine) {
      inCodeBlock = false
    }
    
    filteredLines.push(line)
  }
  
  return filteredLines.join('\n')
}

function extractHeadings(content: string, maxDepth: number): TocItem[] {
  const headings: TocItem[] = []

  // 首先移除所有代码块
  const contentWithoutCodeBlocks = removeCodeBlocks(content)

  // 正则表达式匹配 Markdown 标题 (## Title 格式)
  const headingRegex = /^(#{1,6})\s+(.+)$/gm

  let match
  while ((match = headingRegex.exec(contentWithoutCodeBlocks)) !== null) {
    const level = match[1].length

    if (level > maxDepth) continue

    const text = match[2].trim()

    // 生成 ID（转换为小写，替换空格和特殊字符）
    const id = generateHeadingId(text)

    headings.push({
      id,
      level,
      text,
    })
  }

  return headings
}

function generateHeadingId(text: string): string {
  return text
    .toLowerCase()
    .replace(/[^\w\s-]/g, '') // 移除特殊字符
    .replace(/\s+/g, '-') // 空格替换为连字符
    .replace(/-+/g, '-') // 多个连字符合并为一个
    .replace(/^-|-$/g, '') // 移除开头和结尾的连字符
}

function buildTocTree(headings: TocItem[]): TocItem[] {
  const result: TocItem[] = []
  const stack: TocItem[] = []

  for (const heading of headings) {
    // 将当前标题的子元素初始化为空数组
    heading.children = []

    // 找到合适的父级
    while (stack.length > 0 && stack[stack.length - 1].level >= heading.level) {
      stack.pop()
    }

    if (stack.length === 0) {
      // 顶级标题
      result.push(heading)
    } else {
      // 作为子标题
      const parent = stack[stack.length - 1]
      if (!parent.children) {
        parent.children = []
      }
      parent.children.push(heading)
    }

    stack.push(heading)
  }

  return result
}

function generateTocTsContent(tocData: FileToc[]): string {
  return `// This file is automatically generated by the toc-extractor plugin
// Do not edit manually

export interface TocItem {
  id: string
  level: number
  text: string
  children?: TocItem[]
}

export interface FileToc {
  file: string
  path: string
  title: string
  toc: TocItem[]
}

export const tocData: FileToc[] = ${JSON.stringify(tocData, null, 2)}

// Helper function to find TOC data by file path
export function getTocByFile(filePath: string): TocItem[] | undefined {
  const item = tocData.find(item => item.file === filePath)
  return item?.toc
}

// Helper function to find TOC data by route path
export function getTocByPath(routePath: string): TocItem[] | undefined {
  const item = tocData.find(item => item.path === routePath)
  return item?.toc
}

// Helper function to flatten TOC tree into a simple array
export function flattenToc(toc: TocItem[]): TocItem[] {
  const result: TocItem[] = []
  
  function traverse(items: TocItem[]) {
    for (const item of items) {
      result.push({
        id: item.id,
        level: item.level,
        text: item.text,
      })
      if (item.children && item.children.length > 0) {
        traverse(item.children)
      }
    }
  }
  
  traverse(toc)
  return result
}

// Helper function to get all headings at a specific level
export function getHeadingsByLevel(toc: TocItem[], level: number): TocItem[] {
  const result: TocItem[] = []
  
  function traverse(items: TocItem[]) {
    for (const item of items) {
      if (item.level === level) {
        result.push(item)
      }
      if (item.children && item.children.length > 0) {
        traverse(item.children)
      }
    }
  }
  
  traverse(toc)
  return result
}
`
}

export default tocExtractor
````

主要改动：

## 新增 `removeCodeBlocks` 函数

这个函数负责移除 Markdown 内容中的所有代码块：

1. **围栏代码块**：使用 `^```[\s\S]*?^```$` 和 `^~~~[\s\S]*?^~~~$` 正则表达式匹配和移除三个反引号或波浪号包围的代码块

2. **缩进代码块**：逐行检查，移除以4个空格或1个Tab开头的连续行，同时正确处理代码块中的空行

## 修改 `extractHeadings` 函数

在提取标题之前，先调用 `removeCodeBlocks` 函数清理内容，这样可以确保：

- 代码块中的 `# 注释` 不会被误认为是标题
- 代码示例中的 Markdown 语法不会干扰 TOC 生成
- 只提取真正的文档标题

## 处理边界情况

- **空行处理**：正确识别代码块中的空行，避免将代码块拆分
- **嵌套结构**：支持代码块内的复杂结构
- **多种代码块格式**：同时支持围栏代码块和缩进代码块

这样修改后，TOC 提取器就不会从代码块中提取标题了，只会提取真正的文档结构标题。

---

