Hugo + js = enhanced code shortcode

Hugo + js = enhanced code shortcode

Example on multiple minor additional features to enhance Hugo’s default way of showing code.

Hugo’s default capabilities to show code are fairly good, it supports highlighting and multiple ways of showing code. However i was missing a copy to clipboard and showing the code modal features. They are not mandatory features but nice to have, and while we are at it why not add captions as well (something that is oddly missing form Hugo’s default shortcodes).

This article was written for Hugo 0.70.

Override the default highlight shortcode

The shortcode is an extended version of the default Hugo highlight shortcode ( source). Overwriting the default shortcode is very simple, create a shortcode with the same name in your shortcode directory (/layouts/shortcodes/highlight.html) and you’re set.

Only the highlight shortcode is overwritten, not the markdown syntax “shortcode” because I only the use the former. You can override the markdown “shortcode”, if so desired.

Usage

The default capabilities are still supported, in addition the following options were added. All options are optional, if none are provided the shortcode is identical to Hugo’s original shortcode.

option   effect   optionaldefault
titleadds a title to the code snippetyes
captionadds a caption to the code snippetyes
attradds a source the code snippetyes
attrlinkadds a source link to the code snippet. If attr is set it will be used as link textyes
linkadds a link to the code snippetyes
linkTargettarget for linkyes_blank
linkTxtsets the text for the link. Only applicable if link is setyes
linkRelsets the rel attribute on the link. Only applicable if link is setyesnoreferrer if not set and target is _blank

Unfortunately due to Hugo’s limitations you cannot mix named (added by our shortcode) and unnamed parameters (used in the default shortcode). Therefore if you want to use the extended options you have to name the parameters from the default shortcode as well.

option   effect   optionaldefault
langsets the languageno
optionsspecifies the options for the highlighting engineyesnone

Example

{{< highlight lang="html" title="My code" caption="with caption" attr="Nelis Oostens" >}}
{{- with (.Get "link") -}}
    {{- $target :=  ($.Get "linkTarget") | default "_blank" -}}
    <p>Link: <a href="{{ . }}" target="{{ $target }}" {{- with $.Get "linkRel" -}}{{ . }}{{- end -}}>{{ default . (($.Get "linkTxt") | markdownify) }}</a><p>
{{- end -}}
{{< /highlight >}}
{{- with (.Get "link") -}}
    {{- $target :=  ($.Get "linkTarget") | default "_blank" -}}
    <p>Link: <a href="{{ . }}" target="{{ $target }}" {{- with $.Get "linkRel" -}}{{ . }}{{- end -}}>{{ default . (($.Get "linkTxt") | markdownify) }}</a><p>
{{- end -}}

My code

with caption

Source: Nelis Oostens

Code

The shortcode is straight forward, if there are named parameters add a figcaption if required and otherwise have the same behavior as Hugo’s default shortcode.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{{- if .IsNamedParams  -}}
{{ highlight (trim .Inner "\n\r") (.Get "lang") (default "" (.Get "options")) }}
{{- if or (.Get "link") (or (.Get "attrlink") (or (or (.Get "title") (.Get "caption")) (.Get "attr"))) -}}
<figcaption>
    {{- with (.Get "title") -}}
        <h4>{{ . }}</h4>
    {{- end -}}
    {{- with (.Get "caption") -}}
        <p>{{ . }}</p>
    {{- end -}}
    {{- if and (.Get "attr") (not (.Get "attrlink")) -}}
        <p>Source: {{ (.Get "attr") | markdownify }}</p>
    {{- end -}}
    {{- with (.Get "attrlink") -}}
        <p>Source: <a href="{{ . }}" target="_blank" rel="noreferrer">{{ default . (($.Get "attr") | markdownify) }}</a></p>
    {{- end -}}
    {{- with (.Get "link") -}}
        {{- $target :=  ($.Get "linkTarget") | default "_blank" -}}
        <p>Link: <a href="{{ . }}" target="{{ $target }}" rel="{{- with .Get "linkRel" }}{{ . | plainify }}{{ else }}{{ if eq $target "_blank" }}noreferrer{{ end }}{{ end -}}">{{ default . (($.Get "linkTxt") | markdownify) }}</a></p>
    {{- end -}}
</figcaption>
{{- end -}}
{{- else -}}
{{- if len .Params | eq 2 -}}{{ highlight (trim .Inner "\n\r") (.Get 0) (.Get 1) }}{{ else }}{{ highlight (trim .Inner "\n\r") (.Get 0) "" }}{{- end -}}
{{- end -}}

The accompanying CSS is very minimal, the height is limited to force scroll bars on large code snippets.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
.highlight > div > table {
  max-height: 500px; // Otherwise they will keep expanding and never have a scrollbar
}

