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.
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:
- How to build custom StreamField blocks - Wagtail documentation
- Creating Wagtail Streamfield StructBlocks with a Customised Editor Interface - Step-by-step example of setting up a TextBlock with import from file functionality
- Telepath Documentation - Covering the basics of the Telepath library including a useful tutorial project
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, typehljs.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
next section if using the CDNSkip to the
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.
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
:
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).
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:
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.
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
©_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.
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:
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 classCodeBlock
found in the moduleblocks/code_block.py
. Modify this value if yourCodeBlock
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 andcode-block-form.css
will contain the styles for the StructBlock admin form. We will build these next.register
- finally, theCodeBlockAdapter
is registered with Telepath as the adapter to use with theCodeBlock
.
If you are using the CDN but need to load 3rd party language libraries (those not hosted by highlightjs), you should add these to thejs
attribute of theforms.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 thecode
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:
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 thelanguage_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 theinnerText
value of the saved highlighted markup and set this as the initialcodeEditor
value - create the preview panel, edit/preview tabs
- add an error container for any reported errors.
- hide the
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.
- changes to the entered code (code highlighted on change and written to the hidden
getHighlightCodeHTML
- parse entered code with highlightjs, set this as thecode
child block value. Wrap parsed code value with<pre><code>
tags. Addhljs
class (used by theme css).language-xxx
class added for highlightjs consistency. If no parsed code, return empty string so thatcode
child blockrequired=true
is enforced on page save.updatePreview
- set the preview panel inner html from thecode
child block value (the parsed highlighted markup).showPreview
- show/hide the preview panel
CodeBlockDefinition
- call the standard
StructBlockDefinition.render()
method and store result inthis.block
. - spawn a new
CodeBlock
instance withthis.block
andthis.meta
(meta holds the values we passed in withjs_args
in the PythonCodeBlockAdapter
class.
window.telepath.register('blocks.code_block.CodeBlock', CodeBlockDefinition)
- register the
CodeBlockDefinition
with Telepath as the definition for theCodeBlock
. - the path in the first parameter must match the path to your
CodeBlock
Python class (in this caseblocks/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:
/* ======= 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.
For reference, the collected files for the back-end are below:
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)
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);
/* ======= 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
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:
<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
istrue
, add a1rem
padding to the bottom of the container div (using Bootstrap'spb-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 theexpand_prompt
context variable we set in theCodeBlock
class. - the accordion body includes a call to render the code block template which we'll define next
- unique id's based on the block
Code Block Template
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:
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);
}
}
};
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 .
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:
<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:
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 eventbuttonText
- an object withcopy
,copied
anderror
string attributes to display on the button
- The
codeElement
is identified by finding the parentdiv.code-block-container
and then the<code>
element within that container. - The
innerText
of thecodeElement
is written to the clipboard. - The button text is set to
buttonText.copied
then set back tobuttonText.copy
after 2 seconds. Thecopied-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 ( ) 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:
/* ==================== 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 offont-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.