Practice make perfect

编写一个简单的静态网站生成器

编写一个简单的静态网站生成器

我更换了好几次博客, 从最初的 HexoXblog ,再到现在的 Ghost,(虽然没写几篇文章)。我喜欢 Ghost 这种可以在线编辑,实时预览,一键更换主题的简洁,也喜欢 Hexo 那种 DIY 的自由。

为什么又想要放弃 Ghost 呢? 因为我发现能在线编写博客的机会少之又少,绝大部分都是本地 typora 编辑完成后直接粘贴 (因为 Ghost 编辑器真的太难用了)

为什么要自己写一个呢,hexo/hugo 不好么? Hhh,单纯想重复造个轮子(顺便练习下Go)...

创建项目

$ mkdir go-static-blog && cd go-static-blog
$ go mod init go-static-blog
go: creating new go.mod: module go-static-blog

读入源文件

这里我们规定编写的 markdown 源文件保存在 ./srcs 里,所以我们要读取该目录下所有 *.md 的文件,通过标准库 path/filepath 可直接读取该目录下对应的 md 文件,返回一个文件名列表。

func getSources() []string {
	files, _ := filepath.Glob("srcs/*.md")
	return files
}
$ go run build.go 
[srcs/example1.md srcs/example2.md]%    

解析markdown

首先我们要定义一下文章的格式,如下

---
title: My First Post 
date: 2019-09-10
---

# Some Title
Markdown content here.

这里声明一个文章类,方便后面模板解析

type Post struct {
	Title   string
	Date    string
	Content string
	Source  []byte
	URL     string
}

解析 markdown 我们使用第三方 russross/blackfriday.v2 (文档),值得一提的是,Hugo 用的也是这个库,不过 Hugo 目前(2019年9月10日)用的是v1。

这里文章头部信息我们是手动读行处理的,blackfriday.v2 应该是可以单独处理这一部分,之后完善。

func renderMarkdown(source []byte) string {
	content := string(blackfriday.Run(source))
	return content
}

func parseSource(fileName string) Post {
	sources, _ := ioutil.ReadFile(fileName)
	lines := strings.Split(string(sources), "\n")
	title := strings.Split(string(lines[1]), ": ")[1]
	date := strings.Split(string(lines[2]), ": ")[1]
	source := []byte(strings.Join(lines[5:len(lines)], "\n"))
	content := renderMarkdown(source)
	URL := strings.Replace(strings.ToLower(title), " ", "-", -1)
	return Post{title, date, content, source, URL}
}

渲染文章

有了文章内容就可以填充到html 模板中,这里我们使用标准库 html/template,进行渲染,模板存储在./templates下,文章的模板(post.html)如下:

<!DOCTYPE html>
<html>

<head>
    <title>{{.Title}}</title>
</head>

<body>
    <h1>{{.Title}}</h1>
    <em>Posted on {{.Date}}</em>
    <article>
        {{.Content}}
    </article>
</body>

</html>

之后编写渲染函数,我们将文章放在 ./public 文件夹中,文件名暂定为文章标题的 URL,加os.O_TRUNC是覆盖写。

func writePost(post Post) {
	t, err := template.ParseFiles("templates/post.html")
	if err != nil {
		fmt.Printf("error %s", err)
	}
	fileName := fmt.Sprintf("public/%s.html", post.URL)
	file, err := os.OpenFile(fileName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
	if err != nil {
		fmt.Printf("error %s", err)
	}
	t.Execute(file, post)
}

现在编写一个函数,组合到目前为止的功能,并且返回文章列表,方便我们下一步渲染主页。

func writePosts() []Post {
	files := getSources()
	posts := []Post{}
	for _, file := range files {
		post := parseSource(file)
		post.Content = renderMarkdown(post.Source)
		writePost(post)
		posts = append(posts, post)
	}
	return posts
}

渲染主页

需要有一个入口来访问渲染好的文章,在 ./templates 下创建主页的模板(index.html)

<!doctype html>
<html>

<body>
    <h1>My blog posts</h1>
    <ol>
        {{range .}}
        <li>
            <a href="/{{.URL}}">{{.Title}}</a>
        </li>
        {{end}}
    </ol>
</body>

</html>

这里我们可以对文章根据日期进行排序,最新的文章在最上面,直接比较日期的字符串即可,参考标准库sort,需要实现三个方法。

type ByDate []Post

func (a ByDate) Len() int           { return len(a) }
func (a ByDate) Swap(i, j int)      { a[i], a[j] = a[j], a[i] }
func (a ByDate) Less(i, j int) bool { return a[i].Date > a[j].Date }

接下来编写渲染主页的函数

func writeIndex(posts []Post) {
	sort.Sort(ByDate(posts))
	t, err := template.ParseFiles("templates/index.html")
	if err != nil {
		fmt.Printf("error %s", err)
	}
	file, err := os.OpenFile("public/index.html", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
	if err != nil {
		fmt.Printf("error %s", err)
	}
	t.Execute(file, posts)
}

添加预览

到目前为止,基本功能已经实现了,我们可以通过 net/http 编写一个简单的服务来预览下效果,这里我们主页和文章用一个统一的入口,通过正则来匹配提取文章名。

func handler(w http.ResponseWriter, r *http.Request) {
	var validPath = regexp.MustCompile("^/([a-z0-9-]+)$")
	postURL := validPath.FindStringSubmatch(r.URL.Path)
	filePath := "public/index.html"

	if postURL != nil {
		filePath = fmt.Sprintf("public/%s.html", postURL[1])
	}
	log.Println(filePath)
	t, _ := template.ParseFiles(filePath)
	err := t.Execute(w, nil)
	if err != nil {
		fmt.Printf("error %s", err)
	}
}

编写主函数

func main() {
	posts := writePosts()
	writeIndex(posts)
	http.HandleFunc("/", handler)
	log.Fatal(http.ListenAndServe(":8080", nil))
}

接下来运行下查看下效果。

$ go run build.go
2019/09/13 22:31:55 public/index.html
2019/09/13 22:31:56 public/index.html
2019/09/13 22:32:06 public/my-second-post.html
2019/09/13 22:32:06 public/index.html

浏览器看下效果
主页

文章页面

结语

完整项目 Github 地址

目前来说,这个 blog 仅仅是 demo 级别,仅作为 Go 的学习使用。

后续还有可以优化的地方,如

  • 优化解析文章逻辑
    • 添加分类
    • 添加标签
  • 优化页面,添加样式
  • 添加页面
    • 归档页
    • 分类页
    • 标签云
    • 关于页
  • 添加命令行工具,如生成文章,部署等
  • 添加持续集成服务,如Travis CI 或者 GitHub Actions

参考

Python Writing a small static site generator

Nim 只需五步,自己动手写一个静态博客

How to code a markdown blogging system in Go

Go实战--golang中使用markdown(russross/blackfriday)

评论