birchholt 직접 해보고 남기는 기록

Hugo 메뉴에 현재 페이지 active가 안 붙을 때: url 대신 pageRef를 써야 합니다

Hugo 사이트 상단 메뉴에서 지금 보고 있는 페이지 항목만 진하게 칠하고 싶을 때가 있습니다. 템플릿에서 .IsMenuCurrent(이 메뉴 항목이 지금 페이지인가)와 .HasMenuCurrent(지금 페이지가 이 항목 아래에 있는가)로 갈라 클래스를 붙입니다. IsMenuCurrent 문서의 예제도 이 방식입니다. 그런데 hugo.toml에 메뉴를 적고 이 둘을 찍어 보면 어느 페이지에서나 false가 나오는 경우가 있습니다.

같은 모양 문패가 줄지어 걸린 복도에서 어느 문 앞에도 불이 켜지지 않은 저녁 하숙집

제 블로그의 상단 링크는 템플릿에 주소를 직접 적어 둔 방식이라 메뉴 설정을 쓰지 않습니다. 그래서 이 증상을 겪은 건 아니고, 운영 저장소 사본에 메뉴를 붙여 일부러 재현했습니다. Hugo v0.165.0 extended이고 2026-10-07에 잰 값입니다.

url로 적은 메뉴는 스무 칸이 전부 false였습니다

빈 출석부의 모든 칸에 같은 동그라미만 그려진 채 교탁에 놓인 장면

메뉴는 많이 보이는 예제 그대로 url로 적었습니다. 마지막 항목은 홈의 한 구역으로 바로 가는 앵커 링크입니다.

# lab/cfg/menu-url.toml — 원래 설정 위에 덧씌운 파일
[[menus.main]]
  name = '홈'
  url = '/'
  weight = 10
[[menus.main]]
  name = '운영 기록'
  url = '/posts/'
  weight = 20
[[menus.main]]
  name = '개인정보처리방침'
  url = '/privacy/'
  weight = 30
[[menus.main]]
  name = '운영기록-앵커'
  url = '/#운영기록'
  weight = 40

헤더 템플릿은 메뉴를 돌면서 항목마다 값을 속성으로 찍게 바꿨습니다. 화면에서 눈으로 보는 것보다 빌드 결과물을 grep하는 쪽이 빠짐없이 셀 수 있어서입니다.

{{- $cur := . }}
{{- range site.Menus.main }}
  <a href="{{ .URL }}" data-menu="{{ .Name }}|url={{ .URL }}|page={{ with .Page }}{{ .Path }}{{ else }}nil{{ end }}|is={{ $cur.IsMenuCurrent "main" . }}|has={{ $cur.HasMenuCurrent "main" . }}">{{ .Name }}</a>
{{- end }}

홈, 글 목록, 글 하나, 개인정보처리방침, 목록 2페이지까지 다섯 페이지를 봤습니다.

$ hugo --quiet --config hugo.toml,lab/cfg/menu-url.toml -d lab/out-url
$ grep -o 'data-menu="[^"]*"' lab/out-url/privacy/index.html
"홈|url=/|page=nil|is=false|has=false"
"운영 기록|url=/posts/|page=nil|is=false|has=false"
"개인정보처리방침|url=/privacy/|page=nil|is=false|has=false"
"운영기록-앵커|url=/#%EC%9A%B4%EC%98%81%EA%B8%B0%EB%A1%9D|page=nil|is=false|has=false"

다섯 페이지 × 네 항목, 스무 칸이 전부 is=false|has=false였습니다. /privacy/ 페이지에서 url = '/privacy/' 항목을 물어도 false입니다. 주소가 글자 하나 안 틀리고 같은데도 그렇습니다.

눈여겨볼 건 page=nil입니다. 메뉴 항목에 연결된 페이지가 없습니다.

원인은 주소 비교가 아니라 페이지 비교였습니다

두 장의 똑같은 주소 쪽지를 들고도 사람 얼굴 사진이 없어 문을 열지 못하는 경비실

IsMenuCurrent 문서에 조건이 한 줄로 적혀 있습니다. “When using this method you must either define the menu entry in front matter, or specify a pageRef property when defining the menu entry in your project configuration.” 설정 파일에서 메뉴를 만들었다면 pageRef가 있어야 한다는 뜻입니다.

메뉴 설정 문서는 url을 “Use this for external destinations only"라고 설명합니다. url은 바깥 사이트로 나가는 링크용이고 사이트 안 페이지는 pageRef로 가리키라는 구분입니다.

