Minimizing header sizes in HTML

I tried to build a feature that minimizes the size/level of headers so you can minimize header levels in Markdown and not have enormous-size header text paired with normal-size body text unless you really do use six levels of headers.

Leaving out some complexity, I started with doing something like a search/replace on Page.Content with patterns <h[1-6] and </h[1-6]>.

However, it occurred to me that <h1 could appear in JavaScript or a code block, and those occurrences shouldn’t be changed.

I then tried using a link render hook to add a data-mytheme-render-heading="true" attribute to headings, then incorporating that string into the search pattern. However, it occurred to me that it wouldn’t cover closing tags.

I then considered doing an incremental search/replace to append the right Bootstrap size class to the class attribute value for matching header tags. However, I realized that there isn’t a Hugo regexp function that returns the match index, so I can’t slice the content string manually.

At that point, I gave up and left it as-is, and will have to add a theme config value to turn off this feature if anyone seriously asks for it.

I’d be curious to know if I missed any other approach.

Some ideas come to mind for how to better enable this feature:

  • A regexp function that returns a match index (or the indexes of multiple matches)
  • A way to force the re-rendering of page content so a header render hook can keep track of header levels seen in Page.Store in the first rendering, and then adjust the header level according to the store in the second rendering
  • I guess if there was a magical way to invoke rendering with an option to replace all occurrences of one tag with another

If the scope is Markdown headings, you might look at the Fragments structure to determine the minimum and maximum heading levels.

Thanks, that was exactly what I needed. I changed the heading render hook to traverse Fragments and adjust the level there. It won’t work for non-Markdown headers, but that can be out of scope.

Keep in mind that the .TableOfContents structure is based on the fragment heading level, not the rendered HTML heading level. This make controlling start and end levels in site configuration difficult or impossible, depending on how flexible your hook is.

I don’t want the TOC to be affected by this, so it sounds like this is what I want.

How does this make controlling start and end levels in site config difficult or impossible?

If I understand the objective, this:

#### Section 1

##### Section 1.1

Should be rendered to this:

<h2 class="h5" id="section-1">Section 1</h2>

<h3 class="h6" id="section-11">Section 1.1</h3>

So semantically the structure makes sense (top-down) and we’re sizing our headings bottom-up.

But the fragment levels are 4 and 5, corresponding to rendered HTML heading levels of 2 and 3. And the variance changes depending on how many heading levels are on the page.

I’m seeing some strange behavior. I have this render-heading.html:

{{ $context := . }}

{{ $defaults := dict "id" $context.Anchor }}
{{ $max := 0 }}
{{ $new := $context.Level }}
{{ $old := $context.Level }}

{{ $attrs := merge $context.Attributes $defaults }}

{{ range $k, $v := $context.Page.Fragments.HeadingsMap }}
    {{ if gt $v.Level $max }}
        {{ $max = $v.Level }}
    {{ end }}
{{ end }}

{{ if and $max (lt $max 6) }}
    {{ $new = add $old (sub 6 $max) }}
{{ end }}

{{/* ...snip... */}}

<h1>FOOBARBAZ</h1>

<h{{ $old }} {{ range $k, $v := $attrs }} {{ printf `%s="%s"` $k $v | safeHTMLAttr }} {{ end }}>
    <a href="#{{ $context.Anchor }}">{{ $context.Text }}</a>
</h{{ $old }}>

However, for this Markdown:

## H2
### H3
#### H4
##### H5

I get this raw HTML for the H2 heading:

<h2>FOOBARBAZ</h2>

<h3  class="h3"  id="class-parameter" >
    <a href="#class-parameter">Class parameter</a>
</h3>

It gets the right class (h3, omitted in the snipped code), but the tag is also h3 instead of h2. As can be seen in the template, <h{{ $old }} forms the heading tag, and I’ve verified that $old is 2 in this case. So Hugo is somehow refusing to output an h2 tag.

Also note that the heading tag around FOOBARBAZ is h1 in the template, but is h2 in the raw HTML. Again, Hugo is rewriting things somehow. (Removing <h1>FOOBARBAZ</h1> doesn’t fix anything, either.)

I’ve logged everything to verify the values are as expected, and they are, but Hugo just isn’t spitting out the expected rendered text. Not sure what to make of this.

Is this expected?

Take this for a spin:

