1"""
2Zephyr Extension
3################
4
5Copyright (c) 2023 The Linux Foundation
6SPDX-License-Identifier: Apache-2.0
7
8This extension adds a new ``zephyr`` domain for handling the documentation of various entities
9specific to the Zephyr RTOS project (ex. code samples).
10
11Directives
12----------
13
14- ``zephyr:code-sample::`` - Defines a code sample.
15- ``zephyr:code-sample-category::`` - Defines a category for grouping code samples.
16- ``zephyr:code-sample-listing::`` - Shows a listing of code samples found in a given category.
17- ``zephyr:board-catalog::`` - Shows a listing of boards supported by Zephyr.
18- ``zephyr:board::`` - Flags a document as being the documentation page for a board.
19
20Roles
21-----
22
23- ``:zephyr:code-sample:`` - References a code sample.
24- ``:zephyr:code-sample-category:`` - References a code sample category.
25- ``:zephyr:board:`` - References a board.
26
27"""
28
29import json
30import sys
31from collections.abc import Iterator
32from os import path
33from pathlib import Path
34from typing import Any
35
36from anytree import ChildResolverError, Node, PreOrderIter, Resolver, search
37from docutils import nodes
38from docutils.parsers.rst import directives
39from docutils.statemachine import StringList
40from sphinx import addnodes
41from sphinx.application import Sphinx
42from sphinx.domains import Domain, ObjType
43from sphinx.environment import BuildEnvironment
44from sphinx.roles import XRefRole
45from sphinx.transforms import SphinxTransform
46from sphinx.transforms.post_transforms import SphinxPostTransform
47from sphinx.util import logging
48from sphinx.util.docutils import SphinxDirective, switch_source_input
49from sphinx.util.nodes import NodeMatcher, make_refnode
50from sphinx.util.parsing import nested_parse_to_nodes
51from sphinx.util.template import SphinxRenderer
52
53from zephyr.doxybridge import DoxygenGroupDirective
54from zephyr.gh_utils import gh_link_get_url
55
56__version__ = "0.2.0"
57
58
59sys.path.insert(0, str(Path(__file__).parents[4] / "scripts/dts/python-devicetree/src"))
60sys.path.insert(0, str(Path(__file__).parents[3] / "_scripts"))
61
62from gen_boards_catalog import get_catalog
63
64ZEPHYR_BASE = Path(__file__).parents[4]
65TEMPLATES_DIR = Path(__file__).parent / "templates"
66RESOURCES_DIR = Path(__file__).parent / "static"
67
68logger = logging.getLogger(__name__)
69
70
71class CodeSampleNode(nodes.Element):
72    pass
73
74
75class RelatedCodeSamplesNode(nodes.Element):
76    pass
77
78
79class CodeSampleCategoryNode(nodes.Element):
80    pass
81
82
83class CodeSampleListingNode(nodes.Element):
84    pass
85
86
87class BoardNode(nodes.Element):
88    pass
89
90
91class ConvertCodeSampleNode(SphinxTransform):
92    default_priority = 100
93
94    def apply(self):
95        matcher = NodeMatcher(CodeSampleNode)
96        for node in self.document.traverse(matcher):
97            self.convert_node(node)
98
99    def convert_node(self, node):
100        """
101        Transforms a `CodeSampleNode` into a `nodes.section` named after the code sample name.
102
103        Moves all sibling nodes that are after the `CodeSampleNode` in the document under this new
104        section.
105
106        Adds a "See Also" section at the end with links to all relevant APIs as per the samples's
107        `relevant-api` attribute.
108        """
109        parent = node.parent
110        siblings_to_move = []
111        if parent is not None:
112            index = parent.index(node)
113            siblings_to_move = parent.children[index + 1 :]
114
115            # Create a new section
116            new_section = nodes.section(ids=[node["id"]])
117            new_section += nodes.title(text=node["name"])
118
119            gh_link = gh_link_get_url(self.app, self.env.docname)
120            gh_link_button = nodes.raw(
121                "",
122                f"""
123                <a href="{gh_link}/.." class="btn btn-info fa fa-github"
124                    target="_blank" style="text-align: center;">
125                    Browse source code on GitHub
126                </a>
127                """,
128                format="html",
129            )
130            new_section += nodes.paragraph("", "", gh_link_button)
131
132            # Move the sibling nodes under the new section
133            new_section.extend(siblings_to_move)
134
135            # Replace the custom node with the new section
136            node.replace_self(new_section)
137
138            # Remove the moved siblings from their original parent
139            for sibling in siblings_to_move:
140                parent.remove(sibling)
141
142            # Add a "See Also" section at the end with links to relevant APIs
143            if node["relevant-api"]:
144                see_also_section = nodes.section(ids=["see-also"])
145                see_also_section += nodes.title(text="See also")
146
147                for api in node["relevant-api"]:
148                    desc_node = addnodes.desc()
149                    desc_node["domain"] = "c"
150                    desc_node["objtype"] = "group"
151
152                    title_signode = addnodes.desc_signature()
153                    api_xref = addnodes.pending_xref(
154                        "",
155                        refdomain="c",
156                        reftype="group",
157                        reftarget=api,
158                        refwarn=True,
159                    )
160                    api_xref += nodes.Text(api)
161                    title_signode += api_xref
162                    desc_node += title_signode
163                    see_also_section += desc_node
164
165                new_section += see_also_section
166
167            # Set sample description as the meta description of the document for improved SEO
168            meta_description = nodes.meta()
169            meta_description["name"] = "description"
170            meta_description["content"] = node.children[0].astext()
171            node.document += meta_description
172
173            # Similarly, add a node with JSON-LD markup (only renders in HTML output) describing
174            # the code sample.
175            json_ld = nodes.raw(
176                "",
177                f"""<script type="application/ld+json">
178                {json.dumps({
179                    "@context": "http://schema.org",
180                    "@type": "SoftwareSourceCode",
181                    "name": node['name'],
182                    "description": node.children[0].astext(),
183                    "codeSampleType": "full",
184                    "codeRepository": gh_link_get_url(self.app, self.env.docname)
185                })}
186                </script>""",
187                format="html",
188            )
189            node.document += json_ld
190
191
192class ConvertCodeSampleCategoryNode(SphinxTransform):
193    default_priority = 100
194
195    def apply(self):
196        matcher = NodeMatcher(CodeSampleCategoryNode)
197        for node in self.document.traverse(matcher):
198            self.convert_node(node)
199
200    def convert_node(self, node):
201        # move all the siblings of the category node underneath the section it contains
202        parent = node.parent
203        siblings_to_move = []
204        if parent is not None:
205            index = parent.index(node)
206            siblings_to_move = parent.children[index + 1 :]
207
208            node.children[0].extend(siblings_to_move)
209            for sibling in siblings_to_move:
210                parent.remove(sibling)
211
212        # note document as needing toc patching
213        self.document["needs_toc_patch"] = True
214
215        # finally, replace the category node with the section it contains
216        node.replace_self(node.children[0])
217
218
219class ConvertBoardNode(SphinxTransform):
220    default_priority = 100
221
222    def apply(self):
223        matcher = NodeMatcher(BoardNode)
224        for node in self.document.traverse(matcher):
225            self.convert_node(node)
226
227    def convert_node(self, node):
228        parent = node.parent
229        siblings_to_move = []
230        if parent is not None:
231            index = parent.index(node)
232            siblings_to_move = parent.children[index + 1 :]
233
234            new_section = nodes.section(ids=[node["id"]])
235            new_section += nodes.title(text=node["full_name"])
236
237            # create a sidebar with all the board details
238            sidebar = nodes.sidebar(classes=["board-overview"])
239            new_section += sidebar
240            sidebar += nodes.title(text="Board Overview")
241
242            if node["image"] is not None:
243                figure = nodes.figure()
244                # set a scale of 100% to indicate we want a link to the full-size image
245                figure += nodes.image(uri=f"/{node['image']}", scale=100)
246                figure += nodes.caption(text=node["full_name"])
247                sidebar += figure
248
249            field_list = nodes.field_list()
250            sidebar += field_list
251
252            details = [
253                ("Name", nodes.literal(text=node["id"])),
254                ("Vendor", node["vendor"]),
255                ("Architecture", ", ".join(node["archs"])),
256                ("SoC", ", ".join(node["socs"])),
257            ]
258
259            for property_name, value in details:
260                field = nodes.field()
261                field_name = nodes.field_name(text=property_name)
262                field_body = nodes.field_body()
263                if isinstance(value, nodes.Node):
264                    field_body += value
265                else:
266                    field_body += nodes.paragraph(text=value)
267                field += field_name
268                field += field_body
269                field_list += field
270
271            # Move the sibling nodes under the new section
272            new_section.extend(siblings_to_move)
273
274            # Replace the custom node with the new section
275            node.replace_self(new_section)
276
277            # Remove the moved siblings from their original parent
278            for sibling in siblings_to_move:
279                parent.remove(sibling)
280
281
282class CodeSampleCategoriesTocPatching(SphinxPostTransform):
283    default_priority = 5  # needs to run *before* ReferencesResolver
284
285    def output_sample_categories_list_items(self, tree, container: nodes.Node):
286        list_item = nodes.list_item()
287        compact_paragraph = addnodes.compact_paragraph()
288        # find docname for tree.category["id"]
289        docname = self.env.domaindata["zephyr"]["code-samples-categories"][tree.category["id"]][
290            "docname"
291        ]
292        reference = nodes.reference(
293            "",
294            "",
295            *[nodes.Text(tree.category["name"])],
296            internal=True,
297            refuri=docname,
298            anchorname="",
299            classes=["category-link"],
300        )
301        compact_paragraph += reference
302        list_item += compact_paragraph
303
304        sorted_children = sorted(tree.children, key=lambda x: x.category["name"])
305
306        # add bullet list for children (if there are any, i.e. there are subcategories or at least
307        # one code sample in the category)
308        if sorted_children or any(
309            code_sample.get("category") == tree.category["id"]
310            for code_sample in self.env.domaindata["zephyr"]["code-samples"].values()
311        ):
312            bullet_list = nodes.bullet_list()
313            for child in sorted_children:
314                self.output_sample_categories_list_items(child, bullet_list)
315
316            for code_sample in sorted(
317                [
318                    code_sample
319                    for code_sample in self.env.domaindata["zephyr"]["code-samples"].values()
320                    if code_sample.get("category") == tree.category["id"]
321                ],
322                key=lambda x: x["name"].casefold(),
323            ):
324                li = nodes.list_item()
325                sample_xref = nodes.reference(
326                    "",
327                    "",
328                    *[nodes.Text(code_sample["name"])],
329                    internal=True,
330                    refuri=code_sample["docname"],
331                    anchorname="",
332                    classes=["code-sample-link"],
333                )
334                sample_xref["reftitle"] = code_sample["description"].astext()
335                compact_paragraph = addnodes.compact_paragraph()
336                compact_paragraph += sample_xref
337                li += compact_paragraph
338                bullet_list += li
339
340            list_item += bullet_list
341
342        container += list_item
343
344    def run(self, **kwargs: Any) -> None:
345        if not self.document.get("needs_toc_patch"):
346            return
347
348        code_samples_categories_tree = self.env.domaindata["zephyr"]["code-samples-categories-tree"]
349
350        category = search.find(
351            code_samples_categories_tree,
352            lambda node: hasattr(node, "category") and node.category["docname"] == self.env.docname,
353        )
354
355        bullet_list = nodes.bullet_list()
356        self.output_sample_categories_list_items(category, bullet_list)
357
358        self.env.tocs[self.env.docname] = bullet_list
359
360
361class ProcessCodeSampleListingNode(SphinxPostTransform):
362    default_priority = 5  # needs to run *before* ReferencesResolver
363
364    def output_sample_categories_sections(self, tree, container: nodes.Node, show_titles=False):
365        if show_titles:
366            section = nodes.section(ids=[tree.category["id"]])
367
368            link = make_refnode(
369                self.env.app.builder,
370                self.env.docname,
371                tree.category["docname"],
372                targetid=None,
373                child=nodes.Text(tree.category["name"]),
374            )
375            title = nodes.title("", "", link)
376            section += title
377            container += section
378        else:
379            section = container
380
381        # list samples from this category
382        list = create_code_sample_list(
383            [
384                code_sample
385                for code_sample in self.env.domaindata["zephyr"]["code-samples"].values()
386                if code_sample.get("category") == tree.category["id"]
387            ]
388        )
389        section += list
390
391        sorted_children = sorted(tree.children, key=lambda x: x.name)
392        for child in sorted_children:
393            self.output_sample_categories_sections(child, section, show_titles=True)
394
395    def run(self, **kwargs: Any) -> None:
396        matcher = NodeMatcher(CodeSampleListingNode)
397
398        for node in self.document.traverse(matcher):
399            self.env.domaindata["zephyr"]["has_code_sample_listing"][self.env.docname] = True
400
401            code_samples_categories = self.env.domaindata["zephyr"]["code-samples-categories"]
402            code_samples_categories_tree = self.env.domaindata["zephyr"][
403                "code-samples-categories-tree"
404            ]
405
406            container = nodes.container()
407            container["classes"].append("code-sample-listing")
408
409            if self.env.app.builder.format == "html" and node["live-search"]:
410                search_input = nodes.raw(
411                    "",
412                    """
413                    <div class="cs-search-bar">
414                      <input type="text" class="cs-search-input"
415                             placeholder="Filter code samples..." onkeyup="filterSamples(this)">
416                      <i class="fa fa-search"></i>
417                    </div>
418                    """,
419                    format="html",
420                )
421                container += search_input
422
423            for category in node["categories"]:
424                if category not in code_samples_categories:
425                    logger.error(
426                        f"Category {category} not found in code samples categories",
427                        location=(self.env.docname, node.line),
428                    )
429                    continue
430
431                category_node = search.find(
432                    code_samples_categories_tree,
433                    lambda node, category=category: hasattr(node, "category")
434                    and node.category["id"] == category,
435                )
436                self.output_sample_categories_sections(category_node, container)
437
438            node.replace_self(container)
439
440
441def create_code_sample_list(code_samples):
442    """
443    Creates a bullet list (`nodes.bullet_list`) of code samples from a list of code samples.
444
445    The list is alphabetically sorted (case-insensitive) by the code sample name.
446    """
447
448    ul = nodes.bullet_list(classes=["code-sample-list"])
449
450    for code_sample in sorted(code_samples, key=lambda x: x["name"].casefold()):
451        li = nodes.list_item()
452
453        sample_xref = addnodes.pending_xref(
454            "",
455            refdomain="zephyr",
456            reftype="code-sample",
457            reftarget=code_sample["id"],
458            refwarn=True,
459        )
460        sample_xref += nodes.Text(code_sample["name"])
461        li += nodes.inline("", "", sample_xref, classes=["code-sample-name"])
462
463        li += nodes.inline(
464            text=code_sample["description"].astext(),
465            classes=["code-sample-description"],
466        )
467
468        ul += li
469
470    return ul
471
472
473class ProcessRelatedCodeSamplesNode(SphinxPostTransform):
474    default_priority = 5  # before ReferencesResolver
475
476    def run(self, **kwargs: Any) -> None:
477        matcher = NodeMatcher(RelatedCodeSamplesNode)
478        for node in self.document.traverse(matcher):
479            id = node["id"]  # the ID of the node is the name of the doxygen group for which we
480            # want to list related code samples
481
482            code_samples = self.env.domaindata["zephyr"]["code-samples"].values()
483            # Filter out code samples that don't reference this doxygen group
484            code_samples = [
485                code_sample for code_sample in code_samples if id in code_sample["relevant-api"]
486            ]
487
488            if len(code_samples) > 0:
489                admonition = nodes.admonition()
490                admonition += nodes.title(text="Related code samples")
491                admonition["classes"].append("related-code-samples")
492                admonition["classes"].append("dropdown")  # used by sphinx-togglebutton extension
493                admonition["classes"].append("toggle-shown")  # show the content by default
494
495                sample_list = create_code_sample_list(code_samples)
496                admonition += sample_list
497
498                # replace node with the newly created admonition
499                node.replace_self(admonition)
500            else:
501                # remove node if there are no code samples
502                node.replace_self([])
503
504
505class CodeSampleDirective(SphinxDirective):
506    """
507    A directive for creating a code sample node in the Zephyr documentation.
508    """
509
510    required_arguments = 1  # ID
511    optional_arguments = 0
512    option_spec = {"name": directives.unchanged, "relevant-api": directives.unchanged}
513    has_content = True
514
515    def run(self):
516        code_sample_id = self.arguments[0]
517        env = self.state.document.settings.env
518        code_samples = env.domaindata["zephyr"]["code-samples"]
519
520        if code_sample_id in code_samples:
521            logger.warning(
522                f"Code sample {code_sample_id} already exists. "
523                f"Other instance in {code_samples[code_sample_id]['docname']}",
524                location=(env.docname, self.lineno),
525            )
526
527        name = self.options.get("name", code_sample_id)
528        relevant_api_list = self.options.get("relevant-api", "").split()
529
530        # Create a node for description and populate it with parsed content
531        description_node = nodes.container(ids=[f"{code_sample_id}-description"])
532        self.state.nested_parse(self.content, self.content_offset, description_node)
533
534        code_sample = {
535            "id": code_sample_id,
536            "name": name,
537            "description": description_node,
538            "relevant-api": relevant_api_list,
539            "docname": env.docname,
540        }
541
542        domain = env.get_domain("zephyr")
543        domain.add_code_sample(code_sample)
544
545        # Create an instance of the custom node
546        code_sample_node = CodeSampleNode()
547        code_sample_node["id"] = code_sample_id
548        code_sample_node["name"] = name
549        code_sample_node["relevant-api"] = relevant_api_list
550        code_sample_node += description_node
551
552        return [code_sample_node]
553
554
555class CodeSampleCategoryDirective(SphinxDirective):
556    required_arguments = 1  # Category ID
557    optional_arguments = 0
558    option_spec = {
559        "name": directives.unchanged,
560        "show-listing": directives.flag,
561        "live-search": directives.flag,
562        "glob": directives.unchanged,
563    }
564    has_content = True  # Category description
565    final_argument_whitespace = True
566
567    def run(self):
568        env = self.state.document.settings.env
569        id = self.arguments[0]
570        name = self.options.get("name", id)
571
572        category_node = CodeSampleCategoryNode()
573        category_node["id"] = id
574        category_node["name"] = name
575        category_node["docname"] = env.docname
576
577        description_node = self.parse_content_to_nodes()
578        category_node["description"] = description_node
579
580        code_sample_category = {
581            "docname": env.docname,
582            "id": id,
583            "name": name,
584        }
585
586        # Add the category to the domain
587        domain = env.get_domain("zephyr")
588        domain.add_code_sample_category(code_sample_category)
589
590        # Fake a toctree directive to ensure the code-sample-category directive implicitly acts as
591        # a toctree and correctly mounts whatever relevant documents under it in the global toc
592        lines = [
593            name,
594            "#" * len(name),
595            "",
596            ".. toctree::",
597            "   :titlesonly:",
598            "   :glob:",
599            "   :hidden:",
600            "   :maxdepth: 1",
601            "",
602            f"   {self.options['glob']}" if "glob" in self.options else "   */*",
603            "",
604        ]
605        stringlist = StringList(lines, source=env.docname)
606
607        with switch_source_input(self.state, stringlist):
608            parsed_section = nested_parse_to_nodes(self.state, stringlist)[0]
609
610        category_node += parsed_section
611
612        parsed_section += description_node
613
614        if "show-listing" in self.options:
615            listing_node = CodeSampleListingNode()
616            listing_node["categories"] = [id]
617            listing_node["live-search"] = "live-search" in self.options
618            parsed_section += listing_node
619
620        return [category_node]
621
622
623class CodeSampleListingDirective(SphinxDirective):
624    has_content = False
625    required_arguments = 0
626    optional_arguments = 0
627    option_spec = {
628        "categories": directives.unchanged_required,
629        "live-search": directives.flag,
630    }
631
632    def run(self):
633        code_sample_listing_node = CodeSampleListingNode()
634        code_sample_listing_node["categories"] = self.options.get("categories").split()
635        code_sample_listing_node["live-search"] = "live-search" in self.options
636
637        return [code_sample_listing_node]
638
639
640class BoardDirective(SphinxDirective):
641    has_content = False
642    required_arguments = 1
643    optional_arguments = 0
644
645    def run(self):
646        # board_name is passed as the directive argument
647        board_name = self.arguments[0]
648
649        boards = self.env.domaindata["zephyr"]["boards"]
650        vendors = self.env.domaindata["zephyr"]["vendors"]
651
652        if board_name not in boards:
653            logger.warning(
654                f"Board {board_name} does not seem to be a valid board name.",
655                location=(self.env.docname, self.lineno),
656            )
657            return []
658        elif "docname" in boards[board_name]:
659            logger.warning(
660                f"Board {board_name} is already documented in {boards[board_name]['docname']}.",
661                location=(self.env.docname, self.lineno),
662            )
663            return []
664        else:
665            board = boards[board_name]
666            # flag board in the domain data as now having a documentation page so that it can be
667            # cross-referenced etc.
668            board["docname"] = self.env.docname
669
670            board_node = BoardNode(id=board_name)
671            board_node["full_name"] = board["full_name"]
672            board_node["vendor"] = vendors.get(board["vendor"], board["vendor"])
673            board_node["archs"] = board["archs"]
674            board_node["socs"] = board["socs"]
675            board_node["image"] = board["image"]
676            return [board_node]
677
678
679class BoardCatalogDirective(SphinxDirective):
680    has_content = False
681    required_arguments = 0
682    optional_arguments = 0
683
684    def run(self):
685        if self.env.app.builder.format == "html":
686            self.env.domaindata["zephyr"]["has_board_catalog"][self.env.docname] = True
687
688            domain_data = self.env.domaindata["zephyr"]
689            renderer = SphinxRenderer([TEMPLATES_DIR])
690            rendered = renderer.render(
691                "board-catalog.html",
692                {
693                    "boards": domain_data["boards"],
694                    "vendors": domain_data["vendors"],
695                    "socs": domain_data["socs"],
696                },
697            )
698            return [nodes.raw("", rendered, format="html")]
699        else:
700            return [nodes.paragraph(text="Board catalog is only available in HTML.")]
701
702
703class ZephyrDomain(Domain):
704    """Zephyr domain"""
705
706    name = "zephyr"
707    label = "Zephyr"
708
709    roles = {
710        "code-sample": XRefRole(innernodeclass=nodes.inline, warn_dangling=True),
711        "code-sample-category": XRefRole(innernodeclass=nodes.inline, warn_dangling=True),
712        "board": XRefRole(innernodeclass=nodes.inline, warn_dangling=True),
713    }
714
715    directives = {
716        "code-sample": CodeSampleDirective,
717        "code-sample-listing": CodeSampleListingDirective,
718        "code-sample-category": CodeSampleCategoryDirective,
719        "board-catalog": BoardCatalogDirective,
720        "board": BoardDirective,
721    }
722
723    object_types: dict[str, ObjType] = {
724        "code-sample": ObjType("code sample", "code-sample"),
725        "code-sample-category": ObjType("code sample category", "code-sample-category"),
726        "board": ObjType("board", "board"),
727    }
728
729    initial_data: dict[str, Any] = {
730        "code-samples": {},  # id -> code sample data
731        "code-samples-categories": {},  # id -> code sample category data
732        "code-samples-categories-tree": Node("samples"),
733        # keep track of documents containing special directives
734        "has_code_sample_listing": {},  # docname -> bool
735        "has_board_catalog": {},  # docname -> bool
736    }
737
738    def clear_doc(self, docname: str) -> None:
739        self.data["code-samples"] = {
740            sample_id: sample_data
741            for sample_id, sample_data in self.data["code-samples"].items()
742            if sample_data["docname"] != docname
743        }
744
745        self.data["code-samples-categories"] = {
746            category_id: category_data
747            for category_id, category_data in self.data["code-samples-categories"].items()
748            if category_data["docname"] != docname
749        }
750
751        # TODO clean up the anytree as well
752
753        self.data["has_code_sample_listing"].pop(docname, None)
754        self.data["has_board_catalog"].pop(docname, None)
755
756    def merge_domaindata(self, docnames: list[str], otherdata: dict) -> None:
757        self.data["code-samples"].update(otherdata["code-samples"])
758        self.data["code-samples-categories"].update(otherdata["code-samples-categories"])
759
760        # self.data["boards"] contains all the boards right from builder-inited time, but it still
761        # potentially needs merging since a board's docname property is set by BoardDirective to
762        # indicate the board is documented in a specific document.
763        for board_name, board in otherdata["boards"].items():
764            if "docname" in board:
765                self.data["boards"][board_name]["docname"] = board["docname"]
766
767        # merge category trees by adding all the categories found in the "other" tree that to
768        # self tree
769        other_tree = otherdata["code-samples-categories-tree"]
770        categories = [n for n in PreOrderIter(other_tree) if hasattr(n, "category")]
771        for category in categories:
772            category_path = f"/{'/'.join(n.name for n in category.path)}"
773            self.add_category_to_tree(
774                category_path,
775                category.category["id"],
776                category.category["name"],
777                category.category["docname"],
778            )
779
780        for docname in docnames:
781            self.data["has_code_sample_listing"][docname] = otherdata[
782                "has_code_sample_listing"
783            ].get(docname, False)
784            self.data["has_board_catalog"][docname] = otherdata["has_board_catalog"].get(
785                docname, False
786            )
787
788    def get_objects(self):
789        for _, code_sample in self.data["code-samples"].items():
790            yield (
791                code_sample["id"],
792                code_sample["name"],
793                "code-sample",
794                code_sample["docname"],
795                code_sample["id"],
796                1,
797            )
798
799        for _, code_sample_category in self.data["code-samples-categories"].items():
800            yield (
801                code_sample_category["id"],
802                code_sample_category["name"],
803                "code-sample-category",
804                code_sample_category["docname"],
805                code_sample_category["id"],
806                1,
807            )
808
809        for _, board in self.data["boards"].items():
810            # only boards that do have a documentation page are to be considered as valid objects
811            if "docname" in board:
812                yield (
813                    board["name"],
814                    board["full_name"],
815                    "board",
816                    board["docname"],
817                    board["name"],
818                    1,
819                )
820
821    # used by Sphinx Immaterial theme
822    def get_object_synopses(self) -> Iterator[tuple[tuple[str, str], str]]:
823        for _, code_sample in self.data["code-samples"].items():
824            yield (
825                (code_sample["docname"], code_sample["id"]),
826                code_sample["description"].astext(),
827            )
828
829    def resolve_xref(self, env, fromdocname, builder, type, target, node, contnode):
830        if type == "code-sample":
831            elem = self.data["code-samples"].get(target)
832        elif type == "code-sample-category":
833            elem = self.data["code-samples-categories"].get(target)
834        elif type == "board":
835            elem = self.data["boards"].get(target)
836        else:
837            return
838
839        if elem:
840            if not node.get("refexplicit"):
841                contnode = [nodes.Text(elem["name"] if type != "board" else elem["full_name"])]
842
843            return make_refnode(
844                builder,
845                fromdocname,
846                elem["docname"],
847                elem["id"] if type != "board" else elem["name"],
848                contnode,
849                elem["description"].astext() if type == "code-sample" else None,
850            )
851
852    def add_code_sample(self, code_sample):
853        self.data["code-samples"][code_sample["id"]] = code_sample
854
855    def add_code_sample_category(self, code_sample_category):
856        self.data["code-samples-categories"][code_sample_category["id"]] = code_sample_category
857        self.add_category_to_tree(
858            path.dirname(code_sample_category["docname"]),
859            code_sample_category["id"],
860            code_sample_category["name"],
861            code_sample_category["docname"],
862        )
863
864    def add_category_to_tree(
865        self, category_path: str, category_id: str, category_name: str, docname: str
866    ) -> Node:
867        resolver = Resolver("name")
868        tree = self.data["code-samples-categories-tree"]
869
870        if not category_path.startswith("/"):
871            category_path = "/" + category_path
872
873        # node either already exists (and we update it to make it a category node), or we need to
874        # create it
875        try:
876            node = resolver.get(tree, category_path)
877            if hasattr(node, "category") and node.category["id"] != category_id:
878                raise ValueError(
879                    f"Can't add code sample category {category_id} as category "
880                    f"{node.category['id']} is already defined in {node.category['docname']}. "
881                    "You may only have one category per path."
882                )
883        except ChildResolverError as e:
884            path_of_last_existing_node = f"/{'/'.join(n.name for n in e.node.path)}"
885            common_path = path.commonpath([path_of_last_existing_node, category_path])
886            remaining_path = path.relpath(category_path, common_path)
887
888            # Add missing nodes under the last existing node
889            for node_name in remaining_path.split("/"):
890                e.node = Node(node_name, parent=e.node)
891
892            node = e.node
893
894        node.category = {"id": category_id, "name": category_name, "docname": docname}
895
896        return tree
897
898
899class CustomDoxygenGroupDirective(DoxygenGroupDirective):
900    """Monkey patch for Breathe's DoxygenGroupDirective."""
901
902    def run(self) -> list[Node]:
903        nodes = super().run()
904
905        if self.config.zephyr_breathe_insert_related_samples:
906            return [*nodes, RelatedCodeSamplesNode(id=self.arguments[0])]
907        else:
908            return nodes
909
910
911def compute_sample_categories_hierarchy(app: Sphinx, env: BuildEnvironment) -> None:
912    domain = env.get_domain("zephyr")
913    code_samples = domain.data["code-samples"]
914
915    category_tree = env.domaindata["zephyr"]["code-samples-categories-tree"]
916    resolver = Resolver("name")
917    for code_sample in code_samples.values():
918        try:
919            # Try to get the node at the specified path
920            node = resolver.get(category_tree, "/" + path.dirname(code_sample["docname"]))
921        except ChildResolverError as e:
922            # starting with e.node and up, find the first node that has a category
923            node = e.node
924            while not hasattr(node, "category"):
925                node = node.parent
926            code_sample["category"] = node.category["id"]
927
928
929def install_static_assets_as_needed(
930    app: Sphinx, pagename: str, templatename: str, context: dict[str, Any], doctree: nodes.Node
931) -> None:
932    if app.env.domaindata["zephyr"]["has_code_sample_listing"].get(pagename, False):
933        app.add_css_file("css/codesample-livesearch.css")
934        app.add_js_file("js/codesample-livesearch.js")
935
936    if app.env.domaindata["zephyr"]["has_board_catalog"].get(pagename, False):
937        app.add_css_file("css/board-catalog.css")
938        app.add_js_file("js/board-catalog.js")
939
940
941def load_board_catalog_into_domain(app: Sphinx) -> None:
942    board_catalog = get_catalog()
943    app.env.domaindata["zephyr"]["boards"] = board_catalog["boards"]
944    app.env.domaindata["zephyr"]["vendors"] = board_catalog["vendors"]
945    app.env.domaindata["zephyr"]["socs"] = board_catalog["socs"]
946
947
948def setup(app):
949    app.add_config_value("zephyr_breathe_insert_related_samples", False, "env")
950
951    app.add_domain(ZephyrDomain)
952
953    app.add_transform(ConvertCodeSampleNode)
954    app.add_transform(ConvertCodeSampleCategoryNode)
955    app.add_transform(ConvertBoardNode)
956
957    app.add_post_transform(ProcessCodeSampleListingNode)
958    app.add_post_transform(CodeSampleCategoriesTocPatching)
959    app.add_post_transform(ProcessRelatedCodeSamplesNode)
960
961    app.connect(
962        "builder-inited",
963        (lambda app: app.config.html_static_path.append(RESOURCES_DIR.as_posix())),
964    )
965    app.connect("builder-inited", load_board_catalog_into_domain)
966
967    app.connect("html-page-context", install_static_assets_as_needed)
968    app.connect("env-updated", compute_sample_categories_hierarchy)
969
970    # monkey-patching of the DoxygenGroupDirective
971    app.add_directive("doxygengroup", CustomDoxygenGroupDirective, override=True)
972
973    return {
974        "version": __version__,
975        "parallel_read_safe": True,
976        "parallel_write_safe": True,
977    }
978