zephyr/doc/_extensions/zephyr/domain.py
Gerard Marull-Paretas 9e5e8e8c40 doc: extensions: zephyr-domain: make Breathe inserts optional
So that external users of the domain only interested in e.g. referencing
roles, can skip tweaks made to Breathe's directives.

Signed-off-by: Gerard Marull-Paretas <gerard@teslabs.com>
2024-01-11 10:04:44 +01:00

334 lines
11 KiB
Python

"""
Zephyr Extension
################
Copyright (c) 2023 The Linux Foundation
SPDX-License-Identifier: Apache-2.0
Introduction
============
This extension adds a new ``zephyr`` domain for handling the documentation of various entities
specific to the Zephyr RTOS project (ex. code samples).
Directives
----------
- ``zephyr:code-sample::`` - Defines a code sample.
The directive takes an ID as the main argument, and accepts ``:name:`` (human-readable short name
of the sample) and ``:relevant-api:`` (a space separated list of Doxygen group(s) for APIs the
code sample is a good showcase of) as options.
The content of the directive is used as the description of the code sample.
Example:
```
.. zephyr:code-sample:: blinky
:name: Blinky
:relevant-api: gpio_interface
Blink an LED forever using the GPIO API.
```
Roles
-----
- ``:zephyr:code-sample:`` - References a code sample.
The role takes the ID of the code sample as the argument. The role renders as a link to the code
sample, and the link text is the name of the code sample (or a custom text if an explicit name is
provided).
Example:
```
Check out :zephyr:code-sample:`sample-foo` for an example of how to use the foo API. You may
also be interested in :zephyr:code-sample:`this one <sample-bar>`.
```
"""
from typing import Any, Dict, Iterator, List, Tuple
from breathe.directives.content_block import DoxygenGroupDirective
from docutils import nodes
from docutils.nodes import Node
from docutils.parsers.rst import Directive, directives
from sphinx import addnodes
from sphinx.domains import Domain, ObjType
from sphinx.roles import XRefRole
from sphinx.transforms import SphinxTransform
from sphinx.transforms.post_transforms import SphinxPostTransform
from sphinx.util import logging
from sphinx.util.nodes import NodeMatcher, make_refnode
from zephyr.gh_utils import gh_link_get_url
import json
__version__ = "0.1.0"
logger = logging.getLogger(__name__)
class CodeSampleNode(nodes.Element):
pass
class RelatedCodeSamplesNode(nodes.Element):
pass
class ConvertCodeSampleNode(SphinxTransform):
default_priority = 100
def apply(self):
matcher = NodeMatcher(CodeSampleNode)
for node in self.document.traverse(matcher):
self.convert_node(node)
def convert_node(self, node):
"""
Transforms a `CodeSampleNode` into a `nodes.section` named after the code sample name.
Moves all sibling nodes that are after the `CodeSampleNode` in the documement under this new
section.
"""
parent = node.parent
siblings_to_move = []
if parent is not None:
index = parent.index(node)
siblings_to_move = parent.children[index + 1 :]
# Create a new section
new_section = nodes.section(ids=[node["id"]])
new_section += nodes.title(text=node["name"])
# Move the sibling nodes under the new section
new_section.extend(siblings_to_move)
# Replace the custom node with the new section
node.replace_self(new_section)
# Remove the moved siblings from their original parent
for sibling in siblings_to_move:
parent.remove(sibling)
# Set sample description as the meta description of the document for improved SEO
meta_description = nodes.meta()
meta_description["name"] = "description"
meta_description["content"] = node.children[0].astext()
node.document += meta_description
# Similarly, add a node with JSON-LD markup (only renders in HTML output) describing
# the code sample.
json_ld = nodes.raw(
"",
f"""<script type="application/ld+json">
{json.dumps({
"@context": "http://schema.org",
"@type": "SoftwareSourceCode",
"name": node['name'],
"description": node.children[0].astext(),
"codeSampleType": "full",
"codeRepository": gh_link_get_url(self.app, self.env.docname)
})}
</script>""",
format="html",
)
node.document += json_ld
class ProcessRelatedCodeSamplesNode(SphinxPostTransform):
default_priority = 5 # before ReferencesResolver
def run(self, **kwargs: Any) -> None:
matcher = NodeMatcher(RelatedCodeSamplesNode)
for node in self.document.traverse(matcher):
id = node["id"] # the ID of the node is the name of the doxygen group for which we
# want to list related code samples
code_samples = self.env.domaindata["zephyr"]["code-samples"].values()
# Filter out code samples that don't reference this doxygen group
code_samples = [
code_sample for code_sample in code_samples if id in code_sample["relevant-api"]
]
if len(code_samples) > 0:
admonition = nodes.admonition()
admonition += nodes.title(text="Related code samples")
admonition["classes"].append("related-code-samples")
admonition["classes"].append("dropdown") # used by sphinx-togglebutton extension
admonition["classes"].append("toggle-shown") # show the content by default
sample_dl = nodes.definition_list()
for code_sample in sorted(code_samples, key=lambda x: x["name"]):
term = nodes.term()
sample_xref = addnodes.pending_xref(
"",
refdomain="zephyr",
reftype="code-sample",
reftarget=code_sample["id"],
refwarn=True,
)
sample_xref += nodes.inline(text=code_sample["name"])
term += sample_xref
definition = nodes.definition()
definition += nodes.paragraph(text=code_sample["description"].astext())
sample_dli = nodes.definition_list_item()
sample_dli += term
sample_dli += definition
sample_dl += sample_dli
admonition += sample_dl
# replace node with the newly created admonition
node.replace_self(admonition)
else:
# remove node if there are no code samples
node.replace_self([])
class CodeSampleDirective(Directive):
"""
A directive for creating a code sample node in the Zephyr documentation.
"""
required_arguments = 1 # ID
optional_arguments = 0
option_spec = {"name": directives.unchanged, "relevant-api": directives.unchanged}
has_content = True
def run(self):
code_sample_id = self.arguments[0]
env = self.state.document.settings.env
code_samples = env.domaindata["zephyr"]["code-samples"]
if code_sample_id in code_samples:
logger.warning(
f"Code sample {code_sample_id} already exists. "
f"Other instance in {code_samples[code_sample_id]['docname']}",
location=(env.docname, self.lineno),
)
name = self.options.get("name", code_sample_id)
relevant_api_list = self.options.get("relevant-api", "").split()
# Create a node for description and populate it with parsed content
description_node = nodes.container(ids=[f"{code_sample_id}-description"])
self.state.nested_parse(self.content, self.content_offset, description_node)
code_sample = {
"id": code_sample_id,
"name": name,
"description": description_node,
"relevant-api": relevant_api_list,
"docname": env.docname,
}
domain = env.get_domain("zephyr")
domain.add_code_sample(code_sample)
# Create an instance of the custom node
code_sample_node = CodeSampleNode()
code_sample_node["id"] = code_sample_id
code_sample_node["name"] = name
code_sample_node += description_node
return [code_sample_node]
class ZephyrDomain(Domain):
"""Zephyr domain"""
name = "zephyr"
label = "Zephyr Project"
roles = {
"code-sample": XRefRole(innernodeclass=nodes.inline, warn_dangling=True),
}
directives = {"code-sample": CodeSampleDirective}
object_types: Dict[str, ObjType] = {
"code-sample": ObjType("code-sample", "code-sample"),
}
initial_data: Dict[str, Any] = {"code-samples": {}}
def clear_doc(self, docname: str) -> None:
self.data["code-samples"] = {
sample_id: sample_data
for sample_id, sample_data in self.data["code-samples"].items()
if sample_data["docname"] != docname
}
def merge_domaindata(self, docnames: List[str], otherdata: Dict) -> None:
self.data["code-samples"].update(otherdata["code-samples"])
def get_objects(self):
for _, code_sample in self.data["code-samples"].items():
yield (
code_sample["id"],
code_sample["name"],
"code-sample",
code_sample["docname"],
code_sample["id"],
1,
)
# used by Sphinx Immaterial theme
def get_object_synopses(self) -> Iterator[Tuple[Tuple[str, str], str]]:
for _, code_sample in self.data["code-samples"].items():
yield (
(code_sample["docname"], code_sample["id"]),
code_sample["description"].astext(),
)
def resolve_xref(self, env, fromdocname, builder, type, target, node, contnode):
if type == "code-sample":
code_sample_info = self.data["code-samples"].get(target)
if code_sample_info:
if not node.get("refexplicit"):
contnode = [nodes.Text(code_sample_info["name"])]
return make_refnode(
builder,
fromdocname,
code_sample_info["docname"],
code_sample_info["id"],
contnode,
code_sample_info["description"].astext(),
)
def add_code_sample(self, code_sample):
self.data["code-samples"][code_sample["id"]] = code_sample
class CustomDoxygenGroupDirective(DoxygenGroupDirective):
"""Monkey patch for Breathe's DoxygenGroupDirective."""
def run(self) -> List[Node]:
nodes = super().run()
if self.config.zephyr_breathe_insert_related_samples:
return [RelatedCodeSamplesNode(id=self.arguments[0]), *nodes]
else:
return nodes
def setup(app):
app.add_config_value("zephyr_breathe_insert_related_samples", False, "env")
app.add_domain(ZephyrDomain)
app.add_transform(ConvertCodeSampleNode)
app.add_post_transform(ProcessRelatedCodeSamplesNode)
# monkey-patching of Breathe's DoxygenGroupDirective
app.add_directive("doxygengroup", CustomDoxygenGroupDirective, override=True)
return {
"version": __version__,
"parallel_read_safe": True,
"parallel_write_safe": True,
}