.highlight > pre {
  max-height: 500px;
}

figcaption {
    display: flex;
    flex-wrap: wrap;
    font-weight: 300;
    font-size: $font-size-small;
    color: var(--font-color-light);
  
    > :first-child {
      padding-left: 0;
    }
  
    p {
      margin-top: 0;
      padding-left: 5px;
      display: inline-block;
      line-height: 1em !important;
    }
  
    h4 {
      margin-top: 0 !important;
      font-weight: bolder !important;
      display: inline-block !important;
      margin-bottom: 1rem !important;
    }
  }

Enhanced code shortcode

To provide a caption ect to non highlighted code a new shortcode was created. The options are identical to the overwritten highlight shortcode.

Usage

option   effect   optionaldefault
titleadds a title to the code snippetyes
captionadds a caption to the code snippetyes
attradds a source the code snippetyes
attrlinkadds a source link to the code snippet. If attr has been it will be used as link textyes
linkadds a link to the code snippetyes
linkTargettarget for linkyes_blank
linkTxtsets the text for the link. Only applicable if link is setyes
linkRelsets the rel attribute on the link. Only applicable if link is setyesnoreferrer if not set and target is _blank

Example

{{< code title="My code" caption="with caption" attr="Nelis Oostens" >}}
{{- with (.Get "link") -}}
    {{- $target :=  ($.Get "linkTarget") | default "_blank" -}}
    <p>Link: <a href="{{ . }}" target="{{ $target }}" {{- with $.Get "linkRel" -}}{{ . }}{{- end -}}>{{ default . (($.Get "linkTxt") | markdownify) }}</a><p>
{{- end -}}
{{< /code >}}
{{- with (.Get "link") -}}
    {{- $target :=  ($.Get "linkTarget") | default "_blank" -}}
    <p>Link: <a href="{{ . }}" target="{{ $target }}" {{- with $.Get "linkRel" -}}{{ . }}{{- end -}}>{{ default . (($.Get "linkTxt") | markdownify) }}</a><p>
{{- end -}}

My code

with caption

Source: Nelis Oostens

Code

The code is very similar to the overwritten highlight shortcode. It only supports named parameters. also notice htmlEscape which is required to handle the input as plain text.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<pre><code>{{ trim .Inner "\n" | htmlEscape | safeHTML }}</code></pre>
{{- if or (.Get "link") (or (.Get "attrlink") (or (or (.Get "title") (.Get "caption")) (.Get "attr"))) -}}
<figcaption>
    {{- with (.Get "title") -}}
        <h4>{{ . }}</h4>
    {{- end -}}
    {{- with (.Get "caption") -}}
        <p>{{ . }}</p>
    {{- end -}}
    {{- if and (.Get "attr") (not (.Get "attrlink")) -}}
        <p>Source: {{ (.Get "attr") | markdownify }}</p>
    {{- end -}}
    {{- with (.Get "attrlink") -}}
        <p>Source: <a href="{{ . }}" target="_blank" rel="noreferrer">{{ default . (($.Get "attr") | markdownify) }}</a></p>
    {{- end -}}
    {{- with (.Get "link") -}}
        {{- $target :=  ($.Get "linkTarget") | default "_blank" -}}
        <p>Link: <a href="{{ . }}" target="{{ $target }}" rel="{{- with .Get "linkRel" }}{{ . | plainify }}{{ else }}{{ if eq $target "_blank" }}noreferrer{{ end }}{{ end -}}">{{ default . (($.Get "linkTxt") | markdownify) }}</a></p>
    {{- end -}}
</figcaption>
{{- end -}}

The SCSS is the same as for the overwritten highlight shortcode with addition of limiting the height to force scrollbars on large snippets.

1
2
3
.pre-code pre {
    max-height: 500px;
  }

Code controls

Some simple features were added en enhance the UX. A copy (code) to clipboard button is added to each code snippet and because the height of the snippets is limited in the content a modal code overlay function was added as well.

The (fairly lightweight) clipboardjs library is used to copy the code to clipboard. At the time of writing 2.0.6.

For the tooltips the tooltips CSS library is used.

Usage

The controls are automatically added to each code snippet on the page, there are no options. This included Hugo’s default code shortcodes.

Code

First the HTML overlay is defined, it is very simple. The code-controls-template could also have been defined in Javascript but as explained the comment this approach was preferred over other solutions.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<div id="code-container" class="markdown">
    <div id="code-container-inner">
        <div id="code-placeholder"></div>
    </div>
    <div id="close-code-overlay"></div>
</div>
{{/*  Template for code controls. We can only use inline svg files in HTML, and i prefer to define HTML in HTML and not in JS anyway.  */}}
<div id="code-controls-template" class="hidden code-expanded-controls">
    <div title="Copy to clipboard" class="copy-to-clipboard">
        {{ partial "inline-svg" "clipboard" }}
    </div>
    <div class="hidden" aria-expanded="false" title="Close (ESC)">
        {{ partial "inline-svg" "compress-alt" }}
    </div>
    <div aria-expanded="false" title="Expand">
        {{ partial "inline-svg" "expand-alt" }}
    </div>
</div>

Javascript is used to inject the code controls into every code snippet and wire up the events. First get the control template and prepare it to be inserted (copied) into other controls. Go through all highlight code snippets and insert the code controls, the same applies for the regular code snippets but they have to be filtered first to only include actual code snippets and not just single words/sentences. Due to how the CSS rules are set-up regular code snippets have to be wrapped before the controls can be added. If at least one “code control” element was inserted in the page the clipboardjs library is initialized. Because the copy to clipboard buttons do not contain the to-be-copied-content (inner text) the text function is overwritten to point to the corresponding code element. Finally a tooltip is shown if the code/text was copied to clipboard to inform the user.
To show the code modal the HTML elements containing the code are copied to the HTML overlay element which is made visible.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
window.addEventListener("load", function () {
    // Get the HTML code controls template
    const controlTemplate = document.getElementById("code-controls-template").cloneNode(true);
    controlTemplate.classList.remove("hidden");
    controlTemplate.id = "";

    // Get the code overlay element
    const codeOverlay = document.getElementById("code-container");
    codeOverlay.querySelector("#close-code-overlay").addEventListener("click", HideCodeOverlay);

    let addedCopyClipboardButtons = 0;

    // Add controls to the highlight elements
    document.querySelectorAll(".highlight").forEach(highlightElement => {
        highlightElement.prepend(copy(controlTemplate));
        SetUpCodeControls(highlightElement);
    });

    // Add controls to code elements
    document.querySelectorAll("code").forEach(c => {
        if (c.parentNode.nodeName === "PRE" && (c.parentNode.parentNode.classList.contains("highlight") || c.parentNode.parentNode.nodeName === "TD")) // Highlights are already handled
        {
            return;
        }
        if (c.parentNode.nodeName != "PRE") { // Exclude inline code snippets
            return;
        }
        if (c.childNodes.length === 0 && !(/\r|\n/.exec(c.textContent.index))) { // Filter out non highlighted one line code snippets
            return;
        }

        // Wrap the element (to make our CSS work) and add the controls
        const codeWrapper = document.createElement("div");
        codeWrapper.classList.add("pre-code");
        codeWrapper.appendChild(copy(controlTemplate));
        c.parentNode.parentNode.insertBefore(codeWrapper, c.parentNode);

        codeWrapper.appendChild(c.parentNode); // Move the original element in the wrapper

        SetUpCodeControls(codeWrapper);
    });

    // Wire Copy to clipboard
    if (addedCopyClipboardButtons > 0) {
        const clipboard = new ClipboardJS('.copy-to-clipboard', {
            text: function (trigger) {
                const code_elements = trigger.parentNode.parentNode.querySelectorAll("code");
                return code_elements[code_elements.length - 1].textContent.replace(/^\$\s/gm, ''); // Warning: must work for Highlight and code sections; therefore we take the last code element which is found
            }
        });
        clipboard.on('success', function (e) {
            e.clearSelection();
            showTooltip(e.trigger, 'Copied!');
        });
        clipboard.on('error', function (e) {
            showTooltip(e.trigger, fallbackMessage(e.action));
        });
    }

    function SetUpCodeControls(element) {
        ++addedCopyClipboardButtons;

        // Wire expand button
        element.querySelector(".code-expanded-controls").children[2].addEventListener("click", ShowCodeOverlay);
    }

    function ShowCodeOverlay(event) {
        const copiedCode = (event.target.closest(".highlight") || event.target.closest(".pre-code")).cloneNode(true);
        const copiedControls = copiedCode.querySelector(".code-expanded-controls");
        copiedControls.style.display = "flex";                    // Always show
        copiedControls.children[1].classList.remove("hidden");    // Show close button
        copiedControls.children[2].classList.add("hidden");       // Hide expand button

        // Wire collapse button
        copiedControls.children[1].addEventListener("click", HideCodeOverlay);

        // Wire ESC button
        document.addEventListener("keyup", HideCodeOverlayOnESC);

        // Prevent the background from scrolling
        const scrolledBodyWidth = document.body.offsetWidth;
        codeOverlay.setAttribute("padding", document.body.style.paddingRight);
        document.body.classList.add("noscroll");
        document.body.style.paddingRight = (document.body.offsetWidth - scrolledBodyWidth) + 'px'; // Otherwise the page width might change

        const codePlaceholder = codeOverlay.querySelector("#code-placeholder");
        codePlaceholder.innerHTML = "";
        codePlaceholder.appendChild(copiedCode);
        codePlaceholder.querySelector("code").focus();
        codeOverlay.setAttribute("open", true);
        codeOverlay.style.display = "block";

        document.querySelector("main").classList.add("blur");
    }

    function HideCodeOverlay(_) {
        document.removeEventListener("keyup", HideCodeOverlayOnESC);

        // TODO this will not fix everything; position fixed elements might still be affected
        document.body.classList.remove("noscroll");
        document.body.style.paddingRight = codeOverlay.getAttribute("padding");

        document.querySelector("main").classList.remove("blur");

        codeOverlay.querySelector(".code-expanded-controls").children[1].removeEventListener("click", HideCodeOverlay);
        codeOverlay.style.display = "none";
        codeOverlay.removeAttribute("open");
    }

    function HideCodeOverlayOnESC(event) {
        if (codeOverlay.getAttribute("open") && (event.key === "Escape" || event.key === "Esc")) {
            HideCodeOverlay(event);
        }
    }
});

