"""Manual page writer, extended for Sphinx custom nodes.""" from __future__ import annotations from collections.abc import Iterable from typing import TYPE_CHECKING, Any, cast from docutils import nodes from docutils.writers.manpage import Translator as BaseTranslator from docutils.writers.manpage import Writer from sphinx import addnodes from sphinx.locale import _, admonitionlabels from sphinx.util import logging from sphinx.util.docutils import SphinxTranslator from sphinx.util.i18n import format_date from sphinx.util.nodes import NodeMatcher if TYPE_CHECKING: from docutils.nodes import Element from sphinx.builders import Builder logger = logging.getLogger(__name__) class ManualPageWriter(Writer): def __init__(self, builder: Builder) -> None: super().__init__() self.builder = builder def translate(self) -> None: transform = NestedInlineTransform(self.document) transform.apply() visitor = self.builder.create_translator(self.document, self.builder) self.visitor = cast(ManualPageTranslator, visitor) self.document.walkabout(visitor) self.output = self.visitor.astext() class NestedInlineTransform: """ Flatten nested inline nodes: Before: foo=1 &bar=2 After: foo=var &bar=2 """ def __init__(self, document: nodes.document) -> None: self.document = document def apply(self, **kwargs: Any) -> None: matcher = NodeMatcher(nodes.literal, nodes.emphasis, nodes.strong) for node in list(self.document.findall(matcher)): # type: nodes.TextElement if any(matcher(subnode) for subnode in node): pos = node.parent.index(node) for subnode in reversed(list(node)): node.remove(subnode) if matcher(subnode): node.parent.insert(pos + 1, subnode) else: newnode = node.__class__('', '', subnode, **node.attributes) node.parent.insert(pos + 1, newnode) # move node if all children became siblings of the node if not len(node): node.parent.remove(node) class ManualPageTranslator(SphinxTranslator, BaseTranslator): """ Custom man page translator. """ _docinfo: dict[str, Any] = {} def __init__(self, document: nodes.document, builder: Builder) -> None: super().__init__(document, builder) self.in_productionlist = 0 # first title is the manpage title self.section_level = -1 # docinfo set by man_pages config value self._docinfo['title'] = self.settings.title self._docinfo['subtitle'] = self.settings.subtitle if self.settings.authors: # don't set it if no author given self._docinfo['author'] = self.settings.authors self._docinfo['manual_section'] = self.settings.section # docinfo set by other config values self._docinfo['title_upper'] = self._docinfo['title'].upper() if self.config.today: self._docinfo['date'] = self.config.today else: self._docinfo['date'] = format_date(self.config.today_fmt or _('%b %d, %Y'), language=self.config.language) self._docinfo['copyright'] = self.config.copyright self._docinfo['version'] = self.config.version self._docinfo['manual_group'] = self.config.project # Overwrite admonition label translations with our own for label, translation in admonitionlabels.items(): self.language.labels[label] = self.deunicode(translation) # overwritten -- added quotes around all .TH arguments def header(self) -> str: tmpl = (".TH \"%(title_upper)s\" \"%(manual_section)s\"" " \"%(date)s\" \"%(version)s\" \"%(manual_group)s\"\n") if self._docinfo['subtitle']: tmpl += (".SH NAME\n" "%(title)s \\- %(subtitle)s\n") return tmpl % self._docinfo def visit_start_of_file(self, node: Element) -> None: pass def depart_start_of_file(self, node: Element) -> None: pass ############################################################# # Domain-specific object descriptions ############################################################# # Top-level nodes for descriptions ################################## def visit_desc(self, node: Element) -> None: self.visit_definition_list(node) def depart_desc(self, node: Element) -> None: self.depart_definition_list(node) def visit_desc_signature(self, node: Element) -> None: self.visit_definition_list_item(node) self.visit_term(node) def depart_desc_signature(self, node: Element) -> None: self.depart_term(node) def visit_desc_signature_line(self, node: Element) -> None: pass def depart_desc_signature_line(self, node: Element) -> None: self.body.append(' ') def visit_desc_content(self, node: Element) -> None: self.visit_definition(node) def depart_desc_content(self, node: Element) -> None: self.depart_definition(node) def visit_desc_inline(self, node: Element) -> None: pass def depart_desc_inline(self, node: Element) -> None: pass # Nodes for high-level structure in signatures ############################################## def visit_desc_name(self, node: Element) -> None: pass def depart_desc_name(self, node: Element) -> None: pass def visit_desc_addname(self, node: Element) -> None: pass def depart_desc_addname(self, node: Element) -> None: pass def visit_desc_type(self, node: Element) -> None: pass def depart_desc_type(self, node: Element) -> None: pass def visit_desc_returns(self, node: Element) -> None: self.body.append(' -> ') def depart_desc_returns(self, node: Element) -> None: pass def visit_desc_parameterlist(self, node: Element) -> None: self.body.append('(') self.first_param = 1 def depart_desc_parameterlist(self, node: Element) -> None: self.body.append(')') def visit_desc_type_parameter_list(self, node: Element) -> None: self.body.append('[') self.first_param = 1 def depart_desc_type_parameter_list(self, node: Element) -> None: self.body.append(']') def visit_desc_parameter(self, node: Element) -> None: if not self.first_param: self.body.append(', ') else: self.first_param = 0 def depart_desc_parameter(self, node: Element) -> None: pass def visit_desc_type_parameter(self, node: Element) -> None: self.visit_desc_parameter(node) def depart_desc_type_parameter(self, node: Element) -> None: self.depart_desc_parameter(node) def visit_desc_optional(self, node: Element) -> None: self.body.append('[') def depart_desc_optional(self, node: Element) -> None: self.body.append(']') def visit_desc_annotation(self, node: Element) -> None: pass def depart_desc_annotation(self, node: Element) -> None: pass ############################################## def visit_versionmodified(self, node: Element) -> None: self.visit_paragraph(node) def depart_versionmodified(self, node: Element) -> None: self.depart_paragraph(node) # overwritten -- don't make whole of term bold if it includes strong node def visit_term(self, node: Element) -> None: if any(node.findall(nodes.strong)): self.body.append('\n') else: super().visit_term(node) # overwritten -- we don't want source comments to show up def visit_comment(self, node: Element) -> None: # type: ignore[override] raise nodes.SkipNode # overwritten -- added ensure_eol() def visit_footnote(self, node: Element) -> None: self.ensure_eol() super().visit_footnote(node) # overwritten -- handle footnotes rubric def visit_rubric(self, node: Element) -> None: self.ensure_eol() if len(node) == 1 and node.astext() in ('Footnotes', _('Footnotes')): self.body.append('.SH ' + self.deunicode(node.astext()).upper() + '\n') raise nodes.SkipNode self.body.append('.sp\n') def depart_rubric(self, node: Element) -> None: self.body.append('\n') def visit_seealso(self, node: Element) -> None: self.visit_admonition(node, 'seealso') def depart_seealso(self, node: Element) -> None: self.depart_admonition(node) def visit_productionlist(self, node: Element) -> None: self.ensure_eol() names = [] self.in_productionlist += 1 self.body.append('.sp\n.nf\n') productionlist = cast(Iterable[addnodes.production], node) for production in productionlist: names.append(production['tokenname']) maxlen = max(len(name) for name in names) lastname = None for production in productionlist: if production['tokenname']: lastname = production['tokenname'].ljust(maxlen) self.body.append(self.defs['strong'][0]) self.body.append(self.deunicode(lastname)) self.body.append(self.defs['strong'][1]) self.body.append(' ::= ') elif lastname is not None: self.body.append('%s ' % (' ' * len(lastname))) production.walkabout(self) self.body.append('\n') self.body.append('\n.fi\n') self.in_productionlist -= 1 raise nodes.SkipNode def visit_production(self, node: Element) -> None: pass def depart_production(self, node: Element) -> None: pass # overwritten -- don't emit a warning for images def visit_image(self, node: Element) -> None: if 'alt' in node.attributes: self.body.append(_('[image: %s]') % node['alt'] + '\n') self.body.append(_('[image]') + '\n') raise nodes.SkipNode # overwritten -- don't visit inner marked up nodes def visit_reference(self, node: Element) -> None: self.body.append(self.defs['reference'][0]) # avoid repeating escaping code... fine since # visit_Text calls astext() and only works on that afterwards self.visit_Text(node) # type: ignore[arg-type] self.body.append(self.defs['reference'][1]) uri = node.get('refuri', '') if uri.startswith(('mailto:', 'http:', 'https:', 'ftp:')): # if configured, put the URL after the link if self.config.man_show_urls and node.astext() != uri: if uri.startswith('mailto:'): uri = uri[7:] self.body.extend([ ' <', self.defs['strong'][0], uri, self.defs['strong'][1], '>']) raise nodes.SkipNode def visit_number_reference(self, node: Element) -> None: text = nodes.Text(node.get('title', '#')) self.visit_Text(text) raise nodes.SkipNode def visit_centered(self, node: Element) -> None: self.ensure_eol() self.body.append('.sp\n.ce\n') def depart_centered(self, node: Element) -> None: self.body.append('\n.ce 0\n') def visit_compact_paragraph(self, node: Element) -> None: pass def depart_compact_paragraph(self, node: Element) -> None: pass def visit_download_reference(self, node: Element) -> None: pass def depart_download_reference(self, node: Element) -> None: pass def visit_toctree(self, node: Element) -> None: raise nodes.SkipNode def visit_index(self, node: Element) -> None: raise nodes.SkipNode def visit_tabular_col_spec(self, node: Element) -> None: raise nodes.SkipNode def visit_glossary(self, node: Element) -> None: pass def depart_glossary(self, node: Element) -> None: pass def visit_acks(self, node: Element) -> None: bullet_list = cast(nodes.bullet_list, node[0]) list_items = cast(Iterable[nodes.list_item], bullet_list) self.ensure_eol() bullet_list = cast(nodes.bullet_list, node[0]) list_items = cast(Iterable[nodes.list_item], bullet_list) self.body.append(', '.join(n.astext() for n in list_items) + '.') self.body.append('\n') raise nodes.SkipNode def visit_hlist(self, node: Element) -> None: self.visit_bullet_list(node) def depart_hlist(self, node: Element) -> None: self.depart_bullet_list(node) def visit_hlistcol(self, node: Element) -> None: pass def depart_hlistcol(self, node: Element) -> None: pass def visit_literal_emphasis(self, node: Element) -> None: return self.visit_emphasis(node) def depart_literal_emphasis(self, node: Element) -> None: return self.depart_emphasis(node) def visit_literal_strong(self, node: Element) -> None: return self.visit_strong(node) def depart_literal_strong(self, node: Element) -> None: return self.depart_strong(node) def visit_abbreviation(self, node: Element) -> None: pass def depart_abbreviation(self, node: Element) -> None: pass def visit_manpage(self, node: Element) -> None: return self.visit_strong(node) def depart_manpage(self, node: Element) -> None: return self.depart_strong(node) # overwritten: handle section titles better than in 0.6 release def visit_caption(self, node: Element) -> None: if isinstance(node.parent, nodes.container) and node.parent.get('literal_block'): self.body.append('.sp\n') else: super().visit_caption(node) def depart_caption(self, node: Element) -> None: if isinstance(node.parent, nodes.container) and node.parent.get('literal_block'): self.body.append('\n') else: super().depart_caption(node) # overwritten: handle section titles better than in 0.6 release def visit_title(self, node: Element) -> None: if isinstance(node.parent, addnodes.seealso): self.body.append('.IP "') return None elif isinstance(node.parent, nodes.section): if self.section_level == 0: # skip the document title raise nodes.SkipNode elif self.section_level == 1: self.body.append('.SH %s\n' % self.deunicode(node.astext().upper())) raise nodes.SkipNode return super().visit_title(node) def depart_title(self, node: Element) -> None: if isinstance(node.parent, addnodes.seealso): self.body.append('"\n') return None return super().depart_title(node) def visit_raw(self, node: Element) -> None: if 'manpage' in node.get('format', '').split(): self.body.append(node.astext()) raise nodes.SkipNode def visit_meta(self, node: Element) -> None: raise nodes.SkipNode def visit_inline(self, node: Element) -> None: pass def depart_inline(self, node: Element) -> None: pass def visit_math(self, node: Element) -> None: pass def depart_math(self, node: Element) -> None: pass def visit_math_block(self, node: Element) -> None: self.visit_centered(node) def depart_math_block(self, node: Element) -> None: self.depart_centered(node)