Add Heading Blocks with Bookmarks in Wagtail

Introduction

Wagtail’s Draftail rich text editor lacks any way to add bookmarks to heading tags so that you can link back to that position on the page from elsewhere.

To get around this limitation, we can create a simple block to add to your StreamFields that includes heading size, alignment and an optional bookmark. We'll use ChoiceBlocks to populate field values, include some validation to ensure the entered bookmark is a valid slug and then create a custom template to render the final heading.

This is a good example to work through if you're starting out with Wagtail and getting used to working with blocks and StreamFields.

If you’re looking to add a table of contents, check out my previous blog on a way to automate this using a custom block, including generating the heading bookmarks. The table of contents above was created with this block.

fa-solid fa-circle-info This example is as much about proof-of-concept as anything else. You could also install the wagtail-draftail-anchors package from PyPi which is currently under consideration as a feature request for Draftail.

The Heading Block

We’ll need four fields for a basic heading block:

  1. Heading text (CharBlock)
  2. Size (ChoiceBlock)
  3. Alignment (ChoiceBlock)
  4. Bookmark (CharBlock) – optional

The alignment can be left off if you’ll use a consistent alignment on all your headings throughout your site.

Imports

The imports you’ll need for the code on this page:

import unidecode
from django.forms.utils import ErrorList
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from wagtail.blocks import (CharBlock, ChoiceBlock, StructBlock)
from wagtail.blocks.struct_block import StructBlockValidationError

The Choice Blocks

If you’re not familiar with Wagtail StructBlocks, setting a block field type to ChoiceBlock will render a drop-down selector on the edit interface with the options you list in an array of tuples [(value, label), ... ] .

Set the default for the ChoiceBlock in the StructBlock field definitions rather than the ChoiceBlock itself.

The heading size should range from H2 down to a minimum of H6. Leave off the lower order headings if you don't use them on your site.

 

fa-solid fa-circle-info Header Sizes and SEO

For better SEO, H1 should be reserved for your page title only and placed only once per page, near the top.

Also, for better SEO, you should always descend heading levels in sequence (i.e. H2 to H3, not H2 to H4).

SE Ranking has a good discussion on the use of heading tags and their implications for SEO.

 
class HeadingSizeChoiceBlock(ChoiceBlock):
    choices=[
        ('h2', 'H2'), 
        ('h3', 'H3'), 
        ('h4', 'H4'), 
        ('h5', 'H5'), 
        ('h6', 'H6'), 
    ]

Next, the alignment is a standard ChoiceBlock which will be substituted into the template as a BootStrap class (text-start, text-center etc.):

class TextAlignmentChoiceBlock(ChoiceBlock):
    choices=[
        ('justify', _('Justified')), 
        ('start', _('Left')), 
        ('center', _('Centre')), 
        ('end', _('Right'))
    ]

The Block Code

Here we create the block model and set field types, defaults, labels etc..

  • In the class Meta, set the template to a suitable location and add any label or icon you want.
  • Set the defaults to values suitable for your site.
class HeadingBlock(StructBlock):
    title = CharBlock(required=True)
    heading_size = HeadingSizeChoiceBlock(default='h2')
    alignment = TextAlignmentChoiceBlock(default='start')
    bookmark = CharBlock(
        required=False,
        label=_("Optional Bookmark"),
        help_text=_("Bookmark must be a compatible slug format without spaces or special characters")
    )
    
    class Meta:
        template = 'blocks/heading_block.html'
        label = _("Heading Block")
        icon = 'title'

    def clean(self, value):
        errors = {}
        bookmark = value.get('bookmark')
        slug = slugify(unidecode.unidecode(bookmark))
        
        if bookmark != slug:
            errors['bookmark'] = ErrorList([_(f"\
                '{bookmark}' is not a valid slug for the bookmark. \
                '{slug}' is the suggested value for this.")])
            raise StructBlockValidationError(block_errors=errors)

        return super().clean(value)
 

fa-solid fa-circle-info Note

I’m using StructBlockValidationError here to get the error message displayed on the form field. The usual ValidationError will raise an error but won’t highlight the field in the StructBlock.

 

The custom clean() method uses the slugify function from django.utils.text and unidecode to check if the bookmark is a valid slug before calling the StructBlock clean() method:

  1. First, unidecode will transliterate any extended ASCII characters: "mørden æsops" will be returned as "morden aesops" for example.
  2. Slugify will turn any string into a valid slug – special characters stripped out and spaces replaced by dashes: "$%some words & symbols?!!" becomes "some-words-symbols". Slugify will just drop any extended ASCII which is why it's a good idea to run unidecode first.

If the bookmark is a valid slug, the returned value will match the entered one. If not, an error is raised on the bookmark field with a suggested slug based on the bookmark value.

'$%some words & symbols??!!' is not a valid slug for the bookmark. 'some-words-symbols' is the suggested value for this.

You could avoid the custom validation by using a RegexBlock for the bookmark with regex='^[a-z0-9]+(?:[_-][a-z0-9]+)*$'. I prefer the more helpful error message you get with the above method however.

fa-solid fa-triangle-exclamation Warning
While this validation will check for a valid bookmark, there is no way to check that the id attribute will be unique on the rendered HTML page. Duplicate ID's can produce unexpected outcomes; it's the editor's responsibility to ensure that the bookmark is distinct.

The Template

This is a simple block template with the values from the block instance substituted into the appropriate places. Note that the bookmark is wrapped in a conditional statement so that the id attribute is only added where a value for bookmark has been entered.

<div class="block-container">
    <div class="text-{{self.alignment}}">
        <{{self.heading_size}}{%if self.bookmark%} id="{{self.bookmark}}"{%endif%}>
            {{self.title}}
        </{{self.heading_size}}>
    </div>
</div>

The block-container class is some CSS I use on all StreamField blocks to add a little vertical spacing. Adapt it or drop it to suit your own site:

.block-container {
  margin-top: 1rem;
  margin-bottom: 1rem;
}

The Finished Block

I’ll create an H4 tag with "an-example" bookmark:

An Example Heading Block

If you inspect the heading, you’ll see the id attribute has been set.

To create a link to the heading, use the 'Anchor Link' option in Draftail’s hyperlink form: Link to heading.

Conclusion

In this example, we built a simple StructBlock using ChoiceBlocks, custom validation and basic template logic.

This is a basic but important block for any Wagtail site using StreamFields. It provides a simple way to create headings with adjustable size and alignment. It also overcomes the limitation of Draftail not having the function to add bookmarks to headings.

In the next couple of blogs, I'll go over setting up a rich text block and how to extend the features of Draftail.


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