OpenCode 实践课:4 分钟用 Go 写出一个命令行 Markdown 转 HTML 工具

项目介绍

用 Go 写一个命令行 Markdown 转 HTML 工具,支持单文件和批量目录转换,输出带 GitHub 风格 CSS 的完整 HTML 页面。技术栈:Go + goldmark 库,最终代码 114 行。

效果预览:输入一个 .md 文件或目录,输出带排版样式的 .html 文件,用浏览器打开就是一篇排版精美的文章。

$ md2html -i doc/readme.md
✓ doc/readme.html
完成! 输出目录: doc

准备工作

  • Go 1.18+ 环境
  • OpenCode 已安装(打开终端,输入 opencode 进入交互界面)
  • 初始化一个 Go 模块:go mod init md2html

实践过程

整个开发过程分 4 步,每步只需一句话告诉 OpenCode,它会自动写代码、安装依赖、甚至帮你跑测试。

第 1 步:搭框架——读取 Markdown 并转为 HTML

我对 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 扩展。

第 2 步:加样式——让 HTML 好看

我对 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 快得多。

第 3 步:加批量——支持整个目录转换

我对 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 自己补充了后缀过滤、错误处理、计数汇总。它能把一句话需求翻译成健壮的实现。

第 4 步:加参数——完整的 CLI 体验

我对 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 完成了:

  • 第三方库选型和依赖安装
  • 文件 I/O、目录遍历、命令行参数解析(全是标准 Go 模式)
  • 嵌入式 CSS 模板,无需外部资源
  • 健壮的错误处理和用户友好的输出

手写这 114 行代码,查 goldmark 文档 + 写 CSS + 调 flag 参数,至少 20 分钟。用 OpenCode 从 idea 到可运行产物,实际时间不到 4 分钟。

这种"对话式编程"的核心技巧就两条:描述你想要什么(不是怎么做),分步迭代(每次只加一个功能)。OpenCode 会帮你把自然语言翻译成正确的代码。