Skip to main content

Podcaster

Hosting a podcast and creating its website with Eleventy.

Podcaster Blog

Maintaining the correct heading structure when using an episode partial

MDN’s page on HTML headings describes the best practices for using heading elements to structure a web page. In summary:

  • There should be only one <h1> element per page.
  • Do not skip heading levels: always start from <h1>, followed by <h2> and so on.

On a podcast website, you might have a partial that displays information about a podcast episode. That partial uses an <h1> element for the episode title, and uses <h2> elements for subheadings in the show notes.

If you include that partial on the episode page, it will follow those best practices perfectly: a single <h1> element for the title, and <h2> elements for subheadings.

But if you use that partial to list multiple episodes on a tag page, for example, you will want the tag to be the <h1> element, the episode titles to be <h2> elements, and the show notes subheadings to be <h3> elements.

Is it just easier to create two different partials: a single-episode partial and an episode-list partial? Possibly. But there is another way to approach this problem.


Here’s a podcast episode partial I introduced in an earlier blog post.

episode.njk

{% if not post %}{% set post = collections.podcastEpisode | getCollectionItem %}{% endif %}
<article>
  {%- if linkInTitle -%}
  <h1><a href="{{ post.url }}">{{ post.data.title | safe }}</a></h1>
  {%- else -%}
  <h1>{{ post.data.title | safe }}</h1>
  {%- endif -%}
  <p class="post-metadata">Episode {{ post.data.episode.episodeNumber }} &middot; {{ post.data.topic | safe }} &middot; {{ post.date | readableDate }}</p>
  {% if excerptOnly %}
    {{ post.data.excerpt | safe }}
  {% else %}
    {{ post.content | safe }}
  {% endif %}
  <figure class="audio">
    <audio controls preload="metadata" src="{{ post.data.episode.url }}"></audio>
    <figcaption>
      Episode {{ post.data.episode.episodeNumber }}: {{ post.data.title }}
      &middot; Recorded on {{ post.data.episode.recordingDate | readableDate }}
      &middot; <a target="_blank" href="{{ post.data.episode.url }}">Download</a> ({{ post.data.episode.size | readableSize(1) }})
    </figcaption>
  </figure>
</article>

This partial consists of an <article> element with an <h1> element for the episode title.

And here’s an episode post template, whose content is displayed as {{ post.content }} inside the partial. You’ll see the second-level heading ## Notes and links.

2026-04-12-s4e5-torchwood-children-of-earth-day-five.md

---
title: What the World Is Like
recordingDate: 2026-03-28
topic: "Torchwood: Children of Earth: Day Five"
diaryDate: 2009-07-10
---
It's the end of the week, and although some kids and a whole building full of public servants are dead, we all learn that even the ones who walk away from Omelas discover that the real Omelas was inside them all along.

## Notes and links

We didn't have a whole season of _Doctor Who_ in 2009, because David Tennant took a year off to play [_Hamlet_ at the Royal Shakespeare Company][rsc] in 2008/2009. In fact, it was during the interval of one of the performances in October 2008 that David Tennant appeared remotely at the National Television Awards to [announce his departure][] from the show.

[rsc]: https://www.rsc.org.uk/hamlet/past-productions/in-focus-gregory-doran-2008
[announce his departure]: https://www.theguardian.com/media/2008/oct/29/doctorwho-bbc

So, if we’re including this partial on a tag page, how do we demote the <h1> element to <h2> and the <h2> element to <h3>?

headingoffset

One day, this problem will be kind of solved for us by HTML itself. There’s a current WHATWG spec for an attribute called headingoffset. When headingoffset is added to an element, the browser will change the computed heading level of every heading element inside that element by adding the attribute’s value to the heading element’s heading level. So in an article element with an attribute headingoffset="1", all the <h1> and <h2> elements would have computed heading levels of 2 and 3 respectively.

Unfortunately, we can’t use headingoffset right now. But in the meantime, let’s add the headingoffset attribute to an element as a signal to tell Eleventy to rewrite every heading inside that element and then delete the attribute.

eleventyConfig.addTransform

In your Eleventy config file, you can use eleventyConfig.addTransform to transform the output of a template after Eleventy has converted it. So we can let Eleventy convert our Markdown episode posts to HTML, and then we can use a custom transform to implement the headingoffset attribute and change the heading levels as required.

So, let’s imagine that this file is the template for a tag page. We’re going to repeatedly include the episode partial and pass it a headingoffset value of 1 each time. That way the <h1> inside the partial will become an <h2>, and any <h2> will become an <h3>, and so on.

tag.njk

<h1 class="sr-only">{{ tag }}</h1>
{% for post in collections[tag] %}
{% include 'episode.njk', linkInTitle: true, headingoffset: 1 %}
{% endfor %}

