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

项目介绍

在日常开发中,我们经常需要把 Markdown 文档转为 HTML——比如给团队写技术周报、导出接口文档、发布博客。每次打开在线转换工具来回切换窗口很麻烦。

今天用 OpenCode 写一个命令行工具 md2html,指定输入 Markdown 文件,一键输出 HTML 文件,同时支持管道输入和实时预览服务器。技术栈:Go + goldmark 库,最终代码约 120 行。

准备工作

  • Go 1.18+
  • OpenCode 已安装(见 opencode.ai
  • 一个终端

打开终端,新建项目目录:

mkdir md2html && cd md2html
go mod init md2html

然后用 opencode 启动 AI 编程助手,开始对话。

实践过程

第一步:让 OpenCode 初始化项目结构

我对 OpenCode 说:

> 帮我用 Go 写一个命令行工具 md2html,接收一个 Markdown 文件路径,转换为 HTML 并输出到同名 .html 文件。使用 goldmark 库做转换。

OpenCode 分析了需求后,先添加依赖:

go get github.com/yuin/goldmark

然后生成了 main.go 的基础骨架——读取文件、调用 goldmark 转换、写入输出文件。代码简洁,核心逻辑仅 30 行。

要点:描述清楚输入(文件路径)和输出(.html 文件),OpenCode 会自动选择合适的库和方法。

第二步:添加管道输入支持

我对 OpenCode 说:

> 在原有基础上,增加从 stdin 读取 Markdown 内容的功能。如果没有提供文件参数,就从标准输入读取,转换后的 HTML 输出到 stdout。

OpenCode 修改了 main 函数,用 os.Args 判断参数数量:

  • 有参数 → 文件模式,读写文件
  • 无参数 → 管道模式,os.Stdin 输入,os.Stdout 输出

这让工具更灵活了,可以配合管道使用:

echo "# Hello" | md2html
cat README.md | md2html > output.html

要点:用自然语言描述"如果……就……"的条件逻辑,OpenCode 能准确转化为代码分支。

第三步:接入 HTML 模板

我对 OpenCode 说:

> 给输出 HTML 包一层完整的 HTML5 模板,包含基本的 CSS 样式(使用 GitHub 风格的字体和代码块高亮),标题用 Markdown 文件名。

OpenCode 定义了两个 HTML 模板字符串——一个给文件模式用(完整 HTML 页面),一个给管道模式用(纯片段)。模板中嵌入了 GitHub 风格的暗色代码块样式和系统字体栈。

生成的效果和 GitHub README 预览非常接近。

要点:指定样式风格时,可以用知名产品作为参考("GitHub 风格"),OpenCode 能理解并实现。

第四步:添加实时预览服务器

我对 OpenCode 说:

> 再加一个 serve 子命令,启动本地 HTTP 服务器,实时显示当前目录下 README.md 的渲染结果。

OpenCode 新增了 serveCmd 处理逻辑,用 net/http 启动服务器,请求时动态读取 Markdown 文件、实时转换并返回 HTML。打开浏览器就能预览,修改 Markdown 后刷新页面即可看到最新效果。

这是最让我惊喜的部分——我只说了"实时显示",OpenCode 就自动选择了 HTTP 服务器方案,而不是轮询或复杂的文件监控。

要点:不要过度约束实现方式,让 AI 发挥它的判断力。

完整代码

package main

import (
	"bytes"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"strings"

	"github.com/yuin/goldmark"
)

const pageTpl = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>%s</title>
<style>
  body { max-width: 860px; margin: 40px auto; padding: 0 20px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 16px; line-height: 1.7; color: #24292e; }
  pre { background: #1e1e1e; color: #d4d4d4; padding: 16px; border-radius: 6px; overflow-x: auto; font-size: 14px; line-height: 1.5; }
  code { background: #f6f8fa; padding: 2px 6px; border-radius: 3px; font-size: 85%%; }
  pre code { background: none; padding: 0; }
  table { border-collapse: collapse; width: 100%%; }
  th, td { border: 1px solid #dfe2e5; padding: 8px 12px; text-align: left; }
  th { background: #f6f8fa; }
  blockquote { border-left: 4px solid #dfe2e5; padding: 0 16px; color: #6a737d; margin: 0; }
</style>
</head>
<body>
%s
</body>
</html>`

func main() {
	if len(os.Args) > 1 && os.Args[1] == "serve" {
		serveCmd()
		return
	}
	md := goldmark.New()
	if len(os.Args) > 1 {
		src, err := os.ReadFile(os.Args[1])
		if err != nil {
			fmt.Fprintf(os.Stderr, "读取文件失败: %v\n", err)
			os.Exit(1)
		}
		var buf bytes.Buffer
		if err := md.Convert(src, &buf); err != nil {
			fmt.Fprintf(os.Stderr, "转换失败: %v\n", err)
			os.Exit(1)
		}
		outName := strings.TrimSuffix(filepath.Base(os.Args[1]), filepath.Ext(os.Args[1])) + ".html"
		html := fmt.Sprintf(pageTpl, filepath.Base(os.Args[1]), buf.String())
		if err := os.WriteFile(outName, []byte(html), 0644); err != nil {
			fmt.Fprintf(os.Stderr, "写入文件失败: %v\n", err)
			os.Exit(1)
		}
		fmt.Printf("已生成: %s\n", outName)
	} else {
		src, err := io.ReadAll(os.Stdin)
		if err != nil {
			fmt.Fprintf(os.Stderr, "读取标准输入失败: %v\n", err)
			os.Exit(1)
		}
		var buf bytes.Buffer
		if err := md.Convert(src, &buf); err != nil {
			fmt.Fprintf(os.Stderr, "转换失败: %v\n", err)
			os.Exit(1)
		}
		fmt.Print(buf.String())
	}
}

func serveCmd() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		src, err := os.ReadFile("README.md")
		if err != nil {
			http.Error(w, "README.md 不存在", 404)
			return
		}
		var buf bytes.Buffer
		md := goldmark.New()
		if err := md.Convert(src, &buf); err != nil {
			http.Error(w, "转换失败", 500)
			return
		}
		w.Header().Set("Content-Type", "text/html; charset=utf-8")
		fmt.Fprintf(w, pageTpl, "README.md", buf.String())
	})
	fmt.Println("预览服务器已启动: http://localhost:8080")
	http.ListenAndServe(":8080", nil)
}

小结

从零到完整可用的 Markdown 转 HTML 工具,全程只用了 4 次对话。我没有写一行代码,也没有查 goldmark 的 API 文档——OpenCode 自动完成了依赖安装、代码生成和错误处理。

对比传统开发方式:手动查文档 10 分钟 + 写代码 20 分钟 + 调试样式 10 分钟 = 至少 40 分钟。用 OpenCode,5 分钟搞定。

关键心得:把 OpenCode 当作结对编程的伙伴,用自然语言描述"做什么"而非"怎么做",让它发挥对库和最佳实践的了解,效率远超逐行手写。