git clone --single-branch -b hugo-forum-topic-53808 https://github.com/jmooring/hugo-testing hugo-forum-topic-53808
cd hugo-forum-topic-53808
hugo server
  1. Site configuration cannot consistently manage the table of contents (TOC) start and end levels. This is because the fragment (Markdown) heading level and the rendered heading level differ, and this difference changes based on the number of heading levels on the page.

I decided to not alter the heading tag levels themselves because of this, and instead just alter their class size (“h1”, “h2”, etc). I should have made that clear.

  1. Determine the site-wide initial content heading level. For example, if level 1 is reserved for page titles, the site-wide intitial content heading level is 2. Set this value in the cfg map at the top of the heading render hook.

Unfortunately, it’s unsafe to assume in general the site uses startlevel=2. Perhaps it’s worth exposing that config value to enable this kind of thing.

Take this for a spin:

Thanks. After whittling down your code, I found that this works correctly in your branch, but not in mine, where I see the same behavior described in my previous comment:

{{ $context := . }}
{{ $old := $context.Level }}
{{ $s := slice }}
{{ range $context.Page.Fragments.HeadingsMap }}
  {{ $s = $s | append .Level }}
{{ end }}
{{ $max := math.Max $s | int }}
{{ $cl := add $context.Level (sub 6 $max) }}

<h1>FOOBARBAZ</h1>
<h{{ $old }} class="h{{ $cl }}" id="{{ $context.Anchor }}">{{ $context.Text }}</h{{ $old }}>

For instance, in yours, <h1>FOOBARBAZ</h1> is rendered as-is, whereas in mine, it’s changed to h2. :frowning:

Setting markup.goldmark.renderer = false doesn’t change anything.

Here’s the raw HTML (omitting blank lines):

<h2>FOOBARBAZ</h2>
<h3 class="h3" id="class-parameter">Class parameter</h3>
<h2>FOOBARBAZ</h2>
<h4 class="h4" id="h3">H3</h4>
<h2>FOOBARBAZ</h2>
<h5 class="h5" id="h4">H4</h5>
<h2>FOOBARBAZ</h2>
<h6 class="h6" id="h5">H5</h6>

Is there some other context that could affect the heading render hook behavior like this? Caching, Markdown processing or rendering?

To clarify, in your theme, if you use my render hook without ANY modification, you have this problem?

Yep. For your branch:

Input (blank lines omitted):

## H2
### H3
#### H4
##### H5

Output (blank lines omitted):

<h2 class="h3" id="h2">H2</h2>
<pre>
  fragment/md heading level: 2
  html heading level: 2
  class level: 3
  id: h2
  class: h3
</pre>
<h3 class="h4" id="h3">H3</h3>
<pre>
  fragment/md heading level: 3
  html heading level: 3
  class level: 4
  id: h3
  class: h4
</pre>
<h4 class="h5" id="h4">H4</h4>
<pre>
  fragment/md heading level: 4
  html heading level: 4
  class level: 5
  id: h4
  class: h5
</pre>
<h5 class="h6" id="h5">H5</h5>
<pre>
  fragment/md heading level: 5
  html heading level: 5
  class level: 6
  id: h5
  class: h6
</pre>

For my branch (renamed “Class parameter” to “H2”):

Input:

## H2
### H3
#### H4
##### H5

Output:

<h3 class="h3" id="h2">H2</h3>
<pre>
  fragment/md heading level: 2
  html heading level: 2
  class level: 3
  id: h2
  class: h3
</pre>
<h4 class="h4" id="h3">H3</h4>
<pre>
  fragment/md heading level: 3
  html heading level: 3
  class level: 4
  id: h3
  class: h4
</pre>
<h5 class="h5" id="h4">H4</h5>
<pre>
  fragment/md heading level: 4
  html heading level: 4
  class level: 5
  id: h4
  class: h5
</pre>
<h6 class="h6" id="h5">H5</h6>
<pre>
  fragment/md heading level: 5
  html heading level: 5
  class level: 6
  id: h5
  class: h6
</pre>

At the top of this topic you mentioned some experiments. Any of that still hanging around somewhere? Maybe a case-insensitive search of your code base for “replace”.

OMG, I am so blind. :melting_face:

Thank you, that was it. It’s working fine now. My apologies for taking up your time with something so dumb. I really appreciate the branch you put together to help debug this.

This topic was automatically closed 2 days after the last reply. New replies are no longer allowed.