一个关于「预览 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`""