Implement key splitting in the `:kbd: role and remove KeyboardTransform` (#13227)

``KeyboardTransform.run`` takes 1.2% of the runtime of a build, but
is not be needed if ``:kbd:`` is converted to a proper role.

We implement key splitting in the ``:kbd:`` role, and return a sequence
of parsed nodes rather than a nested collection of roles with ``class="kbd compound"``.
This commit is contained in:
Adam Turner
2025-01-10 19:51:17 +00:00
committed by GitHub
parent fdb1b0cdad
commit d29c2c1ff3
5 changed files with 106 additions and 105 deletions

View File

@@ -15,6 +15,12 @@ Incompatible changes
now unconditionally returns ``True``.
These are replaced by the ``has_maths_elements`` key of the page context dict.
Patch by Adam Turner.
* #13227: HTML output for sequences of keys in the :rst:role:`kbd` role
no longer uses a ``<kbd class="kbd compound">`` element to wrap
the keys and separators, but places them directly in the relevant parent node.
This means that CSS rulesets targeting ``kbd.compound`` or ``.kbd.compound``
will no longer have any effect.
Patch by Adam Turner.
Deprecated
----------
@@ -36,6 +42,8 @@ Features added
* #13146: Napoleon: Unify the type preprocessing logic to allow
Google-style docstrings to use the optional and default keywords.
Patch by Chris Barrick.
* #13227: Implement the :rst:role:`kbd` role as a ``SphinxRole``.
Patch by Adam Turner.
Bugs fixed
----------

View File

@@ -1510,9 +1510,6 @@ def setup(app: Sphinx) -> ExtensionMetadata:
# load default math renderer
app.setup_extension('sphinx.ext.mathjax')
# load transforms for HTML builder
app.setup_extension('sphinx.builders.html.transforms')
return {
'version': 'builtin',
'parallel_read_safe': True,

View File

@@ -1,90 +0,0 @@
"""Transforms for HTML builder."""
from __future__ import annotations
import re
from typing import TYPE_CHECKING, Any
from docutils import nodes
from sphinx.transforms.post_transforms import SphinxPostTransform
from sphinx.util.nodes import NodeMatcher
if TYPE_CHECKING:
from sphinx.application import Sphinx
from sphinx.util.typing import ExtensionMetadata
class KeyboardTransform(SphinxPostTransform):
"""Transform :kbd: role to more detailed form.
Before::
<literal class="kbd">
Control-x
After::
<literal class="kbd compound">
<literal class="kbd">
Control
-
<literal class="kbd">
x
"""
default_priority = 400
formats = ('html',)
pattern = re.compile(r'(?<=.)(-|\+|\^|\s+)(?=.)')
multiwords_keys = (
('caps', 'lock'),
('page', 'down'),
('page', 'up'),
('scroll', 'lock'),
('num', 'lock'),
('sys', 'rq'),
('back', 'space'),
)
def run(self, **kwargs: Any) -> None:
matcher = NodeMatcher(nodes.literal, classes=['kbd'])
# this list must be pre-created as during iteration new nodes
# are added which match the condition in the NodeMatcher.
for node in list(matcher.findall(self.document)):
parts = self.pattern.split(node[-1].astext())
if len(parts) == 1 or self.is_multiwords_key(parts):
continue
node['classes'].append('compound')
node.pop()
while parts:
if self.is_multiwords_key(parts):
key = ''.join(parts[:3])
parts[:3] = []
else:
key = parts.pop(0)
node += nodes.literal('', key, classes=['kbd'])
try:
# key separator (ex. -, +, ^)
sep = parts.pop(0)
node += nodes.Text(sep)
except IndexError:
pass
def is_multiwords_key(self, parts: list[str]) -> bool:
if len(parts) >= 3 and not parts[1].strip():
name = parts[0].lower(), parts[2].lower()
return name in self.multiwords_keys
else:
return False
def setup(app: Sphinx) -> ExtensionMetadata:
app.add_post_transform(KeyboardTransform)
return {
'version': 'builtin',
'parallel_read_safe': True,
'parallel_write_safe': True,
}

View File

@@ -29,7 +29,6 @@ if TYPE_CHECKING:
generic_docroles = {
'command': addnodes.literal_strong,
'dfn': nodes.emphasis,
'kbd': nodes.literal,
'mailheader': addnodes.literal_emphasis,
'makevar': addnodes.literal_strong,
'mimetype': addnodes.literal_emphasis,
@@ -479,6 +478,59 @@ class Abbreviation(SphinxRole):
return [nodes.abbreviation(self.rawtext, text, **options)], []
class Keyboard(SphinxRole):
"""Implement the :kbd: role.
Split words in the text by separator or whitespace,
but keep multi-word keys together.
"""
# capture ('-', '+', '^', or whitespace) in between any two characters
_pattern: Final = re.compile(r'(?<=.)([\-+^]| +)(?=.)')
def run(self) -> tuple[list[Node], list[system_message]]:
classes = ['kbd']
if 'classes' in self.options:
classes.extend(self.options['classes'])
parts = self._pattern.split(self.text)
if len(parts) == 1 or self._is_multi_word_key(parts):
return [nodes.literal(self.rawtext, self.text, classes=classes)], []
compound: list[Node] = []
while parts:
if self._is_multi_word_key(parts):
key = ''.join(parts[:3])
parts[:3] = []
else:
key = parts.pop(0)
compound.append(nodes.literal(key, key, classes=classes))
try:
sep = parts.pop(0) # key separator ('-', '+', '^', etc)
except IndexError:
break
else:
compound.append(nodes.Text(sep))
return compound, []
@staticmethod
def _is_multi_word_key(parts: list[str]) -> bool:
if len(parts) <= 2 or not parts[1].isspace():
return False
name = parts[0].lower(), parts[2].lower()
return name in frozenset({
('back', 'space'),
('caps', 'lock'),
('num', 'lock'),
('page', 'down'),
('page', 'up'),
('scroll', 'lock'),
('sys', 'rq'),
})
class Manpage(ReferenceRole):
_manpage_re = re.compile(r'^(?P<path>(?P<page>.+)[(.](?P<section>[1-9]\w*)?\)?)$')
@@ -576,6 +628,7 @@ specific_docroles: dict[str, RoleFunction] = {
'samp': EmphasizedLiteral(),
# other
'abbr': Abbreviation(),
'kbd': Keyboard(),
'manpage': Manpage(),
}

View File

@@ -11,7 +11,6 @@ from docutils import frontend, nodes, utils
from docutils.parsers.rst import Parser as RstParser
from sphinx import addnodes
from sphinx.builders.html.transforms import KeyboardTransform
from sphinx.builders.latex import LaTeXBuilder
from sphinx.environment import default_settings
from sphinx.roles import XRefRole
@@ -100,7 +99,6 @@ class ForgivingLaTeXTranslator(LaTeXTranslator, ForgivingTranslator):
def verify_re_html(app, parse):
def verify(rst, html_expected):
document = parse(rst)
KeyboardTransform(document).apply()
html_translator = ForgivingHTMLTranslator(document, app.builder)
document.walkabout(html_translator)
html_translated = ''.join(html_translator.fragment).strip()
@@ -357,28 +355,35 @@ def get_verifier(verify, verify_re):
'verify',
':kbd:`Control+X`',
(
'<p><kbd class="kbd compound docutils literal notranslate">'
'<p>'
'<kbd class="kbd docutils literal notranslate">Control</kbd>'
'+'
'<kbd class="kbd docutils literal notranslate">X</kbd>'
'</kbd></p>'
'</p>'
),
(
'\\sphinxAtStartPar\n'
'\\sphinxkeyboard{\\sphinxupquote{Control}}'
'+'
'\\sphinxkeyboard{\\sphinxupquote{X}}'
),
'\\sphinxAtStartPar\n\\sphinxkeyboard{\\sphinxupquote{Control+X}}',
),
(
# kbd role
'verify',
':kbd:`Alt+^`',
(
'<p><kbd class="kbd compound docutils literal notranslate">'
'<p>'
'<kbd class="kbd docutils literal notranslate">Alt</kbd>'
'+'
'<kbd class="kbd docutils literal notranslate">^</kbd>'
'</kbd></p>'
'</p>'
),
(
'\\sphinxAtStartPar\n'
'\\sphinxkeyboard{\\sphinxupquote{Alt+\\textasciicircum{}}}'
'\\sphinxkeyboard{\\sphinxupquote{Alt}}'
'+'
'\\sphinxkeyboard{\\sphinxupquote{\\textasciicircum{}}}'
),
),
(
@@ -386,7 +391,7 @@ def get_verifier(verify, verify_re):
'verify',
':kbd:`M-x M-s`',
(
'<p><kbd class="kbd compound docutils literal notranslate">'
'<p>'
'<kbd class="kbd docutils literal notranslate">M</kbd>'
'-'
'<kbd class="kbd docutils literal notranslate">x</kbd>'
@@ -394,11 +399,17 @@ def get_verifier(verify, verify_re):
'<kbd class="kbd docutils literal notranslate">M</kbd>'
'-'
'<kbd class="kbd docutils literal notranslate">s</kbd>'
'</kbd></p>'
'</p>'
),
(
'\\sphinxAtStartPar\n'
'\\sphinxkeyboard{\\sphinxupquote{M\\sphinxhyphen{}x M\\sphinxhyphen{}s}}'
'\\sphinxkeyboard{\\sphinxupquote{M}}'
'\\sphinxhyphen{}'
'\\sphinxkeyboard{\\sphinxupquote{x}}'
' '
'\\sphinxkeyboard{\\sphinxupquote{M}}'
'\\sphinxhyphen{}'
'\\sphinxkeyboard{\\sphinxupquote{s}}'
),
),
(
@@ -422,6 +433,28 @@ def get_verifier(verify, verify_re):
'<p><kbd class="kbd docutils literal notranslate">sys rq</kbd></p>',
'\\sphinxAtStartPar\n\\sphinxkeyboard{\\sphinxupquote{sys rq}}',
),
(
# kbd role
'verify',
':kbd:`⌘+⇧+M`',
(
'<p>'
'<kbd class="kbd docutils literal notranslate">⌘</kbd>'
'+'
'<kbd class="kbd docutils literal notranslate">⇧</kbd>'
'+'
'<kbd class="kbd docutils literal notranslate">M</kbd>'
'</p>'
),
(
'\\sphinxAtStartPar\n'
'\\sphinxkeyboard{\\sphinxupquote{⌘}}'
'+'
'\\sphinxkeyboard{\\sphinxupquote{⇧}}'
'+'
'\\sphinxkeyboard{\\sphinxupquote{M}}'
),
),
(
# non-interpolation of dashes in option role
'verify_re',