birchholt 직접 해보고 남기는 기록

Hugo 이미지 render hook에서 figure를 냈더니 p 안에 들어가 있었습니다

글마다 이미지를 넣으면서 이미지 render hook(마크다운 이미지 한 줄을 HTML로 바꿀 때 Hugo가 부르는 템플릿, layouts/_markup/render-image.html)이 <img>를 <figure>로 감싸서 내도록 했습니다. <figure>는 본문과 따로 떨어진 그림 한 덩어리를 뜻하는 태그입니다. 그런데 빌드 결과를 열어 보니 그 <figure>가 전부 <p> 안에 들어가 있었습니다.

책장 칸 하나에 억지로 밀어 넣은 액자 때문에 칸막이 판이 앞뒤로 빠져나온 거실 선반

화면은 멀쩡해 보여서 넘어가기 쉬운 문제입니다. 그래서 빈 연습용 사이트와 제 블로그 사본에서 경우를 나눠 직접 빌드하고 HTML 검사기와 브라우저 방식 파서로 결과를 확인했습니다. Hugo v0.165.0 extended, 2026-10-06에 잰 값입니다.

훅만 바꾸면 figure가 문단 안에 들어갑니다

긴 화분 받침 안에 큰 화분을 올렸더니 받침 테두리가 양쪽으로 갈라진 베란다

먼저 가장 단순한 훅을 만들었습니다. 무조건 <figure>로 감싸는 훅입니다.

<figure><img src="{{ .Destination | safeURL }}" alt="{{ .PlainText }}"></figure>

설정은 건드리지 않았습니다. 테스트 글에는 혼자 한 줄을 차지한 이미지, 문장 속 이미지, 링크로 감싼 이미지 등 일곱 가지를 넣었습니다. 빌드 결과는 이렇습니다.

<p><figure><img src="/img/a.png" alt="A 단독"></figure>
</p>
<p>문장 속 <figure><img src="/img/a.png" alt="B 인라인"></figure>
 이미지입니다.</p>

훅은 문단 안쪽만 바꿉니다. 이미지를 감싸는 <p>는 훅 바깥에서 마크다운 변환기가 붙이기 때문에, 훅에서 무엇을 내든 그 <p> 안에 들어갑니다.

HTML 표준에서 <p> 안에는 phrasing content(문장 속에 들어가는 글자·링크·이미지 같은 요소)만 올 수 있습니다. <figure>는 그 목록에 없습니다(WHATWG · the p element). W3C의 HTML 검사기(Nu Html Checker, vnu.jar)를 돌리니 오류가 9개 나왔습니다.

error: No “p” element in scope but a “p” end tag seen.
error: Unclosed element “a”.
error: End tag “figure” seen, but there were open elements.

첫 줄이 핵심입니다. 표준에는 <p> 뒤에 <figure>가 오면 <p>의 닫는 태그를 생략할 수 있다는 규칙이 있습니다. 그래서 <figure>가 시작되는 순간 <p>는 이미 닫힌 것으로 처리됩니다. 뒤에 나오는 </p>는 짝이 없는 닫는 태그가 됩니다.

브라우저가 실제로 무엇을 만드는지 보려고 표준 파싱 규칙을 그대로 구현한 parse5로 같은 파일을 읽었습니다.

<p></p><figure><img … alt="A 단독"></figure>
<p></p>
<p>문장 속 </p><figure><img … alt="B 인라인"></figure>
 이미지입니다.<p></p>

빈 <p></p>가 생기고, 문장 속 이미지는 문장을 둘로 쪼갭니다. " 이미지입니다.“는 문단 밖으로 밀려납니다. 아래 #11123에 달린 답대로라면, 개발자 도구에는 이렇게 고쳐진 결과가 보이고 원본 보기에는 Hugo가 낸 <p><figure>가 그대로 보입니다. 저는 실제 브라우저 대신 parse5 결과로만 확인했습니다.

Hugo 저장소에는 이 증상을 버그로 올린 이슈가 여럿 있습니다. 2020년 #8027은 <p><figure>를, 2023년 #11123은 빈 <p>를 보고했습니다. 후자에 달린 답은 “브라우저가 HTML을 고치는 건 실제로 렌더링한 HTML이 무효이기 때문"이고 “버그가 아니라 설정 문제"라는 것이었습니다(2026-10-06에 열어 확인).

설정 한 줄을 끄고 .IsBlock으로 나눕니다

현관 앞 우산꽂이와 거실 화병을 나눠 두는 손, 긴 우산과 꽃 한 송이가 각자 자리에 놓인 모습

Hugo v0.108.0(2022-12-06)부터 이걸 끌 수 있습니다. 릴리스 노트의 첫 문장이 “단독 마크다운 이미지를 감싸는 문단 없이 렌더링할 수 있다"입니다(v0.108.0).

[markup.goldmark.parser]
  wrapStandAloneImageWithinParagraph = false

기본값은 true입니다. 설정 문서에는 render hook으로 단독 이미지를 figure로 낼 때 false로 두라고 적혀 있습니다.

