From 81cd69928fb0230a6ed5d70b4f1f9f5b33527780 Mon Sep 17 00:00:00 2001
From: Casey Brooks
Date: Fri, 26 Dec 2025 17:59:24 +0000
Subject: [PATCH 1/2] fix(html): handle escaped kbd separators
---
sphinx/builders/html/transforms.py | 79 ++++++++++++++++++++++++------
tests/test_markup.py | 67 +++++++++++++++++++++++++
2 files changed, 132 insertions(+), 14 deletions(-)
diff --git a/sphinx/builders/html/transforms.py b/sphinx/builders/html/transforms.py
index c91da57e993..37ba679ac42 100644
--- a/sphinx/builders/html/transforms.py
+++ b/sphinx/builders/html/transforms.py
@@ -9,7 +9,7 @@
"""
import re
-from typing import Any, Dict
+from typing import Any, Dict, List, Optional, Set, Tuple
from docutils import nodes
@@ -37,26 +37,77 @@ class KeyboardTransform(SphinxPostTransform):
"""
default_priority = 400
builders = ('html',)
- pattern = re.compile(r'(-|\+|\^|\s+)')
+ pattern = re.compile(r'(?<=\S)(?:-|\+|\^|\s+)(?=\S)')
def run(self, **kwargs: Any) -> None:
matcher = NodeMatcher(nodes.literal, classes=["kbd"])
for node in self.document.traverse(matcher): # type: nodes.literal
- parts = self.pattern.split(node[-1].astext())
+ masked_text, placeholders = self._mask_escaped_separators(
+ node.astext(), getattr(node, 'rawsource', None))
+ parts = self.pattern.split(masked_text)
if len(parts) == 1:
continue
- node.pop()
- while parts:
- 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
+ separators = self.pattern.findall(masked_text)
+
+ node[:] = []
+ for index, key in enumerate(parts):
+ restored_key = self._restore_placeholders(key, placeholders)
+ node += nodes.literal('', restored_key, classes=["kbd"])
+
+ if index < len(separators):
+ separator = self._restore_placeholders(separators[index], placeholders)
+ node += nodes.Text(separator)
+
+ def _mask_escaped_separators(self, text: str, rawsource: Optional[str]) -> Tuple[str, Dict[str, str]]:
+ placeholders: Dict[str, str] = {}
+ escaped_indices = self._find_escaped_indices(text, rawsource)
+ if not escaped_indices:
+ return text, placeholders
+
+ masked_parts: List[str] = []
+ for index, char in enumerate(text):
+ if index in escaped_indices and char in '-+^':
+ placeholder = f'@KBD_ESC_{len(placeholders)}@'
+ placeholders[placeholder] = char
+ masked_parts.append(placeholder)
+ else:
+ masked_parts.append(char)
+
+ return ''.join(masked_parts), placeholders
+
+ def _find_escaped_indices(self, text: str, rawsource: Optional[str]) -> Set[int]:
+ if not rawsource:
+ return set()
+
+ start = rawsource.find('`')
+ end = rawsource.rfind('`')
+ if start == -1 or end <= start:
+ content = rawsource
+ else:
+ content = rawsource[start + 1:end]
+
+ indices: Set[int] = set()
+ text_index = 0
+ pos = 0
+
+ while pos < len(content) and text_index < len(text):
+ char = content[pos]
+ if char == '\\' and pos + 1 < len(content) and content[pos + 1] in '-+^':
+ indices.add(text_index)
+ pos += 2
+ text_index += 1
+ else:
+ pos += 1
+ text_index += 1
+
+ return indices
+
+ @staticmethod
+ def _restore_placeholders(text: str, placeholders: Dict[str, str]) -> str:
+ for placeholder, char in placeholders.items():
+ text = text.replace(placeholder, char)
+ return text
def setup(app: Sphinx) -> Dict[str, Any]:
diff --git a/tests/test_markup.py b/tests/test_markup.py
index a2bcb2dc1fe..8f2a9c40e0a 100644
--- a/tests/test_markup.py
+++ b/tests/test_markup.py
@@ -240,6 +240,27 @@ def get(name):
'space
',
'\\sphinxkeyboard{\\sphinxupquote{space}}',
),
+ (
+ # kbd role literal hyphen
+ 'verify',
+ ':kbd:`-`',
+ '-
',
+ None,
+ ),
+ (
+ # kbd role literal plus
+ 'verify',
+ ':kbd:`+`',
+ '+
',
+ None,
+ ),
+ (
+ # kbd role literal caret
+ 'verify',
+ ':kbd:`^`',
+ '^
',
+ None,
+ ),
(
# kbd role
'verify',
@@ -266,6 +287,52 @@ def get(name):
'
'),
'\\sphinxkeyboard{\\sphinxupquote{M\\sphinxhyphen{}x M\\sphinxhyphen{}s}}',
),
+ (
+ # kbd role compound with literal separator key
+ 'verify',
+ ':kbd:`Shift-+`',
+ (''
+ 'Shift'
+ '-'
+ '+'
+ '
'),
+ None,
+ ),
+ (
+ # kbd role boundary hyphen prefix
+ 'verify',
+ ':kbd:`-X`',
+ '-X
',
+ None,
+ ),
+ (
+ # kbd role boundary hyphen suffix
+ 'verify',
+ ':kbd:`X-`',
+ 'X-
',
+ None,
+ ),
+ (
+ # kbd role escaped hyphen separator
+ 'verify',
+ ':kbd:`C\\-x`',
+ 'C-x
',
+ None,
+ ),
+ (
+ # kbd role escaped plus separator
+ 'verify',
+ ':kbd:`A\\+B`',
+ 'A+B
',
+ None,
+ ),
+ (
+ # kbd role escaped caret separator
+ 'verify',
+ ':kbd:`^\\^`',
+ '^^
',
+ None,
+ ),
(
# non-interpolation of dashes in option role
'verify_re',
From ee0c47d7365bfb2fac70cd3cf6d19fb5d4d38e5c Mon Sep 17 00:00:00 2001
From: Casey Brooks
Date: Fri, 26 Dec 2025 18:15:16 +0000
Subject: [PATCH 2/2] fix(html): align escaped index tracking
---
sphinx/builders/html/transforms.py | 21 +++++++++++++++------
tests/test_markup.py | 7 +++++++
2 files changed, 22 insertions(+), 6 deletions(-)
diff --git a/sphinx/builders/html/transforms.py b/sphinx/builders/html/transforms.py
index 37ba679ac42..a2204c9b549 100644
--- a/sphinx/builders/html/transforms.py
+++ b/sphinx/builders/html/transforms.py
@@ -90,16 +90,25 @@ def _find_escaped_indices(self, text: str, rawsource: Optional[str]) -> Set[int]
indices: Set[int] = set()
text_index = 0
pos = 0
+ text_len = len(text)
- while pos < len(content) and text_index < len(text):
+ while pos < len(content) and text_index < text_len:
char = content[pos]
- if char == '\\' and pos + 1 < len(content) and content[pos + 1] in '-+^':
- indices.add(text_index)
+ if char == '\\':
+ if pos + 1 >= len(content):
+ pos += 1
+ continue
+
+ escaped = content[pos + 1]
+ if escaped in '-+^':
+ indices.add(text_index)
+
pos += 2
text_index += 1
- else:
- pos += 1
- text_index += 1
+ continue
+
+ pos += 1
+ text_index += 1
return indices
diff --git a/tests/test_markup.py b/tests/test_markup.py
index 8f2a9c40e0a..4c6911cb4a7 100644
--- a/tests/test_markup.py
+++ b/tests/test_markup.py
@@ -333,6 +333,13 @@ def get(name):
'^^
',
None,
),
+ (
+ # kbd role escaped separator after other escapes
+ 'verify',
+ ':kbd:`\\|\\-\\|`',
+ '|-|
',
+ None,
+ ),
(
# non-interpolation of dashes in option role
'verify_re',