Hugo v0.165.0 소스를 열어 보니 왜 그런지 보였습니다. IsMenuCurrent는 메뉴 항목의 주소가 아니라 메뉴 항목에 붙은 페이지 객체를 지금 페이지와 비교합니다.

// navigation/menu.go
func (m *MenuEntry) isSamePage(p Page) bool {
	if !types.IsNil(m.Page) && !types.IsNil(p) {
		return m.Page == p
	}
	return false
}

url로만 적은 항목은 m.Page가 비어 있으니 여기서 늘 false입니다 (menu.go, pagemenus.go).

예외가 하나 있습니다. 페이지 front matter에 menus: main으로 그 페이지 자신의 메뉴 항목이 있으면, url만 준 설정 항목도 같은 주소일 때 true가 됐습니다. 두 항목을 같은 항목으로 보는 기준이 식별자(identifier)가 없으면 주소라서입니다. 사본에 그런 페이지를 하나 만들어 확인했고, front matter 메뉴가 없는 /privacy/에서는 위처럼 false였습니다.

pageRef로 바꾸니 active와 ancestor가 갈렸습니다

같은 복도에서 한 문 앞에는 등불이, 그 방이 속한 층 안내판에는 작은 불빛이 켜진 장면

url을 pageRef로 바꿨습니다. 값은 주소가 아니라 content/ 아래 파일 위치입니다.

# lab/cfg/menu-pageref.toml (앵커 항목은 아래 별도 절에서)
[[menus.main]]
  name = '홈'
  pageRef = '/'
[[menus.main]]
  name = '운영 기록'
  pageRef = '/posts'
[[menus.main]]
  name = '개인정보처리방침'
  pageRef = '/privacy'

같은 다섯 페이지를 다시 뽑았습니다. is는 IsMenuCurrent, has는 HasMenuCurrent가 true인 칸입니다.

지금 페이지홈운영 기록개인정보처리방침
/is––
/posts/–is–
/posts/soft-404-on-pages/–has–
/privacy/––is
/posts/page/2/–is–

글 하나를 열면 「운영 기록」에 has가 켜집니다. HasMenuCurrent 문서의 “If the Page object associated with the menu entry is a section, this method also returns true for any descendant of that section"이 이 동작입니다. 섹션(content/posts/처럼 글을 묶는 폴더)을 가리키는 항목은 그 아래 글에서 「상위 항목」으로 잡힙니다. 하위 메뉴(children)를 따로 만들지 않아도 됩니다.

두 가지가 더 보였습니다.

pageRef에는 주소가 아니라 파일 위치를 적습니다

지도에 적힌 동네 별명 대신 땅문서 번지로 우편함을 찾아가는 집배원

pageRef가 받는 값을 Hugo는 논리 경로(logical path)라고 부릅니다. 용어집은 이것을 content/ 기준 파일 경로에서 확장자를 뗀 값이라고 설명하고 “This value is neither a file path nor a URL"이라고 못 박습니다.

파일 이름과 주소가 같으면 헷갈릴 일이 없습니다. 그래서 다른 경우를 사본에 만들어 봤습니다.

content/posts/menu-lab-file.md  (slug: menu-lab-slug → 주소 /posts/menu-lab-slug/)
content/about.md                (url: /about-me/)
pageRef결과
/posts/menu-lab-file (파일 쪽)동작합니다
/posts/menu-lab-slug (주소 쪽)페이지를 못 찾습니다
/about (파일 쪽)동작합니다
/about-me (주소 쪽)페이지를 못 찾습니다

slug나 url을 front matter에서 바꾼 페이지는 주소를 적으면 안 걸립니다. 반대로 표기는 꽤 너그러웠습니다. /posts/, posts, /posts/_index.md가 모두 같은 섹션으로 풀렸고 /privacy.md도 동작했습니다.

조심할 건 못 찾았을 때입니다. 오타를 낸 pageRef = '/postz'로 빌드해 봤습니다.

$ hugo --config hugo.toml,lab/cfg/menu-variants.toml -d lab/out-var2
...
Total in 80 ms
$ grep -o '<a href=""[^>]*>[^<]*' lab/out-var2/index.html
<a href="" data-menu="E-typo|url=|page=nil|is=false|has=false">E-typo

경고 한 줄 없이 빌드가 끝났고 링크 주소가 빈 문자열이 됐습니다. 빈 href는 지금 페이지로 다시 오는 링크라서 눌러 봐도 이상한 걸 바로 알아채기 어렵습니다.

