Daraan
Daraan

Reputation: 3947

Add additional class to Interpreted Text Roles in Sphinx

What I am trying to achieve is to (manually) insert a html-class to certain elements using the sphinx' python domain

For example I have this string:

Lore Ipsum :py:mod:`dataclasses`

Which results in:

Lore Ipsum <a class="reference external" href="...dataclasses.html#module-dataclasses">
   <code class="xref py py-mod docutils literal notranslate">
      <span class="pre">dataclass</span>
   </code>
</a>

To the a or code.class I would like to add an additional class. e.g. "injected", to have the following result

   <code class="xref py py-mod docutils literal notranslate injected">

Some research I did to find a solution

"Inherit" role 💥

.. role : injected_mod(?py:mod?)
  :class: injected

:injected_mod:`dataclasses`

Problem: I do not know what to put into the brackets, I think I cannot use domains there -> Not a valid role.

Register a new role ❌

Possible but, Problem: I want to keep the functionality from the py domain.

Add role to :py: domain ❓

# conf.py
def setup():
   app.add_role_to_domain("py", "injected", PyXRefRole())

What works: Adds a "py-injected" class that I could work with
Problem?: The lookup feature and linking does not work to py:module, i.e. no <a class="reference external". I haven't been able to determine where in the sphinx module the lookup feature takes place and if it possible to extend the PyXRefRole to do both.

Nested Parsing/Roles 😑(nearly)

The question of composing roles is similar and provides a useful answer in the comboroles extension.

This is somewhat nice as I can combine it with a role directive to add the class.

:inject:`:py:mod:\`dataclasses\``

Problem: This adds an extra <span class=injected> around the resulting block from py:mod, instead of modifying the existing tags.


I am not sure if nested parsing if somewhat overkill, but so far I have not found a solution to add an additional class. I think using comboroles looks the most promising currently to continue with, but I am not yet sure how to extend it or port its utility to a custom role_function that injects a class instead of nesting an additional tag. I guess I need to access and modify to nodes in a custom function, but this is where I am stuck.

Notes:

Upvotes: 1

Views: 136

Answers (2)

G. Milde
G. Milde

Reputation: 929

The role-extension syntax uses just the role name in parentheses, cf. the "role" directive doc. The role-name is added to the role's classes by default. A different class value (or several custom class values) can be specified with the "class" option.

The following works in plain Docutils (with a dummy "py:mod" role).

.. role:: py:mod(literal)

.. role:: injected(py:mod)
.. role:: injected2(py:mod)
   :class: inject another-class
   
This is :injected:`injected text` 
and :injected2:`injected, too`.

The XML output shows that the "py:mod" role is extended.

<document source="/tmp/inj.rst">
    <paragraph>This is <literal classes="injected">injected text</literal>
        and <literal classes="inject another-class">injected, too</literal>.</paragraph>
</document>

However, while Docutils' standard roles and custom roles based on them can be extended in Sphinx, too, this does not work with roles added by Sphinx. :( This is not limited to domains, e.g.::

.. role:: cref(ref)
   :class: custom-class

results in the ERROR

/tmp/test/index.rst:20: ERROR: Error in "role" directive:
no content permitted.

IMO, this is a bug (or missing feature) in Sphinx.

Upvotes: 0

Daraan
Daraan

Reputation: 3947

For now I found this workaround using the comboroles extension


# conf.py

rst_prolog = """
.. role:: inject-role
    :class: inject
"""

from sphinxnotes.comboroles import CompositeRole
    
class InjectClassRole(CompositeRole):
    def run(self):
        allnodes, messages = super().run()
        inner = allnodes[0].children[0]
        inner.attributes["classes"].extend(allnodes[0].attributes["classes"]) # type: ignore[attr-defined]
        inner.parent = None
        return [inner], messages

def setup(app):
    app.add_role("inject", InjectClassRole(["inject-role"], nested_parse=True))

With this I can use

:inject:`:py:mod:\`dataclasses\``

which will parse :py:mod: and :inject-role:, but instead of creating a new <span class="inject"> will add the inject class to the tag created by the inner role. I still wonder if this is a bit overkill.

Sidenote: A hardcoded combo-role can also be created like this:

app.add_role("inject_mod", InjectClassRole(["inject", "py:mod"], nested_parse=False))

# Usage
:inject_mod:`dataclasses`

Instead of relying on an additional extra inject-role one could adjust the InjectClassRole.__init__ with an additional parameter to be injected in the run method.

Upvotes: 1

Related Questions