doc: boards: extensions: introduce zephyr:board role and directive

A new zephyr:board:: Sphinx directive allows  to flag a documentation
page as being the documentation for a specific board, allowing to
auto-populate some of the contents, ex. by adding a board overview a la
Wikipedia, and later things like supported HW features, etc.

A corresponding :zephyr:board: role allows to link to a board doc page.

Signed-off-by: Benjamin Cabé <benjamin@zephyrproject.org>
diff --git a/doc/_extensions/zephyr/domain/__init__.py b/doc/_extensions/zephyr/domain/__init__.py
index 8f45b45..4814e3c 100644
--- a/doc/_extensions/zephyr/domain/__init__.py
+++ b/doc/_extensions/zephyr/domain/__init__.py
@@ -15,12 +15,14 @@
 - ``zephyr:code-sample-category::`` - Defines a category for grouping code samples.
 - ``zephyr:code-sample-listing::`` - Shows a listing of code samples found in a given category.
 - ``zephyr:board-catalog::`` - Shows a listing of boards supported by Zephyr.
+- ``zephyr:board::`` - Flags a document as being the documentation page for a board.
 
 Roles
 -----
 
 - ``:zephyr:code-sample:`` - References a code sample.
 - ``:zephyr:code-sample-category:`` - References a code sample category.
