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 content1source.html
: Includes source code as a code blocktab.html
: Creates a new tab (should be nested in atabs
block)sourcetab.html
: Combinessource
andtab
in a single commandtabs.html
: Does the heavy lifting of rendering the included tabstabs.js
: Included in the page to allow clicking the various tabs (and automatically clicks the first of each set)p5js.html
: Set up specifically forp5js
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!