pageRef와 url을 한 항목에 같이 적으면 어느 쪽이 이기는지 문서에서는 찾지 못했습니다. 사본에서 pageRef = '/privacy', url = '/somewhere-else/'로 적어 보니 링크는 /privacy/로 나왔고 url은 무시됐습니다.

같은 페이지를 가리키는 앵커 항목은 따로 막아야 했습니다

같은 집 현관과 뒷마당 쪽문에 똑같은 환영 등불이 동시에 켜져 손님이 망설이는 장면

남은 건 홈의 한 구역으로 가는 앵커(주소 뒤 #으로 페이지 안 위치를 가리키는 것) 링크입니다. pageRef에는 # 뒤를 적을 자리가 없어서 홈을 가리키게 두고 구역 이름은 params에 따로 넣었습니다.

[[menus.main]]
  name = '운영기록-앵커'
  pageRef = '/'
  weight = 40
  [menus.main.params]
    fragment = '운영기록'

이렇게 하면 앵커 항목에도 홈 페이지 객체가 붙습니다. 그 결과 홈에서 「홈」과 앵커 항목 두 개가 동시에 is가 됐습니다. aria-current="page"(화면 낭독기에 현재 페이지라고 알리는 속성)가 한 화면에 두 번 붙는 셈입니다.

앵커 항목은 현재 표시를 아예 받지 않게 템플릿에서 걸렀습니다.

{{- $page := . }}
{{- range site.Menus.main }}
  {{- $href := .URL }}
  {{- with .Params.fragment }}{{ $href = printf "%s#%s" $href . }}{{ end }}
  {{- if and (not .Params.fragment) ($page.IsMenuCurrent .Menu .) }}
  <a class="active" aria-current="page" href="{{ $href }}">{{ .Name }}</a>
  {{- else if and (not .Params.fragment) ($page.HasMenuCurrent .Menu .) }}
  <a class="ancestor" aria-current="true" href="{{ $href }}">{{ .Name }}</a>
  {{- else }}
  <a href="{{ $href }}">{{ .Name }}</a>
  {{- end }}
{{- end }}

다시 빌드하니 다섯 페이지 모두 active가 한 개 이하였고, 글 페이지에서는 「운영 기록」만 ancestor였습니다.

# 출력 중 현재 표시가 붙은 링크만 추림
-- /
<a class="active" aria-current="page" href="/">홈
-- /posts/soft-404-on-pages/
<a class="ancestor" aria-current="true" href="/posts/">운영 기록
-- /privacy/
<a class="active" aria-current="page" href="/privacy/">개인정보처리방침

active와 ancestor 클래스, aria-current의 page/true 구분은 IsMenuCurrent 문서의 예제를 따랐습니다. 이번 출력에서 앵커 링크 주소는 /#%ec%9a%b4...처럼 퍼센트 인코딩돼 나왔는데, 브라우저에서 그 구역으로 실제로 스크롤되는지는 이번에 확인하지 않았습니다.

메뉴가 안 켜질 때 이 순서로 봅니다

열쇠 꾸러미를 하나씩 자물쇠에 대 보고 맞는 열쇠에 초록 실을 묶어 두는 손

같은 증상을 만나면 이 순서로 확인하겠습니다.

  1. 메뉴 항목에 페이지가 붙었는지부터 찍어 봅니다. 템플릿에서 {{ with .Page }}{{ .Path }}{{ else }}nil{{ end }}를 출력해 nil이면 IsMenuCurrent는 볼 필요도 없습니다.
  2. 사이트 안 링크는 url을 pageRef로 바꿉니다. 바깥 링크만 url로 남깁니다.
  3. pageRef에는 content/ 아래 파일 위치를 적습니다. slug·url로 바꾼 주소를 적으면 못 찾습니다.
  4. 빌드 후 결과물에서 href=""를 grep합니다. 못 찾은 pageRef는 경고 없이 빈 링크가 됩니다.
  5. 섹션 항목의 하위 글 강조는 HasMenuCurrent가 맡습니다. 하위 메뉴를 따로 만들 필요가 없습니다.
  6. 같은 페이지를 가리키는 항목(앵커 등)이 둘이면 한쪽을 템플릿에서 걸러 현재 표시가 한 번만 나오게 합니다.

false가 나온다고 비교 로직을 의심하기 전에 항목이 무엇을 들고 있는지를 먼저 보면 저처럼 스무 칸을 다 찍어 볼 필요가 없습니다.