深入理解Hugo - 站点发布的流程

1,355 阅读10分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第10天,点击查看活动详情 ,祝福祖国,生日快乐 :)

发布的流程

通过模板的生命周期我们可以看到在最后的渲染阶段,先找到页面的Template,然后对页面进行渲染。 这样我们就有了根据模板生成的待发布内容。

站点发布主要的任务就是将作者所创作的内容,通过模板转换生成待发布的内容,按照站点的输出格式,写入到我们指定的文件目录。

那这里就会有两个问题:

  1. 作者所创作的内容是怎么存储在页面中的,又要怎么使用呢?
  2. 发布过程中的信息,如输出格式、文件名、写入地址又是谁提供的呢?

我们还是从Golang Template基本原理出发:

1-golang-template.svg

在index.html模板中,我们计划用{{.Content}}属性来获取内容。 如果要成功获取,就需要在我们的内容提供商Post实例中,在Content字段设置上正确的值。 如果都按约定准备好后,在最终Golang Template执行结果中,我们就能发现右上角的内容。

在我们的游乐场示例中,我们也有一个用到了.Content的layout - layouts/_default/single.html

-- mycontent/blog/post.md --
---
title: "Post Title"
---
### first blog
Hello Blog

-- layouts/_default/single.html --
{{ .Content }}
===
Static Content
===

single.html会用做独立页面的模板,如上面的mycontent/blog/post.md。 通过模板渲染过后,post.md中的内容就会替换掉single.html中的{{ .Content }}

那Hugo的页面对象PageState又是怎么提供内容服务的呢,也和上面一样,放在属性里吗? 我们在Hugo Playground源码中,很快找到了答案:

// Page is the core interface in Hugo.
type Page interface {
   ContentProvider
   ...
}

// ContentProvider provides the content related values for a Page.
type ContentProvider interface {
    Content() (any, error)
   ...
}

没错,就是ContentProvider。 可以看出Golang Template不仅支持属性,同时还支持方法。 共同的特点就是要对外可见 - 都是大写字母开头。

那上面的两个问题就变得更具体了:

  1. PageState中的ContentProvider是谁?
  2. 发布流程中所需的详细信息是怎么来的?

发布相关的领域事件

同样,我们还是可以通过领域事件风暴中,发布相关的领域事件入手:

7.0-publish-events.svg

可以看到,关键时机有两个。 一个是在站点Site创建的时期,另一个则是在构建时期。

让我们专注在和发布相关的事件上:

7.0.1-publish-events-simple.svg

这些事件的解读在领域事件风暴中有具体描述,这里就不再赘叙。

结合源码,我们进一步将上述事件转换成代码流程图:

7.1-publish-work-flow.svg

不出所料,为PageState提供内容和其它信息服务的对象确实存在,那就是pageOutputs。 但心中不禁升起一丝疑问,为什么是个复数?

// We create a pageOutput for every output format combination, even if this
// particular page isn't configured to be rendered to that format.
type pageOutput struct {
   ...

   // These interface provides the functionality that is specific for this
   // output format.
   pagePerOutputProviders
   page.ContentProvider
   
   ...
}

// these will be shifted out when rendering a given output format.
type pagePerOutputProviders interface {
    targetPather
   
   ...
}

通过查阅上述源码定义,我们更加确定了我们分析的正确性。 PageOutput确实如我们所料,提供了ContentProvider服务和targetPather服务,这样我们前面的两个问题就有了着落。 再加上注解:

We create a pageOutput for every output format combination, even if this particular page isn't configured to be rendered to that format.

我们发现pageOutput是和output format一一对应的,也就是说有多少种输出格式,就有多少个pageOutput,这也解释了上面关于复数的疑问。

那究竟是个什么样的对应关系,为什么要这样设计呢? 让我们还是结合上面的流程图来进行分析。

站点创建时做的准备工作

7.2-publish-start.svg

我们先看在创建Site的时候,都准备了哪些和发布相关的信息。 上图右边是流程图站点的部分,从对象引用关系来看,可以了解到OutputFormats依赖于OutputFormat,但不是聚合关系。 而OutputFormat又依赖于MediaType。

MediaType

从上图左侧的结构图可以看出,OutputFormat是拥有MediaType字段的。 在MediaType包含的主要字段是main和sub。

