Can a shortcode know the heading level of the section it sits in?

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.LineNumber counts from the start of the file, front
    matter included. That happens to match what readFile returns, but
    .Position is 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;
  • .Position is not always available. On pages whose content is also rendered
    outside their own body pass - via .Summary from a slider on another page -
    the shortcode reports LineNumber 1 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.

Not one I can think of.

But here’s a variant that does not depends on parsing readFile. Still uses Position:

render-heading:

{{- /* string sortable LINE_LEVEL */ -}}
{{ $lvl := printf "%06d_%1d" .Position.LineNumber .Level }}
{{- /* save as slice in page.Store */ -}}
{{ with .Page.Store.Get "headings" }}
  {{ $.Page.Store.Add "headings" $lvl }}
{{ else }}
  {{ $.Page.Store.Set "headings" (slice "000000_0" $lvl) }}
{{ end }}
<h{{ .Level }}>{{ .Text }}</h{{ .Level }}>

shortcode

{{- /* make shortcodes linenumber comparable */ -}}
{{- $scPos := printf "%06d" .Position.LineNumber }}
{{- $hxPos := "0" }}
{{- /* find last heading before the shortcode */ -}}
{{- with .Page.Store.Get "headings" }}
  {{- range . }}
    {{- if gt . $scPos  }}{{ break }} {{ end }}
      {{- $hxPos = . }}
  {{- end }}
  {{- $hxPos = index ("_" | split $hxPos) 1 }}
{{- end }}
{{- /* $hxPos contains the headingLevel */ -}}

looks like that works for Summary not tested with .Render , include, …

Thanks - this helps. The “not one I can think of” is, unfortunately, the most useful part for me: I’ll stop looking for a supported API (instead I’d like to propose one to be considered).

Your variant is nicer than my readFile in two ways I hadn’t considered. Both sides of the comparison come from .Position, so the front-matter offset cancels out instead of being an assumption I have to guard - that removes my ugliest caveat. And because the hook only fires for real headings, # inside a fenced code block stops being a special case I have to track by hand.

Where it doesn’t quite reach my use case is an aspect that I now realize I’ve omitted in my question: The enclosing heading is only half of what I need. The same shortcode (tag-slider) plays two roles depending on where it sits.

Reduced to the essentials:

---
title: Example
---

Intro paragraph.

## First section

Some text.

{{< tag-slider tags="example" label="Mid-article" >}}

More text, still belonging to "First section".

## Second section

Some text.

{{< tag-slider tags="example" label="Closing suggestions" >}}

{{< references >}}
* [A source](https://example.com)
{{< /references >}}

Both calls are identical and both follow an h2, but they should not render their label at the same level:

  • the first is an aside inside “First section”, and the article continues past it, so its label belongs at h3. That would stay true if ## Second section followed immediately - what decides is whether the article carries on, not whether this particular section does;
  • the second closes the article. Nothing follows but a references block, which is apparatus rather than content. It belongs at h2, at page level, rather than being nested under “Second section”, which it does not belong to any more than to “First section”.

Telling those two apart needs “does any article content follow this call”, and that is the part a heading table cannot answer: there is no paragraph render hook - the hook set is heading, link, image, codeblock, blockquote, table, passthrough - so an ordinary paragraph is invisible to it. I could catch prose that happens to contain a markdown link via render-link, but most of my body text cross-references through shortcodes, which fire no hook at all.

The same structure in the wild, if the rendered result is easier to judge than the source: Sunburn and UV · Butter bei... has both calls in one article (the first inside “srsly?” with the section continuing, the second closing the page), and Caloric Balance: Cause or Effect? · Butter bei... is the pure closing case. Without looking at the HTML source, the left-hand TOC (at desktop width) is indented according to heading level, and a tag-slider is easy to spot because its label is displayed in brackets.

Concretely, across 240 call sites in my corpus, “is there a heading after this call” and “is there any content after this call” agree on 224 and disagree on 16 - all of them cards sitting mid-flow in the final section, which a heading-only test would wrongly promote to page level.

So I’d still be reading the source file for the forward half, and the heading table would replace only the backward half. Two position mechanisms where I currently have one, which is why I’m staying with what I have for now.

Which does narrow my original request usefully: a read-only .EnclosingHeading(level and text) would cover the backward half completely and cleanly. The forward half - “am I the last thing on this page” - I’m happy to keep solving myself.

One small thing about the snippet, in case someone else picks it up: since the heading entries are %06d_%1d and the shortcode position is %06d, a shortcode on the same line as a heading compares as greater and skips that heading. Doesn’t arise in my content. And .Position being unavailable when a page renders through .Summary elsewhere affects both approaches equally.

Again, thank you for your help - if you think an API feature-request has a chance, and I should file one, let me know.

yes, could be handled by adding .ColumnNumber or maybe better switch to .Offset which will with %06d break at 1M (runes?) :wink: so real int comparison but that’s some additional logic which needs maybe a map.

all that is very special and keep in mind that in general:

  • examining just one markdown won’t be sufficient, another shortcode, hook … may add a heading… Positions wont track I suppose.
  • the look behind pattern is extremely special something after, but only if it’s not …
  • it’s mixing up “content” with “structure” by automating with a content lookup -

So the use case here is imho very special and it does not match markdown or html design which separate structure from content.

Thank you again,

Yes, .Offset is the better key, agreed - a total order and no width limit to worry about.

On the general caveats: the “another shortcode may emit a heading” one applies to both approaches equally, and in the same direction. My card labels are headings in the rendered outline, and neither a source scan nor a render-heading table sees them, because a shortcode emitting raw <h3> never passes through the hook. For this use case that is the behaviour I want: the label marks a block, not a section, so the next card belongs at the section’s level rather than one deeper.

The look-behind rule is special, no argument - that half is editorial and mine to own.

On mixing content with structure, I’d put it the other way round. The label is structure: the shortcode contributes a heading to the outline, a heading needs a level, and its level follows from where it sits. “Where am I in the outline” is a structural question. Reading the source file to answer it is not the design - it’s what’s left as there’s no structural API to ask, which is what I was hoping .EnclosingHeading might become.

I take it that the answer is that shortcodes are deliberately not given outline context. That’s a fair design decision and a useful thing to have confirmed.

Thank you,
Olaf

just to clarify:

i’m just a forum member, and in no way authorative for the @maintainers.

it’s just my personal thinking…

Haha, thank you - fingers crossed, maybe there’s hope when they pop by.

For now, my problem is solved, and the pain isn’t too bad: I don’t need to look at the implementation every day - but I hope to make it more elegant, one day :slightly_smiling_face:

So, the answer to your main question is … no.

The shortcodes currently does not know anything about the surrounding Markdown and you cannot depend on the rendering order depending on e.g. the parent heading. With the {{% delimiter, that’s a given. With the {{<, that’s not given (it should be possible to render them in the order of the other elements in the document.

Goldmark (the Markdown library we use) is in v2.0.0beta5 where one of the options are before/after hooks surrounding the rendering. I have asked him to expand that to any node (e.g. headings), and I think that will happen. That will open up a set of new possibilities, and I will have this issue in mind when I’m thinking about it. The primary thing I would want to do with that is to track the rendered byte slice boundaries for some core Markdown nodes, so instead of just .Content we could expose a data structure with all the sections (e.g. a tree structure with h1…h4).

Awesome - best outcome I could have hoped for. Thank you (both), looking forward, and I’ll keep an eye on Goldmark.