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