那这些字段是干什么用的呢? 我们可以通过上图中间的部分来进一步了解。 在Hugo的DefaultTypes中,经过简化,我们保留了HTML, MD, TOML, TEXT四种类型。 拿HTML MediaType举例,实际上长这样text/html。 没错main字段就是text,而sub字段才是html。 因为首先HTML媒体类型的文件是以文本形式存储在磁盘上的,然后才是内容是以HTML格式进行组织的。 关于Media Type的详细介绍,可以参考Wikipedia

OutputFormat

再来看看OutputFormat:

// Format represents an output representation, usually to a file on disk.
type Format struct {
   // The Name is used as an identifier. Internal output formats (i.e. HTML and RSS)
   // can be overridden by providing a new definition for those types.
   Name string `json:"name"`
   
   MediaType media.Type `json:"-"`
   
   // The base output file name used when not using "ugly URLs", defaults to "index".
   BaseName string `json:"baseName"`
   
   ...
}

通过注解可以看到,实际的作用就是记载了要将一个文件输出到磁盘上的相关说明信息。 比如BaseName的默认值就是index,首页就会用到,默认文件名是index.html。

Hugo提供的DefaultFormats根据我们的示例简化后,保留了HTML, JSON, MD三种。 其中HTML是指我们将以HTML的输出格式将文件写入到磁盘。

实例如下:

HTMLFormat = Format{
     Name:          "HTML",
     MediaType:     media.HTMLType,
     BaseName:      "index",
     Rel:           "canonical",
     IsHTML:        true,
     Permalinkable: true,

     // Weight will be used as first sort criteria. HTML will, by default,
     // be rendered first, but set it to 10 so it's easy to put one above it.
     Weight: 10,
 }

OutputFormats

如果OutputFormats和OutputFormat不是聚合关系,那又是什么关系呢?

我们直接来看看OutputFormats的结构,就清楚答案了。

7.2.1-publish-start-output-formats.svg

这里的OutputFormats实际上就是按照Hugo页面的五种类型,分别提供的OutputFormat映射关系。 如果是home类型的页面,那这种类型的页面只提供按HTML格式渲染的结果,而不是其它的格式,如JSON。 也就是说在一个站点下,会为每个类型的页面都定义清楚合法的输出格式。 这将会有效保障页面在渲染的过程中,输出格式的有效性。

renderFormats

有了全面的站点页面输出规范后,为什么还要这个renderFormats呢?

7.3-publish-output.svg

从上图右下角可以看到,实际上renderFormats来自于outputFormats。 是将所有页面的outputFormats合并,去重后产生的。 可以理解为renderFormats代表着这个站点所有输出的类型。 在我们的实例中,因为所有页面只支持HTML一种类型,所以合并去重后,自然我们这个站点的renderFormats只有一个,就是HTML了。

从值的角度我们已经知道了两者之间的关联了,那为何Hugo要设置一个renderFormats呢? 从名字上看是以站点Site为单位,可以理解为站点在渲染时所有的渲染格式。

那为什么HugoSites也有一个一模一样的renderFormats呢? 从上图左上方可以看出,Site的renderFormats组成了HugoSites的renderFormats。 这个好理解,因为HugoSites是由多个不同语言的Site所组成的,那就是说HugoSites的renderFormats代表了全站点的渲染格式。

继续通过上图往右看,可以发现更多的线索。 原来pageOutputs和HugoSites的renderFormats是一一对应关系,也就是说全站点有多少种渲染格式,就有多少个pageOutputs。

这里不禁又冒出另一个大大的问号 - 每个站点都拥有自己的页面,也都有自己的输出格式,为什么要为单个站点页面提供全站点输出格式的pageOutputs呢?

同样,我们还是可以从源码中找到答案:

// hugo-playground/hugolib/page__new.go
// line 97

// Prepare output formats for all sites.
// We do this even if this page does not get rendered on
// its own. It may be referenced via .Site.GetPage and
// it will then need an output format.
ps.pageOutputs = make([]*pageOutput, len(ps.s.h.renderFormats))

从上面的代码中可以看到,这样设计的原因是页面可能会被其它的页面引用,甚至被不同语言的页面所调用。

