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>
diff --git a/doc/_extensions/zephyr/domain.py b/doc/_extensions/zephyr/domain.py
new file mode 100644
index 0000000..1b5cce2
--- /dev/null
+++ b/doc/_extensions/zephyr/domain.py
@@ -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,
+    }
diff --git a/doc/_static/css/custom.css b/doc/_static/css/custom.css
index da9f379..30d8607 100644
--- a/doc/_static/css/custom.css
+++ b/doc/_static/css/custom.css
@@ -544,6 +544,21 @@
     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 */
 kbd, .kbd,
 .rst-content :not(dl.option-list) > :not(dt):not(kbd):not(.kbd) > kbd,
diff --git a/doc/conf.py b/doc/conf.py
index 551bb59..5382592 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -85,6 +85,7 @@
     "notfound.extension",
     "sphinx_copybutton",
     "zephyr.external_content",
+    "zephyr.domain",
 ]
 
 # Only use SVG converter when it is really needed, e.g. LaTeX.