+- ``:zephyr:board:`` - References a board.
 
 """
 
@@ -85,6 +87,10 @@
     pass
 
 
+class BoardNode(nodes.Element):
+    pass
+
+
 class ConvertCodeSampleNode(SphinxTransform):
     default_priority = 100
 
@@ -213,6 +219,65 @@
         node.replace_self(node.children[0])
 
 
+class ConvertBoardNode(SphinxTransform):
+    default_priority = 100
+
+    def apply(self):
+        matcher = NodeMatcher(BoardNode)
+        for node in self.document.traverse(matcher):
+            self.convert_node(node)
+
+    def convert_node(self, node):
+        parent = node.parent
+        siblings_to_move = []
+        if parent is not None:
+            index = parent.index(node)
+            siblings_to_move = parent.children[index + 1 :]
+
+            new_section = nodes.section(ids=[node["id"]])
+            new_section += nodes.title(text=node["full_name"])
+
+            # create a sidebar with all the board details
+            sidebar = nodes.sidebar(classes=["board-overview"])
+            new_section += sidebar
+            sidebar += nodes.title(text="Board Overview")
+
+            if node["image"] is not None:
+                figure = nodes.figure()
+                # set a scale of 100% to indicate we want a link to the full-size image
+                figure += nodes.image(uri=f"/{node['image']}", scale=100)
+                figure += nodes.caption(text=node["full_name"])
+                sidebar += figure
+
+            field_list = nodes.field_list()
+            sidebar += field_list
+
+            details = [
+                ("Vendor", node["vendor"]),
+                ("Architecture", ", ".join(node["archs"])),
+                ("SoC", ", ".join(node["socs"])),
+            ]
+
+            for property_name, value in details:
+                field = nodes.field()
+                field_name = nodes.field_name(text=property_name)
+                field_body = nodes.field_body()
+                field_body += nodes.paragraph(text=value)
+                field += field_name
+                field += field_body
+                field_list += field
+
+            # Move the sibling nodes under the new section
+            new_section.extend(siblings_to_move)
+
+            # Replace the custom node with the new section
+            node.replace_self(new_section)
+
+            # Remove the moved siblings from their original parent
+            for sibling in siblings_to_move:
+                parent.remove(sibling)
+
+
 class CodeSampleCategoriesTocPatching(SphinxPostTransform):
     default_priority = 5  # needs to run *before* ReferencesResolver
 
@@ -569,6 +634,45 @@
         return [code_sample_listing_node]
 
 
+class BoardDirective(SphinxDirective):
+    has_content = False
+    required_arguments = 1
+    optional_arguments = 0
+
+    def run(self):
+        # board_name is passed as the directive argument
+        board_name = self.arguments[0]
+
+        boards = self.env.domaindata["zephyr"]["boards"]
+        vendors = self.env.domaindata["zephyr"]["vendors"]
+
+        if board_name not in boards:
+            logger.warning(
+                f"Board {board_name} does not seem to be a valid board name.",
+                location=(self.env.docname, self.lineno),
+            )
+            return []
+        elif "docname" in boards[board_name]:
+            logger.warning(
+                f"Board {board_name} is already documented in {boards[board_name]['docname']}.",
+                location=(self.env.docname, self.lineno),
+            )
+            return []
+        else:
+            board = boards[board_name]
+            # flag board in the domain data as now having a documentation page so that it can be
+            # cross-referenced etc.
+            board["docname"] = self.env.docname
+
+            board_node = BoardNode(id=board_name)
+            board_node["full_name"] = board["full_name"]
+            board_node["vendor"] = vendors.get(board["vendor"], board["vendor"])
+            board_node["archs"] = board["archs"]
+            board_node["socs"] = board["socs"]
+            board_node["image"] = board["image"]
+            return [board_node]
+
+
 class BoardCatalogDirective(SphinxDirective):
     has_content = False
     required_arguments = 0
@@ -602,6 +706,7 @@
     roles = {
         "code-sample": XRefRole(innernodeclass=nodes.inline, warn_dangling=True),
         "code-sample-category": XRefRole(innernodeclass=nodes.inline, warn_dangling=True),
+        "board": XRefRole(innernodeclass=nodes.inline, warn_dangling=True),
     }
 
     directives = {
@@ -609,11 +714,13 @@
         "code-sample-listing": CodeSampleListingDirective,
         "code-sample-category": CodeSampleCategoryDirective,
         "board-catalog": BoardCatalogDirective,
+        "board": BoardDirective,
     }
 
     object_types: Dict[str, ObjType] = {
         "code-sample": ObjType("code sample", "code-sample"),
         "code-sample-category": ObjType("code sample category", "code-sample-category"),
+        "board": ObjType("board", "board"),
     }
 
     initial_data: Dict[str, Any] = {
@@ -647,6 +754,12 @@
         self.data["code-samples"].update(otherdata["code-samples"])
         self.data["code-samples-categories"].update(otherdata["code-samples-categories"])
 
+        # self.data["boards"] contains all the boards right from builder-inited time, but it still # potentially needs merging since a board's docname property is set by BoardDirective to
+        # indicate the board is documented in a specific document.
+        for board_name, board in otherdata["boards"].items():
+            if "docname" in board:
+                self.data["boards"][board_name]["docname"] = board["docname"]
+
         # merge category trees by adding all the categories found in the "other" tree that to
         # self tree
         other_tree = otherdata["code-samples-categories-tree"]
@@ -689,6 +802,18 @@
                 1,
             )
 
+        for _, board in self.data["boards"].items():
+            # only boards that do have a documentation page are to be considered as valid objects
+            if "docname" in board:
+                yield (
+                    board["name"],
+                    board["full_name"],
+                    "board",
+                    board["docname"],
+                    board["name"],
+                    1,
+                )
+
     # used by Sphinx Immaterial theme
     def get_object_synopses(self) -> Iterator[Tuple[Tuple[str, str], str]]:
         for _, code_sample in self.data["code-samples"].items():
@@ -702,18 +827,20 @@
             elem = self.data["code-samples"].get(target)
         elif type == "code-sample-category":
             elem = self.data["code-samples-categories"].get(target)
+        elif type == "board":
+            elem = self.data["boards"].get(target)
         else:
             return
 
         if elem:
             if not node.get("refexplicit"):
-                contnode = [nodes.Text(elem["name"])]
+                contnode = [nodes.Text(elem["name"] if type != "board" else elem["full_name"])]
 
             return make_refnode(
                 builder,
                 fromdocname,
                 elem["docname"],
-                elem["id"],
+                elem["id"] if type != "board" else elem["name"],
                 contnode,
                 elem["description"].astext() if type == "code-sample" else None,
             )
@@ -821,6 +948,7 @@
 
     app.add_transform(ConvertCodeSampleNode)
     app.add_transform(ConvertCodeSampleCategoryNode)
+    app.add_transform(ConvertBoardNode)
 
     app.add_post_transform(ProcessCodeSampleListingNode)
     app.add_post_transform(CodeSampleCategoriesTocPatching)
diff --git a/doc/_static/css/custom.css b/doc/_static/css/custom.css
index eccda8f..3713d09 100644
--- a/doc/_static/css/custom.css
+++ b/doc/_static/css/custom.css
@@ -1110,4 +1110,57 @@
 
 li>a.code-sample-link.reference.internal.current {
     text-decoration: underline;
-}
\ No newline at end of file
+}
+
+/* Board overview "card" on board documentation pages */
+.sidebar.board-overview {
+    border-radius: 12px;
+    padding: 0px;
+    background: var(--admonition-note-background-color);
+    color: var(--admonition-note-title-color);
+    border-color: var(--admonition-note-title-background-color);
+}
+
+@media screen and (max-width: 480px) {
+    .sidebar.board-overview {
+        float: none;
+        margin-left: 0;
+    }
+}
+
+.sidebar.board-overview .sidebar-title {
+    font-family: var(--header-font-family);
+    background: var(--admonition-note-title-background-color);
+    color: var(--admonition-note-title-color);
+    border-radius: 12px 12px 0px 0px;
+    margin: 0px;
+    text-align: center;
+}
+
+.sidebar.board-overview * {
+    color: var(--admonition-note-color);
+}
+
+.sidebar.board-overview figure {
+    padding: 1rem;
+    margin-bottom: -1rem;
+}
+
+.sidebar.board-overview figure img {
+    height: auto !important;
+}
+
+.sidebar.board-overview figure figcaption p {
+    margin-bottom: 0px;
+}
+
+.sidebar.board-overview dl.field-list {
+    align-items: center;
+    margin-top: 12px !important;
+    margin-bottom: 12px !important;
+    grid-template-columns: auto 1fr !important;
+}
+
+.sidebar.board-overview dl.field-list > dt {
+    background: transparent !important;
+}
diff --git a/doc/contribute/documentation/guidelines.rst b/doc/contribute/documentation/guidelines.rst
index 6012d32..3c5471b 100644
--- a/doc/contribute/documentation/guidelines.rst
+++ b/doc/contribute/documentation/guidelines.rst
@@ -1184,6 +1184,23 @@
 Boards
 ======
 
+.. rst:directive:: .. zephyr:board:: name
+
+   This directive is used at the beginning of a document to indicate it is the main documentation
+   page for a board whose name is given as the directive argument.
+
+   For example::
+
+      .. zephyr:board:: wio_terminal
+
+   The metadata for the board is read from various config files and used to automatically populate
+   some sections of the board documentation. A board documentation page that uses this directive
+   can be linked to using the :rst:role:`zephyr:board` role.
+
+.. rst:role:: zephyr:board
+
+   This role is used to reference a board documented using :rst:dir:`zephyr:board`.
+
 .. rst:directive:: .. zephyr:board-catalog::
 
    This directive is used to generate a catalog of Zephyr-supported boards that can be used to
diff --git a/doc/templates/board.tmpl b/doc/templates/board.tmpl
index 3650699..9620109 100644
--- a/doc/templates/board.tmpl
+++ b/doc/templates/board.tmpl
@@ -1,20 +1,16 @@
-.. _boardname_linkname:
+.. zephyr:board:: board_name
 
-[Board Name]
-#############
+.. To ensure the board documentation page displays correctly, it is highly
+   recommended to include a picture alongside the documentation page.
+
+   The picture should be named after the board (e.g., "board_name.webp")
+   and preferably be in webp format. Alternatively, png or jpg formats
+   are also accepted.
 
 Overview
 ********
 [A short description about the board, its main features and availability]
 
-
-.. figure:: board_name.png
-   :width: 800px
-   :align: center
-   :alt: Board Name
-
-   Board Name (Credit: <owner>)
-
 Hardware
 ********
 [General Hardware information]