doc: Add Sphinx extension for code samples
This adds a new Sphinx extension for both a code-sample directive and role. Signed-off-by: Benjamin Cabé <benjamin@zephyrproject.org>
This commit is contained in:
parent
7ca35f94ee
commit
3c5f3da4d8
3 changed files with 324 additions and 0 deletions
308
doc/_extensions/zephyr/domain.py
Normal file
308
doc/_extensions/zephyr/domain.py
Normal file
|
@ -0,0 +1,308 @@
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
__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 :]
|
||||||
|
|
||||||
|
# TODO remove once all :ref:`sample-xyz` have migrated to :zephyr:code-sample:`xyz`
|
||||||
|
# as this is the recommended way to reference code samples going forward.
|
||||||
|
self.env.app.env.domaindata["std"]["labels"][node["id"]] = (
|
||||||
|
self.env.docname,
|
||||||
|
node["id"],
|
||||||
|
node["name"],
|
||||||
|
)
|
||||||
|
self.env.app.env.domaindata["std"]["anonlabels"][node["id"]] = (
|
||||||
|
self.env.docname,
|
||||||
|
node["id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a new section
|
||||||
|
new_section = nodes.section(ids=[node["id"]])
|
||||||
|
new_section += nodes.title(text=node["name"])
|
||||||
|
|
||||||
|
# Move existing content from the custom node to the new section
|
||||||
|
new_section.extend(node.children)
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
sample_ul = nodes.bullet_list()
|
||||||
|
for code_sample in sorted(code_samples, key=lambda x: x["name"]):
|
||||||
|
sample_para = nodes.paragraph()
|
||||||
|
sample_xref = addnodes.pending_xref(
|
||||||
|
"",
|
||||||
|
refdomain="zephyr",
|
||||||
|
reftype="code-sample",
|
||||||
|
reftarget=code_sample["id"],
|
||||||
|
refwarn=True,
|
||||||
|
)
|
||||||
|
sample_xref += nodes.inline(text=code_sample["name"])
|
||||||
|
sample_para += sample_xref
|
||||||
|
sample_para += nodes.inline(text=" - ")
|
||||||
|
sample_para += nodes.inline(text=code_sample["description"].astext())
|
||||||
|
sample_li = nodes.list_item()
|
||||||
|
sample_li += sample_para
|
||||||
|
sample_ul += sample_li
|
||||||
|
admonition += sample_ul
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
return [code_sample_node]
|
||||||
|
|
||||||
|
|
||||||
|
class ZephyrDomain(Domain):
|
||||||
|
"""Zephyr domain"""
|
||||||
|
|
||||||
|
name = "zephyr"
|
||||||
|
label = "Zephyr Project"
|
||||||
|
|
||||||
|
roles = {
|
||||||
|
"code-sample": XRefRole(innernodeclass=nodes.inline),
|
||||||
|
}
|
||||||
|
|
||||||
|
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["name"],
|
||||||
|
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"],
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
|
return [RelatedCodeSamplesNode(id=self.arguments[0]), *nodes]
|
||||||
|
|
||||||
|
|
||||||
|
def setup(app):
|
||||||
|
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,
|
||||||
|
}
|
15
doc/_static/css/custom.css
vendored
15
doc/_static/css/custom.css
vendored
|
@ -544,6 +544,21 @@ a.internal:visited code.literal {
|
||||||
color: var(--admonition-tip-title-color);
|
color: var(--admonition-tip-title-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Admonition tweaks - sphinx_togglebutton */
|
||||||
|
|
||||||
|
.rst-content .admonition.toggle {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rst-content .admonition.toggle button {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rst-content .admonition.toggle .tb-icon {
|
||||||
|
height: 1em;
|
||||||
|
width: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
/* Keyboard shortcuts tweaks */
|
/* Keyboard shortcuts tweaks */
|
||||||
kbd, .kbd,
|
kbd, .kbd,
|
||||||
.rst-content :not(dl.option-list) > :not(dt):not(kbd):not(.kbd) > kbd,
|
.rst-content :not(dl.option-list) > :not(dt):not(kbd):not(.kbd) > kbd,
|
||||||
|
|
|
@ -85,6 +85,7 @@ extensions = [
|
||||||
"notfound.extension",
|
"notfound.extension",
|
||||||
"sphinx_copybutton",
|
"sphinx_copybutton",
|
||||||
"zephyr.external_content",
|
"zephyr.external_content",
|
||||||
|
"zephyr.domain",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Only use SVG converter when it is really needed, e.g. LaTeX.
|
# Only use SVG converter when it is really needed, e.g. LaTeX.
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue