先说问题

写博客的时候用 Obsidian 插了张图:

markdown
1![img](assets/example.jpg)

编辑器里看着好好的,一跑 hugo 部署上去,图裂了。打开浏览器开发者工具一看,图片请求的路径根本就不对。

检查了一圈,发现这个问题只出现在 非 bundle 的 md 文件 上,用目录包起来的 index.md 反倒没事。

为什么会有这个问题

Hugo 的两种页面

Hugo 里写文章有两种姿势:

  1. Page Bundle — 建个文件夹,里面放 index.md,图片丢同目录下
text
content/posts/my-post/
├── index.md
└── some-image.jpg
  1. Leaf Page — 直接一个 .md 文件
text
content/posts/my-post.md

我这边的目录结构是这样的:

text
content/posts/
├── _index.md
├── assets/
│   └── example.jpg
├── my-post.md              ← 出问题的
└── my-bundle-post/
    └── index.md            ← 没问题的

Blowfish 主题自带的图片渲染逻辑,会先用 $.Page.Resources.GetMatch 去捞图片。Bundle 页面自然能捞到,但 leaf page 压根没有 page resources,捞了个空。最后 fallback 到直接把原始相对路径当 src 输出——这就是问题的起点。

我的 permalink 配的是:

yaml
1permalinks:
2  posts: /posts/:year/:month/:day/:slug/

假设文件是 content/posts/my-post.md,生成的页面 URL 是 /posts/2026/06/05/my-post/。

Leaf page 里的 ![img](assets/example.jpg),浏览器会以页面 URL 为基准去解析:

text
页面地址: /posts/2026/06/05/my-post/
浏览器请求: /posts/2026/06/05/my-post/assets/example.jpg   ← 404

但图片实际在哪?在 content/posts/assets/example.jpg。Hugo 构建的时候会把 content 目录下的非内容文件也发布出去,所以这张图实际是:

text
public/posts/assets/example.jpg

问题就是:leaf page 在 URL 里多了好几层目录,但图片的相对路径没跟着变。

怎么修的

方案一:改成 Page Bundle(最推荐)

最省事的做法就是把 leaf page 改成 bundle:

text
# 改之前
content/posts/my-post.md
content/posts/assets/example.jpg

# 改之后
content/posts/my-post/index.md
content/posts/my-post/assets/example.jpg

这样 $.Page.Resources.GetMatch 能找到图片,而且 Blowfish 的 responsive 图片优化也会生效。缺点是每个文章都要建目录,已有的存量文章多了有点折腾。

方案二:写个 render hook 兜底(我用的方案)

参考了 Joker 的文章,但里面用的 ../ 补丁只适合单层目录,我的 permalink 太深了不管用。改成了拼绝对路径。

在 layouts/_default/_markup/render-image.html 里覆写了 Blowfish 的图片渲染逻辑:

go
 1{{- if not $isRemote -}}
 2  {{- $isPageBundle := eq $.Page.File.LogicalName "index.md" -}}
 3  {{- if $isPageBundle -}}
 4    {{- /* bundle: 原样走主题的逻辑 */ -}}
 5    {{- $resource = or ($.Page.Resources.GetMatch $urlStr) (resources.Get $urlStr) -}}
 6  {{- else if not (strings.HasPrefix $urlStr "/") -}}
 7    {{- /* leaf page + 相对路径: 用 .File.Dir 拼出正确路径 */ -}}
 8    {{- $correctPath := path.Join $.Page.File.Dir $urlStr -}}
 9    {{- $resource = or ($.Page.Resources.GetMatch $correctPath) (resources.Get $correctPath) -}}
10    {{- if not $resource -}}
11      {{- $urlStr = printf "/%s" $correctPath -}}
12    {{- end -}}
13  {{- else -}}
14    {{- $resource = or ($.Page.Resources.GetMatch $urlStr) (resources.Get $urlStr) -}}
15  {{- end -}}
16{{- end -}}

核心逻辑就几件事:

  1. 先判断是不是 bundle(看 LogicalName 是不是 "index.md")
  2. 不是 bundle,而且图片路径是相对路径——就用 .Page.File.Dir 拼出内容目录下的绝对路径
  3. 试着找一下图片资源,找不到就直接用绝对路径当 src