이 설정을 끄면 훅 안의 .IsBlock(이 이미지가 문단에 감싸이지 않은 단독 이미지인지)이 true를 돌려줄 수 있게 됩니다. 어떤 이미지가 단독으로 판정되는지 보려고 훅이 .IsBlock 값만 찍게 하고 두 설정으로 빌드했습니다.

마크다운기본값(true)false
한 줄에 이미지 하나falsetrue
제목("캡션")이 붙은 이미지falsetrue
문장 속 이미지falsefalse
한 줄에 이미지 두 장falsefalse
이미지 바로 아랫줄에 글falsefalse
목록 항목 안 이미지falsetrue
링크로 감싼 이미지falsetrue

기본값에서는 일곱 가지 모두 .IsBlock이 false였습니다. false로 바꾼 뒤 true가 나온 것은 이미지 한 장(또는 그 이미지를 감싼 링크)이 문단이나 목록 항목을 혼자 차지한 경우였습니다. 이미지 바로 아랫줄에 빈 줄 없이 글을 이어 쓴 경우는 같은 문단이라 false로 남았습니다.

그래서 훅은 이렇게 나눕니다. 문장 속 이미지는 여전히 <p> 안에 있으니 그때는 <img>만 내야 합니다.

{{- $img := printf `<img src="%s" alt="%s">` .Destination (.PlainText | htmlEscape) -}}
{{- if .IsBlock -}}
<figure>{{ $img | safeHTML }}</figure>
{{- else -}}
{{ $img | safeHTML }}
{{- end -}}

제 블로그 사본에서 공개 글 18편을 네 가지 조합으로 빌드해 셌습니다.

설정훅<figure><p><figure>
true(기본)항상 figure132132
false항상 figure1320
true(기본).IsBlock 분기00
false.IsBlock 분기1320

마지막 줄이 지금 라이브에 나가 있는 조합입니다. 라이브 글 세 편을 curl로 받아 보니 <figure> 7·7·8개, <p><figure> 0개였습니다. 한 편을 골라 검사기를 돌리면 첫 줄 조합은 오류 8개(그 글의 figure 8개와 1:1), 마지막 줄은 0개였습니다.

제 사이트의 훅은 이 분기에 width·height와 loading을 더 붙인 것입니다. 이미지 크기와 형식을 다루는 쪽은 Hugo로 블로그 이미지를 WebP로 바꾼 기록에 따로 적었습니다.

설정이 빠지면 오류 없이 figure만 사라집니다

정원 지도에 그려진 화단 하나가 실제 마당에서는 잔디로만 덮여 있는 오후

위 표의 세 번째 줄을 다시 보겠습니다. 분기 훅은 그대로인데 설정만 기본값이면 <figure>가 0개입니다. 132장이 전부 <p><img>로 나갑니다. 검사기 오류도 0개고 빌드 경고도 0줄입니다. 대신 제 CSS가 .post-figure에 걸어둔 여백·테두리·폭 100%와 다크 모드 밝기 조절이 적용되지 않습니다.

설정 두 벌의 결과물을 diff -rq로 비교하니 19개 파일이 달랐습니다. 글 18편과 RSS 피드(index.xml)입니다. 피드의 <description>에도 본문 HTML이 들어가 있어서 피드까지 바뀝니다.

이 설정은 틀린 자리에 적어도 아무 말이 없습니다. 세 군데에 적어 보고 빌드했습니다.

[markup.goldmark.parser]   wrapStandAloneImageWithinParagraph = false  → .IsBlock true
[markup.goldmark]          wrapStandAloneImageWithinParagraph = false  → 무시, 경고 0줄
[markup.goldmark.renderer] wrapStandAloneImageWithinParagraph = false  → 무시, 경고 0줄

키를 전부 소문자로 적은 것은 parser 아래에만 있으면 제대로 먹었습니다.

설정이 먹었는지 hugo config로 확인하려다 한 번 더 헷갈렸습니다.

$ hugo config | grep -i wrapstand                               # 기본
      wrapstandaloneimagewithinparagraph = true
$ hugo config --config hugo.toml,wrapfalse.toml | grep -i wrapstand
$                                                               # false: 출력 없음

false로 두면 그 줄이 출력에서 아예 빠집니다. JSON 형식으로 뽑아도 parser 아래에 키가 없습니다. #14009(Hugo v0.150.1)에도 hugo config --format json 출력에 이 키가 나타나지 않는다는 관찰이 적혀 있습니다. 그 이슈는 이미지도 여전히 감싸인다고 했지만 제 빌드에서는 parser 아래의 false가 제대로 먹었습니다. 줄이 없다고 설정이 안 먹은 게 아니라, 기본값과 다른 false가 생략된 것입니다. 그래서 hugo config 대신 빌드 결과물로 확인했습니다.

링크로 감싼 이미지는 여전히 문단 안에 남습니다

액자를 걸어둔 끈이 벽선반 칸 안으로 늘어져 액자가 선반에 반쯤 걸린 복도 벽

