跳到主要内容

内容适配器

内容适配器(Content Adapters)是 Hugo 提供的一种强大机制,允许你在构建站点时动态创建页面。这在需要从外部数据源(如 API、JSON 文件、CSV 等)生成内容时特别有用。

什么是内容适配器?

传统的 Hugo 内容管理方式是在 content/ 目录下手动创建 Markdown 文件。但有些场景下,内容来源于外部数据源,比如:

  • 从 CMS 或 API 获取的文章数据
  • Google Sheets 中的产品目录
  • JSON 格式的书籍信息
  • CSV 格式的活动列表

内容适配器让你可以在构建时读取这些数据源,并自动创建对应的页面,无需手动维护大量的 Markdown 文件。

基本概念

文件位置和命名

内容适配器文件必须命名为 _content.gotmpl,放置在 content/ 目录下的任意子目录中:

content/
├── posts/
│ ├── _index.md
│ └── article-1.md # 传统方式:手动创建的内容
├── books/
│ ├── _content.gotmpl # 内容适配器
│ └── _index.md
└── products/
├── _content.gotmpl # 内容适配器
└── _index.md

每个目录每种语言只能有一个内容适配器。内容适配器创建的页面,其路径将相对于适配器所在的目录。

工作原理

当 Hugo 构建站点时,会执行内容适配器模板。适配器可以:

  1. 读取本地或远程数据源
  2. 使用 AddPage 方法动态创建页面
  3. 使用 AddResource 方法添加页面资源

核心方法

AddPage 方法

AddPage 用于创建新页面。你需要传入一个包含页面信息的字典(map):

{{/* 创建页面内容 */}}
{{ $content := dict
"mediaType" "text/markdown"
"value" "这是页面的正文内容,支持 *Markdown* 格式。"
}}

{{/* 创建页面信息 */}}
{{ $page := dict
"content" $content
"kind" "page"
"path" "my-article"
"title" "我的文章"
}}

{{/* 添加页面 */}}
{{ .AddPage $page }}

必填字段

  • path:页面的逻辑路径(相对于适配器所在目录),不要包含前导斜杠或文件扩展名

推荐字段

  • title:页面标题
  • content:页面内容

完整字段列表

字段说明必填
path页面逻辑路径
title页面标题否(推荐)
content.mediaType内容媒体类型,默认 text/markdown
content.value内容字符串
dates.date创建日期(time.Time 类型)
dates.lastmod最后修改日期
dates.publishDate发布日期
dates.expiryDate过期日期
params自定义参数(对应 Front Matter)
kind页面类型,默认 page

AddResource 方法

AddResource 用于向页面添加资源文件:

{{/* 获取现有图片资源 */}}
{{ with resources.Get "images/cover.jpg" }}
{{ $content := dict
"mediaType" .MediaType.Type
"value" .
}}
{{ $resource := dict
"content" $content
"path" "my-article/cover.jpg"
}}
{{ $.AddResource $resource }}
{{ end }}

添加资源后,可以在页面模板中通过 .Resources 获取:

{{ with .Resources.Get "cover.jpg" }}
<img src="{{ .RelPermalink }}" width="{{ .Width }}" height="{{ .Height }}" alt="">
{{ end }}

资源字段列表

字段说明必填
path资源逻辑路径
content.mediaType媒体类型
content.value内容字符串或资源对象
name资源名称
title资源标题
params自定义参数

Site 方法

返回当前站点对象,但注意这个 Site 对象在内容适配器执行时还未完全构建,所以不能调用依赖页面的方法(如 .Site.Pages):

{{ .Site.Title }}  <!-- 可以访问站点标题 -->
{{ .Site.Params }} <!-- 可以访问站点参数 -->

Store 方法

提供持久化的临时存储,用于在多次执行之间传递数据:

{{/* 存储数据 */}}
{{ .Store.Set "counter" 0 }}

{{/* 获取数据 */}}
{{ $count := .Store.Get "counter" }}

{{/* 修改数据 */}}
{{ .Store.Add "counter" 1 }}

这在多语言站点中使用 EnableAllLanguages 时特别有用。

EnableAllLanguages 方法

默认情况下,内容适配器只对定义它的语言生效。使用此方法可以让适配器对所有语言生效:

{{ .EnableAllLanguages }}

实战示例

示例一:从远程 JSON 创建页面

假设有一个 JSON API 返回书籍数据,我们想为每本书创建一个页面。

