Extending syntax with roles and directives

Overview

The syntax of both reStructuredText and MyST can be extended by creating new directives - for block-level elements - and roles - for inline elements.

In this tutorial we shall extend Sphinx to add:

  • A hello role, that will simply output the text Hello {text}!.

  • A hello directive, that will simply output the text Hello {text}!, as a paragraph.

For this extension, you will need some basic understanding of Python, and we shall also introduce aspects of the docutils API.

Setting up the project

You can either use an existing Sphinx project or create a new one using sphinx-quickstart.

With this we will add the extension to the project, within the source folder:

  1. Create an _ext folder in source

  2. Create a new Python file in the _ext folder called helloworld.py

Here is an example of the folder structure you might obtain:

└── source
    ├── _ext
    │   └── helloworld.py
    ├── conf.py
    ├── index.rst

Writing the extension

Open helloworld.py and paste the following code in it:

 1from __future__ import annotations
 2
 3from docutils import nodes
 4
 5from sphinx.application import Sphinx
 6from sphinx.util.docutils import SphinxDirective, SphinxRole
 7from sphinx.util.typing import ExtensionMetadata
 8
 9
10class HelloRole(SphinxRole):
11    """A role to say hello!"""
12
13    def run(self) -> tuple[list[nodes.Node], list[nodes.system_message]]:
14        node = nodes.inline(text=f'Hello {self.text}!')
15        return [node], []
16
17
18class HelloDirective(SphinxDirective):
19    """A directive to say hello!"""
20
21    required_arguments = 1
22
23    def run(self) -> list[nodes.Node]:
24        paragraph_node = nodes.paragraph(text=f'hello {self.arguments[0]}!')
25        return [paragraph_node]
26
27
28def setup(app: Sphinx) -> ExtensionMetadata:
29    app.add_role('hello', HelloRole())
30    app.add_directive('hello', HelloDirective)
31
32    return {
33        'version': '0.1',
34        'parallel_read_safe': True,
35        'parallel_write_safe': True,
36    }

Some essential things are happening in this example:

The role class

Our new role is declared in the HelloRole class.

1class HelloRole(SphinxRole):
2    """A role to say hello!"""
3
4    def run(self) -> tuple[list[nodes.Node], list[nodes.system_message]]:
5        node = nodes.inline(text=f'Hello {self.text}!')
6        return [node], []

This class extends the SphinxRole class. The class contains a run method, which is a requirement for every role. It contains the main logic of the role and it returns a tuple containing:

  • a list of inline-level docutils nodes to be processed by Sphinx.

  • an (optional) list of system message nodes

The directive class

Our new directive is declared in the HelloDirective class.

1class HelloDirective(SphinxDirective):
2    """A directive to say hello!"""
3
4    required_arguments = 1
5
6    def run(self) -> list[nodes.Node]:
7        paragraph_node = nodes.paragraph(text=f'hello {self.arguments[0]}!')
8        return [paragraph_node]

This class extends the SphinxDirective class. The class contains a run method, which is a requirement for every directive. It contains the main logic of the directive and it returns a list of block-level docutils nodes to be processed by Sphinx. It also contains a required_arguments attribute, which tells Sphinx how many arguments are required for the directive.

What are docutils nodes?

When Sphinx parses a document, it creates an "Abstract Syntax Tree" (AST) of nodes that represent the content of the document in a structured way, that is generally independent of any one input (rST, MyST, etc) or output (HTML, LaTeX, etc) format. It is a tree because each node can have children nodes, and so on:

<document>
   <paragraph>
      <text>
         Hello world!

The docutils package provides many built-in nodes, to represent different types of content such as text, paragraphs, references, tables, etc.

Each node type generally only accepts a specific set of direct child nodes, for example the document node should only contain "block-level" nodes, such as paragraph, section, table, etc, whilst the paragraph node should only contain "inline-level" nodes, such as text, emphasis, strong, etc.

See also

The docutils documentation on creating directives, and creating roles.

The setup function

This function is a requirement. We use it to plug our new directive into Sphinx.

def setup(app: Sphinx) -> ExtensionMetadata:
    app.add_role('hello', HelloRole())
    app.add_directive('hello', HelloDirective)

    return {
        'version': '0.1',
        'parallel_read_safe': True,
        'parallel_write_safe': True,
    }

The simplest thing you can do is to call the Sphinx.add_role() and Sphinx.add_directive() methods, which is what we've done here. For this particular call, the first argument is the name of the role/directive itself as used in a reStructuredText file. In this case, we would use hello. For example:

Some intro text here...

.. hello:: world

Some text with a :hello:`world` role.

We also return the extension metadata that indicates the version of our extension, along with the fact that it is safe to use the extension for both parallel reading and writing.

Using the extension

The extension has to be declared in your conf.py file to make Sphinx aware of it. There are two steps necessary here:

  1. Add the _ext directory to the Python path using sys.path.append. This should be placed at the top of the file.

  2. Update or create the extensions list and add the extension file name to the list

For example:

import sys
from pathlib import Path

sys.path.append(str(Path('_ext').resolve()))

extensions = ['helloworld']

Tip

Because we haven't installed our extension as a Python package, we need to modify the Python path so Sphinx can find our extension. This is why we need the call to sys.path.append.

You can now use the extension in a file. For example:

Some intro text here...

.. hello:: world

Some text with a :hello:`world` role.

The sample above would generate:

Some intro text here...

Hello world!

Some text with a hello world! role.

Further reading

This is the very basic principle of an extension that creates a new role and directive.

For a more advanced example, refer to Extending the build process.

If you wish to share your extension across multiple projects or with others, check out the 第三方插件 section.