path.Join "posts/" "assets/example.jpg" → posts/assets/example.jpg → /posts/assets/example.jpg

这样不管 permalink 多深,路径都不会偏。

验证

修之前:

html
1<img src="/posts/2026/06/05/my-post/assets/example.jpg">
2<!-- 404 -->

修之后:

html
1<img src="/posts/assets/example.jpg">
2<!-- 200 -->

实际效果:

花火

这张图就是 leaf page 引的,能正常显示说明修好了。

几个需要注意的

  • 参考文章的方案不适用于深层 permalink:他直接在相对路径前面加 ../,只退一层。本项目的 /:year/:month/:day/:slug/ 叠了四层,必须用绝对路径
  • Leaf page 没有图片优化:Page bundle 的图片会被 Blowfish 做 responsive resize(生成多尺寸 + srcset),leaf page 的图不会。如果对图片体积有要求,还是推荐 bundle
  • 我这修复只动了 render hook:不影响 bundle 页面,不影响远程图片,不影响已经用了绝对路径的图片

总结

维度Page BundleLeaf Page + render hook
图片优化✅ responsive + srcset⚠️ 原图直出
改造成本每篇都要建目录配置一次,存量文章自动修
适合场景新文章已有大量 leaf page 或者共享 assets 目录

新文章我建议直接用 bundle。如果你跟我一样有一堆存量 leaf page 不想动,render hook 兜底一把梭也挺香的。

附完整 render-image.html

go
  1{{- define "RenderImageSimple" -}}
  2  {{- $imgObj := .imgObj -}}
  3  {{- $src := .src -}}
  4  {{- $alt := .alt -}}
  5  <img
  6    class="my-0 rounded-md"
  7    loading="lazy"
  8    decoding="async"
  9    fetchpriority="low"
 10    alt="{{ $alt }}"
 11    src="{{ $src }}"
 12    {{ with $imgObj -}}
 13      {{ with $imgObj.Width }}width="{{ . }}"{{ end }}
 14      {{ with $imgObj.Height }}height="{{ . }}"{{ end }}
 15    {{- end }}>
 16{{- end -}}
 17
 18{{- define "RenderImageResponsive" -}}
 19  {{/* Responsive Image
 20    The current setting sizes="(min-width: 768px) 50vw, 65vw" makes the iPhone 16 and 16 Pro
 21    select a smaller image, while the iPhone 16 Pro Max selects a larger image.
 22
 23    Steps:
 24    1. Check the media queries in the `sizes` property.
 25    2. Find the first matching value. For example, on a mobile device with a CSS pixel width
 26    of 390px and DPR = 3 (iPhone 13), given setting sizes="(min-width: 768px) 50vw, 100vw",
 27    it matches the `100vw` option.
 28    3. Calculate the optimal image size: 390 × 3 × 100% (100vw) = 1170.
 29    4. Find the corresponding match in the `srcset`.
 30
 31    To make the browser select a smaller image on mobile devices
 32    override the template and change the `sizes` property to "(min-width: 768px) 50vw, 30vw"
 33
 34    The sizes="auto" is valid only when loading="lazy".
 35  */}}
 36  {{- $imgObj := .imgObj -}}
 37  {{- $alt := .alt -}}
 38  {{- $originalWidth := $imgObj.Width -}}
 39
 40  {{- $img800 := $imgObj -}}
 41  {{- $img1280 := $imgObj -}}
 42  {{- if gt $originalWidth 800 -}}
 43    {{- $img800 = $imgObj.Resize "800x" -}}
 44  {{- end -}}
 45  {{- if gt $originalWidth 1280 -}}
 46    {{- $img1280 = $imgObj.Resize "1280x" -}}
 47  {{- end -}}
 48
 49  {{- $srcset := printf "%s 800w, %s 1280w" $img800.RelPermalink $img1280.RelPermalink -}}
 50
 51
 52  <img
 53    class="my-0 rounded-md"
 54    loading="lazy"
 55    decoding="async"
 56    fetchpriority="auto"
 57    alt="{{ $alt }}"
 58    {{ with $imgObj.Width }}width="{{ . }}"{{ end }}
 59    {{ with $imgObj.Height }}height="{{ . }}"{{ end }}
 60    src="{{ $img800.RelPermalink }}"
 61    srcset="{{ $srcset }}"
 62    sizes="(min-width: 768px) 50vw, 65vw"
 63    data-zoom-src="{{ $imgObj.RelPermalink }}">
 64{{- end -}}
 65
 66{{- define "RenderImageCaption" -}}
 67  {{- with .caption -}}
 68    <figcaption>{{ . | markdownify }}</figcaption>
 69  {{- end -}}
 70{{- end -}}
 71
 72{{- $disableImageOptimizationMD := .Page.Site.Params.disableImageOptimizationMD | default false -}}
 73{{- $urlStr := .Destination | safeURL -}}
 74{{- $url := urls.Parse $urlStr -}}
 75{{- $altText := .Text -}}
 76{{- $caption := .Title -}}
 77{{- $isRemote := findRE "^(https?|data)" $url.Scheme -}}
 78{{- $resource := "" -}}
 79
 80{{- if not $isRemote -}}
 81  {{- /* Check if this is a page bundle (index.md) or a regular leaf page */ -}}
 82  {{- $isPageBundle := eq $.Page.File.LogicalName "index.md" -}}
 83  {{- if $isPageBundle -}}
 84    {{- /* Page bundle: resolve images as page resources (original behavior) */ -}}
 85    {{- $resource = or ($.Page.Resources.GetMatch $urlStr) (resources.Get $urlStr) -}}
 86  {{- else if not (strings.HasPrefix $urlStr "/") -}}
 87    {{- /* Leaf page with relative path: resolve relative to content file directory */ -}}
 88    {{- $correctPath := path.Join $.Page.File.Dir $urlStr -}}
 89    {{- $resource = or ($.Page.Resources.GetMatch $correctPath) (resources.Get $correctPath) -}}
 90    {{- if not $resource -}}
 91      {{- /* Use absolute URL - Hugo publishes non-content files preserving content dir structure */ -}}
 92      {{- $urlStr = printf "/%s" $correctPath -}}
 93    {{- end -}}
 94  {{- else -}}
 95    {{- /* Absolute path: try page resources first, then global resources */ -}}
 96    {{- $resource = or ($.Page.Resources.GetMatch $urlStr) (resources.Get $urlStr) -}}
 97  {{- end -}}
 98{{- end -}}
 99
