OpenCode 实践课:4 分钟用 Go 写出一个命令行 Markdown 预览器

项目介绍

平时写完 Markdown 文档,想看最终效果还得推到 GitHub 或者找个在线工具预览,来回切换挺麻烦。今天我们用 OpenCode 写一个命令行工具——mdpreview,输入一个 .md 文件路径,自动转成带 CSS 样式的 HTML 页面,然后直接在浏览器里打开预览。

  • 技术栈:Go 语言 + gomarkdown
  • 最终代码:约 90 行
  • 功能:Markdown 解析 → HTML 渲染 → 浏览器自动打开

全程不写一行代码,只需要用自然语言对 OpenCode 说几句话就行。

准备工作

  • Go 1.21+ 已安装
  • OpenCode 已配置(如果没有,参考 opencode.ai 安装)
  • 一个终端窗口,在项目目录下输入 opencode 启动

实践过程

第一步:一句话生成全部代码

打开终端,进入工作目录,对 OpenCode 说出需求:

> 我对 OpenCode 说:
>
> 用 Go 写一个命令行 Markdown 预览工具 mdpreview,接收一个 .md 文件路径作为参数,使用 gomarkdown 库将 Markdown 解析为 HTML,嵌入 GitHub 风格的 CSS 样式,将生成的 HTML 保存到临时文件,并自动在浏览器中打开预览。支持跨平台(Windows/macOS/Linux)打开浏览器。

OpenCode 会立即开始工作:初始化 Go module、添加 gomarkdown 依赖、生成 main.go 主体代码。整个过程不到一分钟:

> OpenCode 的响应:
>
> 我来为你创建这个项目。首先初始化 Go module,然后编写完整的 main.go。
>
> ```
> go mod init mdpreview
> go get github.com/gomarkdown/markdown
> ```
>
> 已生成 main.go(约 90 行),包含:文件读取、Markdown 解析、HTML 模板渲染、跨平台浏览器打开。你可以直接 go build 编译运行。

关键要点:提示词要一次性说清楚四个要素——输入(.md 文件)、处理逻辑(gomarkdown 解析 + CSS 模板)、输出(临时 HTML 文件)、行为(打开浏览器)。OpenCode 擅长处理这种完整的功能描述,你描述得越具体,它生成得越准确。

第二步:用测试文件验证效果

代码生成好了,我需要一个测试文件来验证功能:

> 我对 OpenCode 说:
>
> 帮我生成一个 test.md 测试文件,包含标题、代码块、列表、引用、表格等多种 Markdown 语法元素,然后编译运行看看效果。

OpenCode 会创建测试文件并运行编译:

> OpenCode 的响应:
>
> test.md 已创建,包含标题、代码块、无序列表、引用和表格。执行编译:
>
> ```
> go build -o mdpreview . && ./mdpreview test.md
> ```
>
> 编译成功。已在浏览器中打开生成的 HTML 预览页面,样式类似 GitHub 的 Markdown 渲染效果。

此时浏览器已经弹出了预览窗口,样式干净好看,所有语法元素都正确渲染。从说出需求到看到效果,前后不到 4 分钟。

关键要点:测试验证是 AI 编程重要的一环——让 OpenCode 自己生成测试数据并运行,可以即时发现并修复问题,形成"描述→生成→测试→修正"的高效循环。

第三步:优化改进

如果觉得默认样式不够满意,可以继续提需求:

> 我对 OpenCode 说:
>
> 代码块的背景色改成深色主题(类似 VS Code 深色模式),行号再宽一点。

这种微调 OpenCode 也处理得非常快。不过这里我们保持简洁,展示的就是一次性生成的最终版本。实际使用中,你可以反复迭代直到满意为止。

完整代码

package main

import (
	"flag"
	"fmt"
	"html/template"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"strings"

	"github.com/gomarkdown/markdown"
	"github.com/gomarkdown/markdown/html"
	"github.com/gomarkdown/markdown/parser"
)

const tpl = `<!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>
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;line-height:1.6;max-width:860px;margin:0 auto;padding:20px 32px;color:#24292e}
h1,h2{border-bottom:1px solid #eaecef;padding-bottom:.3em}
h1{font-size:2em}h2{font-size:1.5em}h3{font-size:1.25em}
code{background:#f6f8fa;padding:.2em .4em;border-radius:3px;font-family:SFMono-Regular,Consolas,"Liberation Mono",Menlo,monospace;font-size:85%}
pre{background:#f6f8fa;padding:16px;border-radius:6px;overflow:auto}
pre code{background:none;padding:0}
blockquote{border-left:4px solid #dfe2e5;padding:0 1em;color:#6a737d;margin:0}
table{border-collapse:collapse;width:100%}
td,th{border:1px solid #dfe2e5;padding:6px 13px}
a{color:#0366d6;text-decoration:none}
a:hover{text-decoration:underline}
</style>
</head>
<body>
{{.Body}}
</body>
</html>`

func main() {
	flag.Parse()
	if flag.NArg() < 1 {
		fmt.Fprintln(os.Stderr, "用法: mdpreview <markdown文件>")
		os.Exit(1)
	}

	filename := flag.Arg(0)
	data, err := os.ReadFile(filename)
	if err != nil {
		fmt.Fprintf(os.Stderr, "读取文件失败: %v\n", err)
		os.Exit(1)
	}

	extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock
	p := parser.NewWithExtensions(extensions)
	doc := p.Parse(data)

	renderer := html.NewRenderer(html.RendererOptions{Flags: html.CommonFlags | html.HrefTargetBlank})
	body := string(markdown.Render(doc, renderer))

	title := strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename))

	outPath := filepath.Join(os.TempDir(), title+".html")
	f, err := os.Create(outPath)
	if err != nil {
		fmt.Fprintf(os.Stderr, "创建输出文件失败: %v\n", err)
		os.Exit(1)
	}
	defer f.Close()

	t := template.Must(template.New("page").Parse(tpl))
	t.Execute(f, struct {
		Title string
		Body  string
	}{Title: title, Body: body})

	openBrowser(outPath)
	fmt.Printf("已生成预览: %s\n", outPath)
}

func openBrowser(url string) {
	var cmd *exec.Cmd
	switch runtime.GOOS {
	case "windows":
		cmd = exec.Command("cmd", "/c", "start", url)
	case "darwin":
		cmd = exec.Command("open", url)
	default:
		cmd = exec.Command("xdg-open", url)
	}
	cmd.Start()
}

小结

用 OpenCode 开发这个 Markdown 预览器我有几点体会:

描述即开发:把需求用自然语言说清楚,OpenCode 就能生成完整的可运行代码。不需要考虑 Go module 初始化、依赖管理等细节,它都自动处理了。

即时反馈:代码生成后立刻编译运行,看到效果那一刻非常有成就感。从想法到浏览器弹窗,整个过程不到 4 分钟。

迭代自然:不满意的地方随时用中文描述修改需求,OpenCode 精准定位代码位置进行修改,比手动翻代码找位置快得多。

对比传统开发方式——查文档、写代码、调依赖、配环境,OpenCode 把开发和调试融为一体,让"想法到产品"的距离缩短到了一个对话回合的长度。