Create a Wagtail Code Block with highlightjs

Introduction

In this article, we'll work through creating a custom Wagtail block to display code with automatic syntax highlighting using the popular highlightjs library. The finished block will offer the editor a choice of languages (configurable by the developer) and formats to display the code with (simple, collapsible and collapsed).

In setting up the code block, we'll use a few concepts, including:

  • extending default wagtail block types
  • customising the block admin form with css
  • adding additional elements with interaction to use an external library to process entered data

With the latter case, we'll use a solution that also determines if there are missing libraries and loads those as required for that page instance.

the completed code block admin interface
Before starting

In case you are just looking for a simpler solution, there are ready made packages that will give you a highlighted code block. wagtail-code-blocks is one example. This example will give you more flexibility in design and UX, or the methodology may just be useful to give inspiration for other solutions.

Although not necessary for this article, you should read through the basic highlightjs documentation at least to get an overview of how it works, and how to configure it.

For the collapsible functionality, I'm making use of Bootstrap's accordion feature. If you want to make use of this feature but don't use Bootstrap, you'll need to adjust the template accordingly.

This article assumes you have at least a basic understanding of how to add JavaScript functionality to a Wagtail StructBlock using the StructBlockAdapter to register a Telepath class. If this is something you are not familiar with, the following articles can be useful to read first:

Use Case

Our code block will meet the following requirements:

  • a StructBlock specifically for displaying blocks of code with syntax highlighting
  • option to make the block collapsible, and to be open or closed by default on page load
  • option to display a title for the code block
  • option to disable extra spacing at after the block to allow the block to render more compactly when inserted into a flow of text rather than as a standalone piece of content
  • the rendered code block should include a button to assist copying the contents to the clipboard
  • a preview feature on the admin interface to allow the editor to quickly see the parsed & formatted code without needing to load the Wagtail Preview panel

Highlightjs Language Libraries

This example loads highlightjs libraries and css directly from the jsdelivr CDN.

The minified highlightjs base library from that location bundles the following languages (v11.10 at the time of writing):
'bash', 'c', 'cpp', 'csharp', 'css', 'diff', 'go', 'graphql', 'ini', 'java', 'javascript', 'json', 'kotlin', 'less', 'lua', 'makefile', 'markdown', 'objectivec', 'perl', 'php', 'php-template', 'plaintext', 'python', 'python-repl', 'r', 'ruby', 'rust', 'scss', 'shell', 'sql', 'swift', 'typescript', 'vbnet', 'wasm', 'xml', 'yaml'

The bundled languages will depend on the source you get the base highlightjs library from. If you're unsure what's included in your highlightjs library, create an empty .html file, load the library as a script with no other libraries and in the console, type hljs.listLanguages();.

There are around 190 languages supported with 3rd party libraries for another 60 or so. For a full list, see the Supported Languages page. If any of the languages you will use has a link in the package column of that page, you will need to download that package and load it separately in the StuctBlockAdapter later.

Hosting the Language Libraries Locally

fa-solid fa-circle-info Skip to the next section if using the CDN

If you don't want to use the CDN and keep the libraries local instead, you will need to visit the highlightjs download page and select all the language highlighters you'll be using. When you download that, you'll get a zip file with the language files and the base highlight library. You'll find the standard and minified versions for each. We'll just want the minified versions here. Alternatively, you can browse the CDN for the minified language file directly.

If you are using 3rd party language libraries, download to the same directory described below. Make sure the library filename has the form language.min.js where language is the alias name (use the first name if there are multiple aliases).

Some language files are dependent on highlightjs being loaded first before they can be loaded. Loading the language files in the StructBlockDefinition class as described later ensures that this always is true.

organise the highlight libraries in the code-highlight static folder
Suggested Local File Structure:

In your static directory (select the app in your project that suits you best), create a base folder with the following path: js/code-highlight. You will need to use this base path in the StructBlockAdapter later. Add the base highlight library (highlight.min.js) to this folder.

To this base folder, add a subdirectory /languages and copy the minified language files to that directory. Include any 3rd party language library in this folder also (see note previously).

Your file structure should resemble the image to the left.

If you will also host the theme stylesheet locally, then download that to your static directory with the path css/code-highlight/.

Back-end

The Code StructBlock

