A Tabbed View for Hugo

One thing I’ve been using for a lot of my recent posts (such as Backtracking Worms) is a tabbed view of code that can show arbitrarily tabs full of code or other content and render them wonderfully! For example, we can turn:

{{</*tabs*/>}}
    {{</*sourcetab ruby "examples/art-station.rune"*/>}}
    {{</*tab "art-station.svg"*/>}}
        {{</*include "output/art-station.svg"*/>}}
    {{</*/tab*/>}}

    {{</*sourcetab ruby "examples/astrology-and-moons.rune"*/>}}
    {{</*tab "astrology-and-moons.svg"*/>}}
        {{</*include "output/astrology-and-moons.svg"*/>}}
    {{</*/tab*/>}}

    {{</*sourcetab ruby "examples/text-circle.rune"*/>}}
    {{</*tab "text-circle.svg"*/>}}

        {{</*include "output/text-circle.svg"*/>}}
    {{</*/tab*/>}}
{{</*/tabs*/>}}

Into the tabbed example view at the end of yesterday’s post!

To do this, I made a handful of different shortcodes:

{{ $content := readFile (printf "%s/%s" $.Page.File.Dir (.Get 0)) | safeHTML }}
{{ $content | safeHTML }}
{{ $rawContent := readFile (printf "%s/%s" $.Page.File.Dir (.Get 1)) }}
{{ $content := printf "```%s\n%s\n```" (.Get 0) $rawContent }}

{{ $content | markdownify }}
{{ $title := (or (.Get 0) (printf "Tab %v" (add .Ordinal 1))) }}
{{ $content := .Inner }}

{{ .Parent.Scratch.Add "tabs" (slice (dict "title" $title "content" $content)) }}
{{ $title := .Get 1 }}

{{ $rawContent := readFile (printf "%s/%s" $.Page.File.Dir (.Get 1)) }}
{{ $content := printf "```%s\n%s\n```" (.Get 0) $rawContent }}

{{ .Parent.Scratch.Add "tabs" (slice (dict "title" $title "content" $content)) }}
{{ $id := (or ($.Get "id") (substr (.Inner | md5) 0 4)) }}

<div class="tab">
    {{ if (.Scratch.Get "tabs") }}
        {{ range $tab := .Scratch.Get "tabs" }}
        <button class="tablinks" data-tabset="{{ $id }}" onclick="changeTab(event, '{{ $id }}', '{{ index $tab "title" }}')">{{ index $tab "title" }}</button>
        {{ end }}
    {{ else }}
        <button class="tablinks" data-tabset="{{ $id }}" onclick="changeTab(event, '{{ $id }}', 'default')">Content</button>
    {{ end }}
</div>

{{ if (.Scratch.Get "tabs") }}
    {{ range $tab := .Scratch.Get "tabs" }}
        <div class="tabcontent" data-tabset="{{ $id }}" id="{{ index $tab "title" }}">
        {{ index $tab "content" | markdownify }}
        </div>
    {{ end }}
{{ else }}
    <div class="tabcontent" data-tabset="{{ $id }}" id="default">
        {{ .Inner | markdownify }}
    </div>
{{ end }}
/*
Tabs
Source: https://www.w3schools.com/howto/howto_js_tabs.asp
*/

function changeTab(evt, tabset, tabName) {
    // Declare all variables
    var i, tabcontent, tablinks;

    // Get all elements with class="tabcontent" and hide them
    tabcontent = document.querySelectorAll('.tabcontent[data-tabset="' + tabset + '"]');
    for (i = 0; i < tabcontent.length; i++) {
        tabcontent[i].style.display = "none";
    }

    // Get all elements with class="tablinks" and remove the class "active"
    tablinks = document.querySelectorAll('.tablinks[data-tabset="' + tabset + '"]');
    for (i = 0; i < tablinks.length; i++) {
        tablinks[i].className = tablinks[i].className.replace(" active", "");
    }

    // Show the current tab, and add an "active" class to the button that opened the tab
    document.getElementById(tabName).style.display = "block";
    evt.currentTarget.className += " active";
} 

$(function() {
    let tabsetset = [];

    document.querySelectorAll('.tablinks').forEach((el) => {
        let attr = el.attributes['data-tabset'].value;
        if (!tabsetset.includes(attr)) {
            tabsetset.push(attr);
            el.click();
        }
    })
});
{{ $width := mul 1.1 (int (or ($.Get "width") 400)) }}
{{ $height := mul 1.1 (int (or ($.Get "height") 400)) }}
{{ $id := or ($.Get "id") (substr (.Inner | md5) 0 4) }}

{{ $header := `
<html>
<head>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.1.9/p5.min.js" integrity="sha512-WIklPM6qPCIp6d3fSSr90j+1unQHUOoWDS4sdTiR8gxUTnyZ8S2Mr8e10sKKJ/bhJgpAa/qG068RDkg6fIlNFA==" crossorigin="anonymous"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.1.9/addons/p5.sound.min.js" integrity="sha512-wM+t5MzLiNHl2fwT5rWSXr2JMeymTtixiw2lWyVk1JK/jDM4RBSFoH4J8LjucwlDdY6Mu84Kj0gPXp7rLGaDyA==" crossorigin="anonymous"></script>    
    
    <script src="https://unpkg.com/[email protected]/libraries/quicksettings.js" integrity="sha384-XlyRxqW2TTF2gFC0VpBI8OqnCTsZOdgrhBFikxYD4hEu68QdNQ64kiej07hCAiJq" crossorigin="anonymous"></script>
    <script src="https://unpkg.com/[email protected]/libraries/p5.gui.js" integrity="sha384-/Uy+8NNvYJjG164REYQP/RnqVcrnsFTtnoCJcLg/tVBKaHUaovtr+ZJgKQKHGAPW" crossorigin="anonymous"></script>

    <style>canvas { border: 1px solid black; border-radius: 1em; }</style>
</head>
<body>
` }}