And now let’s add the headingoffset attribute to the episode.njk partial.

episode.njk

{% if not post %}{% set post = collections.podcastEpisode | getCollectionItem %}{% endif %}
- <article>
+ <article{% if headingoffset %} headingoffset="{{ headingoffset }}"{% endif %}>
  {%- if linkInTitle -%}
  <h1><a href="{{ post.url }}">{{ post.data.title | safe }}</a></h1>
  {%- else -%}
  <h1>{{ post.data.title | safe }}</h1>
  {%- endif -%}
  <p class="post-metadata">Episode {{ post.data.episode.episodeNumber }} &middot; {{ post.data.topic | safe }} &middot; {{ post.date | readableDate }}</p>
  {% if excerptOnly %}
    {{ post.data.excerpt | safe }}
  {% else %}
    {{ post.content | safe }}
  {% endif %}
  <figure class="audio">
    <audio controls preload="metadata" src="{{ post.data.episode.url }}"></audio>
    <figcaption>
      Episode {{ post.data.episode.episodeNumber }}: {{ post.data.title }}
      &middot; Recorded on {{ post.data.episode.recordingDate | readableDate }}
      &middot; <a target="_blank" href="{{ post.data.episode.url }}">Download</a> ({{ post.data.episode.size | readableSize(1) }})
    </figcaption>
  </figure>
</article>

And now let’s implement the headingoffset attribute in our Eleventy config file. To create our transformation, we’re going to use the posthtml package.

eleventy.config.js

import posthtml from 'posthtml'

function clampHeadingLevel (level) {
  if (level < 1) return 1
  if (level > 6) return 6
  return level
}

function shiftHeadings (node, offset) {
  if (!node.content) return

  for (const child of node.content) {
    if (typeof child === 'string') continue

    const match = /^h([1-6])$/.exec(child.tag)
    if (match) {
      child.tag = `h${clampHeadingLevel(Number(match[1]) + offset)}`
    } else {
      shiftHeadings(child, offset)
    }
  }
}

export default function (eleventyConfig) {
  .
  .
  .
  eleventyConfig.addTransform('headingoffset', async function (content, outputPath) {
    if (!outputPath || !outputPath.endsWith('.html')) return content
    if (!content.includes('headingoffset')) return content

    const { html } = await posthtml([
      tree => tree.match({ attrs: { headingoffset: true } }, node => {
        const offset = parseInt(node.attrs.headingoffset, 10)
        delete node.attrs.headingoffset
        if (offset) shiftHeadings(node, offset)
        return node
      })
    ]).process(content)

    return html
  })
  .
  .
  .
}

Just a couple of points about posthtml to finish up.

Firstly, posthtml is used internally by Eleventy to perform some of its own HTML transforms. But if you’re going to use it explicitly, as we have done here, you really should add it to package.json.

npm install posthtml --save-dev

And secondly, in eleventy.config.js, the call to the posthtml function isn’t particularly clear. So let me explain. posthtml is a function that takes an array of transformations. Each of these transformations is a function that takes a parsed HTML tree and returns a modified version of that tree. posthtml returns a PostHTML instance whose asynchronous process method applies the transformations to some HTML content, returning a result object whose html property contains the transformed HTML output.

Adding a transcript word count to your podcast About page

If you have told Podcaster where to find your podcast audio files, it can provide you with some fun facts about your podcast that you can include in your podcast templates.

field value
podcastData.numberOfEpisodes The number of episodes in the podcast
podcastData.totalSize The total size in bytes of all of the episode files
podcastData.totalDuration The total duration in seconds of all of the episode files

Continue reading…

Storing episode metadata in filenames

Important

This blog post was written for Version 1 of Podcaster. The feature described here has been included in Version 2 — in fact it’s now the default. You can find out more about it here.

Each podcast episode in Podcaster is represented by a template with the tag podcastEpisode. On my podcast websites, I put all the podcast episode templates in a /src/posts directory, and I give each template the podcastEpisode tag by using a directory data file.

Continue reading…

Presenting a podcast episode on your site

In my first post a few weeks ago, I showed you a simple way to create a home page for your podcast — by creating a template which loops through the podcastEpisode collection and renders some HTML containing the information for each episode.

Today, I’m going to show you an include file which presents more extensive detail about an episode. This include file can be used by itself on the episode’s dedicated page, but it can also be used as part of a list of episodes — like an index page or a tag page, for example.

An include file like this is sometimes called a partial.

Continue reading…

Describing a podcast episode

Podcasts are an audio medium, of course, but your listeners’ podcast players describe each of your episodes using text — a title, a short description and a long description.

And so Podcaster lets you provide all three of those.

I’ve posted about titles already — so let’s talk about the short and long descriptions.

Continue reading…