数据源 (https://api.example.com/books.json):

[
{
"title": "Go 语言编程",
"author": "张三",
"isbn": "978-7-111-12345-6",
"date": "2024-01-15",
"summary": "本书全面介绍 Go 语言的核心概念...",
"tags": ["Go", "编程"],
"cover": "https://example.com/covers/go.jpg"
},
{
"title": "Rust 实战",
"author": "李四",
"isbn": "978-7-111-65432-1",
"date": "2024-02-20",
"summary": "深入浅出讲解 Rust 语言特性...",
"tags": ["Rust", "系统编程"],
"cover": "https://example.com/covers/rust.jpg"
}
]

创建目录结构

content/
└── books/
├── _content.gotmpl # 内容适配器
└── _index.md # 列表页描述

内容适配器 (content/books/_content.gotmpl):

{{/* 获取远程数据 */}}
{{ $url := "https://api.example.com/books.json" }}
{{ $data := slice }}

{{ with try (resources.GetRemote $url) }}
{{ with .Err }}
{{ errorf "无法获取远程数据 %s: %s" $url . }}
{{ else with .Value }}
{{ $data = . | transform.Unmarshal }}
{{ else }}
{{ errorf "无法获取远程数据 %s" $url }}
{{ end }}
{{ end }}

{{/* 为每本书创建页面 */}}
{{ range $data }}
{{/* 创建页面内容 */}}
{{ $content := dict
"mediaType" "text/markdown"
"value" .summary
}}

{{/* 创建日期 */}}
{{ $dates := dict
"date" (time.AsTime .date)
}}

{{/* 创建自定义参数 */}}
{{ $params := dict
"author" .author
"isbn" .isbn
"tags" .tags
}}

{{/* 创建页面 */}}
{{ $page := dict
"content" $content
"dates" $dates
"kind" "page"
"params" $params
"path" .title
"title" .title
}}

{{ $.AddPage $page }}

{{/* 下载并添加封面图片 */}}
{{ $book := . }}
{{ with .cover }}
{{ with try (resources.GetRemote .) }}
{{ with .Err }}
{{ warnf "无法获取封面图片: %s" . }}
{{ else with .Value }}
{{ $imgContent := dict
"mediaType" .MediaType.Type
"value" .Content
}}
{{ $imgParams := dict "alt" $book.title }}
{{ $resource := dict
"content" $imgContent
"params" $imgParams
"path" (printf "%s/cover.%s" $book.title .MediaType.SubType)
}}
{{ $.AddResource $resource }}
{{ end }}
{{ end }}
{{ end }}
{{ end }}

页面模板 (layouts/books/single.html):

{{ define "main" }}
<article class="book-review">
<header>
<h1>{{ .Title }}</h1>
<div class="meta">
<span>作者:{{ .Params.author }}</span>
<span>ISBN:{{ .Params.isbn }}</span>
<time>{{ .Date.Format "2006-01-02" }}</time>
</div>
{{ with .Params.tags }}
<div class="tags">
{{ range . }}<span class="tag">{{ . }}</span>{{ end }}
</div>
{{ end }}
</header>

{{ with .Resources.GetMatch "cover.*" }}
<img
src="{{ .RelPermalink }}"
alt="{{ .Params.alt }}"
class="cover"
width="{{ .Width }}"
height="{{ .Height }}">
{{ end }}

<div class="content">
{{ .Content }}
</div>

{{ with .GetTerms "tags" }}
<footer class="book-tags">
<h3>标签</h3>
<ul>
{{ range . }}
<li><a href="{{ .RelPermalink }}">{{ .LinkTitle }}</a></li>
{{ end }}
</ul>
</footer>
{{ end }}
</article>
{{ end }}

示例二:从本地 JSON 创建页面

数据也可以来自本地文件。

本地数据文件 (data/events.json):

[
{
"name": "技术分享会",
"slug": "tech-sharing-2024",
"date": "2024-03-15T14:00:00",
"location": "北京市朝阳区",
"description": "本次分享会将讨论云原生技术趋势...",
"speakers": ["张三", "李四"]
},
{
"name": "开源黑客松",
"slug": "hackathon-2024",
"date": "2024-04-20T09:00:00",
"location": "上海市浦东新区",
"description": "为期两天的开源项目贡献活动...",
"speakers": ["王五"]
}
]

内容适配器 (content/events/_content.gotmpl):

{{ range .Site.Data.events }}
{{ $content := dict
"mediaType" "text/markdown"
"value" .description
}}

{{ $dates := dict
"date" (time.AsTime .date)
}}

{{ $params := dict
"location" .location
"speakers" .speakers
}}

{{ $page := dict
"content" $content
"dates" $dates
"params" $params
"path" .slug
"title" .name
}}

{{ $.AddPage $page }}
{{ end }}

示例三:从 CSV 创建页面

CSV 格式的数据也很常见。

CSV 数据文件 (assets/data/products.csv):

name,slug,price,description,category
智能手表,smart-watch,299,功能丰富的智能穿戴设备,电子产品
无线耳机,wireless-earbuds,199,高音质无线蓝牙耳机,电子产品
机械键盘,mechanical-keyboard,599,专业级机械键盘,电脑配件

内容适配器 (content/products/_content.gotmpl):

{{ $csv := resources.Get "data/products.csv" | transform.Unmarshal }}

{{/* 跳过表头行 */}}
{{ range after 1 $csv }}
{{ $name := index . 0 }}
{{ $slug := index . 1 }}
{{ $price := index . 2 }}
{{ $description := index . 3 }}
{{ $category := index . 4 }}

{{ $content := dict
"mediaType" "text/markdown"
"value" $description
}}

{{ $params := dict
"price" $price
"categories" (slice $category)
}}

{{ $page := dict
"content" $content
"params" $params
"path" $slug
"title" $name
}}

{{ $.AddPage $page }}
{{ end }}

多语言支持

方式一:使用 EnableAllLanguages

如果所有语言的内容来源相同,可以使用一个适配器服务所有语言:

{{ .EnableAllLanguages }}

{{/* 获取数据并创建页面 */}}
{{ range $data }}
{{ $page := dict
"path" .slug
"title" .title
}}
{{ $.AddPage $page }}
{{ end }}

方式二:为每种语言创建独立适配器

如果不同语言的内容来源不同,可以创建语言特定的适配器:

配置

[languages]
[languages.zh]
weight = 1
[languages.en]
weight = 2

目录结构

content/
├── books/
│ ├── _content.zh.gotmpl # 中文适配器
│ ├── _content.en.gotmpl # 英文适配器
│ ├── _index.zh.md
│ └── _index.en.md

或者使用内容目录方式:

[languages]
[languages.zh]
contentDir = 'content/zh'
weight = 1
[languages.en]
contentDir = 'content/en'
weight = 2
content/
├── zh/
│ └── books/
│ ├── _content.gotmpl
│ └── _index.md
└── en/
└── books/
├── _content.gotmpl
└── _index.md

路径处理

Hugo 会自动转换路径字符串为合法的 URL 路径:

{{ $page := dict
"path" "Go 语言编程"
"title" "Go 语言编程"
}}
{{ $.AddPage $page }}

这将生成路径 /books/go-语言编程/(假设适配器在 books/ 目录下)。

路径转换规则:

  • 转换为小写
  • 空格替换为连字符
  • 特殊字符可能被编码

页面冲突

当内容适配器创建的页面与现有 Markdown 文件路径相同时,会发生冲突:

content/
└── books/
├── _content.gotmpl
├── _index.md
└── go-programming.md # 手动创建的文件

如果适配器也创建了 go-programming 页面,由于并发处理,最终内容是不确定的。

检测冲突

hugo --printPathWarnings

避免冲突

  • 使用唯一的路径命名
  • 不要在适配器目录中手动创建 Markdown 文件
  • 使用不同的目录分离手动内容和动态内容

内容适配器 vs 数据模板

内容适配器和数据模板都可以使用外部数据,但用途不同:

特性内容适配器数据模板
主要用途创建页面在模板中使用数据
文件位置content/ 目录data/ 目录或远程
生成内容完整页面数据片段
URL 支持是(生成页面 URL)
资源支持有限

选择建议:

  • 需要为数据创建独立页面 → 使用内容适配器
  • 只需在现有页面展示数据 → 使用数据模板

错误处理

内容适配器中应该妥善处理错误:

{{/* 使用 try 函数安全获取远程数据 */}}
{{ with try (resources.GetRemote $url) }}
{{ with .Err }}
{{ errorf "获取数据失败 %s: %s" $url . }}
{{ else with .Value }}
{{/* 处理数据 */}}
{{ else }}
{{ errorf "数据为空: %s" $url }}
{{ end }}
{{ end }}

使用 errorf 会让构建失败,使用 warnf 只会输出警告。

最佳实践

1. 合理设置缓存

远程数据会被缓存,可以在配置中设置缓存时间:

[caches]
[caches.getjson]
dir = ':cacheDir/:project'
maxAge = '12h'

2. 使用 try 函数处理错误

远程请求可能失败,始终使用 try 函数:

{{ with try (resources.GetRemote $url) }}
{{ with .Err }}
{{ warnf "请求失败: %s" . }}
{{ else with .Value }}
{{/* 处理成功响应 */}}
{{ end }}
{{ end }}

3. 提供默认值

当数据源不可用时提供合理的默认值:

{{ $title := .title | default "未命名" }}
{{ $date := .date | default (now.Format "2006-01-02") }}

4. 路径命名规范

使用有意义的、URL 友好的路径:

{{ $path := .slug | default (.title | urlize) }}

5. 分离数据处理和页面创建

保持代码清晰:

{{/* 1. 获取数据 */}}
{{ $data := resources.GetRemote $url | transform.Unmarshal }}

{{/* 2. 处理数据 */}}
{{ $processed := slice }}
{{ range $data }}
{{ $processed = $processed | append (dict
"title" .title
"path" .slug
"content" .summary
) }}
{{ end }}

{{/* 3. 创建页面 */}}
{{ range $processed }}
{{ $.AddPage . }}
{{ end }}

小结

内容适配器是 Hugo 的高级功能,主要用途:

  • 从远程 API 动态创建页面
  • 从本地数据文件批量生成内容
  • 自动下载并关联资源文件
  • 支持 CMS 无头架构

使用内容适配器时需要注意:

  • 文件必须命名为 _content.gotmpl
  • 使用 AddPageAddResource 方法
  • 妥善处理错误和边缘情况
  • 避免路径冲突

这个功能特别适合需要从外部系统同步内容的场景,如电商产品页、活动页面、文档站点等。