{{ $footer := `
<noscript>To display this p5.js sketch, JavaScript must be enabled.</noscript>
</body>
</html>
`}}

{{ $additionalScript := `
var oldSetup = setup;
setup = () => {
    // Load saved settings from the browser hash fragment
    if (parent.location.hash && typeof params !== "undefined") {
        try {
            var settings = JSON.parse(atob(parent.location.hash.substring(1)));
            Object.keys(params).forEach((key) => params[key] = settings[key] || params[key]);
        } catch(ex) {
        }
    }

    oldSetup();

    createButton("play/pause").mousePressed(() => {
        if (isLooping()) {
        noLoop();
        } else {
        loop();
        }
    });

    createButton("save").mousePressed(() => {
        saveCanvas('photo', 'png')
    });

    createButton("clear").mousePressed(() => {
        if (typeof reset !== 'undefined') {
            reset();
        } else {
            clear();
        }
    });

    if (typeof params !== "undefined") {
        for (var el of document.querySelectorAll('input')) {
            if (el.id && el.id.startsWith('qs_')) {
                el.addEventListener('change', () => {
                    parent.location.hash = btoa(JSON.stringify(params));
                });
            }
        }
    }
}
`}}

<div class="tab">
    <button class="tablinks default" data-tabset="{{ $id }}" onclick="changeTab(event, '{{ $id }}', 'iframedemo')">Demo</button>
    {{ if (.Scratch.Get "tabs") }}
        {{ range $title, $content := .Scratch.Get "tabs" }}
        <button class="tablinks" data-tabset="{{ $id }}" onclick="changeTab(event, '{{ $id }}', '{{ $title }}')">{{ $title }}</button>
        {{ end }}
    {{ else }}
        <button class="tablinks" data-tabset="{{ $id }}" onclick="changeTab(event, '{{ $id }}', 'defaultscript')">Script</button>
    {{ end }}
</div>

<div class="tabcontent" data-tabset="{{ $id }}" id="iframedemo">
    <iframe 
        width="{{ $width }}" height="{{ $height }}" frameBorder="0"
        srcdoc="
            {{ $header }}
            <script>

            {{ if (.Scratch.Get "tabs") }}
                {{ range $title, $content := .Scratch.Get "tabs" }}
                    {{ $content }}
                {{ end }}
            {{ else }}
                {{ .Inner }}
            {{ end }}

            {{ $additionalScript }}
            </script>
            {{ $footer }}
        "
    ></iframe>
</div>

{{ if (.Scratch.Get "tabs") }}
    {{ range $title, $content := .Scratch.Get "tabs" }}
        <div class="tabcontent" data-tabset="{{ $id }}" id="{{ $title }}">
        {{ (printf "```javascript\n%s\n```" $content) | markdownify }}
        </div>
    {{ end }}
{{ else }}
    <div class="tabcontent" data-tabset="{{ $id }}" id="defaultscript">
    {{ (printf "```javascript\n%s\n```" .Inner) | markdownify }}
    </div>
{{ end }}

Each of these expects the source file being rendered to be in the same directory as the content being included. For example, this post has a directory structure like this. There’s a bit of a gotcha in that if a file has the HTML extension, funny things happen (it tries to render the file even without a header), but so it goes.

Each of the views builds on the one(s) before:

  • include.html: Directly includes content1
  • source.html: Includes source code as a code block
  • tab.html: Creates a new tab (should be nested in a tabs block)
  • sourcetab.html: Combines source and tab in a single command
  • tabs.html: Does the heavy lifting of rendering the included tabs
  • tabs.js: Included in the page to allow clicking the various tabs (and automatically clicks the first of each set)
  • p5js.html: Set up specifically for p5js entries, includes a live view of the entire script with some helper content2

The main magic comes about with Hugo’s scratch functions and how they work in shortcodes. In a nutshell, you can access a shortcodes .Parent element, which in turn comes with a .Scratch instance which can store semi-arbitrary content. So we can create a list of tabs like so:

{{ .Parent.Scratch.Add "tabs" (slice (dict "title" $title "content" $content)) }}

Originally, I directly used a dict to map $title to $content, but that has two problems:

  • You can’t have two pages with the same name
  • You lose the order of tabs (it will sort them alphabetically, but that’s not always what you want)

Most of the rest of the content comes from properly generating the HTML elements for the tabs, which I lifted mostly from this W3 Schools page, although I added a tabset ID to each element (either specified or generated automatically by a hash of the content) so that two sets of tabs can be on the same page.

And that’s really it. Hugo is really powerful, although working in a templating language is … a bit weird sometimes. It’s always fun (for me at least :)) to tune your tools though.

I’m curious if anyone else has done something similar/what you did. It works, but it feels somewhat imperfect and could always be better. Let me know!


  1. Yes, I know I’m explicitly trusting the content with safeHTML, which is an XSS waiting to happen… but I’m the only one that writes content to this blog, so the impact is minimal; this is the usecase for safeHTML ↩︎

  2. I’ll probably write up some of the tricks I used here in a future post ↩︎