【译】用Go实现一个静态博客生成器

时间:2022-05-06
本文章向大家介绍【译】用Go实现一个静态博客生成器,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。
静态站点生成器是一种工具,给一些输入(例如,markdown),使用HTML,CSS和JavaScript生成完全静态的网站。

为什么这很酷?一般来说,搭建一个静态网站更容易,而且通常运行也会比较快一些,同时占用资源也更少。虽然静态网站不是所有场景的最佳选择,但是对于大多数非交互型网站(如博客)来说,它们是非常好的。

在这篇文章中,我将讲述我用Go写的静态博客生成器。

动机

您可能熟悉静态站点生成器,比如伟大的Hugo,它具有关于静态站点生成的所有功能。

那么为什么我还要来编写另外一个功能较少的类似工具呢? 原因是双重的。

一个原因是我想深入了解Go,一个基于命令行的静态站点生成器似乎是磨练我技能很好的方式。

第二个原因就是我从来没有这样做过。 我已经完成了平常的Web开发工作,但是我从未创建过一个静态站点生成器。

这听起来很有趣,因为理论上,从我的网站开发背景来看,我满足所有先决条件和技能来构建这样一个工具,,但我从来没有尝试过这样做。

大约2个星期,我实现了它,并且很享受做的过程。 我使用我的博客生成器创建我的博客,迄今为止,它运行良好。

概念

早些时候,我决定采用 markdown 格式写博客,同时保存在 GitHub Repo。这些文章是以文件夹的形式组织的,它们代表博客文章的网址。

对于元数据,如发布日期,标签,标题和副标题,我决定保存在每篇文章的(post.md) meta.yml 文件中,它具有以下格式:

标题:玩BoltDB 简介:“为你的 Go 应用程序寻找一个简单的 key/value 存储器吗?看它足够了! 日期:20.04.2017 标签: - golang - go - boltdb - bolt

这样,我将内容与元数据分开了,但稍后会发现,其实仍然是将所有内容都放在了同一个地方。

GitHub Repo 是我的数据源。下一步是想功能,我想出了如下功能列表:

* 非常精益(在 gzipped 压缩情况下,入口页1请求应<10K) * 列表存档 * 在博客文章中使用代码语法高亮和和图像 * tags * RSS feed(index.xml) * 可选静态页面(例如 About) * 高可维护性 – 使用尽可能少的模板 * 针对 SEO 的 sitemap.xml * 整个博客的本地预览(一个简单的 run.sh 脚本)

相当健康的功能集。 从一开始,对我来说非常重要的是保持一切简单,快速和干净 – 没有任何第三方跟踪器或广告,因为这会影响隐私,并会影响速度。

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

架构概述

应用程序足够简单 高层次的要素有:

* 命令行工具(CLI) * 数据源(DataSource) * 生成器(Generators)

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

DataSource 接口如下所示:

type DataSource interface {

    Fetch(from, to string) ([]string, error)

}

Generator 接口如下所示:

type Generator interface {

    Generate() error

}

很简单。每个生成器还接收一个配置结构,其中包含生成器所需的所有必要数据。

目前已有 7 个生成器:

* SiteGenerator * ListingGenerator * PostGenerator * RSSGenerator * SitemapGenerator * StaticsGenerator * TagsGenerator

SiteGenerator 是元生成器,它调用所有其他生成器并输出整个静态网站。

目前版本是基于 HTML 模板的,使用的是 Go 的 html/template 包。

实现细节

在本节中,我将只介绍几个有觉得有意思的部分,例如 git DataSource 和不同的 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,from 是一个仓库 URL,to 是目标文件夹。 该函数创建并清除目标文件夹,使用 os/exec 加上 git 命令克隆仓库,最后读取文件夹,返回仓库中所有文件的路径列表。

如上所述,仓库仅包含表示不同博客文章的文件夹。 然后将具有这些文件夹路径的数组传递给生成器,它可以为仓库中的每个博客文章执行其相应的操作。

拉开帷幕

Fetch 之后,就是 Generate 阶段。执行博客生成器时,最高层执行以下代码:

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 方法,它返回 error。 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 文件。

列表创建

当用户访问博客的着陆页时,她会看到最新的文章,其中包含文章的阅读时间和简短描述等信息。 对于此功能和归档,我实现了ListingGenerator,它使用以下配置:

type ListingConfig struct {

    Posts                  []*Post

    Template               *template.Template

    Destination, PageTitle string

}

该生成器的 Generate 方法遍历该文章,组装其元数据,并根据给定的模板创建概要。 然后这些块被附加并写入相应的 index 模板。

我喜欢一个媒体的功能:文章大概阅读时间,所以我实现了我自己的版本,基于一个普通人每分钟读取大约 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)

}

Tags

接下来,要有一种按主题归类和过滤文章的方法,我选择实现一个简单的 tag(标签) 机制。 文章在他们的 meta.yml 文件中有一个标签列表。这些标签应该列在单独的标签页上,并且点击标签后,用户应该看到带有所选标签的文章列表。

首先,我们创建一个从 tag 到 Post 的 map:

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 来实现。 我们只需要迭代标签,为每个标签创建一个文件夹,选择要显示的帖子并为它们生成一个列表。

Sitemap 和 RSS

为了提高网络的可搜索性,最好建立一个可以由机器人爬取的 sitemap.xml。创建这样的文件是非常简单的,可以使用 Go 标准库来完成。

然而,在这个工具中,我选择使用了 etree 库,它为创建和读取 XML 提供了一个很好的 API。

SitemapGenerator 使用如下配置:

type SitemapConfig struct {

    Posts       []*Post

    TagPostsMap map[string][]*Post

    Destination string

}

博客生成器采用基本的方法来处理 sitemap,只需使用 addURL 函数生成 URL 和图像。

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是从静态文件夹中的模板到其指定的输出路径的映射。

这种配置可能看起来像这样:

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,

}}

用于生成这些静态资源的代码并不是特别有趣 – 您可以想像,这些文件只是遍历并复制到给定的目标。

并行执行

为了使博客生成器运行更快,所有生成器应该并行执行。正因为此,它们都遵循 Generator 接口, 这样我们可以将它们全部放在一个 slice 中,并发地调用 Generate。

这些生成器都可以彼此独立工作,不使用任何全局可变状态,因此使用 channel 和 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:   "",

    }}



    …创建其他的生成器...



    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 函数使用带缓冲的 channel,限制最多只能开启50个 goroutines,来创建所有生成器,将它们添加到一个 slice 中,然后并发运行。

这些例子只是在 Go 中编写静态站点生成器的基本概念的一个很短的片段。

如果您对完整的实现感兴趣,可以在此处找到代码。

总结

写个人博客生成器是绝对的爆炸和伟大的学习实践。 使用我自己的工具创建我的博客也是非常令人满意的。

为了发布我的文章到 AWS,我还创建了 static-aws-deploy,这是另一个 Go命令行工具。

如果你想自己使用这个工具,只需要 fork repo 并更改配置。 但是,由于 Hugo 提供了所有这些和更多的功能,我没有花太多时间进行可定制性或可配置性。

当然,应该不要一直重新发明轮子,但是有时重新发明一两轮可能是有益的,可以帮助你在这个过程中学到很多东西。