1""" 2Zephyr Extension 3################ 4 5Copyright (c) 2023-2025 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 re 31import sys 32from collections.abc import Iterator 33from os import path 34from pathlib import Path 35from typing import Any 36 37from anytree import ChildResolverError, Node, PreOrderIter, Resolver, search 38from docutils import nodes 39from docutils.parsers.rst import directives, roles 40from docutils.statemachine import StringList 41from sphinx import addnodes 42from sphinx.application import Sphinx 43from sphinx.domains import Domain, ObjType 44from sphinx.environment import BuildEnvironment 45from sphinx.roles import XRefRole 46from sphinx.transforms import SphinxTransform 47from sphinx.transforms.post_transforms import SphinxPostTransform 48from sphinx.util import logging 49from sphinx.util.docutils import SphinxDirective, switch_source_input 50from sphinx.util.nodes import NodeMatcher, make_refnode 51from sphinx.util.parsing import nested_parse_to_nodes 52from sphinx.util.template import SphinxRenderer 53 54from zephyr.doxybridge import DoxygenGroupDirective 55from zephyr.gh_utils import gh_link_get_url 56 57__version__ = "0.2.0" 58 59 60sys.path.insert(0, str(Path(__file__).parents[4] / "scripts/dts/python-devicetree/src")) 61sys.path.insert(0, str(Path(__file__).parents[3] / "_scripts")) 62 63from gen_boards_catalog import get_catalog 64 65ZEPHYR_BASE = Path(__file__).parents[4] 66TEMPLATES_DIR = Path(__file__).parent / "templates" 67RESOURCES_DIR = Path(__file__).parent / "static" 68 69# Load and parse binding types from text file 70BINDINGS_TXT_PATH = ZEPHYR_BASE / "dts" / "bindings" / "binding-types.txt" 71ACRONYM_PATTERN = re.compile(r'([a-zA-Z0-9-]+)\s*\((.*?)\)') 72BINDING_TYPE_TO_DOCUTILS_NODE = {} 73 74 75def parse_text_with_acronyms(text): 76 """Parse text that may contain acronyms into a list of nodes.""" 77 result = nodes.inline() 78 last_end = 0 79 80 for match in ACRONYM_PATTERN.finditer(text): 81 # Add any text before the acronym 82 if match.start() > last_end: 83 result += nodes.Text(text[last_end : match.start()]) 84 85 # Add the acronym 86 abbr, explanation = match.groups() 87 result += nodes.abbreviation(abbr, abbr, explanation=explanation) 88 last_end = match.end() 89 90 # Add any remaining text 91 if last_end < len(text): 92 result += nodes.Text(text[last_end:]) 93 94 return result 95 96 97with open(BINDINGS_TXT_PATH) as f: 98 for line in f: 99 line = line.strip() 100 if not line or line.startswith('#'): 101 continue 102 103 key, value = line.split('\t', 1) 104 BINDING_TYPE_TO_DOCUTILS_NODE[key] = parse_text_with_acronyms(value) 105 106logger = logging.getLogger(__name__) 107 108 109class CodeSampleNode(nodes.Element): 110 pass 111 112 113class RelatedCodeSamplesNode(nodes.Element): 114 pass 115 116 117class CodeSampleCategoryNode(nodes.Element): 118 pass 119 120 121class CodeSampleListingNode(nodes.Element): 122 pass 123 124 125class BoardNode(nodes.Element): 126 pass 127 128 129class ConvertCodeSampleNode(SphinxTransform): 130 default_priority = 100 131 132 def apply(self): 133 matcher = NodeMatcher(CodeSampleNode) 134 for node in self.document.traverse(matcher): 135 self.convert_node(node) 136 137 def convert_node(self, node): 138 """ 139 Transforms a `CodeSampleNode` into a `nodes.section` named after the code sample name. 140 141 Moves all sibling nodes that are after the `CodeSampleNode` in the document under this new 142 section. 143 144 Adds a "See Also" section at the end with links to all relevant APIs as per the samples's 145 `relevant-api` attribute. 146 """ 147 parent = node.parent 148 siblings_to_move = [] 149 if parent is not None: 150 index = parent.index(node) 151 siblings_to_move = parent.children[index + 1 :] 152 153 # Create a new section 154 new_section = nodes.section(ids=[node["id"]]) 155 new_section += nodes.title(text=node["name"]) 156 157 gh_link = gh_link_get_url(self.app, self.env.docname) 158 gh_link_button = nodes.raw( 159 "", 160 f""" 161 <a href="{gh_link}/.." class="btn btn-info fa fa-github" 162 target="_blank" style="text-align: center;"> 163 Browse source code on GitHub 164 </a> 165 """, 166 format="html", 167 ) 168 new_section += nodes.paragraph("", "", gh_link_button) 169 170 # Move the sibling nodes under the new section 171 new_section.extend(siblings_to_move) 172 173 # Replace the custom node with the new section 174 node.replace_self(new_section) 175 176 # Remove the moved siblings from their original parent 177 for sibling in siblings_to_move: 178 parent.remove(sibling) 179 180 # Add a "See Also" section at the end with links to relevant APIs 181 if node["relevant-api"]: 182 see_also_section = nodes.section(ids=["see-also"]) 183 see_also_section += nodes.title(text="See also") 184 185 for api in node["relevant-api"]: 186 desc_node = addnodes.desc() 187 desc_node["domain"] = "c" 188 desc_node["objtype"] = "group" 189 190 title_signode = addnodes.desc_signature() 191 api_xref = addnodes.pending_xref( 192 "", 193 refdomain="c", 194 reftype="group", 195 reftarget=api, 196 refwarn=True, 197 ) 198 api_xref += nodes.Text(api) 199 title_signode += api_xref 200 desc_node += title_signode 201 see_also_section += desc_node 202 203 new_section += see_also_section 204 205 # Set sample description as the meta description of the document for improved SEO 206 meta_description = nodes.meta() 207 meta_description["name"] = "description" 208 meta_description["content"] = node.children[0].astext() 209 node.document += meta_description 210 211 # Similarly, add a node with JSON-LD markup (only renders in HTML output) describing 212 # the code sample. 213 json_ld = nodes.raw( 214 "", 215 f"""<script type="application/ld+json"> 216 {json.dumps({ 217 "@context": "http://schema.org", 218 "@type": "SoftwareSourceCode", 219 "name": node['name'], 220 "description": node.children[0].astext(), 221 "codeSampleType": "full", 222 "codeRepository": gh_link_get_url(self.app, self.env.docname) 223 })} 224 </script>""", 225 format="html", 226 ) 227 node.document += json_ld 228 229 230class ConvertCodeSampleCategoryNode(SphinxTransform): 231 default_priority = 100 232 233 def apply(self): 234 matcher = NodeMatcher(CodeSampleCategoryNode) 235 for node in self.document.traverse(matcher): 236 self.convert_node(node) 237 238 def convert_node(self, node): 239 # move all the siblings of the category node underneath the section it contains 240 parent = node.parent 241 siblings_to_move = [] 242 if parent is not None: 243 index = parent.index(node) 244 siblings_to_move = parent.children[index + 1 :] 245 246 node.children[0].extend(siblings_to_move) 247 for sibling in siblings_to_move: 248 parent.remove(sibling) 249 250 # note document as needing toc patching 251 self.document["needs_toc_patch"] = True 252 253 # finally, replace the category node with the section it contains 254 node.replace_self(node.children[0]) 255 256 257class ConvertBoardNode(SphinxTransform): 258 default_priority = 100 259 260 def apply(self): 261 matcher = NodeMatcher(BoardNode) 262 for node in self.document.traverse(matcher): 263 self.convert_node(node) 264 265 def convert_node(self, node): 266 parent = node.parent 267 siblings_to_move = [] 268 if parent is not None: 269 index = parent.index(node) 270 siblings_to_move = parent.children[index + 1 :] 271 272 new_section = nodes.section(ids=[node["id"]]) 273 new_section += nodes.title(text=node["full_name"]) 274 275 # create a sidebar with all the board details 276 sidebar = nodes.sidebar(classes=["board-overview"]) 277 new_section += sidebar 278 sidebar += nodes.title(text="Board Overview") 279 280 if node["image"] is not None: 281 figure = nodes.figure() 282 # set a scale of 100% to indicate we want a link to the full-size image 283 figure += nodes.image(uri=f"/{node['image']}", scale=100) 284 figure += nodes.caption(text=node["full_name"]) 285 sidebar += figure 286 287 field_list = nodes.field_list() 288 sidebar += field_list 289 290 details = [ 291 ("Name", nodes.literal(text=node["id"])), 292 ("Vendor", node["vendor"]), 293 ("Architecture", ", ".join(node["archs"])), 294 ("SoC", ", ".join(node["socs"])), 295 ] 296 297 for property_name, value in details: 298 field = nodes.field() 299 field_name = nodes.field_name(text=property_name) 300 field_body = nodes.field_body() 301 if isinstance(value, nodes.Node): 302 field_body += value 303 else: 304 field_body += nodes.paragraph(text=value) 305 field += field_name 306 field += field_body 307 field_list += field 308 309 gh_link = gh_link_get_url(self.app, self.env.docname) 310 gh_link_button = nodes.raw( 311 "", 312 f""" 313 <div id="board-github-link"> 314 <a href="{gh_link}/../.." class="btn btn-info fa fa-github" 315 target="_blank"> 316 Browse board sources 317 </a> 318 </div> 319 """, 320 format="html", 321 ) 322 sidebar += gh_link_button 323 324 # Move the sibling nodes under the new section 325 new_section.extend(siblings_to_move) 326 327 # Replace the custom node with the new section 328 node.replace_self(new_section) 329 330 # Remove the moved siblings from their original parent 331 for sibling in siblings_to_move: 332 parent.remove(sibling) 333 334 335class CodeSampleCategoriesTocPatching(SphinxPostTransform): 336 default_priority = 5 # needs to run *before* ReferencesResolver 337 338 def output_sample_categories_list_items(self, tree, container: nodes.Node): 339 list_item = nodes.list_item() 340 compact_paragraph = addnodes.compact_paragraph() 341 # find docname for tree.category["id"] 342 docname = self.env.domaindata["zephyr"]["code-samples-categories"][tree.category["id"]][ 343 "docname" 344 ] 345 reference = nodes.reference( 346 "", 347 "", 348 *[nodes.Text(tree.category["name"])], 349 internal=True, 350 refuri=docname, 351 anchorname="", 352 classes=["category-link"], 353 ) 354 compact_paragraph += reference 355 list_item += compact_paragraph 356 357 sorted_children = sorted(tree.children, key=lambda x: x.category["name"]) 358 359 # add bullet list for children (if there are any, i.e. there are subcategories or at least 360 # one code sample in the category) 361 if sorted_children or any( 362 code_sample.get("category") == tree.category["id"] 363 for code_sample in self.env.domaindata["zephyr"]["code-samples"].values() 364 ): 365 bullet_list = nodes.bullet_list() 366 for child in sorted_children: 367 self.output_sample_categories_list_items(child, bullet_list) 368 369 for code_sample in sorted( 370 [ 371 code_sample 372 for code_sample in self.env.domaindata["zephyr"]["code-samples"].values() 373 if code_sample.get("category") == tree.category["id"] 374 ], 375 key=lambda x: x["name"].casefold(), 376 ): 377 li = nodes.list_item() 378 sample_xref = nodes.reference( 379 "", 380 "", 381 *[nodes.Text(code_sample["name"])], 382 internal=True, 383 refuri=code_sample["docname"], 384 anchorname="", 385 classes=["code-sample-link"], 386 ) 387 sample_xref["reftitle"] = code_sample["description"].astext() 388 compact_paragraph = addnodes.compact_paragraph() 389 compact_paragraph += sample_xref 390 li += compact_paragraph 391 bullet_list += li 392 393 list_item += bullet_list 394 395 container += list_item 396 397 def run(self, **kwargs: Any) -> None: 398 if not self.document.get("needs_toc_patch"): 399 return 400 401 code_samples_categories_tree = self.env.domaindata["zephyr"]["code-samples-categories-tree"] 402 403 category = search.find( 404 code_samples_categories_tree, 405 lambda node: hasattr(node, "category") and node.category["docname"] == self.env.docname, 406 ) 407 408 bullet_list = nodes.bullet_list() 409 self.output_sample_categories_list_items(category, bullet_list) 410 411 self.env.tocs[self.env.docname] = bullet_list 412 413 414class ProcessCodeSampleListingNode(SphinxPostTransform): 415 default_priority = 5 # needs to run *before* ReferencesResolver 416 417 def output_sample_categories_sections(self, tree, container: nodes.Node, show_titles=False): 418 if show_titles: 419 section = nodes.section(ids=[tree.category["id"]]) 420 421 link = make_refnode( 422 self.env.app.builder, 423 self.env.docname, 424 tree.category["docname"], 425 targetid=None, 426 child=nodes.Text(tree.category["name"]), 427 ) 428 title = nodes.title("", "", link) 429 section += title 430 container += section 431 else: 432 section = container 433 434 # list samples from this category 435 list = create_code_sample_list( 436 [ 437 code_sample 438 for code_sample in self.env.domaindata["zephyr"]["code-samples"].values() 439 if code_sample.get("category") == tree.category["id"] 440 ] 441 ) 442 section += list 443 444 sorted_children = sorted(tree.children, key=lambda x: x.name) 445 for child in sorted_children: 446 self.output_sample_categories_sections(child, section, show_titles=True) 447 448 def run(self, **kwargs: Any) -> None: 449 matcher = NodeMatcher(CodeSampleListingNode) 450 451 for node in self.document.traverse(matcher): 452 self.env.domaindata["zephyr"]["has_code_sample_listing"][self.env.docname] = True 453 454 code_samples_categories = self.env.domaindata["zephyr"]["code-samples-categories"] 455 code_samples_categories_tree = self.env.domaindata["zephyr"][ 456 "code-samples-categories-tree" 457 ] 458 459 container = nodes.container() 460 container["classes"].append("code-sample-listing") 461 462 if self.env.app.builder.format == "html" and node["live-search"]: 463 search_input = nodes.raw( 464 "", 465 """ 466 <div class="cs-search-bar"> 467 <input type="text" class="cs-search-input" 468 placeholder="Filter code samples..." onkeyup="filterSamples(this)"> 469 <i class="fa fa-search"></i> 470 </div> 471 """, 472 format="html", 473 ) 474 container += search_input 475 476 for category in node["categories"]: 477 if category not in code_samples_categories: 478 logger.error( 479 f"Category {category} not found in code samples categories", 480 location=(self.env.docname, node.line), 481 ) 482 continue 483 484 category_node = search.find( 485 code_samples_categories_tree, 486 lambda node, category=category: hasattr(node, "category") 487 and node.category["id"] == category, 488 ) 489 self.output_sample_categories_sections(category_node, container) 490 491 node.replace_self(container) 492 493 494def create_code_sample_list(code_samples): 495 """ 496 Creates a bullet list (`nodes.bullet_list`) of code samples from a list of code samples. 497 498 The list is alphabetically sorted (case-insensitive) by the code sample name. 499 """ 500 501 ul = nodes.bullet_list(classes=["code-sample-list"]) 502 503 for code_sample in sorted(code_samples, key=lambda x: x["name"].casefold()): 504 li = nodes.list_item() 505 506 sample_xref = addnodes.pending_xref( 507 "", 508 refdomain="zephyr", 509 reftype="code-sample", 510 reftarget=code_sample["id"], 511 refwarn=True, 512 ) 513 sample_xref += nodes.Text(code_sample["name"]) 514 li += nodes.inline("", "", sample_xref, classes=["code-sample-name"]) 515 516 li += nodes.inline( 517 text=code_sample["description"].astext(), 518 classes=["code-sample-description"], 519 ) 520 521 ul += li 522 523 return ul 524 525 526class ProcessRelatedCodeSamplesNode(SphinxPostTransform): 527 default_priority = 5 # before ReferencesResolver 528 529 def run(self, **kwargs: Any) -> None: 530 matcher = NodeMatcher(RelatedCodeSamplesNode) 531 for node in self.document.traverse(matcher): 532 id = node["id"] # the ID of the node is the name of the doxygen group for which we 533 # want to list related code samples 534 535 code_samples = self.env.domaindata["zephyr"]["code-samples"].values() 536 # Filter out code samples that don't reference this doxygen group 537 code_samples = [ 538 code_sample for code_sample in code_samples if id in code_sample["relevant-api"] 539 ] 540 541 if len(code_samples) > 0: 542 admonition = nodes.admonition() 543 admonition += nodes.title(text="Related code samples") 544 admonition["classes"].append("related-code-samples") 545 admonition["classes"].append("dropdown") # used by sphinx-togglebutton extension 546 admonition["classes"].append("toggle-shown") # show the content by default 547 548 sample_list = create_code_sample_list(code_samples) 549 admonition += sample_list 550 551 # replace node with the newly created admonition 552 node.replace_self(admonition) 553 else: 554 # remove node if there are no code samples 555 node.replace_self([]) 556 557 558class CodeSampleDirective(SphinxDirective): 559 """ 560 A directive for creating a code sample node in the Zephyr documentation. 561 """ 562 563 required_arguments = 1 # ID 564 optional_arguments = 0 565 option_spec = {"name": directives.unchanged, "relevant-api": directives.unchanged} 566 has_content = True 567 568 def run(self): 569 code_sample_id = self.arguments[0] 570 env = self.state.document.settings.env 571 code_samples = env.domaindata["zephyr"]["code-samples"] 572 573 if code_sample_id in code_samples: 574 logger.warning( 575 f"Code sample {code_sample_id} already exists. " 576 f"Other instance in {code_samples[code_sample_id]['docname']}", 577 location=(env.docname, self.lineno), 578 ) 579 580 name = self.options.get("name", code_sample_id) 581 relevant_api_list = self.options.get("relevant-api", "").split() 582 583 # Create a node for description and populate it with parsed content 584 description_node = nodes.container(ids=[f"{code_sample_id}-description"]) 585 self.state.nested_parse(self.content, self.content_offset, description_node) 586 587 code_sample = { 588 "id": code_sample_id, 589 "name": name, 590 "description": description_node, 591 "relevant-api": relevant_api_list, 592 "docname": env.docname, 593 } 594 595 domain = env.get_domain("zephyr") 596 domain.add_code_sample(code_sample) 597 598 # Create an instance of the custom node 599 code_sample_node = CodeSampleNode() 600 code_sample_node["id"] = code_sample_id 601 code_sample_node["name"] = name 602 code_sample_node["relevant-api"] = relevant_api_list 603 code_sample_node += description_node 604 605 return [code_sample_node] 606 607 608class CodeSampleCategoryDirective(SphinxDirective): 609 required_arguments = 1 # Category ID 610 optional_arguments = 0 611 option_spec = { 612 "name": directives.unchanged, 613 "show-listing": directives.flag, 614 "live-search": directives.flag, 615 "glob": directives.unchanged, 616 } 617 has_content = True # Category description 618 final_argument_whitespace = True 619 620 def run(self): 621 env = self.state.document.settings.env 622 id = self.arguments[0] 623 name = self.options.get("name", id) 624 625 category_node = CodeSampleCategoryNode() 626 category_node["id"] = id 627 category_node["name"] = name 628 category_node["docname"] = env.docname 629 630 description_node = self.parse_content_to_nodes() 631 category_node["description"] = description_node 632 633 code_sample_category = { 634 "docname": env.docname, 635 "id": id, 636 "name": name, 637 } 638 639 # Add the category to the domain 640 domain = env.get_domain("zephyr") 641 domain.add_code_sample_category(code_sample_category) 642 643 # Fake a toctree directive to ensure the code-sample-category directive implicitly acts as 644 # a toctree and correctly mounts whatever relevant documents under it in the global toc 645 lines = [ 646 name, 647 "#" * len(name), 648 "", 649 ".. toctree::", 650 " :titlesonly:", 651 " :glob:", 652 " :hidden:", 653 " :maxdepth: 1", 654 "", 655 f" {self.options['glob']}" if "glob" in self.options else " */*", 656 "", 657 ] 658 stringlist = StringList(lines, source=env.docname) 659 660 with switch_source_input(self.state, stringlist): 661 parsed_section = nested_parse_to_nodes(self.state, stringlist)[0] 662 663 category_node += parsed_section 664 665 parsed_section += description_node 666 667 if "show-listing" in self.options: 668 listing_node = CodeSampleListingNode() 669 listing_node["categories"] = [id] 670 listing_node["live-search"] = "live-search" in self.options 671 parsed_section += listing_node 672 673 return [category_node] 674 675 676class CodeSampleListingDirective(SphinxDirective): 677 has_content = False 678 required_arguments = 0 679 optional_arguments = 0 680 option_spec = { 681 "categories": directives.unchanged_required, 682 "live-search": directives.flag, 683 } 684 685 def run(self): 686 code_sample_listing_node = CodeSampleListingNode() 687 code_sample_listing_node["categories"] = self.options.get("categories").split() 688 code_sample_listing_node["live-search"] = "live-search" in self.options 689 690 return [code_sample_listing_node] 691 692 693class BoardDirective(SphinxDirective): 694 has_content = False 695 required_arguments = 1 696 optional_arguments = 0 697 698 def run(self): 699 # board_name is passed as the directive argument 700 board_name = self.arguments[0] 701 702 boards = self.env.domaindata["zephyr"]["boards"] 703 vendors = self.env.domaindata["zephyr"]["vendors"] 704 705 if board_name not in boards: 706 logger.warning( 707 f"Board {board_name} does not seem to be a valid board name.", 708 location=(self.env.docname, self.lineno), 709 ) 710 return [] 711 elif "docname" in boards[board_name]: 712 logger.warning( 713 f"Board {board_name} is already documented in {boards[board_name]['docname']}.", 714 location=(self.env.docname, self.lineno), 715 ) 716 return [] 717 else: 718 self.env.domaindata["zephyr"]["has_board"][self.env.docname] = True 719 board = boards[board_name] 720 # flag board in the domain data as now having a documentation page so that it can be 721 # cross-referenced etc. 722 board["docname"] = self.env.docname 723 724 board_node = BoardNode(id=board_name) 725 board_node["full_name"] = board["full_name"] 726 board_node["vendor"] = vendors.get(board["vendor"], board["vendor"]) 727 board_node["supported_features"] = board["supported_features"] 728 board_node["archs"] = board["archs"] 729 board_node["socs"] = board["socs"] 730 board_node["image"] = board["image"] 731 return [board_node] 732 733 734class BoardCatalogDirective(SphinxDirective): 735 has_content = False 736 required_arguments = 0 737 optional_arguments = 0 738 739 def run(self): 740 if self.env.app.builder.format == "html": 741 self.env.domaindata["zephyr"]["has_board_catalog"][self.env.docname] = True 742 743 domain_data = self.env.domaindata["zephyr"] 744 renderer = SphinxRenderer([TEMPLATES_DIR]) 745 rendered = renderer.render( 746 "board-catalog.html", 747 { 748 "boards": domain_data["boards"], 749 "vendors": domain_data["vendors"], 750 "socs": domain_data["socs"], 751 "hw_features_present": self.env.app.config.zephyr_generate_hw_features, 752 }, 753 ) 754 return [nodes.raw("", rendered, format="html")] 755 else: 756 return [nodes.paragraph(text="Board catalog is only available in HTML.")] 757 758 759class BoardSupportedHardwareDirective(SphinxDirective): 760 """A directive for showing the supported hardware features of a board.""" 761 762 has_content = False 763 required_arguments = 0 764 optional_arguments = 0 765 766 def run(self): 767 env = self.env 768 docname = env.docname 769 770 matcher = NodeMatcher(BoardNode) 771 board_nodes = list(self.state.document.traverse(matcher)) 772 if not board_nodes: 773 logger.warning( 774 "board-supported-hw directive must be used in a board documentation page.", 775 location=(docname, self.lineno), 776 ) 777 return [] 778 779 board_node = board_nodes[0] 780 supported_features = board_node["supported_features"] 781 result_nodes = [] 782 783 paragraph = nodes.paragraph() 784 paragraph += nodes.Text("The ") 785 paragraph += nodes.literal(text=board_node["id"]) 786 paragraph += nodes.Text(" board supports the hardware features listed below.") 787 result_nodes.append(paragraph) 788 789 if not env.app.config.zephyr_generate_hw_features: 790 note = nodes.admonition() 791 note += nodes.title(text="Note") 792 note["classes"].append("warning") 793 note += nodes.paragraph( 794 text="The list of supported hardware features was not generated. Run a full " 795 "documentation build for the required metadata to be available." 796 ) 797 result_nodes.append(note) 798 return result_nodes 799 800 html_contents = """<div class="legend admonition"> 801 <dl class="supported-hardware field-list"> 802 <dt> 803 <span class="location-chip onchip">on-chip</span> / 804 <span class="location-chip onboard">on-board</span> 805 </dt> 806 <dd> 807 Feature integrated in the SoC / present on the board. 808 </dd> 809 <dt> 810 <span class="count okay-count">2</span> / 811 <span class="count disabled-count">2</span> 812 </dt> 813 <dd> 814 Number of instances that are enabled / disabled. <br/> 815 Click on the label to see the first instance of this feature in the board/SoC DTS files. 816 </dd> 817 <dt> 818 <code class="docutils literal notranslate"><span class="pre">vnd,foo</span></code> 819 </dt> 820 <dd> 821 Compatible string for the Devicetree binding matching the feature. <br/> 822 Click on the link to view the binding documentation. 823 </dd> 824 </dl> 825</div>""" 826 result_nodes.append(nodes.raw("", html_contents, format="html")) 827 828 for target, features in sorted(supported_features.items()): 829 if not features: 830 continue 831 832 target_heading = nodes.section(ids=[f"{board_node['id']}-{target}-hw-features"]) 833 heading = nodes.title() 834 heading += nodes.literal(text=target) 835 heading += nodes.Text(" target") 836 target_heading += heading 837 result_nodes.append(target_heading) 838 839 table = nodes.table(classes=["colwidths-given", "hardware-features"]) 840 tgroup = nodes.tgroup(cols=4) 841 842 tgroup += nodes.colspec(colwidth=15, classes=["type"]) 843 tgroup += nodes.colspec(colwidth=12, classes=["location"]) 844 tgroup += nodes.colspec(colwidth=53, classes=["description"]) 845 tgroup += nodes.colspec(colwidth=20, classes=["compatible"]) 846 847 thead = nodes.thead() 848 row = nodes.row() 849 headers = ["Type", "Location", "Description", "Compatible"] 850 for header in headers: 851 entry = nodes.entry(classes=[header.lower()]) 852 entry += nodes.paragraph(text=header) 853 row += entry 854 thead += row 855 tgroup += thead 856 857 tbody = nodes.tbody() 858 859 def feature_sort_key(feature): 860 # Put "CPU" first. Later updates might also give priority to features 861 # like "sensor"s, for example. 862 if feature == "cpu": 863 return (0, feature) 864 return (1, feature) 865 866 sorted_features = sorted(features.keys(), key=feature_sort_key) 867 868 for feature in sorted_features: 869 items = list(features[feature].items()) 870 num_items = len(items) 871 872 for i, (key, value) in enumerate(items): 873 row = nodes.row() 874 875 # TYPE column 876 if i == 0: 877 type_entry = nodes.entry(morerows=num_items - 1, classes=["type"]) 878 type_entry += nodes.paragraph( 879 "", 880 "", 881 BINDING_TYPE_TO_DOCUTILS_NODE.get( 882 feature, nodes.Text(feature) 883 ).deepcopy(), 884 ) 885 row += type_entry 886 887 # LOCATION column 888 location_entry = nodes.entry(classes=["location"]) 889 location_para = nodes.paragraph() 890 891 if "board" in value["locations"]: 892 location_chip = nodes.inline( 893 classes=["location-chip", "onboard"], 894 text="on-board", 895 ) 896 location_para += location_chip 897 elif "soc" in value["locations"]: 898 location_chip = nodes.inline( 899 classes=["location-chip", "onchip"], 900 text="on-chip", 901 ) 902 location_para += location_chip 903 904 location_entry += location_para 905 row += location_entry 906 907 # DESCRIPTION column 908 desc_entry = nodes.entry(classes=["description"]) 909 desc_para = nodes.paragraph(classes=["status"]) 910 desc_para += nodes.Text(value["description"]) 911 912 # Add count indicators for okay and not-okay instances 913 okay_nodes = value.get("okay_nodes", []) 914 disabled_nodes = value.get("disabled_nodes", []) 915 916 role_fn, _ = roles.role( 917 "zephyr_file", self.state_machine.language, self.lineno, self.state.reporter 918 ) 919 920 def create_count_indicator(nodes_list, class_type, role_function=role_fn): 921 if not nodes_list: 922 return None 923 924 count = len(nodes_list) 925 926 if role_function is None: 927 return nodes.inline( 928 classes=["count", f"{class_type}-count"], text=str(count) 929 ) 930 931 # Create a reference to the first node in the list 932 first_node = nodes_list[0] 933 file_ref = f"{count} <{first_node['filename']}#L{first_node['lineno']}>" 934 935 role_nodes, _ = role_function( 936 "zephyr_file", file_ref, file_ref, self.lineno, self.state.inliner 937 ) 938 939 count_node = role_nodes[0] 940 count_node["classes"] = ["count", f"{class_type}-count"] 941 942 return count_node 943 944 desc_para += create_count_indicator(okay_nodes, "okay") 945 desc_para += create_count_indicator(disabled_nodes, "disabled") 946 947 desc_entry += desc_para 948 row += desc_entry 949 950 # COMPATIBLE column 951 compatible_entry = nodes.entry(classes=["compatible"]) 952 xref = addnodes.pending_xref( 953 "", 954 refdomain="std", 955 reftype="dtcompatible", 956 reftarget=key, 957 refexplicit=False, 958 refwarn=(not value.get("custom_binding", False)), 959 ) 960 xref += nodes.literal(text=key) 961 compatible_entry += nodes.paragraph("", "", xref) 962 row += compatible_entry 963 964 tbody += row 965 966 tgroup += tbody 967 table += tgroup 968 result_nodes.append(table) 969 970 return result_nodes 971 972 973class ZephyrDomain(Domain): 974 """Zephyr domain""" 975 976 name = "zephyr" 977 label = "Zephyr" 978 979 roles = { 980 "code-sample": XRefRole(innernodeclass=nodes.inline, warn_dangling=True), 981 "code-sample-category": XRefRole(innernodeclass=nodes.inline, warn_dangling=True), 982 "board": XRefRole(innernodeclass=nodes.inline, warn_dangling=True), 983 } 984 985 directives = { 986 "code-sample": CodeSampleDirective, 987 "code-sample-listing": CodeSampleListingDirective, 988 "code-sample-category": CodeSampleCategoryDirective, 989 "board-catalog": BoardCatalogDirective, 990 "board": BoardDirective, 991 "board-supported-hw": BoardSupportedHardwareDirective, 992 } 993 994 object_types: dict[str, ObjType] = { 995 "code-sample": ObjType("code sample", "code-sample"), 996 "code-sample-category": ObjType("code sample category", "code-sample-category"), 997 "board": ObjType("board", "board"), 998 } 999 1000 initial_data: dict[str, Any] = { 1001 "code-samples": {}, # id -> code sample data 1002 "code-samples-categories": {}, # id -> code sample category data 1003 "code-samples-categories-tree": Node("samples"), 1004 # keep track of documents containing special directives 1005 "has_code_sample_listing": {}, # docname -> bool 1006 "has_board_catalog": {}, # docname -> bool 1007 "has_board": {}, # docname -> bool 1008 } 1009 1010 def clear_doc(self, docname: str) -> None: 1011 self.data["code-samples"] = { 1012 sample_id: sample_data 1013 for sample_id, sample_data in self.data["code-samples"].items() 1014 if sample_data["docname"] != docname 1015 } 1016 1017 self.data["code-samples-categories"] = { 1018 category_id: category_data 1019 for category_id, category_data in self.data["code-samples-categories"].items() 1020 if category_data["docname"] != docname 1021 } 1022 1023 # TODO clean up the anytree as well 1024 1025 self.data["has_code_sample_listing"].pop(docname, None) 1026 self.data["has_board_catalog"].pop(docname, None) 1027 self.data["has_board"].pop(docname, None) 1028 1029 def merge_domaindata(self, docnames: list[str], otherdata: dict) -> None: 1030 self.data["code-samples"].update(otherdata["code-samples"]) 1031 self.data["code-samples-categories"].update(otherdata["code-samples-categories"]) 1032 1033 # self.data["boards"] contains all the boards right from builder-inited time, but it still 1034 # potentially needs merging since a board's docname property is set by BoardDirective to 1035 # indicate the board is documented in a specific document. 1036 for board_name, board in otherdata["boards"].items(): 1037 if "docname" in board: 1038 self.data["boards"][board_name]["docname"] = board["docname"] 1039 1040 # merge category trees by adding all the categories found in the "other" tree that to 1041 # self tree 1042 other_tree = otherdata["code-samples-categories-tree"] 1043 categories = [n for n in PreOrderIter(other_tree) if hasattr(n, "category")] 1044 for category in categories: 1045 category_path = f"/{'/'.join(n.name for n in category.path)}" 1046 self.add_category_to_tree( 1047 category_path, 1048 category.category["id"], 1049 category.category["name"], 1050 category.category["docname"], 1051 ) 1052 1053 for docname in docnames: 1054 self.data["has_code_sample_listing"][docname] = otherdata[ 1055 "has_code_sample_listing" 1056 ].get(docname, False) 1057 self.data["has_board_catalog"][docname] = otherdata["has_board_catalog"].get( 1058 docname, False 1059 ) 1060 self.data["has_board"][docname] = otherdata["has_board"].get(docname, False) 1061 1062 def get_objects(self): 1063 for _, code_sample in self.data["code-samples"].items(): 1064 yield ( 1065 code_sample["id"], 1066 code_sample["name"], 1067 "code-sample", 1068 code_sample["docname"], 1069 code_sample["id"], 1070 1, 1071 ) 1072 1073 for _, code_sample_category in self.data["code-samples-categories"].items(): 1074 yield ( 1075 code_sample_category["id"], 1076 code_sample_category["name"], 1077 "code-sample-category", 1078 code_sample_category["docname"], 1079 code_sample_category["id"], 1080 1, 1081 ) 1082 1083 for _, board in self.data["boards"].items(): 1084 # only boards that do have a documentation page are to be considered as valid objects 1085 if "docname" in board: 1086 yield ( 1087 board["name"], 1088 board["full_name"], 1089 "board", 1090 board["docname"], 1091 board["name"], 1092 1, 1093 ) 1094 1095 # used by Sphinx Immaterial theme 1096 def get_object_synopses(self) -> Iterator[tuple[tuple[str, str], str]]: 1097 for _, code_sample in self.data["code-samples"].items(): 1098 yield ( 1099 (code_sample["docname"], code_sample["id"]), 1100 code_sample["description"].astext(), 1101 ) 1102 1103 def resolve_xref(self, env, fromdocname, builder, type, target, node, contnode): 1104 if type == "code-sample": 1105 elem = self.data["code-samples"].get(target) 1106 elif type == "code-sample-category": 1107 elem = self.data["code-samples-categories"].get(target) 1108 elif type == "board": 1109 elem = self.data["boards"].get(target) 1110 else: 1111 return 1112 1113 if elem and "docname" in elem: 1114 if not node.get("refexplicit"): 1115 contnode = [nodes.Text(elem["name"] if type != "board" else elem["full_name"])] 1116 1117 return make_refnode( 1118 builder, 1119 fromdocname, 1120 elem["docname"], 1121 elem["id"] if type != "board" else elem["name"], 1122 contnode, 1123 elem["description"].astext() if type == "code-sample" else None, 1124 ) 1125 1126 def add_code_sample(self, code_sample): 1127 self.data["code-samples"][code_sample["id"]] = code_sample 1128 1129 def add_code_sample_category(self, code_sample_category): 1130 self.data["code-samples-categories"][code_sample_category["id"]] = code_sample_category 1131 self.add_category_to_tree( 1132 path.dirname(code_sample_category["docname"]), 1133 code_sample_category["id"], 1134 code_sample_category["name"], 1135 code_sample_category["docname"], 1136 ) 1137 1138 def add_category_to_tree( 1139 self, category_path: str, category_id: str, category_name: str, docname: str 1140 ) -> Node: 1141 resolver = Resolver("name") 1142 tree = self.data["code-samples-categories-tree"] 1143 1144 if not category_path.startswith("/"): 1145 category_path = "/" + category_path 1146 1147 # node either already exists (and we update it to make it a category node), or we need to 1148 # create it 1149 try: 1150 node = resolver.get(tree, category_path) 1151 if hasattr(node, "category") and node.category["id"] != category_id: 1152 raise ValueError( 1153 f"Can't add code sample category {category_id} as category " 1154 f"{node.category['id']} is already defined in {node.category['docname']}. " 1155 "You may only have one category per path." 1156 ) 1157 except ChildResolverError as e: 1158 path_of_last_existing_node = f"/{'/'.join(n.name for n in e.node.path)}" 1159 common_path = path.commonpath([path_of_last_existing_node, category_path]) 1160 remaining_path = path.relpath(category_path, common_path) 1161 1162 # Add missing nodes under the last existing node 1163 for node_name in remaining_path.split("/"): 1164 e.node = Node(node_name, parent=e.node) 1165 1166 node = e.node 1167 1168 node.category = {"id": category_id, "name": category_name, "docname": docname} 1169 1170 return tree 1171 1172 1173class CustomDoxygenGroupDirective(DoxygenGroupDirective): 1174 """Monkey patch for Breathe's DoxygenGroupDirective.""" 1175 1176 def run(self) -> list[Node]: 1177 nodes = super().run() 1178 1179 if self.config.zephyr_breathe_insert_related_samples: 1180 return [*nodes, RelatedCodeSamplesNode(id=self.arguments[0])] 1181 else: 1182 return nodes 1183 1184 1185def compute_sample_categories_hierarchy(app: Sphinx, env: BuildEnvironment) -> None: 1186 domain = env.get_domain("zephyr") 1187 code_samples = domain.data["code-samples"] 1188 1189 category_tree = env.domaindata["zephyr"]["code-samples-categories-tree"] 1190 resolver = Resolver("name") 1191 for code_sample in code_samples.values(): 1192 try: 1193 # Try to get the node at the specified path 1194 node = resolver.get(category_tree, "/" + path.dirname(code_sample["docname"])) 1195 except ChildResolverError as e: 1196 # starting with e.node and up, find the first node that has a category 1197 node = e.node 1198 while not hasattr(node, "category"): 1199 node = node.parent 1200 code_sample["category"] = node.category["id"] 1201 1202 1203def install_static_assets_as_needed( 1204 app: Sphinx, pagename: str, templatename: str, context: dict[str, Any], doctree: nodes.Node 1205) -> None: 1206 if app.env.domaindata["zephyr"]["has_code_sample_listing"].get(pagename, False): 1207 app.add_css_file("css/codesample-livesearch.css") 1208 app.add_js_file("js/codesample-livesearch.js") 1209 1210 if app.env.domaindata["zephyr"]["has_board_catalog"].get(pagename, False): 1211 app.add_css_file("css/board-catalog.css") 1212 app.add_js_file("js/board-catalog.js") 1213 1214 if app.env.domaindata["zephyr"]["has_board"].get(pagename, False): 1215 app.add_css_file("css/board.css") 1216 app.add_js_file("js/board.js") 1217 1218 1219def load_board_catalog_into_domain(app: Sphinx) -> None: 1220 board_catalog = get_catalog( 1221 generate_hw_features=( 1222 app.builder.format == "html" and app.config.zephyr_generate_hw_features 1223 ) 1224 ) 1225 app.env.domaindata["zephyr"]["boards"] = board_catalog["boards"] 1226 app.env.domaindata["zephyr"]["vendors"] = board_catalog["vendors"] 1227 app.env.domaindata["zephyr"]["socs"] = board_catalog["socs"] 1228 1229 1230def setup(app): 1231 app.add_config_value("zephyr_breathe_insert_related_samples", False, "env") 1232 app.add_config_value("zephyr_generate_hw_features", False, "env") 1233 1234 app.add_domain(ZephyrDomain) 1235 1236 app.add_transform(ConvertCodeSampleNode) 1237 app.add_transform(ConvertCodeSampleCategoryNode) 1238 app.add_transform(ConvertBoardNode) 1239 1240 app.add_post_transform(ProcessCodeSampleListingNode) 1241 app.add_post_transform(CodeSampleCategoriesTocPatching) 1242 app.add_post_transform(ProcessRelatedCodeSamplesNode) 1243 1244 app.connect( 1245 "builder-inited", 1246 (lambda app: app.config.html_static_path.append(RESOURCES_DIR.as_posix())), 1247 ) 1248 app.connect("builder-inited", load_board_catalog_into_domain) 1249 1250 app.connect("html-page-context", install_static_assets_as_needed) 1251 app.connect("env-updated", compute_sample_categories_hierarchy) 1252 1253 # monkey-patching of the DoxygenGroupDirective 1254 app.add_directive("doxygengroup", CustomDoxygenGroupDirective, override=True) 1255 1256 return { 1257 "version": __version__, 1258 "parallel_read_safe": True, 1259 "parallel_write_safe": True, 1260 } 1261