静态网站生成器是一个工具,它给定一些输入(例如markdown),使用HTML、CSS和JavaScript生成一个完全静态的网站。
为什么这很酷?好吧,首先,托管一个静态网站要容易得多,而且它也(通常)要快得多,资源友好。静态网站并不是所有用途的最佳选择,但对于大多数非交互式网站,如博客,它们是伟大的。
在这篇文章中,我将介绍我用Go编写的[静态博客生成器],它为这个博客提供动力。
动机
你可能对静态网站生成器很熟悉,比如伟大的Hugo,它拥有人们所希望的静态网站生成的所有功能。
那么,我为什么要写一个和它一样功能较少的工具呢?原因是双重的。
一个原因是我想更深入地研究Go,而基于命令行的静态网站生成器似乎是一个磨练我技能的好地方。
第二个原因是,我以前从来没有做过。我做过相当多的网络开发,但我从来没有创建过静态网站生成器。
这使得它很有吸引力,因为从理论上讲,我有所有的先决条件和技能来建立这样一个工具,我的网络开发背景,但我从来没有尝试过这样做。
我被迷住了,在大约2周内实施了它,并且在做这件事的时候很开心。我使用我的博客生成器来创建这个博客,到目前为止,它运行得很好。)
概念
早期,我决定用markdown 写我的博客文章,并把它们放在GitHub Repo中。这些文章被结构化地放在文件夹里,这些文件夹代表了博文的网址。
对于元数据,如出版日期、标签、标题和副标题,我决定保留一个meta.yml 文件,每个post.md 文件的格式如下。
title: Playing Around with BoltDB
short: "Looking for a simple key/value store for your Go applications? Look no further!"
date: 20.04.2017
tags:
- golang
- go
- boltdb
- bolt
这使我能够将内容与元数据分开,但仍将所有东西放在同一个地方,以便我以后能找到它们。
GitHub Repo是我的数据源。下一步是考虑功能,我想出了这个清单。
- 非常精简(登陆页面应该是1个请求< 10K gzipped)
- 着陆页的清单和一个存档
- 可以在博客文章中使用语法高亮的代码和图片
- 标签
- RSS订阅(index.xml)。
- 可选的静态页面(例如:关于)。
- 高可维护性 - 尽可能使用最少的模板
- 用于SEO的sitemap.xml
- 整个博客的本地预览(一个简单的
run.sh脚本)。
相当于一个健康的功能集。对我来说,从一开始就非常重要的是保持一切简单、快速和干净--没有任何第三方跟踪器或广告,因为它们会损害隐私并减慢一切。
基于这些想法,我开始制定一个粗略的架构计划并开始编码。
架构概述
该应用程序足够简单。高级别的元素是。
CLIDataSourceGenerators
在这种情况下,CLI 是非常简单的,因为我没有在可配置性方面添加任何功能。它基本上只是从DataSource 中获取数据并在上面运行Generators 。
DataSource 的界面看起来是这样的。
type DataSource interface {
Fetch(from, to string) ([]string, error)
}
Generator 的界面看起来是这样的。
type Generator interface {
Generate() error
}
相当简单。每个Generator 也会收到一个配置结构,其中包含所有必要的生成数据。
在写这篇文章的时候,有7个Generators 。
SiteGeneratorListingGeneratorPostGeneratorRSSGeneratorSitemapGeneratorStaticsGeneratorTagsGenerator
其中SiteGenerator 是元生成器,它调用所有其他生成器并输出整个静态网站。
生成是基于HTML 模板,使用Go的html/template 包。
实施细节
在这一节中,我将只介绍一些我认为可能感兴趣的部分,如gitDataSource 和不同的Generators 。
数据源
首先,我们需要一些数据来生成我们的博客。如上所述,这些数据是以git仓库的形式存在的。下面的Fetch 函数抓住了DataSource 实现的大部分内容。
func (ds *GitDataSource) Fetch(from, to string) ([]string, error) {
fmt.Printf("Fetching data from %s into %s...\n", from, to)
if err := createFolderIfNotExist(to); err != nil {
return nil, err
}
if err := clearFolder(to); err != nil {
return nil, err
}
if err := cloneRepo(to, from); err != nil {
return nil, err
}
dirs, err := getContentFolders(to)
if err != nil {
return nil, err
}
fmt.Print("Fetching complete.\n")
return dirs, nil
}
Fetch 被调用时有两个参数: ,这是一个版本库的URL; ,这是目标文件夹。该函数创建并清除目标文件夹,使用 和 git 命令克隆版本库,最后读取文件夹,返回版本库内所有文件的路径列表。from to os/exec
如上所述,版本库只包含文件夹,这些文件夹代表不同的博客文章。然后,包含这些文件夹路径的数组被传递给生成器,生成器就可以对版本库中的每一篇博文进行处理。
开始了
在Fetch 之后是Generate 阶段。当blog-generator 被执行时,下面的代码会在最高层被执行。
ds := datasource.New()
dirs, err := ds.Fetch(RepoURL, TmpFolder)
if err != nil {
log.Fatal(err)
}
g := generator.New(&generator.SiteConfig{
Sources: dirs,
Destination: DestFolder,
})
err = g.Generate()
if err != nil {
log.Fatal(err)
}
generator.New 函数创建一个新的SiteGenerator ,它基本上是一个生成器,可以调用其他生成器。它被传递给一个目标文件夹和资源库内的博客文章的目录。
正如每一个实现上述接口的Generator ,SiteGenerator 有一个Generate 方法,它返回一个错误。SiteGenerator 的Generate 方法准备目标文件夹,读入模板,为博客文章准备数据结构,注册其他生成器并同时运行它们。
SiteGenerator 还为博客注册了一些设置,如URL、语言、日期格式等。这些设置是简单的全局常量,这当然不是最漂亮的解决方案,也不是最具可扩展性的,但它很简单,这也是这里的最高目标。
帖子
博客上最重要的概念是--惊喜,惊喜--博文在这个博客生成器的背景下,它们由以下数据结构表示。
type Post struct {
Name string
HTML []byte
Meta *Meta
ImagesDir string
Images []string
}
这些文章是通过迭代资源库中的文件夹,读取meta.yml 文件,将post.md 文件转换为HTML,如果有的话,还可以添加图片来创建。
相当多的工作,但一旦我们把帖子表示为数据结构,帖子的生成就相当简单,看起来像这样。
func (g *PostGenerator) Generate() error {
post := g.Config.Post
destination := g.Config.Destination
t := g.Config.Template
staticPath := fmt.Sprintf("%s%s", destination, post.Name)
if err := os.Mkdir(staticPath, os.ModePerm); err != nil {
return fmt.Errorf("error creating directory at %s: %v", staticPath, err)
}
if post.ImagesDir != "" {
if err := copyImagesDir(post.ImagesDir, staticPath); err != nil {
return err
}
}
if err := writeIndexHTML(staticPath, post.Meta.Title, template.HTML(string(post.HTML)), t); err != nil {
return err
}
return nil
}
首先,我们为帖子创建一个目录,然后把图片复制到那里,最后使用模板创建帖子的index.html文件。PostGenerator 还实现了语法高亮。
创建列表
当用户来到博客的登陆页面时,她会看到最新的文章,并附有文章的阅读时间和简短的描述等信息。为了这个功能和存档,我实现了ListingGenerator ,它需要以下配置。
type ListingConfig struct {
Posts []*Post
Template *template.Template
Destination, PageTitle string
}
这个生成器的Generate 方法迭代文章,集合它们的元数据并根据给定的模板创建短块。然后这些块被追加并写入索引模板。
我喜欢medium的近似阅读一篇文章的时间的功能,所以我实现了我自己的版本,基于人类平均每分钟阅读200字的假设。图片也计入整个阅读时间,文章中的每个img 标签都会增加12秒。这显然不能适用于任意的内容,但对于我通常的文章来说应该是一个很好的近似值。
func calculateTimeToRead(input string) string {
// an average human reads about 200 wpm
var secondsPerWord = 60.0 / 200.0
// multiply with the amount of words
words := secondsPerWord * float64(len(strings.Split(input, " ")))
// add 12 seconds for each image
images := 12.0 * strings.Count(input, "<img")
result := (words + float64(images)) / 60.0
if result < 1.0 {
result = 1.0
}
return fmt.Sprintf("%.0fm", result)
}
标签
接下来,为了有办法按主题对文章进行分类和过滤,我选择了实现一个简单的标签机制。帖子在其meta.yml 文件中有一个标签列表。这些标签应该被列在一个单独的Tags 页面上,当点击一个标签时,用户应该看到一个具有所选标签的帖子列表。
首先,我们创建一个从标签到Post 的映射。
func createTagPostsMap(posts []*Post) map[string][]*Post {
result := make(map[string][]*Post)
for _, post := range posts {
for _, tag := range post.Meta.Tags {
key := strings.ToLower(tag)
if result[key] == nil {
result[key] = []*Post{post}
} else {
result[key] = append(result[key], post)
}
}
}
return result
}
然后,有两个任务要实现。
- 标签页
- 所选标签的帖子列表
Tag 的数据结构看起来像这样。
type Tag struct {
Name string
Link string
Count int
}
所以,我们有实际的标签(名称),到标签列表页的链接,以及有这个标签的帖子数量。这些标签是由tagPostsMap ,然后按Count 降序排序的。
tags := []*Tag{}
for tag, posts := range tagPostsMap {
tags = append(tags, &Tag{Name: tag, Link: getTagLink(tag), Count: len(posts)})
}
sort.Sort(ByCountDesc(tags))
标签页基本上只是由渲染到tags/index.html 文件中的这个列表组成。
所选标签的帖子列表可以通过上述的ListingGenerator 来实现。我们只需要迭代标签,为每个标签创建一个文件夹,选择要显示的帖子并为它们生成一个列表。
网站地图和RSS
为了提高网络的可搜索性,有一个可以被机器人抓取的sitemap.xml ,这是一个好主意。创建这样一个文件是相当简单的,可以使用Go标准库来完成。
然而,在这个工具中,我选择了使用伟大的etree库,它为创建和读取XML提供了很好的API。
SitemapGenerator 使用这个配置。
type SitemapConfig struct {
Posts []*Post
TagPostsMap map[string][]*Post
Destination string
}
blog-generator 对网站地图采取了一个基本的方法,只是通过使用 函数来生成 和 的位置。addURL url image
func addURL(element *etree.Element, location string, images []string) {
url := element.CreateElement("url")
loc := url.CreateElement("loc")
loc.SetText(fmt.Sprintf("%s/%s/", blogURL, location))
if len(images) > 0 {
for _, image := range images {
img := url.CreateElement("image:image")
imgLoc := img.CreateElement("image:loc")
imgLoc.SetText(fmt.Sprintf("%s/%s/images/%s", blogURL, location, image))
}
}
}
在用etree 创建XML文档后,它只是被保存到一个文件中并存储在输出文件夹中。
RSS的生成也是同样的方法--迭代所有的帖子,为每个帖子创建XML条目,然后写到index.xml 。
处理静态因素
我需要的最后一个概念是完全静态的资产,如favicon.ico ,或像About 的静态页面。为了做到这一点,该工具用这个配置运行StaticsGenerator 。
type StaticsConfig struct {
FileToDestination map[string]string
TemplateToFile map[string]string
Template *template.Template
}
FileToDestination-map代表静态文件,如图片,或者robots.txt 和TemplateToFile 是从static 文件夹中的模板到其指定输出路径的映射。
这个配置在实践中可能是这样的。
fileToDestination := map[string]string{
"static/favicon.ico": fmt.Sprintf("%s/favicon.ico", destination),
"static/robots.txt": fmt.Sprintf("%s/robots.txt", destination),
"static/about.png": fmt.Sprintf("%s/about.png", destination),
}
templateToFile := map[string]string{
"static/about.html": fmt.Sprintf("%s/about/index.html", destination),
}
statg := StaticsGenerator{&StaticsConfig{
FileToDestination: fileToDestination,
TemplateToFile: templateToFile,
Template: t,
}}
生成这些静态文件的代码并不特别有趣--你可以想象,这些文件只是被迭代并复制到指定的目的地。
并行执行
为了使blog-generator 快速,生成器都是并行运行的。为此,它们都遵循Generator 的接口--这样我们就可以把它们都放在一个片断里,并为它们同时调用Generate 。
这些生成器都是独立工作的,不使用任何全局状态的突变,所以并行化它们只是使用通道和这样的sync.WaitGroup 。
func runTasks(posts []*Post, t *template.Template, destination string) error {
var wg sync.WaitGroup
finished := make(chan bool, 1)
errors := make(chan error, 1)
pool := make(chan struct{}, 50)
generators := []Generator{}
for _, post := range posts {
pg := PostGenerator{&PostConfig{
Post: post,
Destination: destination,
Template: t,
}}
generators = append(generators, &pg)
}
fg := ListingGenerator{&ListingConfig{
Posts: posts[:getNumOfPagesOnFrontpage(posts)],
Template: t,
Destination: destination,
PageTitle: "",
}}
...create the other generators...
generators = append(generators, &fg, &ag, &tg, &sg, &rg, &statg)
for _, generator := range generators {
wg.Add(1)
go func(g Generator) {
defer wg.Done()
pool <- struct{}{}
defer func() { <-pool }()
if err := g.Generate(); err != nil {
errors <- err
}
}(generator)
}
go func() {
wg.Wait()
close(finished)
}()
select {
case <-finished:
return nil
case err := <-errors:
if err != nil {
return err
}
}
return nil
}
runTasks 函数使用一个最大的池子。50个goroutines,创建所有的生成器,将它们添加到一个片断中,然后并行运行它们。
这些例子只是对用Go编写静态网站生成器的基本概念进行了简短的深入探讨。
总结
如果你想自己使用这个工具,只需fork这个 repo并改变配置即可。然而,我并没有在可定制性或可配置性上花太多时间,因为Hugo提供了所有这些和更多。
当然,一个人应该努力不要总是重新发明轮子,但有时重新发明一两个轮子也是很有意义的,可以帮助你在这个过程中学到很多东西。)