内容适配器
内容适配器(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 构建站点时,会执行内容适配器模板。适配器可以:
- 读取本地或远程数据源
- 使用
AddPage方法动态创建页面 - 使用
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 - 使用
AddPage和AddResource方法 - 妥善处理错误和边缘情况
- 避免路径冲突
这个功能特别适合需要从外部系统同步内容的场景,如电商产品页、活动页面、文档站点等。