用 Go 写一个命令行 Markdown 转 HTML 工具,支持单文件和批量目录转换,输出带 GitHub 风格 CSS 的完整 HTML 页面。技术栈:Go + goldmark 库,最终代码 114 行。
效果预览:输入一个 .md 文件或目录,输出带排版样式的 .html 文件,用浏览器打开就是一篇排版精美的文章。
$ md2html -i doc/readme.md ✓ doc/readme.html 完成! 输出目录: doc
opencode 进入交互界面)go mod init md2html整个开发过程分 4 步,每步只需一句话告诉 OpenCode,它会自动写代码、安装依赖、甚至帮你跑测试。
我对 OpenCode 说:
> 帮我用 Go 写一个 md2html 工具,读取 .md 文件,用 goldmark 库转成 HTML,输出到同名 .html 文件。
OpenCode 做了以下事情:
检查 Go 环境和 go.mod
自动 go get github.com/yuin/goldmark
生成 main.go,包含文件读取、goldmark 转换、HTML 输出逻辑
核心转换代码就三行:
var buf strings.Builder md.Convert(data, &buf) // 转换 MD → HTML os.WriteFile(outPath, []byte(buf.String()), 0644) // 写出文件
关键要点: OpenCode 会自动选型第三方库。你说"用 goldmark",它就去 go get;如果你不指定库,它会搜索社区最佳实践然后推荐。这里 goldmark 是 Go 生态里最成熟的 Markdown 解析库,支持 CommonMark 和 GFM 扩展。
我对 OpenCode 说:
> 给生成的 HTML 嵌入 GitHub 风格的 CSS,包含代码块高亮、表格、引用块的样式,用 -apple-system 字体族。
OpenCode 的响应:
它直接在代码里嵌入了一个 CSS 常量 tmpl,把 <style> 标签写死在 HTML 模板中。这样生成的 HTML 文件无需外部 CSS 依赖,一个文件即拿即用。
const tmpl = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { max-width: 860px; margin: 0 auto; font-family: -apple-system, ...; }
pre { background: #f6f8fa; padding: 16px; border-radius: 6px; }
blockquote { border-left: 4px solid #dfe2e5; color: #6a737d; }
...
</style>
</head>
<body>%s</body>
</html>`
关键要点: 当你告诉 OpenCode"GitHub 风格",它会理解你想要的视觉效果——浅灰背景代码块、蓝色链接、底部边框标题。这种"设计意图"级别的自然语言描述,比手写 CSS 快得多。
我对 OpenCode 说:
> 如果 -i 传入的是目录,遍历目录下所有 .md 文件,逐个转换,最后打印转换数量。
OpenCode 做了:
用 os.Stat 判断输入是文件还是目录,如果是目录就用 filepath.Walk 遍历,只处理 .md 后缀文件。它还会自动过滤子目录,避免递归进入 node_modules 这类非预期目录。
if info.IsDir() {
count := 0
filepath.Walk(*input, func(path string, fi os.FileInfo, err error) error {
if err != nil || fi.IsDir() || !strings.HasSuffix(fi.Name(), ".md") {
return nil
}
if convertFile(md, path, *output) { count++ }
return nil
})
fmt.Printf("\n完成! 共转换 %d 个文件\n", count)
}
关键要点: 这个需求我给得很模糊——"如果是目录就遍历",OpenCode 自己补充了后缀过滤、错误处理、计数汇总。它能把一句话需求翻译成健壮的实现。
我对 OpenCode 说:
> 用 flag 包加命令行参数,-i 输入文件/目录,-o 输出目录。如果没指定 -o,默认输出到输入文件同级目录。
OpenCode 做了:
补充了完整的 flag.String 参数定义、用法提示、参数校验。还加上了 os.MkdirAll 确保输出目录存在,以及中文友好的错误提示。
最终用法:
md2html -i ./docs -o ./html # 批量转换 md2html -i readme.md # 单文件转换
关键要点: 像"如果没指定 -o,默认输出到同级目录"这种边界逻辑,OpenCode 处理得比手写还严谨——它会先 filepath.Abs 拿到绝对路径,再通过 filepath.Dir 取父目录,避免相对路径带来的歧义。
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/yuin/goldmark"
)
const tmpl = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>%s</title>
<style>
body { max-width: 860px; margin: 0 auto; padding: 40px 20px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; line-height: 1.7; color: #24292e; }
pre { background: #f6f8fa; padding: 16px; border-radius: 6px; overflow-x: auto; }
code { background: #f6f8fa; padding: 2px 6px; border-radius: 3px; font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; font-size: 85%%; color: #24292e; }
pre code { background: none; padding: 0; font-size: 100%%; }
table { border-collapse: collapse; width: 100%%; margin: 16px 0; }
th, td { border: 1px solid #dfe2e5; padding: 8px 12px; text-align: left; }
th { background: #f6f8fa; font-weight: 600; }
blockquote { border-left: 4px solid #dfe2e5; padding: 0 15px; color: #6a737d; margin: 16px 0; }
h1, h2 { border-bottom: 1px solid #eaecef; padding-bottom: 8px; }
img { max-width: 100%%; }
a { color: #0366d6; text-decoration: none; }
a:hover { text-decoration: underline; }
</style>
</head>
<body>
%s
</body>
</html>`
func main() {
input := flag.String("i", "", "输入 Markdown 文件或目录路径")
output := flag.String("o", "", "输出目录(默认与输入同目录)")
flag.Parse()
if *input == "" {
fmt.Fprintln(os.Stderr, "用法: md2html -i <文件或目录> [-o <输出目录>]")
os.Exit(1)
}
if *output == "" {
absInput, _ := filepath.Abs(*input)
info, err := os.Stat(absInput)
if err != nil {
fmt.Fprintf(os.Stderr, "错误: %v\n", err)
os.Exit(1)
}
if info.IsDir() {
*output = absInput
} else {
*output = filepath.Dir(absInput)
}
}
os.MkdirAll(*output, 0755)
md := goldmark.New()
info, err := os.Stat(*input)
if err != nil {
fmt.Fprintf(os.Stderr, "错误: %v\n", err)
os.Exit(1)
}
if info.IsDir() {
count := 0
filepath.Walk(*input, func(path string, fi os.FileInfo, err error) error {
if err != nil || fi.IsDir() || !strings.HasSuffix(fi.Name(), ".md") {
return nil
}
if convertFile(md, path, *output) {
count++
}
return nil
})
fmt.Printf("\n完成! 共转换 %d 个文件,输出目录: %s\n", count, *output)
} else {
if convertFile(md, *input, *output) {
fmt.Printf("完成! 输出目录: %s\n", *output)
}
}
}
func convertFile(md goldmark.Markdown, src, outDir string) bool {
data, err := os.ReadFile(src)
if err != nil {
fmt.Fprintf(os.Stderr, "读取失败 %s: %v\n", src, err)
return false
}
var buf strings.Builder
if err := md.Convert(data, &buf); err != nil {
fmt.Fprintf(os.Stderr, "转换失败 %s: %v\n", src, err)
return false
}
title := strings.TrimSuffix(filepath.Base(src), filepath.Ext(src))
html := fmt.Sprintf(tmpl, title, buf.String())
outPath := filepath.Join(outDir, title+".html")
if err := os.WriteFile(outPath, []byte(html), 0644); err != nil {
fmt.Fprintf(os.Stderr, "写入失败 %s: %v\n", outPath, err)
return false
}
fmt.Printf("✓ %s\n", outPath)
return true
}
从零到跑通,我只输入了 4 句提示词,OpenCode 完成了:
手写这 114 行代码,查 goldmark 文档 + 写 CSS + 调 flag 参数,至少 20 分钟。用 OpenCode 从 idea 到可运行产物,实际时间不到 4 分钟。
这种"对话式编程"的核心技巧就两条:描述你想要什么(不是怎么做),分步迭代(每次只加一个功能)。OpenCode 会帮你把自然语言翻译成正确的代码。