雏形

问题背景

Hugo 在构建站点后,所有资源都输出到 public/ 目录,浏览器访问时,/ 代表站点的根目录(即 public/ 文件夹)。因此,如果你在 Markdown 中引用图片时写成:

![我的图片](../images/photo.jpg)

实际生成的 HTML 会是 <img src="../images/photo.jpg">,这显然不是我们期望的位置。正确的方式应该是根相对路径 /images/photo.jpg

PaperMod 主题默认的 render-image.html 已经对页面资源(Page Resources)和全局资源(assets/)进行了智能处理,但如果图片既不是页面资源也不在 assets/ 中,而只是放在 static/ 目录下(经典用法),那么生成的 src 可能是相对路径,导致 404。

因此,我们需要在主题默认逻辑的基础上,增加一个兜底逻辑:当资源找不到时,自动将路径转换为以 / 开头的根相对路径。

覆盖主题模板

Hugo 的模板渲染遵循优先级规则:站点根目录下的 layouts/ 会覆盖主题中的同名文件。所以,我们只需在 layouts/_markup/render-image.html 中放置自定义代码,即可覆盖 PaperMod 的默认实现。

具体做法是:

  1. 在站点根目录下创建 layouts/_markup/ 文件夹(如果不存在)。
  2. 从 PaperMod 主题目录中复制 render-image.html 到该位置。
  3. 基于主题代码进行修改。

PaperMod主题

我用了PaperMod主题,修改后的文件,直接复制可用
博客根目录\layouts\_markup\render-image.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
{{- $u := urls.Parse .Destination -}}
{{- $src := $u.String -}}
{{- if not $u.IsAbs -}}
{{- $path := strings.TrimPrefix "./" $u.Path }}
{{- with or (.PageInner.Resources.Get $path) (resources.Get $path) -}}
{{- $src = .RelPermalink -}}
{{- with $u.RawQuery -}}
{{- $src = printf "%s?%s" $src . -}}
{{- end -}}
{{- with $u.Fragment -}}
{{- $src = printf "%s#%s" $src . -}}
{{- end -}}
{{- /* ========== 资源找不到时自动补路径前缀 / ========== */ -}}
{{- else -}}
{{- /* 资源文件不存在,手动处理静态路径 */ -}}
{{- /* 判断路径是否以 / 开头,无则在最前面拼接根斜杠 */ -}}
{{- if not (hasPrefix $path "/") -}}
{{- $path = printf "/%s" $path -}}
{{- end -}}
{{- /* 将处理好带/的路径赋值给图片src */ -}}
{{- $src = $path -}}
{{- /* 还原原链接携带的查询参数 */ -}}
{{- with $u.RawQuery -}}
{{- $src = printf "%s?%s" $src . -}}
{{- end -}}
{{- /* 还原原链接携带的锚点哈希 */ -}}
{{- with $u.Fragment -}}
{{- $src = printf "%s#%s" $src . -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- $attributes := merge .Attributes (dict "alt" .Text "src" $src "title" (.Title | transform.HTMLEscape) "loading" "lazy") -}}
<img
{{- range $k, $v := $attributes -}}
{{- if $v -}}
{{- printf " %s=%q" $k $v | safeHTMLAttr -}}
{{- end -}}
{{- end -}}>
{{- /**/ -}}

Hugo 功能扩展

Hugo 本身没有传统意义上的”插件”机制(如 WordPress 的 .php 插件或 VS Code 的 Extension API)。但 Hugo 提供了多种强大的扩展方式,核心是通过 Hugo Modules(模块)Shortcodes(短代码)Partials(局部模板)自定义函数 来实现功能扩展。

扩展方式

Hugo Modules(模块)—— 最推荐的扩展方式

Hugo Modules 是 Hugo 0.56.0+ 引入的依赖管理系统,基于 Go Modules 构建。它允许你将任何可复用的组件(主题、短代码、数据、静态资源等)打包为独立模块,供多个项目复用。

模块可以包含的内容

Hugo 的 7 种标准目录都可以作为模块的一部分:

