Tested with hugo v0.155.2-d8c0dfccf72ab43db2b2bca1483a61c8660021d9+extended linux/amd64 BuildDate=2026-02-02T10:04:51Z VendorInfo=gohugoio
What I am trying to do
I have shortcodes that render a labelled block - a card, a related-articles
slider - where the label acts as an intermediate heading. The label has to be
emitted at some heading level, and the shortcode cannot know where in the
document outline it was called. So it hardcodes one.
The result is an <h2> label appearing inside an <h3> section, which inverts
the document outline and any table of contents derived from it.
Page.Store works in render hooks, but not in shortcodes
The standard advice - have a render-heading hook record the current level in
Page.Store, then read it back - is correct for render hooks and does not carry
over to shortcodes. Both halves in one build, Hugo 0.155.2+extended.
render-heading.html:
{{- .Page.Store.Set "hlevel" .Level -}}<h{{ .Level }}>{{ .Text }}</h{{ .Level }}>
render-link.html:
<a href="{{ .Destination }}" data-under-h="{{ .Page.Store.Get "hlevel" }}">{{ .Text }}</a>
layouts/shortcodes/probe.html:
<p>level=[{{ with .Page.Store.Get "hlevel" }}{{ . }}{{ else }}EMPTY{{ end }}]</p>
Content:
## Chapter One
[linkA](https://a.example) and {{< probe >}}
### Sub One
[linkB](https://b.example) and {{< probe >}}
#### Deep
[linkC](https://c.example) and {{< probe >}}
Result:
render-link hook : data-under-h="2", "3", "4" <- enclosing heading, correct
shortcode : level=[4], [4], [4] <- page's last heading, always
Render hooks run inside the markdown pass, in document order, so a link hook
sees whatever the preceding heading hook stored. Shortcode placeholders are
substituted only after that pass has finished, so every shortcode reads the
final value. Changing the last heading to ## makes all three shortcodes report
2, which confirms it is the document order and not the nesting that decides.
.Page.Fragments.Headings has the same limitation from a shortcode: it gives the
complete outline, but nothing to locate the call site within it.
What does work, and why I would rather not rely on it
Combining .Position.LineNumber with os.ReadFile on the page’s own source and
scanning backwards for the last markdown heading:
{{- $src := strings.TrimPrefix (printf "%s/" hugo.WorkingDir) .Filename -}}
{{- $lines := split (readFile $src) "\n" -}}
{{- range first $.position.LineNumber $lines -}}
{{- if findRE `^#{1,6}\s` . -}}
{{- $enclosing = len (index (findRE `^#+` .) 0) -}}
{{- end -}}
{{- end -}}
This works and uses only public functions, but:
- it re-reads the source file on every invocation;
- it assumes
.Position.LineNumbercounts from the start of the file, front
matter included. That happens to match whatreadFilereturns, but
.Positionis documented for error messages, not as a stable content offset -
if that ever changed, the detection would be silently off by the length of the
front matter rather than failing; .Positionis not always available. On pages whose content is also rendered
outside their own body pass - via.Summaryfrom a slider on another page -
the shortcode reportsLineNumber1 and the detection has to fall back.
The question
Is there a supported way to learn the enclosing heading from a shortcode?
If not, would read-only access to the call site’s position in the outline be
considered - something like .EnclosingHeading (level and text) or an
.OutlinePath slice on the shortcode context? This asks only for information
about where the call sits, not for any change to how or when shortcodes render.
Related but distinct: the existing discussions about dynamically adjusting
heading levels when rendering content blocks describe the writing direction -
“render this block one level deeper”. This is the reading direction: the caller
wants to know where it already is.