100
101<figure
102  {{- range $k, $v := .Attributes -}}
103    {{- if $v -}}
104      {{- printf " %s=%q" $k ($v | transform.HTMLEscape) | safeHTMLAttr -}}
105    {{- end -}}
106  {{- end -}}>
107  {{- if $isRemote -}}
108    {{- template "RenderImageSimple" (dict "imgObj" "" "src" $urlStr "alt" $altText) -}}
109  {{- else if $resource -}}
110    {{- $isSVG := eq $resource.MediaType.SubType "svg" -}}
111    {{- $shouldOptimize := and (not $disableImageOptimizationMD) (not $isSVG) -}}
112    {{- if $shouldOptimize -}}
113      {{- template "RenderImageResponsive" (dict "imgObj" $resource "alt" $altText) -}}
114    {{- else -}}
115      {{/* Not optimize image
116        If it is an SVG file, pass the permalink
117        Otherwise, pass the resource to allow width and height attributes
118      */}}
119      {{- if $isSVG -}}
120        {{- template "RenderImageSimple" (dict "imgObj" "" "src" $resource.RelPermalink "alt" $altText) -}}
121      {{- else -}}
122        {{- template "RenderImageSimple" (dict "imgObj" $resource "src" $resource.RelPermalink "alt" $altText) -}}
123      {{- end -}}
124    {{- end -}}
125  {{- else -}}
126    {{- template "RenderImageSimple" (dict "imgObj" "" "src" $urlStr "alt" $altText) -}}
127  {{- end -}}
128
129  {{- template "RenderImageCaption" (dict "caption" $caption) -}}
130</figure>