표에서 걸리는 줄이 하나 있습니다. 링크로 감싼 이미지는 .IsBlock이 true인데 출력은 <p> 안에 있습니다.

[![F 링크 감싼 이미지](/img/a.png)](https://example.org/)
<p><a href="https://example.org/"><figure><img src="/img/a.png" alt="F 링크 감싼 이미지"></figure></a></p>

설정을 끄고 분기 훅을 써도 이 경우만은 검사기 오류가 5개 남았습니다. 브라우저 방식으로 읽으면 이렇게 됩니다.

<p><a href="https://example.org/"></a></p><figure><a href="https://example.org/"><img …></a></figure><p></p>

빈 링크 하나와 빈 문단 하나가 생깁니다. #11123에서 보고된 모양과 같습니다. 이미지 render hook 문서에 나오는 훅 변수 목록에는 바깥에 링크가 있는지 알려주는 값이 없습니다. 그래서 훅 안에서 이 경우를 가려낼 방법을 찾지 못했습니다.

문단에 든 것이 이미지가 아니라 링크라 단독 이미지 문단으로 처리되지 않는 것으로 보입니다. 다만 Hugo 소스로 확인하지는 않았고, 결과만 직접 쟀습니다.

제가 택한 방법은 링크 걸린 그림만 HTML로 직접 쓰는 것입니다. 마크다운 안의 HTML을 그대로 내보내는 설정(markup.goldmark.renderer.unsafe = true)이 켜져 있으면 이렇게 됩니다.

<figure><a href="https://example.org/"><img src="/img/a.png" alt="F 링크 감싼 이미지"></a></figure>

출력이 적은 그대로 나오고 앞뒤 문단도 정상이며 검사기 오류는 0개였습니다. 제 블로그 본문에는 링크로 감싼 이미지가 한 장도 없어서 지금은 해당 사항이 없습니다. grep으로 [![를 찾아 0건인 것을 확인했습니다.

검색하면 나오는 닫는 태그 꼼수는 설정과 같이 쓰면 깨집니다

찢어진 포장지를 테이프로 이어 붙인 자리 위에 새 포장지를 또 덧댄 소포 상자

설정이 생기기 전에 쓰던 우회법이 아직 검색에 걸립니다. 훅의 맨 앞에서 </p>로 바깥 문단을 닫고 맨 뒤에서 <p>를 새로 여는 방식입니다. #10492 본문에도 이 방법과 빈 <p>를 지우는 후처리가 함께 적혀 있습니다.

</p><figure><img src="{{ .Destination | safeURL }}" alt="{{ .PlainText }}"></figure><p>

두 설정으로 빌드해 봤습니다.

<!-- 기본값(true) -->
<p></p><figure><img src="/img/a.png" alt="A 단독"></figure><p></p>

<!-- false -->
</p><figure><img src="/img/a.png" alt="A 단독"></figure><p><p>끝 문단입니다.</p>

기본값에서는 검사기 오류가 0개지만 이미지마다 빈 <p>가 두 개씩 붙습니다. false로 바꾸면 감쌀 <p>가 없으니 훅의 </p>가 짝을 잃습니다. 검사기는 No “p” element in scope but a “p” end tag seen.을 냈습니다. 설정을 바꿨다면 이 꼼수는 훅에서 지워야 합니다. 둘을 같이 두면 고친 것이 다시 깨집니다.

훅을 바꾼 뒤 제가 돌리는 확인

숲길 갈림길 세 곳에 놓인 이정표 기둥을 한 손으로 차례로 짚어보는 산책자

이번 실험에서 쓴 확인을 그대로 남겨 둡니다. 빌드한 뒤 public/에 대해 돌립니다.

$ grep -rhoE '<p>[[:space:]]*<figure' public/posts | wc -l     # 0이어야 함
$ grep -rho '<figure' public/posts | wc -l                     # 단독 이미지 수와 같아야 함
$ java -jar vnu.jar --errors-only public/posts/<글>/index.html  # 출력이 없어야 함

첫 줄과 둘째 줄은 짝으로 봐야 합니다. <p><figure>가 0개라는 것만으로는 부족합니다. 설정이 빠졌을 때도 0개가 나오기 때문입니다. <figure> 수가 단독 이미지 수와 맞는지까지 봐야 설정이 실제로 먹었다는 걸 알 수 있습니다.

  1. 훅에서 <figure>를 낼 거면 wrapStandAloneImageWithinParagraph = false를 [markup.goldmark.parser] 아래에 둡니다. 다른 자리는 경고 없이 무시됩니다.
  2. 훅은 .IsBlock으로 나눕니다. 문장 속 이미지는 false 설정에서도 <p> 안에 있습니다.
  3. 링크로 감싼 이미지는 .IsBlock이 true여도 <p> 안입니다. 필요하면 HTML로 직접 씁니다.
  4. 설정 확인은 hugo config보다 결과물로 합니다. false는 출력에 나오지 않습니다.
  5. 예전 </p> 꼼수가 훅에 남아 있으면 지웁니다.