Sphinx

# 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 Third-party extensions section.