目录 用途
layouts/ 模板、短代码、Partials
assets/ SCSS、JS、图片等需要 Hugo Pipes 处理的资源
static/ 直接复制的静态文件(字体、图片等)
data/ 数据文件(JSON/YAML/TOML)
content/ 可复用的内容片段
i18n/ 翻译文件
archetypes/ 内容模板

创建模块的基本步骤

1. 初始化模块

hugo mod init github.com/<用户名>/<模块名>

2. 配置模块挂载点(hugo.toml

1
2
3
4
5
6
7
8
9
10
11
12
13
[module]
[module.hugoVersion]
extended = true
min = "0.120.0"
[[module.mounts]]
source = "dist/katex.js"
target = "assets/js/modules/katex/katex.js"
[[module.mounts]]
source = "dist/fonts"
target = "static/fonts"
[[module.mounts]]
source = "layouts"
target = "layouts"

3. 在项目中引用模块

1
2
3
[module]
[[module.imports]]
path = "github.com/<用户名>/<模块名>"

4. 常用命令

1
2
3
4
hugo mod get -u ./...      # 更新所有模块
hugo mod get -u <模块路径> # 更新指定模块
hugo mod vendor # 将模块缓存到本地 _vendor 目录
hugo mod tidy # 清理未使用的依赖

本地开发技巧

使用 replacements 将远程模块替换为本地路径,方便实时调试:

1
2
[module]
replacements = "github.com/<用户名>/<模块名> -> ../<本地路径>"

或使用 Go Workspace 文件(*.work)管理本地开发环境。

Shortcodes(短代码)—— 内容层面的扩展

Shortcodes 让你在 Markdown 内容中插入复杂的 HTML/逻辑,而不破坏 Markdown 的纯净性。

创建自定义 Shortcode

layouts/shortcodes/ 下创建模板文件:

示例:GitHub 风格提示框

1
2
3
4
5
6
7
8
9
10
11
12
<!-- layouts/shortcodes/gh-blockquote.html -->
{{ $type := .Get "type" | lower }}
{{ $supportedTypes := slice "important" "note" "warning" }}

{{ if in $supportedTypes $type }}
<blockquote class="gh-blockquote gh-{{ $type }}">
<span class="font-bold">{{ $type | title }}</span><br>
{{ .Inner | markdownify }}
</blockquote>
{{ else }}
{{ errorf "Invalid parameter: '%s'. Supported: %q." $type $supportedTypes }}
{{ end }}

在 Markdown 中使用:

1
2
3
{{< gh-blockquote type="warning" >}}
这是一个警告信息。
{{< /gh-blockquote >}}

Shortcode 传参方式

方式 示例 模板中获取
命名参数 {{< mycode color="red" >}} {{ .Get "color" }}
位置参数 {{< mycode "red" "large" >}} {{ .Get 0 }}{{ .Get 1 }}
内部内容 {{< mycode >}}内容{{< /mycode >}} {{ .Inner }}

内置 Shortcodes

Hugo 内置了多个实用短代码,开箱即用:

  • {{< figure src="..." title="..." >}} — 带标题的图片
  • {{< youtube VIDEO_ID >}} — 嵌入 YouTube
  • {{< tweet TWEET_ID >}} — 嵌入推文
  • {{< gist USERNAME GIST_ID >}} — 嵌入 Gist

Partials(局部模板)—— 模板层面的复用

Partials 是 layouts/partials/ 下的可复用模板片段,适合封装通用逻辑。

示例:可复用的 SEO 元数据 Partial

1
2
3
4
<!-- layouts/partials/seo.html -->
<meta name="description" content="{{ .Summary | default .Site.Params.description }}">
<meta property="og:title" content="{{ .Title }}">
<meta property="og:url" content="{{ .Permalink }}">

在模板中调用:

1
2
3
<head>
{{ partial "seo.html" . }}
</head>

带缓存的 Partial(性能优化):

{{ partialCached "footer.html" . .Section }}

自定义函数与数据文件

1. 使用 Hugo 内置函数

Hugo 提供了丰富的内置模板函数,如:

  • hugo.Version — 获取 Hugo 版本
  • now.Format "2006-01-02" — 格式化日期
  • resources.Get / resources.Match — 资源处理
  • markdownify — Markdown 渲染

2. 数据文件扩展

data/ 目录放置 JSON/YAML/TOML 文件,通过 site.Data.文件名 在模板中访问,适合:

  • 导航菜单配置
  • 多语言字典
  • 外部 API 数据缓存

参考资源

Go Template

Hugo 的模板引擎基于 Go 的 text/template,但做了大量扩展。以下是核心语法和常用模式的速查指南:

1. 基础语法

所有模板逻辑都包裹在 {{ }} 中。

1
2
3
4
5
{{/* 注释 */}}

{{ .Title }} // 输出当前上下文的 Title 字段
{{ .Params.author }} // 访问嵌套字段
{{ .Content | safeHTML }} // 管道:先取内容,再转义为安全 HTML

上下文(.:在模板中,. 代表当前数据上下文。在单页模板中它是 Page 对象;在列表页中它是当前遍历的元素。

2. 变量

1
2
3
4
5
{{ $title := .Title }}           // 定义变量
{{ $count := len .Pages }} // 赋值函数结果
{{ $x := add 1 2 }} // 数学运算

{{ $title }} // 使用变量

注意:Go Template 中变量作用域受 {{ if }}{{ range }}{{ with }} 影响,内部赋值不会泄漏到外部。

3. 条件判断

1
2
3
4
5
6
7
{{ if .Params.draft }}
<span>草稿</span>
{{ else if .Date.After (now.AddDate 0 0 -7) }}
<span>本周新文</span>
{{ else }}
<span>历史文章</span>
{{ end }}

Truthy 判断:空字符串、0、空切片、nil 都为 false。

4. 循环(range)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{{ range .Pages }}
<h2><a href="{{ .RelPermalink }}">{{ .Title }}</a></h2>
{{ end }}

// 带索引
{{ range $index, $page := .Pages }}
{{ add $index 1 }}. {{ $page.Title }}
{{ end }}

// else 分支(当集合为空时)
{{ range .Params.tags }}
<span>{{ . }}</span>
{{ else }}
<span>无标签</span>
{{ end }}

range 内部,. 变成了当前元素。如需访问外层上下文:

{{ $.Site.Title }}   // $ 绑定最外层上下文

5. with(上下文切换)

with 在值存在时进入新作用域,并将 . 设为该值:

1
2
3
4
5
{{ with .Params.featured_image }}
<img src="{{ . }}" alt="Featured"> // 这里的 . 就是图片 URL
{{ else }}
<p>无特色图片</p>
{{ end }}

6. 管道(Pipes)

Go Template 的管道类似 Unix 管道,从左到右传递:

1
2
3
{{ .Summary | strings.ToUpper | htmlEscape }}
{{ .Date | time.Format ":date_long" }}
{{ .Title | printf "文章标题:%s" }}

7. 常用 Hugo 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// ===== 内容 =====
{{ .Summary }} // 页面摘要
{{ .WordCount }} // 字数
{{ .ReadingTime }} // 阅读时长(分钟)

// ===== URL =====
{{ .RelPermalink }} // 相对链接
{{ .Permalink }} // 绝对链接

// ===== 字符串 =====
{{ .Title | upper }} // 转大写
{{ .Title | truncate 50 }} // 截断 50 字符

// ===== 数学 =====
{{ add 1 2 }} // 1 + 2
{{ div 10 3 }} // 10 / 3
{{ mod 7 2 }} // 7 % 2

// ===== 逻辑 =====
{{ and $a $b }} // 与
{{ or $a $b }} // 或
{{ not $a }} // 非

// ===== 比较 =====
{{ eq .Type "post" }} // 等于
{{ gt .Weight 10 }} // 大于

// ===== 时间 =====
{{ now.Format "2006-01-02" }} // Go 固定参考时间格式

// ===== 排序/过滤 =====
{{ range where .Pages "Type" "post" }}

// ===== 安全 =====
{{ .Content | safeHTML }} // 防止 HTML 转义
{{ safeCSS }} // CSS 安全输出

8. 模板组织

1
2
3
4
5
6
7
8
9
10
11
// 引入 Partial(组件)
{{ partial "head.html" . }} // 注意最后的 . 传递上下文
{{ partial "header.html" (dict "active" "blog") }} // 传递自定义字典

// 定义 Block(布局继承)
{{ define "main" }}
<article>{{ .Content }}</article>
{{ end }}

// 调用 Shortcode(内容中使用的短代码)
{{< figure src="a.jpg" title="图注" >}}

9. 作用域陷阱(常见错误)

1
2
3
4
5
6
7
8
9
10
11
{{ $author := .Params.author }}    // 外层定义

{{ if .Params.coauthor }}
{{ $author = .Params.coauthor }} // 错误::= 会创建新的局部变量
{{ $author = .Params.coauthor }} // 正确:= 是赋值(Go 1.11+)
{{ end }}

{{ range .Pages }}
{{ .Title }} // . 变成了 Page
{{ $.Site.Title }} // $ 始终指向模板顶层上下文
{{ end }}

10. 调试技巧

1
2
{{ printf "%#v" . }}           // 打印完整对象结构
{{ warnf "Debug: %s" .Title }} // 输出到终端(构建日志)

背景

我发现,在Markdown文章中使用 ![]() 时,为了让Hugo博客正常显示本地图片,本地图片路径必须以根相对路径(以/开头)开头。

为什么必须以 / 开头?

Hugo 构建后,所有内容最终都输出到 public/ 目录。浏览器访问你的站点时,/ 就是站点的根目录,也就是 public/ 文件夹。

如果路径不是以/开头,就要在路径前面添加/

public 目录

1
2
3
4
5
6
7
8
9
10
11
12
你的项目/
├── content/
│ └── posts/
│ └── hello.md ← 在这里写 ![](/images/cat.png)
| └── images/
│ └── cat.png ← 源文件在这里
└── public/ ← 构建输出
├── posts/
│ └── hello/
│ └── index.html
└── images/
└── cat.png ← 构建后复制到这里

开发

选型

决策链

1
2
3
4
5
6
7
8
9
10
11
需要统一图片路径?

是否要保持标准 Markdown 语法? → 是,排除 Shortcode

是否要避免外部构建依赖? → 是,排除前置脚本

是否要精准操作而非正则替换? → 是,排除 HTML 后处理

图片是否与文章强绑定且需资源处理? → 否,排除 Page Bundles

= Image Render Hook 是最佳平衡点

在 Hugo 中,最优雅的做法是使用 Image Render Hook(渲染钩子)。Hugo 会在渲染 Markdown 的 ![]() 图片语法时,调用你自定义的模板,你可以在模板中修改图片路径后再输出 HTML。

Modules

在你的项目或主题中创建文件render-image.html

1
2
3
4
layouts/
└── _default/
└── _markup/
└── render-image.html

写入以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{{- $url := .Destination -}}
{{- $alt := .PlainText -}}
{{- $title := .Title -}}

{{- /* 外部链接不处理 */ -}}
{{- if or (strings.HasPrefix $url "http://") (strings.HasPrefix $url "https://") (strings.HasPrefix $url "//") -}}
<img src="{{ $url | safeURL }}"{{ with $alt }} alt="{{ htmlEscape . }}"{{ end }}{{ with $title }} title="{{ htmlEscape . }}"{{ end }}>

{{- /* 已以 / 开头的本地路径,保持不变 */ -}}
{{- else if strings.HasPrefix $url "/" -}}
<img src="{{ $url | safeURL }}"{{ with $alt }} alt="{{ htmlEscape . }}"{{ end }}{{ with $title }} title="{{ htmlEscape . }}"{{ end }}>

{{- /* 其他本地路径:直接补上 / */ -}}
{{- else -}}
{{- $fixedUrl := printf "/%s" $url -}}
<img src="{{ $fixedUrl | safeURL }}"{{ with $alt }} alt="{{ htmlEscape . }}"{{ end }}{{ with $title }} title="{{ htmlEscape . }}"{{ end }}>
{{- end -}}

路径转换效果

Markdown 原文 最终输出 HTML
![图](./image.png) <img src="/./image.png" alt="图">
![图](../assets/image.png) <img src="/../assets/image.png" alt="图">
![图](/image.png) <img src="/image.png" alt="图">(不变)
![图](https://example.com/a.png) <img src="https://example.com/a.png" alt="图">(不变)

代码解析

Hugo 的模板语法(Go Template)。它长得有点像 HTML 里插了一些特殊标签,思路是:拿到数据 → 判断条件 → 拼接字符串 → 输出 HTML

基础:

  • {{- … -}}:这是 Hugo 的模板代码块,- 表示去掉前后空白(让输出更干净)。
  • {{- /* ... */ -}}:注释,Hugo 不会输出,给人看的

开头:存变量

1
2
3
{{- $url := .Destination -}}
{{- $alt := .PlainText -}}
{{- $title := .Title -}}
  • .Destination:图片路径,如 image.png/images/a.jpg
  • .PlainText![这里面的文字](...) 里的 alt 文本
  • .Title![](... "这里面的title") 里的 title

{{- $url := .Destination -}}

  • $url :=:定义一个变量,名字叫 $url
  • .Destination从 Hugo 拿到图片的原始路径
    比如文章里写了 ![](image.png),这里 .Destination 的值就是 image.png

1:外部链接

1
2
{{- if or (strings.HasPrefix $url "http://") (strings.HasPrefix $url "https://") (strings.HasPrefix $url "//") -}}
<img src="{{ $url | safeURL }}"{{ with $alt }} alt="{{ htmlEscape . }}"{{ end }}{{ with $title }} title="{{ htmlEscape . }}"{{ end }}>
代码 意思
{{- /* ... */ -}} 注释,Hugo 不会输出,给人看的
strings.HasPrefix $url "http://" 检查是否以 http:// 开头
or(...) 三个条件满足一个就行
safeURL 告诉 Hugo 这个链接是安全的,不要转义 :// 等特殊符号
{{ with $alt }} 如果有 alt 文字,才输出 alt="..."
htmlEscape . 把 alt 里的特殊字符(如 " < > &)转义成安全字符

如果是网上图片(http、https、// 开头),直接原样输出,什么都不改。

2:已以 / 开头的路径

1
2
{{- else if strings.HasPrefix $url "/" -}}
<img src="{{ $url | safeURL }}"{{ with $alt }} alt="{{ htmlEscape . }}"{{ end }}{{ with $title }} title="{{ htmlEscape . }}"{{ end }}>
代码 意思
strings.TrimPrefix "/" $url 去掉开头的 /。如 /images/a.jpgimages/a.jpg
resources.Get ... 去 Hugo 的 assets 文件夹 里找这张图片
{{- with $img -}} 如果找到了,执行这块
.RelPermalink 图片的最终 URL(Hugo 处理后的)
.Width / .Height 图片的宽高(Hugo 自动读取)
{{- else -}} 如果没找到,走下面这块
{{ $url | safeURL }} 原样输出,就当它是个普通路径

如果地址已经是 /xxx 开头,不用改,直接输出。

3 相对路径(不以 / 开头的)

1
2
3
4
{{- else -}}
{{- $fixedUrl := printf "/%s" $url -}}
<img src="{{ $fixedUrl | safeURL }}"{{ with $alt }} alt="{{ htmlEscape . }}"{{ end }}{{ with $title }} title="{{ htmlEscape . }}"{{ end }}>
{{- end -}}
代码 意思
{{- else -}} 上面两个条件都不满足,走这里
printf "/%s" $url 字符串拼接:/ + 原路径
$fixedUrl := 把拼接结果存到新变量 $fixedUrl

只要不是外部链接,也不是 / 开头,就在前面加一个 /

直观感受

1
2
3
4
5
6
7
8
9
用户写: ![alt](image.png)

Hugo 读到 .Destination = "image.png"

检查: 不以 / 开头?✓ 是

加 / → "/image.png"

输出: <img src="/image.png" alt="alt">
1
2
3
4
5
6
7
用户写: ![](/image.png)

检查: 以 / 开头?✓ 是

跳过

输出: <img src="/image.png">
1
2
3
4
5
6
7
用户写: ![](https://a.com/b.jpg)

检查: 以 https:// 开头?✓ 是

跳过

输出: <img src="https://a.com/b.jpg">