diff --git a/docs/_ext/rule_directive.py b/docs/_ext/rule_directive.py index feaee7a..ce7ad09 100644 --- a/docs/_ext/rule_directive.py +++ b/docs/_ext/rule_directive.py @@ -19,10 +19,17 @@ from __future__ import annotations +import functools import typing +from dataclasses import dataclass +from types import SimpleNamespace from typing import cast +import docutils.frontend from docutils import nodes +from docutils.parsers.rst import Parser +from docutils.parsers.rst.states import Inliner +from docutils.utils import new_document from sphinx.addnodes import pending_xref from sphinx.domains import Domain, ObjType from sphinx.roles import XRefRole @@ -33,7 +40,7 @@ from collections.abc import Set from typing import Any, ClassVar - from docutils.nodes import Element, Node, document, system_message + from docutils.nodes import Element, Node, document, reference, system_message from sphinx.application import Sphinx from sphinx.builders import Builder from sphinx.environment import BuildEnvironment @@ -53,10 +60,47 @@ def setup(app: Sphinx): } -class RuleEntry(typing.NamedTuple): +@dataclass(frozen=True) +class RuleEntry: + + code: str + title: str doc_name: str - node_id: str - rule_name: str + + def __post_init__(self): + object.__setattr__(self, "code", self.code.upper()) + + @functools.cached_property + def ref_name(self) -> str: + return "tg.rule." + self.code.lower() + + @functools.cached_property + def node_id(self) -> str: + return nodes.make_id(self.ref_name) + + def title_nodes(self) -> list[Node]: + settings = docutils.frontend.get_default_settings(Parser()) + doc = new_document("", settings=settings) + + inliner = Inliner() + inliner.init_customizations(settings) + memo = SimpleNamespace(document=doc, reporter=doc.reporter, language=None) + + text_nodes, _ = inliner.parse(self.title, 1, memo, doc) + return text_nodes + + def title_stripped(self) -> str: + return "".join(node.astext() for node in self.title_nodes()) + + def node(self): + """Create reference node for this rule.""" + container = nodes.inline(classes=["inline-rule-ref"]) + container += [ + nodes.inline(text=self.code, classes=["rule-code"]), + nodes.Text(" "), + *self.title_nodes(), + ] + return container class RuleDirective(SphinxDirective): @@ -71,18 +115,9 @@ def run(self): rule_code, rule_name = self.arguments rule_code = rule_code.upper() - ref_name = "tg.rule." + rule_code.lower() - node_id = nodes.make_id(ref_name) - - # register the label in Sphinx's standard domain - std_domain = self.env.domains.standard_domain - std_domain.note_hyperlink_target( - ref_name, self.env.docname, node_id, f"{rule_code} ({rule_name})" - ) - # register the rule in tugboat domain tugboat_domain = cast("TugboatDomain", self.env.get_domain("tg")) - tugboat_domain.note_rule(rule_code, rule_name, node_id) + entry = tugboat_domain.note_rule(rule_code, rule_name) # create section and populate it with title and content rule_name_nodes, _ = self.parse_inline(rule_name) @@ -98,7 +133,7 @@ def run(self): section += title section += self.parse_content_to_nodes() - target = nodes.target(ids=[node_id]) + target = nodes.target(ids=[entry.node_id]) return [target, section] @@ -134,25 +169,15 @@ def result_nodes( rule_code: str = node["reftarget"] domain = cast("TugboatDomain", env.get_domain("tg")) - data = domain.rules.get(rule_code) - if data and not node.get("explicit"): + entry = domain.rules.get(rule_code.upper()) + + if entry and not node.get("explicit"): node.clear() - node += make_rule_contnode(rule_code, data.rule_name) + node += entry.node() return [node], [] -def make_rule_contnode(rule_code: str, rule_name: str) -> Node: - """Create content node for a rule reference.""" - container = nodes.inline(classes=["inline-rule-ref"]) - container += [ - nodes.inline(text=rule_code, classes=["rule-code"]), - nodes.Text(" "), - nodes.Text(rule_name), - ] - return container - - class TugboatDomain(Domain): name = "tg" @@ -166,16 +191,45 @@ class TugboatDomain(Domain): def rules(self) -> dict[str, RuleEntry]: return self.data.setdefault("rules", {}) - def note_rule(self, rule_code: str, rule_name: str, node_id: str): - self.rules[rule_code.upper()] = RuleEntry( + def note_rule(self, rule_code: str, rule_name: str) -> RuleEntry: + """ + Note a rule in the domain. + + Parameters + ---------- + rule_code : str + The unique code for the rule. + rule_name : str + The name of the rule. + + Returns + ------- + entry : RuleEntry + The registered rule entry. + """ + # register a rule in this domain + entry = RuleEntry( + code=rule_code, + title=rule_name, doc_name=self.env.docname, - node_id=node_id, - rule_name=rule_name, ) + self.rules[entry.code] = entry + + # register the label in Sphinx's standard domain + std_domain = self.env.domains.standard_domain + std_domain.note_hyperlink_target( + name=entry.ref_name, + docname=self.env.docname, + node_id=entry.node_id, + title=f"{entry.code} ({entry.title_stripped()})", + ) + + return entry + def get_objects(self): - for label, (doc_name, node_id, rule_name) in self.rules.items(): - yield (label, rule_name, "rule", doc_name, node_id, 1) + for label, rule in self.rules.items(): + yield (label, rule.title, "rule", rule.doc_name, rule.node_id, 1) def clear_doc(self, docname: str): to_remove = [] @@ -199,14 +253,15 @@ def resolve_xref( target: str, node: pending_xref, contnode: Element, - ) -> Element | None: - if data := self.rules.get(target): + ) -> reference | None: + if entry := self.rules.get(target): return make_refnode( builder, fromdocname, - data.doc_name, - data.node_id, - make_rule_contnode(target, data.rule_name), - data.node_id, + entry.doc_name, + entry.node_id, + entry.node(), + entry.title, ) + return None