function copy(controlTemplate) {
    const copy = controlTemplate.cloneNode(true);
    copy.children[0].addEventListener('mouseleave', clearTooltip);
    copy.children[0].addEventListener('blur', clearTooltip);
    return copy;
}

function clearTooltip(e) {
    e.currentTarget.classList.remove('tooltipped', 'tooltipped-s');
    e.currentTarget.removeAttribute('aria-label');
}

function showTooltip(elem, msg) {
    elem.classList.add('tooltipped', 'tooltipped-s');
    elem.setAttribute('aria-label', msg);
}

function fallbackMessage(action) {
    const actionMsg = ''; var actionKey = (action === 'cut' ? 'X' : 'C'); if (/iPhone|iPad/i.test(navigator.userAgent)) { actionMsg = 'No support :('; }
    else if (/Mac/i.test(navigator.userAgent)) { actionMsg = 'Press ⌘-' + actionKey + ' to ' + action; }
    else { actionMsg = 'Press Ctrl-' + actionKey + ' to ' + action; }
    return actionMsg;
}

The accompanying SCSS, nothing special.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
@mixin overlay {
    display: none;
    z-index: 9999;
    position: fixed;
    width: 100%;
    left: 0;
    right: 0;
    top: 0;
    bottom: 0;
    background-color: rgba(var(--line-color), 0.2);
  }
  

// Code-overlay

#code-container {
    @include overlay;
}

#code-container-inner {
    display: flex;
    flex-direction: column;
    margin: 2.5rem auto;
    width: 90vw;
    max-width: 900px;
    max-height: calc(100vh - 2 * 2.5em);
}

#code-placeholder {
    flex-grow: 1;
    overflow: hidden;

    // To make sure they can scroll
    .pre-code pre {
        max-height: calc(100vh - 2 * 3em);
        max-width: 900px;
        margin: 0;
    }

    .highlight>div>table {
        max-height: calc(100vh - 2 * 2.5em);
        max-width: 900px;
    }

    .highlight {
        max-height: calc(100vh - 2 * 2.5em);
        max-width: 900px;
    }
}

#close-code-overlay {
    display: block;
    position: absolute;
    left: 0;
    right: 0;
    top: 0;
    bottom: 0;
    z-index: -1;
    cursor: pointer;
}

// Code controls

.code-expanded-controls {
    display: none;
    justify-content: flex-end;
    padding: 20px;
    position: absolute;
    right: 0px;

    div {
        margin: 0px 5px;
        width: 25px;
        height: 25px;
        font-size: 1.25em;
        cursor: pointer;
        background-color: rgba(255, 255, 255, 0.5);
        border-radius: $border-radius;
        z-index: 10;
        text-align: center;

        &:hover {
            opacity: 0.7;
        }
    }
}

.highlight,
.pre-code {
    position: relative;

    &:hover .code-expanded-controls {
        display: flex;
    }
}

.hidden {
    display: none;
}

.noscroll {
    overflow: hidden;
}

.blur {
    -webkit-filter: blur(2px);
    -moz-filter: blur(2px);
    -o-filter: blur(2px);
    -ms-filter: blur(2px);
    filter: blur(2px);
}

Don’t forget to include clipboardjs and tooltips.scss as well.

Noticed an error in this post? Corrections are appreciated.

© Nelis Oostens