← 返回文章列表

为了看AI写的Markdown,我让AI写了个浏览器,结果它自己给自己挖了个坑

一个关于「预览 Markdown」这件小事的折腾记录


一、痛点:我只是想看一眼啊

我不知道你有没有这种体验。

现在写 Markdown 这件事,大多时候已经不是自己在写了——是 AI 在写。我在这头跟 AtomCode 或者别的什么模型说「给我出一份分析报告」,那头哗啦啦几百行 Markdown 就出来了。

然后呢?

我只需要看一眼。确认结构对不对、数据有没有错、逻辑顺不顺。看完改两句,完事。

但就是这「看一眼」,在 Windows 上居然找不到一个趁手的工具。

VSCode 当然能看,但为了看个 Markdown 开一个 IDE?太重了。Typora 挺好,但它是编辑器,而且也不小,何况我根本不需要编辑,我只需要预览。Obsidian、Mark Text、Notion……每一个都大而全,每一个都藏着编辑器的野心。

我想要的很简单:

右键 → 点一下 → 浏览器打开 → 看完关掉。

没有编辑器 UI,没有目录树,没有插件市场,没有任何超过「把 Markdown 转成方便阅读的 HTML」之外的功能。

找了一圈,没找到。行吧,那我让 AI 给我写一个。

二、AI 写了个脚本,看起来很完美

需求很明确:一个 PowerShell 脚本,把 Markdown 转成 HTML,然后在浏览器里打开。

我把需求喂给 AI,它刷刷刷吐出来一个 md-view.ps1,功能还挺全:

  • ✅ 标题(H1-H6)
  • ✅ 加粗、斜体、删除线
  • ✅ 代码块和内联代码
  • ✅ 引用、列表(有序/无序)
  • ✅ 图片和链接
  • 表格(看起来是有的)
  • ✅ 深色/浅色模式自动适配
  • ✅ 样式还挺好看,GitHub 风格

然后在注册表里加一个右键菜单项:

HKEY_LOCAL_MACHINE\Software\Classes\SystemFileAssociations\.md\shell\md-view

命令指向:

powershell.exe -ExecutionPolicy Bypass -File "C:\Users\a\Tools\md-view.ps1" "%1"

搞定。右键 .md 文件 → 「用 Markdown 预览器打开」→ Edge 浏览器弹出来 → 完美。

……吗?

三、幽灵般的 Bug:表头去哪了?

脚本用了一阵子,一直没发现问题。直到今天。

我让AI写了一份关于长鑫科技上市的深度分析报告,里面密密麻麻十几个表格——工艺节点对比、HBM 代差、产能扩张节奏……全是表格,全是核心信息。

然!后!我右键用我的「Markdown 预览器」打开——

表头不见了。

所有的表格,第一行都消失了。每一个表格的第一列直接变成了原本的第二行。数据对不上,结构看不懂,整个报告没法读。

我第一反应:是不是 AI 把表头写掉了?

在 VSCode 里看原始 Markdown,表格完整,表头清晰:

| 维度 | 三星 | SK海力士 | 美光 | 长鑫科技 |
|------|------|----------|------|----------|
| 当前量产节点 | 1b | 1b→1c | 1γ | G4 ≈ 1z |

仔细检查源文件——Markdown 是对的。

那就是预览器的问题了。而且这个预览器……是我让 AI 写的。

四、抓 Bug:一个字符的锅

AI写的脚本还得AI来抓,把脚本翻出来看表解析的逻辑:

# 检测到以 | 开头,进入表格模式
if ($line -match '^\|') {
    $tableLines = [System.Collections.Generic.List[string]]::new()
    # ↓↓↓ 开始从下一行收集,但当前行(表头)呢???
    while ($i -lt $lines.Count -and $lines[$i].TrimEnd() -match '^\|') {
        $tableLines.Add($lines[$i].TrimEnd())
        $i++
    }
    $html.Add((Render-Table $tableLines))
    continue
}

看出来了没?

脚本一行行读文件,读到 $line| 维度 | 三星 | SK海力士 | …… (表头行),好,匹配到 ^\| 了,进入表格处理分支。

然后它创建了一个空列表,接着从下一行$lines[$i])开始往列表里加。

当前这一行,这个可爱的、包含所有列名的表头行,就这么被遗忘了。

后面 Render-Table 拿到列表后,把第一个非分隔行的行当成表头渲染——结果那是原本的数据行。表头没了。

一个 off-by-one 错误。经典中的经典。写代码的人——不管是人还是 AI——都很容易犯。

修复就一句话:

$tableLines = [System.Collections.Generic.List[string]]::new()
$tableLines.Add($line.TrimEnd())   # ← 补上这一行,把当前行加进去
while ($i -lt $lines.Count -and $lines[$i].TrimEnd() -match '^\|') {
    $tableLines.Add($lines[$i].TrimEnd())
    $i++
}

一个 Add 调用,五个单词的注释。

