xref: /aosp_15_r20/external/pigweed/pw_docgen/py/pw_docgen/sphinx/module_metadata.py (revision 61c4878ac05f98d0ceed94b57d316916de578985)
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