用400行Go代码构建极简静态博客生成器

作者厌倦了Hugo、Jekyll等复杂工具的频繁破坏性更新,于是自己动手用Go语言编写了一个不足400行的静态博客生成器。文章详述了其核心设计、Markdown解析、HTML/RSS生成等具体实现,体现了极简的软件哲学。

Hello blog

今年夏天,我偶然读到了《重构》英文版。这让我萌生了自己尝试(技术)写作的念头。然而,我需要一种简单的方式来发布博客文章,同时也需要一个无法立即开始写作的借口。于是,作为开发者的我告诉自己:

“首要之事,与其练习写作、阅读书籍或草拟可以写的内容,不如先定制一个静态网站生成器。”

但现在,这反而成了 nobloat.org 上的第一篇文章,我觉得这很好。

TL;DR

这个博客由 400 行手写的 Go 代码生成,主要是因为它很有趣,同时也因为我对现有解决方案每次更新带来的破坏性变更感到厌烦。

这种情况不会再发生了 :)

我对写作的构想

现有工具让我分心

静态网站生成器的生态系统非常庞大,但大多数解决方案都伴随着显著的复杂性。从写作的角度来看,最大的问题其实是建立一个既能让我不分散注意力,又看起来顺眼的工作环境。

Hugo 速度很快,也很流行,我过去也用过它。但是,它是一个庞大的框架,包含了数百个我永远不会用到的功能,却仍然要为此付出复杂性的代价。配置文件、主题系统和插件生态系统增加了一层层的抽象,让人难以理解实际发生了什么。当某些东西出错或我想自定义行为时,这一切对我来说都过于复杂,难以理清。而且我过去遇到过问题,一两年后,未经修改的静态网站突然就无法构建了。

Jekyll 需要 Ruby、Bundler 和一个复杂的 gem 生态系统。每次我想要更新或部署时,我都需要确保使用正确的 Ruby 版本,管理 gem 依赖项,并处理潜在的版本冲突。仅仅为了生成静态 HTML 而维护一个 Ruby 环境,感觉开销太大了。同样的问题也适用于 Pelican,只不过是用 Python 代替了 Ruby。

Next.js 和其他基于 React 的静态网站生成器功能强大,但它们带来了整个 JavaScript 生态系统。Node 模块、构建工具、转译,以及 npm 生态系统的持续动荡——而这一切本质上只是为了文本处理和模板渲染。

即便是更简单的工具,如 Zola 或 11ty,仍然需要学习它们特定的约定、配置格式和模板语言。它们比那些重量级工具要好,但它们仍然是带有自己抽象层的框架。

我需要的是:

  • 写 Markdown 文件
  • 运行一个简单的命令,得到 HTML。
  • 一切都应该在 Git 中,能在文本编辑器中使用,并且除了安装 Go 之外,不需要任何设置。
  • 无需配置文件、无需主题系统、无需我先去学习的插件架构。

现有的解决方案没有一个能满足这些要求。它们要么需要复杂的设置,有太多依赖项,引入了不必要的抽象,要么对结构有太多固执己见。此外,这也可以成为公园里某个阳光明媚的下午的一个有趣项目。

实现

实现包含两个 Go 文件:main.go(核心功能)和 data.go(站点配置),除了标准库外没有外部依赖。它读取 Markdown 文件,将其转换为 HTML,生成索引页,创建 RSS 订阅源,并将所有内容输出到 public/ 目录。整个代码库不到 400 行,只做我需要的事情,不多不少。

工作原理

博客生成器遵循一个简单的工作流程:

1. 内容结构

文章是位于 articles/ 目录下的 Markdown 文件,文件名带有日期前缀:YYYY-MM-DD-title.md。日期前缀有两个用途:一是为排序和 RSS 订阅提供发布日期,二是使得按时间顺序浏览文件时结构一目了然。

1
2
3
articles/
  2025-07-01-hello-blog.md
  2025-12-03-x-platform-translation-system.md