对应的.Site.GetPage功能函数在多语言使用场景下的具体说明如下:

The previous examples have used the full content filename to lookup the post. Depending on how you have organized your content (whether you have the language code in the file name or not, e.g. my-post.en.md), you may want to do the lookup without extension. This will get you the current language’s version of the page:

{{ with .Site.GetPage "/blog/my-post" }}{{ .Title }}{{ end }}

既然存在跨站点调用的情况,那就得为调用方准备好调用方所需要的输出格式。 这样回过头来看,为每个页面pageState准备全站点渲染格式的pageOutputs是必需的。

pageOutputs

在发布流程中,除了内容,还有一些基础信息也很重用,如发布到哪,以什么名字写入等等。 pageOutput做为输出信息的总载体,这些信息自然了也包在其中,通过上图右上方可以看到负责提供这些信息的对象是PagePaths。 其中提供目标文件名的则是其中的targetPaths。

PagePaths

细心的小伙伴会发现Site的OutputFormats和PagePaths之间有一条虚线连接。 之所以是虚线,是因为PagePaths实际上是从pageMeta中获取的信息,但根源,实际上还是来源于Site的OutputFormats。

结合中下方有进一步实样例解释。 可以看到PagePaths会根据当前页面pageState的类型pageKind来获取当前类型的OutputFormats。 会根据每一种OutputFormat生成相应的targetPathsHolder。

根据每一个PageOutput的OutputFormat类型,选中相应的targetPathsHolder,设置在pagePerOutputProviders之中。 这样在渲染页面时:

func pageRenderer(
   ctx *siteRenderContext,
   s *Site,
   pages <-chan *pageState,
   results chan<- error,
   wg *sync.WaitGroup) {
   defer wg.Done()

   for p := range pages {
      templ, found, err := p.resolveTemplate()
      ...
      targetPath := p.targetPaths().TargetFilename

      if err := s.renderAndWritePage("page "+p.Title(), targetPath, p, templ); err != nil {
         fmt.Println(" render err")
         fmt.Printf("%#v", err)
         results <- err
      }

      ...
   }
}

就可以通过p.targetPaths().TargetFilename获取到目标文件名了。

独立页面的发布流程

Hugo将页面分成了两大类,一类就是上面介绍的常规页面,另一类就是接下来要看的独立Standalone页面,如404页面。

func (s *Site) render404() error {
   p, err := newPageStandalone(&pageMeta{
      s:    s,
      kind: kind404,
      urlPaths: pagemeta.URLPath{
         URL: "404.html",
      },
   },
      output.HTMLFormat,
   )
   if err != nil {
      return err
   }

   if !p.render {
      return nil
   }

   var d output.LayoutDescriptor
   d.Kind = kind404

   templ, found, err := s.Tmpl().LookupLayout(d, output.HTMLFormat)
   if err != nil {
      return err
   }
   if !found {
      return nil
   }

   targetPath := p.targetPaths().TargetFilename

   if targetPath == "" {
      return errors.New("failed to create targetPath for 404 page")
   }

   return s.renderAndWritePage("404 page", targetPath, p, templ)
}

代码流程很清晰,先通过newPageStandalone创建页面,紧接着查找模板,获取目标文件名,最后渲染并写入页面。 整体流程基本一致。

既然PageOutput是发布流程上的关系所在,那我们还是用PageOutput的视角来看看独立页面会有哪些不同,同时也可以检测一下我们之前的理解是否正确。

// hugo-playground/hugolib/page__new.go
// line 92
func newPageFromMeta(
n *contentNode,
parentBucket *pagesMapBucket,
meta map[string]any,
metaProvider *pageMeta) (*pageState, error) {
   ...
    if ps.m.standalone {
        ps.pageOutput = makeOut(ps.m.outputFormats()[0], shouldRenderPage)
    } else {
        outputFormatsForPage := ps.m.outputFormats()
    }
   ...
}

通过newPageFromMeta源码我们发现,对standalone的页面是有特殊处理的,而且用的就是render404中传入的output.HTMLFormat生成了pageOutput。

这样一来,我们可以确定的说,无论是普通页面,还是独立页面,基本上都是以pageOutput为核心进行展开的,符合同一套发布流程。 让我们再来回顾一下发布流程全景图:

7.4-publish-full.svg