如何用Go编写静态博客生成器

203 阅读4分钟

静态网站生成器是一个工具,它给定一些输入(例如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 脚本)。

相当于一个健康的功能集。对我来说,从一开始就非常重要的是保持一切简单、快速和干净--没有任何第三方跟踪器或广告,因为它们会损害隐私并减慢一切。

基于这些想法,我开始制定一个粗略的架构计划并开始编码。

架构概述

该应用程序足够简单。高级别的元素是。

  • CLI
  • DataSource
  • Generators

在这种情况下,CLI 是非常简单的,因为我没有在可配置性方面添加任何功能。它基本上只是从DataSource 中获取数据并在上面运行Generators

DataSource 的界面看起来是这样的。

type DataSource interface {
    Fetch(from, to string) ([]string, error)
}

Generator 的界面看起来是这样的。

type Generator interface {
    Generate() error
}

相当简单。每个Generator 也会收到一个配置结构,其中包含所有必要的生成数据。

在写这篇文章的时候,有7个Generators

  • SiteGenerator
  • ListingGenerator
  • PostGenerator
  • RSSGenerator
  • SitemapGenerator
  • StaticsGenerator
  • TagsGenerator

其中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 ,它基本上是一个生成器,可以调用其他生成器。它被传递给一个目标文件夹和资源库内的博客文章的目录。

正如每一个实现上述接口的GeneratorSiteGenerator 有一个Generate 方法,它返回一个错误。SiteGeneratorGenerate 方法准备目标文件夹,读入模板,为博客文章准备数据结构,注册其他生成器并同时运行它们。

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.txtTemplateToFile 是从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提供了所有这些和更多。

当然,一个人应该努力不要总是重新发明轮子,但有时重新发明一两个轮子也是很有意义的,可以帮助你在这个过程中学到很多东西。)