每个 Markdown 文件的第一行被视为标题(一个 # 标题),其余部分则转换为 HTML 内容。

2. Markdown 到 HTML 的转换

Markdown 解析器有意设计得极简。它处理:

  • 标题(#, ##, ###
  • 段落
  • 列表(- item
  • 行内格式(粗体、斜体、代码、链接、图片)
  • 带有语法高亮类的代码块
  • ## 标题自动生成锚点

解析器逐行处理 Markdown 文件,将支持的表达式转换为 HTML。对于列表和代码块,它会跟踪是否仍在列表或代码片段内部。

代码片段有所缩短,只显示相关部分。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
func parseMarkdown(input string) (content string, title string) {
	lines := strings.Split(input, "\n")
	var out strings.Builder
	inList := false
	inCode := false
	codeLang := ""

	if len(lines) > 0 && strings.HasPrefix(lines[0], "# ") {
		title = strings.TrimPrefix(lines[0], "# ")
	}

	for _, raw := range lines {
		line := strings.TrimSpace(raw)
		if strings.HasPrefix(line, "```") {
			if inCode {
				out.WriteString("</code></pre>\n</div>\n")
				inCode = false
				continue
			}
			inCode = true
			codeLang = strings.TrimSpace(strings.TrimPrefix(line, "```"))
			if codeLang == "" {
				out.WriteString("<pre><code>")
			} else {
				out.WriteString(fmt.Sprintf("<pre><code class=\"language-%s\">", codeLang))
			}
			continue
		}
		if inCode {
			out.WriteString(html.EscapeString(raw) + "\n")
			continue
		}
		if inList && line == "" {
			out.WriteString("</ul>\n")
			inList = false
			continue
		}
		switch {
		case strings.HasPrefix(line, "> "):
			if inList {
				out.WriteString("</ul>\n")
				inList = false
			}
			quote := formatInline(strings.TrimPrefix(line, "> "))
			out.WriteString("<blockquote><p>" + quote + "</p></blockquote>\n")
		case strings.HasPrefix(line, "# "):
			if inList {
				out.WriteString("</ul>\n")
				inList = false
			}
			heading := formatInline(strings.TrimPrefix(line, "# "))
			out.WriteString("<h1>" + heading  + "</h1>\n")
		case strings.HasPrefix(line, "- "):
			if !inList {
				out.WriteString("<ul>\n")
				inList = true
			}
			item := formatInline(strings.TrimPrefix(line, "- "))
			out.WriteString("<li>" + item + "</li>\n")
		case line == "":
			if inList {
				out.WriteString("</ul>\n")
				inList = false
			}
		default:
			if inList {
				out.WriteString("</ul>\n")
				inList = false
			}
			out.WriteString("<p>" + formatInline(line) + "</p>\n")
		}
	}

	if inList {
		out.WriteString("</ul>\n")
	}
	if inCode {
		out.WriteString("</code></pre>\n</div>\n")
	}

	return out.String(), title
}

3. 文章加载与排序

loadPosts() 函数扫描 articles 目录,读取每个 .md 文件,从文件名前缀解析日期,将 Markdown 转换为 HTML,并按日期降序(最新的在前)对文章进行排序。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
func loadPosts(dir string) []Post {
    files, _ := os.ReadDir(dir)
    var posts []Post
    for _, f := range files {
        if strings.HasSuffix(f.Name(), ".md") {
            // Parse date from filename: YYYY-MM-DD-title.md
            dateStr := f.Name()[:10]
            postDate, err := time.Parse("2006-01-02", dateStr)
            // Convert markdown to HTML
        }
    }
    // Sort by date, newest first
    sort.Slice(posts, func(i, j int) bool {
        return posts[i].Date.After(posts[j].Date)
    })
    return posts
}

如果文件不符合预期的格式,它会记录警告并跳过,确保只包含格式正确的文章。

4. HTML 和 RSS 订阅源生成

生成器创建三种类型的 HTML:

  • 索引页 (index.html): 列出所有文章及其链接,外加一个用于外部资源的链接部分。
  • 文章页 (YYYY-MM-DD-title.html): 单个文章页面,包含返回索引页的导航。
  • RSS 订阅源 (feed.xml): 供 RSS 阅读器使用的标准 Atom 订阅源。

所有 HTML 都是使用 Go 标准库中的 html/template 包生成的。模板从简单的 HTML 文件(index.htmlarticle.html)中读取,这些文件使用 Go 的模板语法——没有复杂的模板系统,只是带有模板变量的简单明了的 HTML。

5. 配置

站点元数据作为简单的 Go 结构体存储在 data.go 中。这包括站点标题、口号、基础 URL、链接,以及出现在索引页上的项目、工具等。配置只是一个变量声明,因此没有 YAML,没有 JSON,没有复杂的配置解析。

要更改站点标题或添加链接,我只需直接编辑 data.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
var config = Config{
    Title:   "][ nobloat.org",
    Slogan:  "pragmatic software minimalism",
    BaseURL: "https://nobloat.org",
    Links: map[string]string{
        "Choosing boring technology": "https://boringtechnology.club/",
        // ...
    },
    // ...
}

6. 文件监控(可选)

对于开发,main.go 包含一个 --watch 标志,它使用 fsnotify 包来监控 articles 目录、CSS 文件、模板文件和生成器本身。当任何文件发生更改时,它会自动重新构建站点。

1
go run main.go --watch

当你修改内容、模板或 CSS 时,更改会立即被检测到,站点会自动重新构建。编辑一篇文章,可以看到它更新。修改 HTML 模板,获得即时反馈。更改样式表,看到新样式生效。

然而,它不会检测 *.go 文件本身的变化,因为这需要更复杂的重启机制,而且我反正也很少碰它们。

这是唯一的外部依赖(github.com/fsnotify/fsnotify),并且仅用于监控功能。核心构建功能不需要外部包。

结论

这个博客生成器完全满足我的需求:将 Markdown 转换为 HTML,生成索引和 RSS 订阅源,并输出静态文件。它不到 400 行代码,核心功能仅使用 Go 标准库,并且我理解它的每一部分。

对于那些需要复杂功能(如标签、分类、分页或主题系统)的人来说,它可能不合适。但对于一个简单的博客来说,它是完美的。它符合 “nobloat”(无膨胀)的哲学。

整个代码库非常小,易于阅读、修改和维护。

对我个人来说,最好的部分是:我不需要 node、npm 或类似工具来构建它。本地预览只需在 Firefox 中打开 public/index.html。部署只是一个

1
rsync -av --delete public/ user@host:html/blog/

我还有一些关于在 nobloat 的背景下可以进一步写作的想法。鼓起勇气发布这篇文章是我迈出的最大一步。

参考资料

  • github.com/nobloat/blog
  • nobloat.org
  • GitHub: nobloat

欢迎随时反馈至 dev@spiessknafl.at

返回首页 | RSS 订阅源 | GitHub

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计