五、一点感想

这个故事有几个层面的讽刺:

第一层:我为了「不看编辑器 UI,只看内容」写了这个脚本,结果因为一个 Bug,我看到的表格内容全是错的——比编辑器还糟糕。

第二层:让 AI 写一个「预览 AI 写的 Markdown」的工具,AI 写的代码里有 Bug,导致 AI 写的 Markdown 里的表格没法看,然后这个BUG还得AI来修改。(有点绕对不对?🤣)


最后,这个脚本现在修好了。如果你也在 Windows 上缺一个「右键就看」的 Markdown 预览器,脚本贴在下面(修好了表头 Bug 的版本),自取。

<#
.SYNOPSIS
    Lightweight Markdown viewer with full rendering
.USAGE
    .\md-view.ps1 "C:\path\to\file.md"
#>

param(
    [Parameter(Mandatory=$true, Position=0)]
    [string]$FilePath
)

if (-not (Test-Path $FilePath)) {
    Write-Host "File not found: $FilePath" -ForegroundColor Red
    exit 1
}

$md = [System.IO.File]::ReadAllText($FilePath, [System.Text.Encoding]::UTF8)

function Convert-MarkdownToHtml {
    param([string]$text)
    
    $lines = $text -split "`n"
    $html = [System.Collections.Generic.List[string]]::new()
    $i = 0
    $inCodeBlock = $false
    
    while ($i -lt $lines.Count) {
        $line = $lines[$i].TrimEnd()
        $i++
        
        if ($line -match '^\s*```(\w*)') {
            if ($inCodeBlock) {
                $html.Add("</code></pre>")
                $inCodeBlock = $false
            } else {
                $lang = $Matches[1]
                $langClass = if ($lang) { " class=`"language-$lang`"" } else { "" }
                $html.Add("<pre><code$langClass>")
                $inCodeBlock = $true
            }
            continue
        }
        
        if ($inCodeBlock) {
            $html.Add([System.Net.WebUtility]::HtmlEncode($line))
            continue
        }
        
        if ($line -match '^\s*$') { continue }
        
        # Headings
        if ($line -match '^(#{1,6})\s+(.+)') {
            $level = $Matches[1].Length
            $content = Convert-InlineMarkdown $Matches[2]
            $html.Add("<h$level>$content</h$level>")
            continue
        }
        
        # Horizontal rule
        if ($line -match '^\s*([-*_])\s*\1\s*\1[\s\1]*$') {
            $html.Add("<hr>")
            continue
        }
        
        # Unordered list
        if ($line -match '^(\s*)[-*+]\s+(.+)') {
            $indent = $Matches[1].Length
            $content = Convert-InlineMarkdown $Matches[2]
            $class = if ($indent -ge 2) { " class=`"nested`"" } else { "" }
            $html.Add("<ul$class><li>$content</li></ul>")
            continue
        }
        
        # Ordered list
        if ($line -match '^(\s*)\d+\.\s+(.+)') {
            $content = Convert-InlineMarkdown $Matches[2]
            $html.Add("<ol><li>$content</li></ol>")
            continue
        }
        
        # Blockquote
        if ($line -match '^\s*>\s?(.*)') {
            $content = Convert-InlineMarkdown $Matches[1]
            $html.Add("<blockquote>$content</blockquote>")
            continue
        }
        
        # Table - collect all table lines(已修复表头丢失 Bug)
        if ($line -match '^\|') {
            $tableLines = [System.Collections.Generic.List[string]]::new()
            $tableLines.Add($line.TrimEnd())   # ← 修复:加入当前行(表头行)
            while ($i -lt $lines.Count -and $lines[$i].TrimEnd() -match '^\|') {
                $tableLines.Add($lines[$i].TrimEnd())
                $i++
            }
            $html.Add((Render-Table $tableLines))
            continue
        }
        
        # Image
        if ($line -match '!\[([^\]]*)\]\(([^\)]+)\)') {
            $alt = [System.Net.WebUtility]::HtmlEncode($Matches[1])
            $src = $Matches[2]
            $html.Add("<p><img src=`"$src`" alt=`"$alt`" style=`"max-width:100%`"></p>")
            continue
        }
        
        # Paragraph
        $content = Convert-InlineMarkdown $line
        $html.Add("<p>$content</p>")
    }
    
    return $html -join "`n"
}

function Render-Table {
    param([System.Collections.Generic.List[string]]$tableLines)
    
    $result = [System.Collections.Generic.List[string]]::new()
    $result.Add("<table>")
    
    $isFirstRow = $true
    foreach ($line in $tableLines) {
        if ($line -match '^\|[\s:-]+(\|[\s:-]+)*\|?\s*$') { continue }
        
        $cells = $line -split '\|' | Where-Object { $_ -ne '' }
        $tag = if ($isFirstRow) { "th" } else { "td" }
        
        $row = "<tr>"
        foreach ($cell in $cells) {
            $content = Convert-InlineMarkdown $cell.Trim()
            $row += "<$tag>$content</$tag>"
        }
        $row += "</tr>"
        $result.Add($row)
        $isFirstRow = $false
    }
    
    $result.Add("</table>")
    return $result -join "`n"
}

function Convert-InlineMarkdown {
    param([string]$text)
    
    $text = $text -replace '`([^`]+)`', '<code>$1</code>'
    $text = $text -replace '!\[([^\]]*)\]\(([^\)]+)\)', '<img src="$2" alt="$1">'
    $text = $text -replace '\[([^\]]+)\]\(([^\)]+)\)', '<a href="$2">$1</a>'
    $text = $text -replace '\*\*\*(.+?)\*\*\*', '<strong><em>$1</em></strong>'
    $text = $text -replace '\*\*(.+?)\*\*', '<strong>$1</strong>'
    $text = $text -replace '\*(.+?)\*', '<em>$1</em>'
    $text = $text -replace '~~(.+?)~~', '<del>$1</del>'
    
    return $text
}

$rendered = Convert-MarkdownToHtml $md
$title = [System.IO.Path]::GetFileNameWithoutExtension($FilePath)

$htmlDocument = @"
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>$title</title>
<style>
:root { --bg: #ffffff; --fg: #1f2328; --border: #d1d9e0; --code-bg: #f6f8fa; --link: #0969da; --table-stripe: #f6f8fa; }
@media (prefers-color-scheme: dark) {
    :root { --bg: #0d1117; --fg: #e6edf3; --border: #30363d; --code-bg: #161b22; --link: #58a6ff; --table-stripe: #161b22; }
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
    line-height: 1.6; color: var(--fg); background: var(--bg);
    max-width: 900px; margin: 0 auto; padding: 2rem 2.5rem;
}
h1, h2, h3 { margin: 1.5em 0 0.5em; font-weight: 600; }
h1 { font-size: 2em; border-bottom: 1px solid var(--border); padding-bottom: 0.3em; }
h2 { font-size: 1.5em; border-bottom: 1px solid var(--border); padding-bottom: 0.3em; }
h3 { font-size: 1.25em; }
p { margin: 0.8em 0; }
a { color: var(--link); text-decoration: none; }
a:hover { text-decoration: underline; }
code { background: var(--code-bg); padding: 0.2em 0.4em; border-radius: 6px; font-family: Consolas, monospace; font-size: 0.9em; }
pre { background: var(--code-bg); padding: 1em; border-radius: 8px; overflow-x: auto; border: 1px solid var(--border); }
pre code { background: none; padding: 0; font-size: 0.85em; }
blockquote { border-left: 4px solid var(--border); padding: 0 1em; color: #6e7781; margin: 1em 0; }
ul, ol { padding-left: 2em; margin: 0.5em 0; }
li { margin: 0.2em 0; }
hr { border: none; border-top: 1px solid var(--border); margin: 1.5em 0; }
table { border-collapse: collapse; width: 100%; margin: 1em 0; }
th, td { border: 1px solid var(--border); padding: 0.5em 0.8em; text-align: left; }
th { background: var(--code-bg); font-weight: 600; }
tr:nth-child(even) { background: var(--table-stripe); }
img { max-width: 100%; border-radius: 8px; }
del { color: #6e7781; }
</style>
</head>
<body>
$rendered
</body>
</html>
"@

$tempFile = [System.IO.Path]::Combine($env:TEMP, "md_preview_$(Get-Random).html")
[System.IO.File]::WriteAllText($tempFile, $htmlDocument, [System.Text.Encoding]::UTF8)

Start-Process "msedge" "--new-window `"$tempFile`"" -ErrorAction SilentlyContinue
if (-not $?) { Start-Process "chrome" "--new-window `"$tempFile`"" -ErrorAction SilentlyContinue }
if (-not $?) { Start-Process $tempFile }

Start-Sleep -Seconds 30
Remove-Item -Path $tempFile -Force -ErrorAction SilentlyContinue

注册表右键菜单添加方法(管理员 PowerShell):

New-Item -Path "HKLM:\Software\Classes\SystemFileAssociations\.md\shell\md-view" -Force
Set-ItemProperty -Path "HKLM:\Software\Classes\SystemFileAssociations\.md\shell\md-view" -Name "(default)" -Value "用 Markdown 预览器打开"
Set-ItemProperty -Path "HKLM:\Software\Classes\SystemFileAssociations\.md\shell\md-view" -Name "Icon" -Value "imageres.dll,71"
New-Item -Path "HKLM:\Software\Classes\SystemFileAssociations\.md\shell\md-view\command" -Force
Set-ItemProperty -Path "HKLM:\Software\Classes\SystemFileAssociations\.md\shell\md-view\command" -Name "(default)" -Value "powershell.exe -ExecutionPolicy Bypass -File `"C:\Users\a\Tools\md-view.ps1`" `"%1`""