Add Heading Blocks with Anchor Targets in Wagtail

Introduction

Wagtail’s Draftail rich text editor lacks any way to add anchor targets 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 anchor target. We'll use ChoiceBlocks to populate field values, include some validation to ensure the entered anchor target 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 anchor targets. The table of contents above was created with this block.

fa-regular fa-pen-to-square fa-xl Note
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. Optional Anchor target (CharBlock)

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

In case unidecode doesn't resolve, you'll need to install it with pip install unidecode or equivalent for your environment.

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.

Heading 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')
    anchor_target = CharBlock(
        required=False,
        label=_("Optional Anchor Target"),
        help_text=_("Anchor target 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 = {}
        anchor_target = value.get('anchor_target ')
        slug = slugify(unidecode.unidecode(anchor_target ))
        
        if anchor_target != slug:
            errors['anchor_target'] = ErrorList([_(f"\
                '{anchor_target}' is not a valid slug for the anchor target. \
                '{slug}' is the suggested value for this.")])
            raise StructBlockValidationError(block_errors=errors)

        return super().clean(value)
fa-regular fa-pen-to-square fa-xl 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 anchor target 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 anchor_targetis a valid slug, the returned value will match the entered one. If not, an error is raised on the anchor_targetfield with a suggested slug based on the anchor_targetvalue.

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

You could avoid the custom validation by using a RegexBlock for the anchor target 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 fa-xl Warning
While this validation will check for a valid anchor target, 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 id is distinct.

The Template

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

<div class="block-container">
    <div class="text-{{ self.alignment }}">
        <{{ self.heading_size }}{% if self.anchor_target %} id="{{ self.anchor_target }}"{% 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" for anchor target:

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 StreamField's. 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 anchor targets 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