1# Copyright 2023 The Pigweed Authors 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); you may not 4# use this file except in compliance with the License. You may obtain a copy of 5# the License at 6# 7# https://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12# License for the specific language governing permissions and limitations under 13# the License. 14"""Generates content related to Pigweed module metadata on pigweed.dev. 15 16This file implements the following pigweed.dev features: 17 18* The `.. pigweed-module::` and `.. pigweed-module-subpage::` directives. 19* The auto-generated "Source code" and "Issues" URLs that appear in the site 20 nav for each module. 21 22Everything is implemented through the Sphinx Extension API. 23""" 24 25from dataclasses import dataclass 26import json 27import os 28import sys 29from typing import cast, TypeVar 30 31# We use BeautifulSoup for certain docs rendering features. It may not be 32# available in downstream projects. If so, no problem. We fall back to simpler 33# docs rendering. 34# pylint: disable=import-error 35try: 36 from bs4 import BeautifulSoup # type: ignore 37 from bs4.element import Tag as HTMLTag # type: ignore 38 39 bs_enabled = True 40except ModuleNotFoundError: 41 bs_enabled = False 42 43try: 44 import jsonschema # type: ignore 45 46 jsonschema_enabled = True 47except ModuleNotFoundError: 48 jsonschema_enabled = False 49# pylint: enable=import-error 50 51import docutils 52from docutils import nodes 53from docutils.nodes import Element 54import docutils.statemachine 55 56# pylint: disable=consider-using-from-import 57import docutils.parsers.rst.directives as directives # type: ignore 58 59# pylint: enable=consider-using-from-import 60from sphinx.addnodes import document as Document 61from sphinx.application import Sphinx 62from sphinx.environment import BuildEnvironment 63from sphinx.util.docutils import SphinxDirective 64 65from sphinx_design.cards import CardDirective 66 67EnvAttrT = TypeVar('EnvAttrT') 68 69 70# The module metadata is exposed as a global because it's used as read-only 71# data. Opening and reading the metadata file in one of the event handlers 72# would cause hundreds of filesystem reads on each build because those event 73# handlers fire once for each docs page that's built. 74try: # Bazel location for the data 75 from python.runfiles import runfiles # type: ignore 76 77 r = runfiles.Create() 78 schema_file = r.Rlocation('pigweed/docs/module_metadata_schema.json') 79 r = runfiles.Create() 80 metadata_file = r.Rlocation('pigweed/docs/module_metadata.json') 81except ImportError: # GN location for the data 82 schema_file = f'{os.environ["PW_ROOT"]}/docs/module_metadata_schema.json' 83 metadata_file = f'{os.environ["PW_ROOT"]}/docs/module_metadata.json' 84with open(schema_file, 'r') as f: 85 schema = json.load(f) 86with open(metadata_file, 'r') as f: 87 metadata = json.load(f) 88# Make sure the metadata matches its schema. Raise an uncaught exception 89# if not. 90if jsonschema_enabled: 91 jsonschema.validate(metadata, schema) 92 93 94@dataclass 95class ParsedBody: 96 topnav: str 97 body_without_topnav: str 98 99 100class EnvMetadata: 101 """Easier access to the Sphinx `env` for custom metadata. 102 103 You can store things in the Sphinx `env`, which is just a dict. But each 104 time you do, you have to handle the possibility that the key you want 105 hasn't been set yet, and set it to a default. The `env` is also untyped, 106 so you have to cast the value you get to whatever type you expect it to be. 107 108 Or you can use this class to define your metadata keys up front, and just 109 access them like: `value = EnvMetadata(env).my_value` 110 111 ... which will handle initializing the value if it hasn't been yet and 112 provide you a typed result. 113 """ 114 115 def __init__(self, env: BuildEnvironment): 116 self._env = env 117 118 def _get_env_attr(self, attr: str, default: EnvAttrT) -> EnvAttrT: 119 if not hasattr(self._env, attr): 120 value: EnvAttrT = default 121 setattr(self._env, attr, value) 122 else: 123 value = getattr(self._env, attr) 124 125 return value 126 127 @property 128 def pw_parsed_bodies(self) -> dict[str, ParsedBody]: 129 default: dict[str, ParsedBody] = {} 130 return self._get_env_attr('pw_module_nav', default) 131 132 133def get_languages(module_name: str) -> list[str] | None: 134 """Returns the list of languages that a module supports. 135 136 Args: 137 module_name: The module to look up. 138 139 Returns: 140 A list of programming languages that the module supports, or ``None`` 141 if this has not been defined in ``//docs/module_metadata.json``. 142 """ 143 if module_name not in metadata: 144 return None 145 if 'languages' not in metadata[module_name]: 146 return None 147 return metadata[module_name]['languages'] 148 149 150def get_status(module_name: str) -> str: 151 """Returns the status of a module. 152 153 Preconditions: 154 The status must be defined in ``//docs/module_metadata.json``. 155 156 Args: 157 module_name: The module to look up. 158 159 Returns: 160 The status of the module as a string. 161 """ 162 if module_name not in metadata: 163 sys.exit(f'{module_name} not found in {metadata_file}') 164 if 'status' not in metadata[module_name]: 165 sys.exit(f'{module_name}.status not found in {metadata_file}') 166 return metadata[module_name]['status'] 167 168 169def get_tagline(module_name: str) -> str | None: 170 """Returns the tagline for a module. 171 172 Args: 173 module_name: The module to look up. 174 175 Returns: 176 The module's tagline or ``None`` if no tagline has been defined 177 in ``//docs/module_metadata.json``. 178 """ 179 if module_name not in metadata: 180 return None 181 if 'tagline' not in metadata[module_name]: 182 return None 183 return metadata[module_name]['tagline'] 184 185 186def get_code_size(module_name: str) -> str | None: 187 """Returns the code size impact summary for a module. 188 189 Args: 190 module_name: The module to look up. 191 192 Returns: 193 The code size impact summary as a string or ``None`` if no summary 194 has been defined in ``//docs/module_metadata.json``. 195 """ 196 if module_name not in metadata: 197 return None 198 if 'size' not in metadata[module_name]: 199 return None 200 return metadata[module_name]['size'] 201 202 203def status_badge(module_status: str) -> str: 204 """Given a module status, return the status badge for rendering.""" 205 # Use a different badge for each module status value. 206 # https://sphinx-design.readthedocs.io/en/latest/badges_buttons.html#badges 207 status_to_badge = { 208 'stable': 'primary', 209 'unstable': 'secondary', 210 'experimental': 'warning', 211 'deprecated': 'danger', 212 } 213 badge = status_to_badge[module_status] 214 role = f':bdg-{badge}:' 215 return role + f'`{module_status.title()}`' 216 217 218def cs_url(module_name: str) -> str: 219 """Return the codesearch URL for the given module.""" 220 return f'https://cs.opensource.google/pigweed/pigweed/+/main:{module_name}/' 221 222 223def issues_url(module_name: str) -> str: 224 """Returns open issues that mention the given module name.""" 225 return f'https://issues.pigweed.dev/issues?q={module_name}%20status:open' 226 227 228def rustdoc_url(module_name: str) -> str: 229 """Returns the rustdoc URL for a given module.""" 230 return f'https://pigweed.dev/rustdoc/{module_name}' 231 232 233def concat_tags(*tag_lists: list[str]) -> list[str]: 234 """Given a list of tag lists, return them concat'ed and ready for render.""" 235 236 all_tags = tag_lists[0] 237 238 for tag_list in tag_lists[1:]: 239 if len(tag_list) > 0: 240 all_tags.append(':octicon:`dot-fill`') 241 all_tags.extend(tag_list) 242 243 return all_tags 244 245 246def create_topnav( 247 subtitle: str | None, 248 extra_classes: list[str] | None = None, 249) -> nodes.Node: 250 """Create the nodes for the top title and navigation bar.""" 251 252 topnav_classes = ( 253 ['pw-topnav'] + extra_classes if extra_classes is not None else [] 254 ) 255 256 topnav_container = nodes.container(classes=topnav_classes) 257 258 if subtitle: 259 subtitle_node = nodes.paragraph( 260 classes=['pw-topnav-subtitle'], 261 text=subtitle, 262 ) 263 topnav_container += subtitle_node 264 265 return topnav_container 266 267 268class PigweedModuleDirective(SphinxDirective): 269 """Directive registering module metadata, rendering title & info card.""" 270 271 required_arguments = 0 272 final_argument_whitespace = True 273 has_content = True 274 option_spec = {'name': directives.unchanged_required} 275 276 def _try_get_option(self, option: str): 277 """Try to get an option by name and raise on failure.""" 278 279 try: 280 return self.options[option] 281 except KeyError: 282 raise self.error(f' :{option}: option is required') 283 284 def _maybe_get_option(self, option: str): 285 """Try to get an option by name and return None on failure.""" 286 return self.options.get(option, None) 287 288 def run(self) -> list[nodes.Node]: 289 module_name = self._try_get_option('name') 290 tagline = get_tagline(module_name) 291 status = get_status(module_name) 292 293 status_tags: list[str] = [ 294 status_badge(status), 295 ] 296 297 languages = get_languages(module_name) 298 language_tags = [] 299 if languages: 300 for language in languages: 301 language_tags.append(f':bdg-info:`{language}`') 302 303 code_size_impact = [] 304 305 code_size_text = get_code_size(module_name) 306 if code_size_text: 307 code_size_impact.append(f'**Code Size Impact:** {code_size_text}') 308 309 # Move the directive content into a section that we can render wherever 310 # we want. 311 raw_content = cast(list[str], self.content) # type: ignore 312 content = nodes.paragraph() 313 self.state.nested_parse(raw_content, 0, content) 314 315 # The card inherits its content from this node's content, which we've 316 # already pulled out. So we can replace this node's content with the 317 # content we need in the card. 318 self.content = docutils.statemachine.StringList( 319 concat_tags(status_tags, language_tags, code_size_impact) 320 ) 321 322 card = CardDirective.create_card( 323 inst=self, 324 arguments=[], 325 options={}, 326 ) 327 328 topbar = create_topnav( 329 tagline, 330 ['pw-module-index'], 331 ) 332 333 return [topbar, card, content] 334 335 336class PigweedModuleSubpageDirective(PigweedModuleDirective): 337 """Directive registering module metadata, rendering title & info card.""" 338 339 required_arguments = 0 340 final_argument_whitespace = True 341 has_content = True 342 option_spec = { 343 'name': directives.unchanged_required, 344 'nav': directives.unchanged_required, 345 } 346 347 def run(self) -> list[nodes.Node]: 348 module_name = self._try_get_option('name') 349 tagline = get_tagline(module_name) 350 # Prepend the module name on sub-pages so that it's very clear what 351 # the tagline is referring to. 352 tagline = f'{module_name}: {tagline}' 353 354 topbar = create_topnav( 355 tagline, 356 ['pw-module-subpage'], 357 ) 358 359 return [topbar] 360 361 362def _parse_body(body: str) -> ParsedBody: 363 """From the `body` HTML, return the topnav and the body without topnav. 364 365 The fundamental idea is this: Our Sphinx directives can only render nodes 366 *within* the docutils doc, but we want to elevate the top navbar *outside* 367 of that doc into the web theme. Sphinx by itself provides no mechanism for 368 this, since it's model looks something like this: 369 370 ┌──────────────────┐ 371 │ Theme │ 372 │ ┌──────────────┐│ When Sphinx builds HTML, the output is plain HTML 373 │ │ Sphinx HTML ││ with a structure defined by docutils. Themes can 374 │ │ ││ build *around* that and cascade styles down *into* 375 │ │ ││ that HTML, but there's no mechanism in the Sphinx 376 │ └──────────────┘│ build to render docutils nodes in the theme. 377 └──────────────────┘ 378 379 The escape hatch is this: 380 - Render things within the Sphinx HTML output (`body`) 381 - Use Sphinx theme templates to run code during the final render phase 382 - Extract the HTML from the `body` and insert it in the theme via templates 383 384 So this function extracts the things that we rendered in the `body` but 385 actually want in the theme (the top navbar), returns them for rendering in 386 the template, and returns the `body` with those things removed. 387 """ 388 if not bs_enabled: 389 return ParsedBody('', body) 390 391 def _add_class_to_tag(tag: HTMLTag, classname: str) -> None: 392 tag['class'] = tag.get('class', []) + [classname] # type: ignore 393 394 def _add_classes_to_tag( 395 tag: HTMLTag, classnames: str | list[str] | None 396 ) -> None: 397 tag['class'] = tag.get('class', []) + classnames # type: ignore 398 399 html = BeautifulSoup(body, features='html.parser') 400 401 # Render the doc unchanged, unless it has the module doc topnav 402 if (topnav := html.find('div', attrs={'class': 'pw-topnav'})) is None: 403 return ParsedBody('', body) 404 405 assert isinstance(topnav, HTMLTag) 406 407 # Find the topnav title and subtitle 408 topnav_title = topnav.find('p', attrs={'class': 'pw-topnav-title'}) 409 topnav_subtitle = topnav.find('p', attrs={'class': 'pw-topnav-subtitle'}) 410 assert isinstance(topnav_title, HTMLTag) 411 assert isinstance(topnav_subtitle, HTMLTag) 412 413 # Find the single `h1` element, the doc's canonical title 414 doc_title = html.find('h1') 415 assert isinstance(doc_title, HTMLTag) 416 417 topnav_str = '' 418 419 if 'pw-module-index' in topnav['class']: 420 # Take the standard Sphinx/docutils title and add topnav styling 421 _add_class_to_tag(doc_title, 'pw-topnav-title') 422 # Replace the placeholder title in the topnav with the "official" `h1` 423 topnav_title.replace_with(doc_title) 424 # Promote the subtitle to `h2` 425 topnav_subtitle.name = 'h2' 426 # We're done mutating topnav; write it to string for rendering elsewhere 427 topnav_str = str(topnav) 428 # Destroy the instance that was rendered in the document 429 topnav.decompose() 430 431 elif 'pw-module-subpage' in topnav['class']: 432 # Take the title from the topnav (the module name), promote it to `h1` 433 topnav_title.name = 'h1' 434 # Add the heading link, but pointed to the module index page 435 heading_link = html.new_tag( 436 'a', 437 attrs={ 438 'class': ['headerlink'], 439 'href': 'docs.html', 440 'title': 'Permalink to module index', 441 }, 442 ) 443 heading_link.string = '#' 444 topnav_title.append(heading_link) 445 # Promote the subtitle to `h2` 446 topnav_subtitle.name = 'h2' 447 # We're done mutating topnav; write it to string for rendering elsewhere 448 topnav_str = str(topnav) 449 # Destroy the instance that was rendered in the document 450 topnav.decompose() 451 452 return ParsedBody(topnav_str, str(html)) 453 454 455def setup_parse_body(_app, _pagename, _templatename, context, _doctree): 456 def parse_body(body: str) -> ParsedBody: 457 return _parse_body(body) 458 459 context['parse_body'] = parse_body 460 461 462def fix_canonical_url(canonical_url: str | None) -> str | None: 463 """Rewrites the canonical URL for `pigweed.dev/*/docs.html` pages. 464 465 Our server is configured to remove `docs.html` from URLs. E.g. 466 pigweed.dev/pw_string/docs.html` redirects to `pigweed.dev/pw_string`. 467 To improve our SEO, the `<link rel="canonical" href="..."/>` tag in our 468 HTML should match the URL that the server provides. 469 470 Args: 471 docname: 472 Basically the relative path to the doc, except `.rst` is omitted 473 from the filename. E.g. `pw_string/docs`. 474 canonical_url: 475 The default canonical URL that Sphinx has generated for the doc. 476 477 Returns: 478 The corrected canonical URL if the page would normally end with 479 `docs.html`, otherwise the original canonical URL value unmodified. 480 """ 481 if canonical_url is None or not canonical_url.endswith('/docs.html'): 482 return canonical_url 483 canonical_url = canonical_url.replace('/docs.html', '/') 484 return canonical_url 485 486 487def on_html_page_context( 488 app: Sphinx, # pylint: disable=unused-argument 489 docname: str, # pylint: disable=unused-argument 490 templatename: str, # pylint: disable=unused-argument 491 context: dict[str, str | None] | None, 492 doctree: Document, # pylint: disable=unused-argument 493) -> None: 494 """Handles modifications to HTML page metadata, e.g. canonical URLs. 495 496 Args: 497 docname: 498 Basically the relative path to the doc, except `.rst` is omitted 499 from the filename. E.g. `pw_string/docs`. 500 context: 501 A dict containing the HTML page's metadata. 502 503 Returns: 504 None. Modifications happen to the HTML metadata in-place. 505 """ 506 canonical_url_key = 'pageurl' 507 if context is None or canonical_url_key not in context: 508 return 509 canonical_url = context[canonical_url_key] 510 context[canonical_url_key] = fix_canonical_url(canonical_url) 511 512 513def add_links(module_name: str, toctree: Element) -> None: 514 """Adds source code and issues URLs to a module's table of contents tree. 515 516 This function is how we auto-generate the source code and issues URLs 517 that appear for each module in the pigweed.dev site nav. 518 519 Args: 520 module_name: 521 The Pigweed module that we're creating links for. 522 toctree: 523 The table of contents tree from that module's homepage. 524 525 Returns: 526 `None`. `toctree` is modified in-place. 527 """ 528 languages = get_languages(module_name) 529 if languages is not None and 'Rust' in languages: 530 rustdoc = ('Rust API reference', rustdoc_url(module_name)) 531 toctree['entries'] += [rustdoc] 532 toctree['rawentries'] += [rustdoc[0]] 533 src = ('Source code', cs_url(module_name)) 534 issues = ('Issues', issues_url(module_name)) 535 # Maintenance tip: the trick here is to create the `toctree` the same way 536 # that Sphinx generates it. When in doubt, enable logging in this file, 537 # manually modify the `.. toctree::` directive on a module's homepage, log 538 # out `toctree` from somewhere in this script (you should see an XML-style 539 # node), and then just make sure your code modifies the `toctree` the same 540 # way that Sphinx generates it. 541 toctree['entries'] += [src, issues] 542 toctree['rawentries'] += [src[0], issues[0]] 543 544 545def find_first_toctree(doctree: Document) -> Element | None: 546 """Finds the first `toctree` (table of contents tree) node in a `Document`. 547 548 Args: 549 doctree: 550 The content of a doc, represented as a tree of Docutils nodes. 551 552 Returns: 553 The first `toctree` node found in `doctree` or `None` if none was 554 found. 555 """ 556 for node in doctree.traverse(nodes.Element): 557 if node.tagname == 'toctree': 558 return node 559 return None 560 561 562def parse_module_name(docname: str) -> str: 563 """Extracts a Pigweed module name from a Sphinx docname. 564 565 Preconditions: 566 `docname` is assumed to start with `pw_`. I.e. the docs are assumed to 567 have a flat directory structure, where the first directory is the name 568 of a Pigweed module. 569 570 Args: 571 docname: 572 Basically the relative path to the doc, except `.rst` is omitted 573 from the filename. E.g. `pw_string/docs`. 574 575 Returns: 576 Just the Pigweed module name, e.g. `pw_string`. 577 """ 578 tokens = docname.split('/') 579 return tokens[0] 580 581 582def on_doctree_read(app: Sphinx, doctree: Document) -> None: 583 """Event handler that enables manipulating a doc's Docutils tree. 584 585 Sphinx fires this listener after it has parsed a doc's reStructuredText 586 into a tree of Docutils nodes. The listener fires once for each doc that's 587 processed. 588 589 In general, this stage of the Sphinx event lifecycle can only be used for 590 content changes that do not affect the Sphinx build environment [1]. For 591 example, creating a `toctree` node at this stage does not work, but 592 inserting links into a pre-existing `toctree` node is OK. 593 594 Args: 595 app: 596 Our Sphinx docs build system. 597 doctree: 598 The doc content, structured as a tree. 599 600 Returns: 601 `None`. The main modifications happen in-place in `doctree`. 602 603 [1] See link in `on_source_read()` 604 """ 605 docname = app.env.docname 606 if not is_module_homepage(docname): 607 return 608 toctree = find_first_toctree(doctree) 609 if toctree is None: 610 # `add_toctree_to_module_homepage()` should ensure that every 611 # `pw_*/docs.rst` file has a `toctree` node but if something went wrong 612 # then we should bail. 613 sys.exit(f'[module_metadata.py] error: toctree missing in {docname}') 614 module_name = parse_module_name(docname) 615 add_links(module_name, toctree) 616 617 618def is_module_homepage(docname: str) -> bool: 619 """Determines if a doc is a module homepage. 620 621 Any doc that matches the pattern `pw_*/docs.rst` is considered a module 622 homepage. Watch out for the false positive of `pw_*/*/docs.rst`. 623 624 Preconditions: 625 `docname` is assumed to start with `pw_`. I.e. the docs are assumed to 626 have a flat directory structure, where the first directory is the name 627 of a Pigweed module. 628 629 Args: 630 docname: 631 Basically the relative path to the doc, except `.rst` is omitted 632 from the filename. 633 634 Returns: 635 `True` if the doc is a module homepage, else `False`. 636 """ 637 tokens = docname.split('/') 638 if len(tokens) != 2: 639 return False 640 if not tokens[0].startswith('pw_'): 641 return False 642 if tokens[1] != 'docs': 643 return False 644 return True 645 646 647def add_toctree_to_module_homepage(docname: str, source: str) -> str: 648 """Appends an empty `toctree` to a module homepage. 649 650 Note that this function only needs to create the `toctree` node; it doesn't 651 need to fully populate the `toctree`. Inserting links later via the more 652 ergonomic Docutils API works fine. 653 654 Args: 655 docname: 656 Basically the relative path to `source`, except `.rst` is omitted 657 from the filename. 658 source: 659 The reStructuredText source code of `docname`. 660 661 Returns: 662 For module homepages that did not already have a `toctree`, the 663 original contents of `source` plus an empty `toctree` is returned. 664 For all other cases, the original contents of `source` are returned 665 with no modification. 666 """ 667 # Don't do anything if the page is not a module homepage, i.e. its 668 # `docname` doesn't match the pattern `pw_*`/docs`. 669 if not is_module_homepage(docname): 670 return source 671 # Don't do anything if the module homepage already has a `toctree`. 672 if '.. toctree::' in source: 673 return source 674 # Append an empty `toctree` to the content. 675 # yapf: disable 676 return ( 677 f'{source}\n\n' 678 '.. toctree::\n' 679 ' :hidden:\n' 680 ' :maxdepth: 1\n' 681 ) 682 # yapf: enable 683 # Python formatting (yapf) is disabled in the return statement because the 684 # formatter tries to change it to a less-readable single line string. 685 686 687# inclusive-language: disable 688def on_source_read( 689 app: Sphinx, # pylint: disable=unused-argument 690 docname: str, 691 source: list[str], 692) -> None: 693 """Event handler that enables manipulating a doc's reStructuredText. 694 695 Sphinx fires this event early in its event lifecycle [1], before it has 696 converted a doc's reStructuredText (reST) into a tree of Docutils nodes. 697 The listener fires once for each doc that's processed. 698 699 This is the place to make docs changes that have to propagate across the 700 site. Take our use case of adding a link in the site nav to each module's 701 source code. To do this we need a `toctree` (table of contents tree) node 702 on each module's homepage; the `toctree` is where we insert the source code 703 link. If we try to dynamically insert the `toctree` node via the Docutils 704 API later in the event lifecycle, e.g. during the `doctree-read` event, we 705 have to do a bunch of complex and fragile logic to make the Sphinx build 706 environment [2] aware of the new node. It's simpler and more reliable to 707 just insert a `.. toctree::` directive into the doc source before Sphinx 708 has processed the doc and then let Sphinx create its build environment as 709 it normally does. We just have to make sure the reStructuredText we're 710 injecting into the content is syntactically correct. 711 712 Args: 713 app: 714 Our Sphinx docs build system. 715 docname: 716 Basically the relative path to `source`, except `.rst` is omitted 717 from the filename. 718 source: 719 The reStructuredText source code of `docname`. 720 721 Returns: 722 None. `source` is modified in-place. 723 724 [1] www.sphinx-doc.org/en/master/extdev/appapi.html#sphinx-core-events 725 [2] www.sphinx-doc.org/en/master/extdev/envapi.html 726 """ 727 # inclusive-language: enable 728 # If a module homepage doesn't have a `toctree`, add one. 729 source[0] = add_toctree_to_module_homepage(docname, source[0]) 730 731 732def setup(app: Sphinx) -> dict[str, bool]: 733 """Hooks the extension into our Sphinx docs build system. 734 735 This runs only once per docs build. 736 737 Args: 738 app: 739 Our Sphinx docs build system. 740 741 Returns: 742 A dict that provides Sphinx info about our extension. 743 """ 744 # Register the `.. pigweed-module::` and `.. pigweed-module-subpage::` 745 # directives that are used on `pw_*/*.rst` pages. 746 app.add_directive('pigweed-module', PigweedModuleDirective) 747 app.add_directive('pigweed-module-subpage', PigweedModuleSubpageDirective) 748 # inclusive-language: disable 749 # Register the Sphinx event listeners that automatically generate content 750 # for `pw_*/*.rst` pages: 751 # www.sphinx-doc.org/en/master/extdev/appapi.html#sphinx-core-events 752 # inclusive-language: enable 753 app.connect('source-read', on_source_read) 754 app.connect('doctree-read', on_doctree_read) 755 app.connect('html-page-context', on_html_page_context) 756 return { 757 'parallel_read_safe': True, 758 'parallel_write_safe': True, 759 } 760