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.
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:
- Heading text (
CharBlock
) - Size (
ChoiceBlock
) - Alignment (
ChoiceBlock
) - 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.
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)
Note
I’m usingStructBlockValidationError
here to get the error message displayed on the form field. The usualValidationError
will raise an error but won’t highlight the field in theStructBlock
.
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:
- First,
unidecode
will transliterate any extended ASCII characters: "mørden æsops" will be returned as "morden aesops" for example. 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 rununidecode
first.
If anchor_target
is a valid slug, the returned value will match the entered one. If not, an error is raised on the anchor_target
field with a suggested slug based on the anchor_target
value.
- '$%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.
Warning
While this validation will check for a valid anchor target, there is no way to check that theid
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 theid
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_target
is 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.