I'm in the habit of creating blocks in their own definition file and gathered in a single blocks app. It keeps them easy to find and easily portable to other projects. We'll create the code block in code_block.py (I'll leave you to decide where best to place that in your project).

Imports

I'll start with the imports we'll need for code_block.py:

code_block.py: imports
Copy
from django import forms
from django.templatetags.static import static
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from wagtail.blocks import BooleanBlock, CharBlock, ChoiceBlock, RawHTMLBlock, StructBlock
from wagtail.blocks.struct_block import StructBlockAdapter
from wagtail.telepath import register

Language Choice Block

In the choices array, the first element of each tuple (the choice value) should match the language of each that you'll offer (i.e. it should exactly match the checkbox label on the download page, or the alias in the supported languages page - choose the first alias if there are multiple aliases).

code_block.py: CodeChoiceBlock
Copy
class CodeChoiceBlock(ChoiceBlock):
    choices = [
        ('plaintext', _('Plain Text')),
        ('python', 'Python'),
        ('css', 'CSS'),
        ('django', _('Django Template')),
        ('javascript', 'Javascript'),
        ('typescript', 'Typescript'),
        ('xml', 'HTML / XML'),
        ('json', 'JSON'),
    ]

Adjust the choices to suit your own use case.

Collapsible ChoiceBlock

If you want to add the ability to make your code blocks collapsible (see the code block below, click on the title to collapse), we'll use a choice block for the relevant options:

Copy
class CollapsibleChoiceBlock(ChoiceBlock):
    choices = [
        ('', _('Not Collapsible')),
        ('collapsible', _('Collapsible')),
        ('collapsed', _('Collapsed')),
    ]

The default is 'Not Collapsible' - a title bar will be displayed if title has a value, otherwise only the code will be displayed. collapsible is expanded on page load, collapsed is collapsed when the page loads and only expanded on click.

Code Block Class

We're ready to define the code block now.

In order to store the highlighted code markup, we'll use a RawHTMLBlock.

We'll hide the textarea widget for the code child block from the admin interface in the StructBlockDefinition class later on and create an extra one to enter the raw code text into. This class will take care of processing the raw code into highlighted HTML markup and storing that value in the hidden textarea.

In addition to our fields required to meet the use case, we need to add some context to provide translatable text to support multilingual sites and paths to load additional libraries on the front end template.

CodeBlock
Copy
class CodeBlock(StructBlock):
    base_library_path = "https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/"
    theme_path = base_library_path + "styles/github-dark-dimmed.min.css"

    title = CharBlock(label=_("Title"), required=False)
    collapsible = CollapsibleChoiceBlock(label=_("Format"), required=False)
    language = CodeChoiceBlock(label=_("Language"), default='python')
    code = RawHTMLBlock(label=_("Code"))
    bottom_padding = BooleanBlock(
        label=_("Include extra space beneath code block?"),
        required=False,
        default=True
    )

    def get_context(self, value, parent_context=None):
        context = super().get_context(value, parent_context)
        context['expand_prompt'] = _("Click to expand")
        context['copy_button_text'] = {
            'copy': _("Copy"),
            'copied': _("Copied"),
            'error': _("Error"),
        }
        # paths - only required if loading libraries in block template
        context['paths'] = {
            'themeCSS': self.theme_path,
            'codeBlockCSS': static('css/code-highlight/code-block.css'),
            'codeBlockJS': static('js/code-highlight/code-block.js'),
        }
        return context

    class Meta:
        template = "blocks/code-block-wrapper.html"
        icon = "code"
        label = _("Code Block")
        label_format = _("Code") + ": {language}"
        form_classname = "struct-block code-block"
Context
  • expand_prompt & copy_button_text contain text for the collapse and copy buttons, included here to allow multilingual sites to provide translations.
  • paths sets out paths for the highlightjs theme and block css & js that we'll use on the front end. Amend the theme path to match the theme that you will use. Note, this is only required if you load the front-end css & js libraries from the block template - this is discussed later when we create the code block templates.
Meta
  • The template specified (to be created below) will handle adding the collapsible markup (if required) before including a template for the code block. If you're not using the collapsible feature in your block, set this to blocks/code-block.html instead.
  • label_format helps identify the block instance in the slide-out minimap panel in Wagtail admin.
  • form_classname will be used in the admin CSS to modify the layout and styles for the block admin form.

StructBlockAdapter

For the StructBlockAdapter, we need to load the JavaScript libraries for highlightjs and the Telepath StructBlockDefinition class (which we will define next), and for the highlighter theme css.

We'll build paths for these and pass through a base path for additional languages through to the StructBlockDefinition where they will be loaded as needed. We'll also pass through a couple of text literals to keep the editor interface multilanguage compatible.

Choosing a Theme

The theme you use here is for the preview tab in the admin form only, it does not affect the highlighted markup at all. You can load any theme you like on your front-end regardless of the theme used here.

I've used the github-dark theme in the code below. If you want to use a different theme and are unsure which to use, go to the highlightjs demo page where you can enter some code and try different themes.

If you're hosting the highlightjs files rather than using the CDN, the styles can be downloaded from jsdelivr.

In code_block.py, after the CodeBlock definition, add the following:

code_block.py: CodeBlockAdapter
Copy
class CodeBlockAdapter(StructBlockAdapter):
    js_constructor = "blocks.code_block.CodeBlock"

    base_library_path = "https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/"
    language_base_path = base_library_path + "languages/"
    admin_theme_path = base_library_path + "styles/github-dark.min.css"

    def js_args(self, block):
        args = super().js_args(block)
        # keys added to args[2] found in this.meta in StructBlockDefinition
        args[2]['language_base_path'] = self.language_base_path
        args[2]['text'] = {
            'languageScriptErrorLabel': _("Failed to load language"),
            'highlighterErrorLabel': _("Error highlighting code")
        }
        return args
        
    @cached_property
    def media(self):
        structblock_media = super().media
        return forms.Media(
            js=structblock_media._js + [
                "js/admin/code-block-adapter.js",
                f"{self.base_library_path}highlight.min.js"
            ],
            css={"all": (
                "css/admin/code-block-form.css",
                self.admin_theme_path
            )},
        )

register(CodeBlockAdapter(), CodeBlock)
  • js_constructor="blocks.code_block.CodeBlock" - This is telling Telepath to construct the StructBlockDefinition class from the class CodeBlock found in the module blocks/code_block.py. Modify this value if your CodeBlock is in a different path.
  • base_library_path - the path to the directory which contains the highlightjs.min.js library and used here to build the path to the additional language libraries and theme css. If you are hosting the files rather than using the CDN, change this to the appropriate location.
  • language_base_path - the directory to source any language files not bundled in the highlightjs.min.js library.
  • admin_theme_path - the theme stylesheet to use for the preview panel.
  • js_args - values to pass into the StructBlockDefinition class: the path to load extra language files from and string literals for multilingual support.
  • media - load the JavaScript and CSS needed for the block admin form. code-block-adapter.js will contain the StructBlockDefinition class and code-block-form.css will contain the styles for the StructBlock admin form. We will build these next.
  • register - finally, the CodeBlockAdapter is registered with Telepath as the adapter to use with the CodeBlock.
If you are using the CDN but need to load 3rd party language libraries (those not hosted by highlightjs), you should add these to the js attribute of the forms.media above.

StructBlockDefinition Class

This is where we modify existing, and add new, elements to the StructBlock form, and provide the interaction to convert entered text into highlighted html. We'll also add the preview panel and tabs to switch between editor and preview mode.

We'll create a CodeBlock JavaScript class which will provide the following functionality:

  • hide the textarea widget for the code RawHTMLBlock
  • create a new textarea element which the editor will enter raw code into
  • update the hidden code textarea with highlighted markup based on entered raw code and the selected language
  • provide a preview panel that the editor can reveal via a tab selection

The raw code is not stored in the CodeBlock and is discarded on page save.

In the StructBlockDefinition class, a new instance of the CodeBlock JavaScript class is created each time the block is rendered. It's important that this class is spawned outside of the StructBlockDefinition as this class is only instantiated once per page load. Making the changes to each this.block directly will correctly add the necessary markup, but the event listeners will all point to the last created instance rather than the instance in the same block.

During the render, we'll load any languages listed in the choice block not already loaded in the highlightjs library, configure the CodeBlock admin form with necessary modifications and add event listeners to handle changes to the entered code, tab clicks and changes to the selected language.

In the StructBlockApdapter, we added static/js/admin/code-block-adapter.js to the list of js media files to load. Create that file now and add the following:

Copy
class CodeBlock {
    constructor(structBlock, meta) {
        this.block = structBlock;
        this.meta = meta;
        // code child block textarea widget
        this.codeTextarea = this.block.childBlocks.code.widget.input;
        // language child block select widget
        this.languageSelector = this.block.childBlocks.language.widget.input;
        // set up block form
        this.configureBlockAdminForm();
        this.addEventListeners();
        this.registerMissingLanguages();
        this.previewActive = false;
    }

    configureBlockAdminForm = () => {
        // hide rawHTMLBlock textarea widget
        this.codeTextarea.style.setProperty('display', 'none');

        // create code editor textarea
        this.codeEditor = document.createElement('textarea');
        this.codeEditor.dataset.controller = "w-autosize";
        this.codeEditor.className = "w-field__autosize";
        this.codeEditor.setAttribute('spellcheck', 'false');
        this.codeTextarea.after(this.codeEditor);
        // set initial value from code innerText (extracts raw code from highlighted html)
        if (this.codeTextarea.value) {
            const tempDiv = document.createElement('div');
            tempDiv.innerHTML = this.codeTextarea.value;
            this.codeEditor.value = tempDiv.innerText;
        }

        // create preview container, insert after textarea
        this.preview = document.createElement('div');
        this.preview.className = "code-block-preview";
        this.codeTextarea.after(this.preview);

        // create write/preview tabs, insert before textarea
        this.tabs = document.createElement('div');
        this.tabs.className = "code-block-tabs"
        this.writeTab = document.createElement('label');
        this.writeTab.className = "w-field__label code-block-tab active";
        this.writeTab.innerText = "Write"
        this.tabs.appendChild(this.writeTab);
        this.previewTab = document.createElement('label');
        this.previewTab.className = "w-field__label code-block-tab";
        this.previewTab.innerText = "Preview"
        this.tabs.appendChild(this.previewTab);
        this.codeTextarea.before(this.tabs);

        // placeholder for displaying any highlighter errors
        this.highlighterErrors = document.createElement('div');
        this.highlighterErrors.className = "code-block-highlighter-errors"
        this.block.childBlocks.code.field.prepend(this.highlighterErrors);
    }

    addEventListeners = () => {
        // code editor content changed - convert entered code to highlighted markup
        this.codeEditor.addEventListener('input', this.getHighlightCodeHTML.bind(this));
        // language changed - parse entered code with new language setting
        this.languageSelector.addEventListener('change', this.getHighlightCodeHTML.bind(this));
        // tab clicks - show/hide the preview pane
        this.writeTab.addEventListener('click', this.showPreview.bind(this, false));
        this.previewTab.addEventListener('click', this.showPreview.bind(this, true));
    }

    getHighlightCodeHTML = () => {
        this.highlighterErrors.innerText = '';
        // parse entered code with hljs, set this as the code child block value
        let parsedCode = {};
        try {
            parsedCode = hljs.highlight(this.codeEditor.value, { language: this.languageSelector.value, ignoreIllegals: 'true' });
        } catch (error) {
            // on error, plain text used, error displayed above tabs
            parsedCode.value = this.codeEditor.value;
            const errMessage = `${this.meta.text.highlighterErrorLabel}: ${error.message}`;
            console.error(errMessage);
            this.highlighterErrors.innerText = errMessage;
        }
        // wrap parsedCode value with <pre><code> tags - hljs class used by theme css, language class added for highlightjs consistency
        // if no parsedCode, return empty string so code child block required=true is enforced on page save
        this.codeTextarea.value = parsedCode.value 
            ? `<pre><code class="language-${this.languageSelector.value} hljs">${parsedCode.value}</code></pre>` 
            : '';
        // set the preview panel inner html from the code child block value
        this.preview.innerHTML = this.codeTextarea.value;
    }

    showPreview = (active) => {
        // set css classes to show/hide the preview panel, update tabs
        if (active === true) this.preview.innerHTML = this.codeTextarea.value;
        this.codeTextarea.parentElement.classList.toggle('preview-active', active);
        this.writeTab.classList.toggle('active', !active);
        this.previewTab.classList.toggle('active', active);
        this.previewActive = active;
    }

    registerMissingLanguages() {
        // check registered languages against those in language choice block, add script for each missing one
        let errors = [];
        const availableLanguages = hljs.listLanguages();
        const optionValues = Array.from(this.languageSelector.options)
            .map(option => option.value)
            .filter(value => value);
        const missing_languages = optionValues.filter(optionValue => !availableLanguages.includes(optionValue));
        if (missing_languages) {
            // if scripts fail to load, write simple message to block form and error message with path to console
            const scriptPromises = missing_languages.map(language => {
                return new Promise((resolve, reject) => {
                    const script = document.createElement('script');
                    script.src = `${this.meta.language_base_path}${language}.min.js`;
                    script.onload = () => {
                        resolve();
                    };
                    script.onerror = () => {
                        const displayError = `${this.meta.text.languageScriptErrorLabel}: ${language}`;
                        const consoleError = `${displayError} (${script.src})`;
                        errors.push([displayError, consoleError]);
                        reject(new Error(consoleError));
                    };
                    document.head.appendChild(script);
                });
            });
            // When all scripts are either loaded or failed, report any errors
            Promise.allSettled(scriptPromises).then(() => {
                if (errors.length > 0) {
                    // displayError
                    this.highlighterErrors.innerText = errors.map(error => error[0]).join('\n');
                    // consoleError
                    errors.forEach(error => console.error(error[1]));
                }
            });
        }
    }
}

class CodeBlockDefinition extends window.wagtailStreamField.blocks
    .StructBlockDefinition {
    render(placeholder, prefix, initialState, initialError) {
        this.block = super.render(
            placeholder,
            prefix,
            initialState,
            initialError,
        );
        new CodeBlock(this.block, this.meta);
        return this.block;
    };
}

window.telepath.register('blocks.code_block.CodeBlock', CodeBlockDefinition);
CodeBlock
  • registerMissingLanguages - compare the list of languages in the language selector with those already registered. If any are not registered, load the missing language library asynchronously using the language_base_path variable passed in the StructBlockAdapater earlier. Report back any errors to the block form and to the console.
  • configureBlockAdminForm:
    • hide the code child block textarea
    • create an unbound textarea to take the raw code (codeEditor) with attributes of a Wagtail autosize textarea widget and spellcheck disabled
    • if code has a value on load, extract the raw code from the innerText value of the saved highlighted markup and set this as the initial codeEditor value
    • create the preview panel, edit/preview tabs
    • add an error container for any reported errors.
  • addEventListeners - event listeners to handle the following:
    • changes to the entered code (code highlighted on change and written to the hidden code textarea).
    • change to the selected language (same as changes to entered code but also updates the preview panel if shown).
    • clicking on the 'Write' or 'Preview' tab to show/hide the preview panel.
  • getHighlightCodeHTML - parse entered code with highlightjs, set this as the code child block value. Wrap parsed code value with <pre><code> tags. Add hljs class (used by theme css). language-xxx class added for highlightjs consistency. If no parsed code, return empty string so that code child block required=true is enforced on page save.
  • updatePreview - set the preview panel inner html from the code child block value (the parsed highlighted markup).
  • showPreview - show/hide the preview panel
CodeBlockDefinition
  • call the standard StructBlockDefinition.render() method and store result in this.block.
  • spawn a new CodeBlock instance with this.block and this.meta (meta holds the values we passed in with js_args in the Python CodeBlockAdapter class.

window.telepath.register('blocks.code_block.CodeBlock', CodeBlockDefinition)

  • register the CodeBlockDefinition with Telepath as the definition for the CodeBlock.
  • the path in the first parameter must match the path to your CodeBlock Python class (in this case blocks/code_block.py) - update to your own case.

Style the StructBlock Admin Form

We need to add some styling and classes for our custom block admin form to render everything correctly, including showing and hiding the preview panel.

Wagtail is extremely generous with it's spacing on the admin interface. For my taste, too generous - 4 select fields and it's already filled a laptop screen. We'll add some styling to put the title and select fields on the same row and to put the check box alongside the label instead of requiring an entire row for itself. It'll be responsive so that the fields wrap on smaller screens. We'll also set a monospace font for the code textarea element (use a monospace font relevant to your site). If you prefer to leave the layout as standard, copy from end flex layout onwards.

The CSS will also add styling for the write/preview tabs and class definitions to show/hide the panels in response to clicks on those tabs, and styling for any error messages.

In our CodeBlockAdapter, we specified a stylesheet with the path /static/css/admin/code-block-form.css. Create that file and add the following styles:

Copy
/* ======= start flex layout ======= */
/* use flex on the StructBlock form to allow responsive layout */
div.struct-block.code-block {
    display: flex;
    flex-wrap: wrap;
    column-gap: 2rem;
}
/* set title to min width 300px */
div.struct-block.code-block div[data-contentpath="title"] {
    flex-grow: 1;
    flex-basis: 300px;
}
/* set code to full width */
div.struct-block.code-block div[data-contentpath="code"] {
    flex-basis: 100%;
}
/* inline check box + label (comment button left of check box) */
div.struct-block.code-block div[data-contentpath="bottom_padding"] {
    display: flex;
    align-items: baseline;
}
div.struct-block.code-block div[data-contentpath="bottom_padding"] label {
    order: 1;
}
div.struct-block.code-block div[data-contentpath="bottom_padding"] button.w-field__comment-button {
    left: -2rem;
}
/* ======= end flex layout ======= */
/* set font style on textarea */
div.struct-block.code-block div[data-contentpath="code"] textarea {
    font-family: 'Roboto Mono', monospace;
    font-size: 0.9em;
    border-radius: 0 0 .3125rem .3125rem;
    border-top: 0;
}
/* preview panel style */
div.struct-block.code-block div.w-field__input>div.code-block-preview {
    border: 1px solid var(--w-color-border-field-default);
    border-top: 0;
    border-radius: 0 0 .3125rem .3125rem;
    width: 100%;
    display: none;
}
div.struct-block.code-block div.w-field__input>div.code-block-preview>pre {
    margin: 0;
}
div.struct-block.code-block div.w-field__input>div.code-block-preview,
div.struct-block.code-block div.w-field__input>div.code-block-preview>pre>code {
    border-top-left-radius: 0;
    border-top-right-radius: 0;
    min-height: 64px;
}
/* when preview active, hide the code editor and display the preview container */
div.struct-block.code-block div.w-field__input.preview-active>textarea {
    display: none;
}
div.struct-block.code-block div.w-field__input.preview-active>div.code-block-preview {
    display: block;
}
/* write/preview tab styles */
div.struct-block.code-block div.code-block-tabs {
    background-color: var(--w-color-surface-button-inactive);
    border-radius: .3125rem .3125rem 0 0;
    border: 1px solid var(--w-color-border-field-default);
    border-bottom: 0;
    overflow: hidden;
}
div.struct-block.code-block label.code-block-tab {
    cursor: pointer;
    display: inline-block;
    padding: 0.8rem 1rem 0.8rem 1rem;
    margin-bottom: -1px;
    color: var(--w-color-text-button);
}
div.struct-block.code-block label.code-block-tab:hover {
    text-decoration: underline;
}
div.struct-block.code-block label.code-block-tab.active {
    background-color: var(--w-color-surface-menus);
    border-radius: 0 1rem 0 0;
}
div.struct-block.code-block label.code-block-tab+label.code-block-tab.active {
    border-top-left-radius: 1rem;
}
/* highlighter error styles */
div.struct-block.code-block div.code-block-highlighter-errors {
    color: var(--w-color-text-error);
    font-size: .875rem;
    font-weight: 600;
}

Completed Code for the Back-end

You now have everything ready to render the block form in the admin site.

the code block admin form
The CodeBlock admin form with styling applied

For reference, the collected files for the back-end are below:

Copy
from django import forms
from django.templatetags.static import static
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from wagtail.blocks import (BooleanBlock, CharBlock, ChoiceBlock, RawHTMLBlock,
                            StructBlock)
from wagtail.blocks.struct_block import StructBlockAdapter
from wagtail.telepath import register

class CodeChoiceBlock(ChoiceBlock):
    choices = [
        ('plaintext', _('Plain Text')),
        ('python', 'Python'),
        ('css', 'CSS'),
        ('django', _('Django Template')),
        ('javascript', 'Javascript'),
        ('typescript', 'Typescript'),
        ('xml', 'HTML / XML'),
        ('json', 'JSON'),
    ]

class CollapsibleChoiceBlock(ChoiceBlock):
    choices=[
        ('', _('Not Collapsible')),
        ('collapsible', _('Collapsible')),
        ('collapsed', _('Collapsed')),
    ]    

class CodeBlock(StructBlock):
    base_library_path = "https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/"
    theme_path = base_library_path + "styles/github-dark-dimmed.min.css"

    title = CharBlock(label=_("Title"), required=False)
    collapsible = CollapsibleChoiceBlock(label=_("Format"), required=False)
    language = CodeChoiceBlock(label=_("Language"), default='python')
    code = RawHTMLBlock(label=_("Code"))
    bottom_padding = BooleanBlock(
        label=_("Include extra space beneath code block?"),
        required=False,
        default=True
    )

    def get_context(self, value, parent_context=None):
        context = super().get_context(value, parent_context)
        context['expand_prompt'] = _("Click to expand")
        context['copy_button_text'] = {
            'copy': _("Copy"),
            'copied': _("Copied"),
            'error': _("Error"),
        }
        # paths - only required if loading libraries in block template
        context['paths'] = {
            'themeCSS': self.theme_path,
            'codeBlockCSS': static('css/code-highlight/code-block.css'),
            'codeBlockJS': static('js/code-highlight/code-block.js'),
        }
        return context

    class Meta:
        template = "blocks/code-block-wrapper.html"
        icon = "code"
        label = _("Code Block")
        label_format = _("Code") + ": {language}"
        form_classname = "struct-block code-block"

class CodeBlockAdapter(StructBlockAdapter):
    js_constructor = "blocks.code_block.CodeBlock"

    base_library_path = "https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/"
    language_base_path = base_library_path + "languages/"
    admin_theme_path = base_library_path + "styles/github-dark.min.css"

    def js_args(self, block):
        args = super().js_args(block)
        # keys added to args[2] found in this.meta in StructBlockDefinition
        args[2]['language_base_path'] = self.language_base_path
        args[2]['text'] = {
            'languageScriptErrorLabel': _("Failed to load language"),
            'highlighterErrorLabel': _("Error highlighting code")
        }
        return args
        
    @cached_property
    def media(self):
        structblock_media = super().media
        return forms.Media(
            js=structblock_media._js + [
                "js/admin/code-block-adapter.js",
                f"{self.base_library_path}highlight.min.js"
            ],
            css={"all": (
                "css/admin/code-block-form.css",
                self.admin_theme_path
            )},
        )

register(CodeBlockAdapter(), CodeBlock)
Copy
class CodeBlock {
    constructor(structBlock, meta) {
        this.block = structBlock;
        this.meta = meta;
        // code child block textarea widget
        this.codeTextarea = this.block.childBlocks.code.widget.input;
        // language child block select widget
        this.languageSelector = this.block.childBlocks.language.widget.input;
        // set up block form
        this.configureBlockAdminForm();
        this.addEventListeners();
        this.registerMissingLanguages();
        this.previewActive = false;
    }

    configureBlockAdminForm = () => {
        // hide rawHTMLBlock textarea widget
        this.codeTextarea.style.setProperty('display', 'none');

        // create code editor textarea
        this.codeEditor = document.createElement('textarea');
        this.codeEditor.dataset.controller = "w-autosize";
        this.codeEditor.className = "w-field__autosize";
        this.codeEditor.setAttribute('spellcheck', 'false');
        this.codeTextarea.after(this.codeEditor);
        // set initial value from code innerText (extracts raw code from highlighted html)
        if (this.codeTextarea.value) {
            const tempDiv = document.createElement('div');
            tempDiv.innerHTML = this.codeTextarea.value;
            this.codeEditor.value = tempDiv.innerText;
        }

        // create preview container, insert after textarea
        this.preview = document.createElement('div');
        this.preview.className = "code-block-preview";
        this.codeTextarea.after(this.preview);

        // create write/preview tabs, insert before textarea
        this.tabs = document.createElement('div');
        this.tabs.className = "code-block-tabs"
        this.writeTab = document.createElement('label');
        this.writeTab.className = "w-field__label code-block-tab active";
        this.writeTab.innerText = "Write"
        this.tabs.appendChild(this.writeTab);
        this.previewTab = document.createElement('label');
        this.previewTab.className = "w-field__label code-block-tab";
        this.previewTab.innerText = "Preview"
        this.tabs.appendChild(this.previewTab);
        this.codeTextarea.before(this.tabs);

        // placeholder for displaying any highlighter errors
        this.highlighterErrors = document.createElement('div');
        this.highlighterErrors.className = "code-block-highlighter-errors"
        this.block.childBlocks.code.field.prepend(this.highlighterErrors);
    }

    addEventListeners = () => {
        // code editor content changed - convert entered code to highlighted markup
        this.codeEditor.addEventListener('input', this.getHighlightCodeHTML.bind(this));
        // language changed - parse entered code with new language setting
        this.languageSelector.addEventListener('change', this.getHighlightCodeHTML.bind(this));
        // tab clicks - show/hide the preview pane
        this.writeTab.addEventListener('click', this.showPreview.bind(this, false));
        this.previewTab.addEventListener('click', this.showPreview.bind(this, true));
    }

    getHighlightCodeHTML = () => {
        this.highlighterErrors.innerText = '';
        // parse entered code with hljs, set this as the code child block value
        let parsedCode = {};
        try {
            parsedCode = hljs.highlight(this.codeEditor.value, { language: this.languageSelector.value, ignoreIllegals: 'true' });
        } catch (error) {
            // on error, plain text used, error displayed above tabs
            parsedCode.value = this.codeEditor.value;
            const errMessage = `${this.meta.text.highlighterErrorLabel}: ${error.message}`;
            console.error(errMessage);
            this.highlighterErrors.innerText = errMessage;
        }
        // wrap parsedCode value with <pre><code> tags - hljs class used by theme css, language class added for highlightjs consistency
        // if no parsedCode, return empty string so code child block required=true is enforced on page save
        this.codeTextarea.value = parsedCode.value 
            ? `<pre><code class="language-${this.languageSelector.value} hljs">${parsedCode.value}</code></pre>` 
            : '';
        // set the preview panel inner html from the code child block value
        this.preview.innerHTML = this.codeTextarea.value;
    }

    showPreview = (active) => {
        // set css classes to show/hide the preview panel, update tabs
        if (active === true) this.preview.innerHTML = this.codeTextarea.value;
        this.codeTextarea.parentElement.classList.toggle('preview-active', active);
        this.writeTab.classList.toggle('active', !active);
        this.previewTab.classList.toggle('active', active);
        this.previewActive = active;
    }

    registerMissingLanguages() {
        // check registered languages against those in language choice block, add script for each missing one
        let errors = [];
        const availableLanguages = hljs.listLanguages();
        const optionValues = Array.from(this.languageSelector.options)
            .map(option => option.value)
            .filter(value => value);
        const missing_languages = optionValues.filter(optionValue => !availableLanguages.includes(optionValue));
        if (missing_languages) {
            // if scripts fail to load, write simple message to block form and error message with path to console
            const scriptPromises = missing_languages.map(language => {
                return new Promise((resolve, reject) => {
                    const script = document.createElement('script');
                    script.src = `${this.meta.language_base_path}${language}.min.js`;
                    script.onload = () => {
                        resolve();
                    };
                    script.onerror = () => {
                        const displayError = `${this.meta.text.languageScriptErrorLabel}: ${language}`;
                        const consoleError = `${displayError} (${script.src})`;
                        errors.push([displayError, consoleError]);
                        reject(new Error(consoleError));
                    };
                    document.head.appendChild(script);
                });
            });
            // When all scripts are either loaded or failed, report any errors
            Promise.allSettled(scriptPromises).then(() => {
                if (errors.length > 0) {
                    // displayError
                    this.highlighterErrors.innerText = errors.map(error => error[0]).join('\n');
                    // consoleError
                    errors.forEach(error => console.error(error[1]));
                }
            });
        }
    }
}

class CodeBlockDefinition extends window.wagtailStreamField.blocks
    .StructBlockDefinition {
    render(placeholder, prefix, initialState, initialError) {
        this.block = super.render(
            placeholder,
            prefix,
            initialState,
            initialError,
        );
        new CodeBlock(this.block, this.meta);
        return this.block;
    };
}

window.telepath.register('blocks.code_block.CodeBlock', CodeBlockDefinition);
Copy
/* ======= start flex layout ======= */
/* use flex on the StructBlock form to allow responsive layout */
div.struct-block.code-block {
    display: flex;
    flex-wrap: wrap;
    column-gap: 2rem;
}
/* set title to min width 300px */
div.struct-block.code-block div[data-contentpath="title"] {
    flex-grow: 1;
    flex-basis: 300px;
}
/* set code to full width */
div.struct-block.code-block div[data-contentpath="code"] {
    flex-basis: 100%;
}
/* inline check box + label (comment button left of check box) */
div.struct-block.code-block div[data-contentpath="bottom_padding"] {
    display: flex;
    align-items: baseline;
}
div.struct-block.code-block div[data-contentpath="bottom_padding"] label {
    order: 1;
}
div.struct-block.code-block div[data-contentpath="bottom_padding"] button.w-field__comment-button {
    left: -2rem;
}
/* ======= end flex layout ======= */
/* set font style on textarea */
div.struct-block.code-block div[data-contentpath="code"] textarea {
    font-family: 'Roboto Mono', monospace;
    font-size: 0.9em;
    border-radius: 0 0 .3125rem .3125rem;
    border-top: 0;
}
/* preview panel style */
div.struct-block.code-block div.w-field__input>div.code-block-preview {
    border: 1px solid var(--w-color-border-field-default);
    border-top: 0;
    border-radius: 0 0 .3125rem .3125rem;
    width: 100%;
    display: none;
}
div.struct-block.code-block div.w-field__input>div.code-block-preview>pre {
    margin: 0;
}
div.struct-block.code-block div.w-field__input>div.code-block-preview,
div.struct-block.code-block div.w-field__input>div.code-block-preview>pre>code {
    border-top-left-radius: 0;
    border-top-right-radius: 0;
    min-height: 64px;
}
/* when preview active, hide the code editor and display the preview container */
div.struct-block.code-block div.w-field__input.preview-active>textarea {
    display: none;
}
div.struct-block.code-block div.w-field__input.preview-active>div.code-block-preview {
    display: block;
}
/* write/preview tab styles */
div.struct-block.code-block div.code-block-tabs {
    background-color: var(--w-color-surface-button-inactive);
    border-radius: .3125rem .3125rem 0 0;
    border: 1px solid var(--w-color-border-field-default);
    border-bottom: 0;
    overflow: hidden;
}
div.struct-block.code-block label.code-block-tab {
    cursor: pointer;
    display: inline-block;
    padding: 0.8rem 1rem 0.8rem 1rem;
    margin-bottom: -1px;
    color: var(--w-color-text-button);
}
div.struct-block.code-block label.code-block-tab:hover {
    text-decoration: underline;
}
div.struct-block.code-block label.code-block-tab.active {
    background-color: var(--w-color-surface-menus);
    border-radius: 0 1rem 0 0;
}
div.struct-block.code-block label.code-block-tab+label.code-block-tab.active {
    border-top-left-radius: 1rem;
}
/* highlighter error styles */
div.struct-block.code-block div.code-block-highlighter-errors {
    color: var(--w-color-text-error);
    font-size: .875rem;
    font-weight: 600;
}

Front-end

Using Bootstrap

The templates and CSS described here use Bootstrap 5.3 for the collapsible accordion and styling classes. If you don't use Bootstrap on your site, you will need to adjust the code from here on to suit your site's needs.

Templates

There are two templates to define here. The first is a wrapper that creates the collapsible markup (if not set to disabled). That template then calls the template responsible for rendering the code block itself.

Code Wrapper Template

As mentioned earlier, I'm making use of Bootstrap's accordion feature to provide the collapsible functionality. If you want to include the collapsible feature but don't use Bootstrap, you'll need to amend the template and add your own expand/collapse interaction to the accordion button.

If you're not including the collapsible feature in your implementation, skip straight to the Code Block Template, though you may want to include the line that adds the title if you're using that.

When we defined the CodeBlock class, we defined the template Meta attribute as blocks/code-block-wrapper.html. Create this file in your templates folder with the following content:

templates/blocks/code-block-wrapper.html
Copy
<div class="block-container{% if self.bottom_padding %} pb-4{% endif %}" data-bs-theme="dark">
    {% if not self.collapsible %}
        {% if self.title %}<div class="code-block-title">{{ self.title }}</div>{% endif %}
        {% include "blocks/code-block.html" %}
    {% else %}
        <div class="highlight-code-wrapper">
            <div class="accordion" id="code-wrapper-{{ block.id }}">
                <div class="accordion-item">
                    <div class="accordion-header">
                        <button class="accordion-button{% if self.collapsible == "collapsed" %} collapsed{% endif %}"
                                type="button"
                                data-bs-toggle="collapse"
                                data-bs-target="#code-item-{{ block.id }}"
                                data-expand-prompt="{{ expand_prompt }}"
                                aria-expanded="{% if self.collapsible == "collapsed" %}false{% else %}true{% endif %}"
                                aria-controls="code-item-{{ block.id }}"
                                onclick="this.blur();">{{ self.title }}</button>
                    </div>
                    <div id="code-item-{{ block.id }}"
                         class="accordion-collapse collapse{% if self.collapsible == "collapsible" %} show{% endif %}"
                         data-bs-parent="#code-wrapper-{{ block.id }}">
                        <div class="accordion-body">{% include "blocks/code-block.html" %}</div>
                    </div>
                </div>
            </div>
        </div>
    {% endif %}
</div>

Working down the template:

  • if bottom_padding is true, add a 1rem padding to the bottom of the container div (using Bootstrap's pb-4 class).
  • if collapsible has no value, display the title if defined then render the code block template (to be defined next)
  • the rest of the template deals with setting up the Bootstrap accordion
    • unique id's based on the block id are added to both accordion container and body elements - these must be unique to ensure that multiple code blocks can coexist on the same page
    • the collapsible block attribute is used to determine if the block should be expanded or collapsed on load
    • we're adding a custom attribute data-expand-prompt to the accordion button which we'll use later in CSS to display a message when the block is collapsed, this takes its value from the expand_prompt context variable we set in the CodeBlock class.
    • the accordion body includes a call to render the code block template which we'll define next

Code Block Template

Loading JavaScript and CSS on Demand

There are times where you have blocks that rely on some fairly heavy scripts, or are just used rarely, and you don't want to load on every page when the block isn't in use.

A method I use is to define a couple of JavaScript functions, one each for CSS and JavaScript. Both functions will load the library only when first called and skip any subsequent attempts to load the same library. In the case of JavaScript, it will also resolve a Promise so that you can run dependent code once it has fully loaded.

I include both of these on the loading page then use them to load extra libraries as required by the content:

Copy
const include_css = (css, options = {}) => {
  let link_tag = document.querySelector(`link[href="${css}"]`);
  if (!link_tag) {
    try {
      const head = document.head || document.getElementsByTagName('head')[0];
      link_tag = document.createElement('link');
      link_tag.rel = 'stylesheet';
      link_tag.href = css;
      link_tag.type = options.type || "text/css";
      if (options.media) link_tag.media = options.media;
      if (options.integrity) link_tag.integrity = options.integrity;
      if (options.crossorigin) link_tag.crossOrigin = options.crossorigin; 
      head.appendChild(link_tag);
    } catch (error) {
      console.error(`Failed to load ${css}:`, error);
    }
  }
};
Copy
const include_js = (js, options={}) => {
  return new Promise((resolve, reject) => {
    let script_tag = document.querySelector(`script[src="${js}"]`);
    if (!script_tag) {
      const head = document.head || document.getElementsByTagName('head')[0];
      script_tag = document.createElement('script');
      script_tag.src = js;
      script_tag.type = options.type || 'text/javascript';
      if (options.integrity) script_tag.integrity = options.integrity;
      if (options.crossorigin) script_tag.crossOrigin = options.crossorigin;
      if (options.defer) script_tag.defer = true;
      if (options.async) script_tag.async = true;
      script_tag.onload = () => {
        script_tag.dataset.scriptLoaded = true; // Set attribute once loaded
        resolve();
      };
      script_tag.onerror = () => {
        console.error(`Failed to load script: ${js}`);
        reject(new Error(`Script load error: ${js}`));
      };
      head.appendChild(script_tag);
    } else {
      // Script tag exists, check if it's fully loaded
      if (script_tag.dataset.scriptLoaded === "true") {
        resolve();  // Script is already fully loaded, resolve immediately
      } else {
        // Script is still loading, add event listeners
        script_tag.addEventListener('load', resolve);
        script_tag.addEventListener('error', reject);
      }
    }
  });
};

For more information on these methods, see the article Loading CSS and Javascript On Demand in CMS fa-solid fa-arrow-up-right-from-square.

The wrapper template has calls to include blocks/code-block.html - here we'll render the highlighted markup stored in the code child block. We'll also add the copy-to-clipboard button and its click handler (which we will define next).

Create blocks/code-block.html in your templates folder and add the following content:

templates/blocks/code-block.html
Copy
<div class="code-block-container position-relative">
  <div class="position-absolute top-0 end-0">
    <span class="badge bg-light rounded-pill btn-code-copy"
      onclick="copyCodeToClipboard(event, {{ copy_button_text }})">
      {{ copy_button_text.copy }}
    </span>
  </div>
  {{ self.code }}
</div>
<script>
  try {
    include_css("{{ paths.themeCSS }}");
    include_css("{{ paths.codeBlockCSS }}");
    include_js("{{ paths.codeBlockJS }}");
  } catch(error) {
    console.error("error");
  }
</script>
  • the copy 'button' (a styled span tag) is placed in the top right via absolute positioning
  • the button's onclick event points to a handler (defined in the next section) which will take the event and button text (from template context)
  • the template loads the highlightjs theme and block CSS if not already loaded, and the JavaScript for the copy button (which we will create next).

The css & js are loaded using the include_css & include_js methods described earlier. These make sure these libraries are only loaded on the page if a code block is used, and only loaded once in the case of multiple blocks on the same page.

Alternatively, load these at the page level - either in the <head> each load, or with a check in the footer for the presence of a div.code-block-container element for example. In either case, you can remove the <script> element from the template and the paths context variable from the CodeBlock Python class.

JavaScript

In the CodeBlock paths context variable, we defined the codeBlockJS path as static/js/code-highlight/code-block.js. This file will define the Copy button click handler. We also defined the copy_button_text context variable which we will pass into the handler to display the appropriate text.

Create code-block.js and add the following code:

code-block.js
Copy
const copyCodeToClipboard = (event, buttonText) => {
    try {
        const codeElement = event.target.closest('div.code-block-container').querySelector('code');
        navigator.clipboard.writeText(codeElement.innerText);
        event.target.innerText = `${buttonText.copied} ✓`;
        event.target.classList.add('copied-to-clipboard');
        setTimeout(() => {
            event.target.innerText = buttonText.copy;
            event.target.classList.remove('copied-to-clipboard');
        }, 2000);
    } catch (error) {
        event.target.innerText = buttonText.error;
        console.error('Error copying the code to clipboard:', error);
    }
}
  • The copyCodeToClipboard function takes 2 parameters:
    • event - the click event
    • buttonText - an object with copy, copied and error string attributes to display on the button
  • The codeElement is identified by finding the parent div.code-block-container and then the <code> element within that container.
  • The innerText of the codeElement is written to the clipboard.
  • The button text is set to buttonText.copied then set back to buttonText.copy after 2 seconds. The copied-to-clipboard css class will ensure the button is visible for the duration of that 2 seconds.
  • If an error is encountered for some reason, the button text is set to buttonText.error

CSS

The block CSS is the final piece to put in place to properly render everything on the front end.

In the block CSS, we will:

  • Style the accordion button
    • move the chevron (fa-solid fa-chevron-up) to left hand side of the button to better call to action and where it is less likely to be missed (Bootstrap styling puts the chevron on the right hand side).
    • configure an expand prompt to display only when the accordion is collapsed (we set this text as a context variable in the CodeBlock and added it as a data-expand-prompt to the accordion button in the template).
    • dim the accordion button when collapsed except when hovered over.
  • Add styling for using the title when the code block is not collapsible
  • Style the copy button so that it is only displayed when the code block is hovered and dimmed unless the copy button is hovered. An additional style ensures the button remains visible while the "copied" message is active.
  • Style the scroll bar for the code element (displayed if the code content overflows) to a more thematic appearance rather than the default browser scroll bar.

In the CodeBlock paths context variable, we defined the codeBlockCSS path as static/css/code-highlight/code-block.css. Create that file in your static folder and add the following contents:

Copy
/* ==================== collapse button ==================== */
div.highlight-code-wrapper>div.accordion>div.accordion-item>div.accordion-header>button.accordion-button {
    font-family: var(--font-family-headings);
    font-size: 1em;
    min-height: 2em;
    position: relative;
    color: #e6db74;
    opacity: 0.9;
    padding: 0.5rem 1rem 0.5rem 3rem;
    box-shadow: none;
}
/* move animated chevron to the left */
div.highlight-code-wrapper>div.accordion>div.accordion-item>div.accordion-header>button.accordion-button::after {
    position: absolute;
    left: 1rem;
    margin-right: unset;
}
/* add expand prompt to the ::before pseudo-element, hidden by default */
div.highlight-code-wrapper>div.accordion>div.accordion-item>div.accordion-header>button.accordion-button::before {
    content: attr(data-expand-prompt);
    position: absolute;
    top: 50%;
    right: 1.5rem;
    opacity: 0;
    visibility: hidden;
    transform: translateY(-50%);
    color: var(--bs-light);
    z-index: 1;
    font-size: var(--font-size-6);
    font-family: var(--font-family-headings);
    transition: opacity 0.4s ease-in-out;
}
/* if accoridon collapsed, show the expand prompt, dimmed unless hovered over */
div.highlight-code-wrapper>div.accordion>div.accordion-item>div.accordion-header>button.accordion-button.collapsed::before {
    visibility: visible;
    opacity: 0.8;
}
div.highlight-code-wrapper>div.accordion>div.accordion-item>div.accordion-header>button.accordion-button.collapsed:hover::before {
    opacity: 1;
}
div.highlight-code-wrapper>div.accordion>div.accordion-item>div.accordion-header>button.accordion-button:not(.collapsed),
div.highlight-code-wrapper>div.accordion>div.accordion-item>div.accordion-header>button.accordion-button:hover {
    opacity: 1;
}
div.highlight-code-wrapper>div.accordion>div.accordion-item>div.accordion-collapse>div.accordion-body {
    padding: 0;
}

/* ==================== Code Block ==================== */
/* title bar when not collapsible */
div.code-block-title {
    color: #e6db74;
    font-family: var(--font-family-headings);
    font-size: 1em;
    background-color: var(--bs-dark-bg-subtle);
    padding: 0.5rem 1rem;
    border-radius: 0.5rem 0.5rem 0 0;
}
div.code-block-container pre {
    background-color: rgb(28, 27, 27);
    color: ghostwhite;
    border-radius: 0.5rem;
    margin: 0;
}
div.code-block-title+div.code-block-container pre {
    border-top-left-radius: 0;
    border-top-right-radius: 0;
}
div.code-block-container pre>code {
    font-family: var(--font-family-monospace);
}

/* ==================== Copy Code Button ==================== */
div.code-block-container span.btn-code-copy {
    font-family: var(--font-family-headings);
    padding: 0.5em 1em;
    opacity: 0;
    margin-top: 0.2rem;
    margin-right: 0.2rem;
    cursor: pointer;
    transition: all 0.4s ease-in-out;
}
div.code-block-container:hover span.btn-code-copy {
    opacity: .7;
}
div.code-block-container span.btn-code-copy:hover,
div.code-block-container span.btn-code-copy.copied-to-clipboard {
    opacity: 1;
}

/* ======= Code Scroll Bar ======= */
div.code-block-container pre>code::-webkit-scrollbar {
    width: 0.75rem;
    height: 0.75rem;
}
div.code-block-container pre>code::-webkit-scrollbar-track {
    box-shadow: inset 0 0 6px darkgray;
    border-radius: 8px;
}
div.code-block-container pre>code::-webkit-scrollbar-thumb {
    background: #b9b8b8;
    border-radius: 8px;
    box-shadow: inset -5px -5px 8px #797979;
}
div.code-block-container pre>code::-webkit-scrollbar-thumb:hover {
    background: #adb5bd;
    box-shadow: inset -5px -5px 8px #494949;
}
Note the use of font-family: var(--font-family-headings) var(--font-family-monospace) & var(--font-size-6) in the styles above. You will need to either use variables applicable to your site or set these values directly.

Conclusion

By following the steps outlined in this article, you should now have a functional custom Wagtail block capable of displaying code with syntax highlighting, fully integrated into your Wagtail CMS.

This block not only enhances the user experience by offering flexible display options such as collapsibility and custom titles, but also provides a clean, visually distinct way of showing code with proper formatting and easy copying capabilities.

The solution leverages the power of the highlightjs library for language specific styling, while ensuring that missing resources are loaded on-demand. The integration of Wagtail's block architecture, Telepath, and custom JavaScript makes the block dynamic and interactive, supporting multilingual environments and handling a variety of editor preferences.

With this setup, editors gain a powerful tool to manage code blocks directly from the admin interface, seeing real-time previews and adjusting the block’s behaviour as required.

This example demonstrates the flexibility of Wagtail in meeting complex content management needs, while also highlighting the importance of modular design, extendable functionality, and seamless user experiences in web development.


  Please feel free to leave any questions or comments below, or send me a message here