Christopher Pitt Software Developer

Custom Web Components to Highlight Code

Last updated on 11th April 2024

I’ve just been down a rabbit hole. It started with wanting to cut back on boilerplate and ended with being able to highlight code in Reveal.js using tempest/highlight. Along the way, I made my first web component.

I’ve been building my portfolio website, and recently needed to give a talk. I whipped up a “decks” feature, which uses Reveal.js to render HTML which I have stored in a database.

This works fine, and the presentation went well. Looking back on it, though…I had to type a lot of boilerplate:

<pre><code data-trim class="php">
public function resolveHttp(): stdClass
{
    $domain = request()->getHost();
    $tenants = config('tenants');

    foreach ($tenants as $tenant) {
        if (str($tenant['domain'])->is($domain)) {
            return to_object($tenant);
        }
    }

    return $this->defaultTenant();
}
</code></pre>

I’m specifically referring to the <pre><code data-trim class="php"> repeated for every code block. What’s more, the syntax highlighting is done with Highlight.js, so it looks different from the syntax highlighting on the rest of the app.

I wasn’t thinking about this second problem when I decided to try and make a web component to solve the boilerplate issue. At first, I struggled to realise that you can just have and use child components inside a custom web component. This is what I ended up with:

customElements.define('w-code', class extends HTMLElement {
    constructor() {
        super();

        const pre = document.createElement('pre');

        const code = document.createElement('code');
        code.setAttribute('data-trim', '1');
        code.innerHTML = this.innerHTML;
        code.classList.add(this.getAttribute('lang') || 'txt');

        this.innerHTML = '';

        this.appendChild(pre);
        pre.appendChild(code);
    }
});

I like the w-* prefix for these, because that makes it easier to differentiate from x-* (Blade) components in my codebase. This already makes the boilerplate issue better, because I can use <w-code lang="php">...</w-code> instead of <pre><code data-trim class="php">...</code></pre>.

It did get me thinking, though. If I could just move innerHTML around like that; why not use Tempest to highlight it instead?

So, I made the following responder:

namespace App\Http\Responders;

use Illuminate\Http\Request;
use Tempest\Highlight\Highlighter;

class HighlightCodeResponder
{
    public function __invoke(Request $request): array
    {
        $code = $request->input('code');
        $language = $request->input('language');

        $highlighter = new Highlighter();

        return [
            'highlighted' => html_entity_decode(
                $highlighter->parse($code, $language)
            ),
        ];
    }
}

This is from app/Responders/HighlightCodeResponder.php

Any JS code on my app can post raw code to this and get highlighted code back. The component needs to change, a bit, though:

customElements.define('w-code', class extends HTMLElement {
    constructor() {
        super();

        axios.post('/highlight', {
            language: this.getAttribute('lang') || 'txt',
            code: this.innerHTML,
        })
            .then((response) => {
                const code = document.createElement('code');
                code.innerHTML = response.data.highlighted;

                const pre = document.createElement('pre');
                pre.classList.add('code-wrapper');
                pre.appendChild(code);

                this.innerHTML = '';
                this.appendChild(pre);
            });
    }
});

If you’re particularly interested in reusing this for Reveal.js purposes, then you’ll also need the corresponding CSS:

@import "../../vendor/tempest/highlight/src/Themes/highlight-light-lite.css";

.reveal .code-wrapper {
    box-shadow: none;
    background: white;
}

.reveal .code-wrapper code {
    line-height: 150%;

    background:

        linear-gradient(
            white 30%,
            rgba(255, 255, 255, 0)
        ) center top,

        linear-gradient(
            rgba(255, 255, 255, 0),
            white 70%
        ) center bottom,

        radial-gradient(
            farthest-side at 50% 0,
            rgba(0, 0, 0, 0.2),
            rgba(0, 0, 0, 0)
        ) center top,

        radial-gradient(
            farthest-side at 50% 100%,
            rgba(0, 0, 0, 0.2),
            rgba(0, 0, 0, 0)
        ) center bottom;

    background-repeat: no-repeat;
    background-size: 100% 40px, 100% 40px, 100% 14px, 100% 14px;
    background-attachment: local, local, scroll, scroll;
}

This is what it looks like, including cool shadows to indicate the content is scrollable:

Edit:

After posting this, I wrestled a bit of rate limiting. Making a new HTTP request for each highlight block isn't great if you need to do this after each page refresh. It would be ideal if we could cache the highlighted code in the browser and on the server.

namespace App\Http\Responders;

use Illuminate\Http\Request;
use Tempest\Highlight\Highlighter;

class HighlightCodeResponder
{
    public function __invoke(Request $request): array
    {
        $code = $request->input('code');
        $language = $request->input('language');

        $hash = sha1($language.$code);

        return cache()
            ->remember("highlight-{$hash}", 60 * 60 * 24, fn() => [
                'highlighted' => html_entity_decode(
                    (new Highlighter())->parse($code, $language)
                ),
            ]);
    }
}
customElements.define('w-code', class extends HTMLElement {
    constructor() {
        super();

        const language = this.getAttribute('lang') || 'txt';
        const code = this.innerHTML;

        const hash = sha1.create();
        hash.update(`${language}${code}`);
        hash.hex();

        let cache = localStorage.getItem(hash.hex());

        if (cache) {
            this.innerHTML = '';
            this.appendChild(preFromHighlighted(cache));
        } else {
            axios.post('/highlight', {
                language,
                code,
            })
                .then((response) => {
                    const highlighted = response.data.highlighted;
                    localStorage.setItem(hash.hex(), highlighted);

                    this.innerHTML = '';
                    this.appendChild(preFromHighlighted(highlighted));

                    this.style.visibility = 'visible';
                });

            this.style.visibility = 'hidden';
        }
    }
});

function preFromHighlighted(highlighted) {
    const code = document.createElement('code');
    code.innerHTML = highlighted;

    const pre = document.createElement('pre');
    pre.classList.add('code-wrapper');
    pre.appendChild(code);

    return pre;
}

This reduces work on the server (returning cached highlighted code when possible) and work on the client. Smort!