doc: extensions: samples: Introduce code sample categories
This commit adds support for categorizing code samples in the
documentation.
It introduces two new directives:
- `zephyr:code-sample-category::` to create a category and associated
brief description, that implicitly acts as a toctree too.
- `zephyr:code-sample-listing::` to allow dumping a list of samples
corresponding to a category anywhere in the documentation.
Fixes #62453.
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 e4c1ade..51eda4a 100644
--- a/doc/_extensions/zephyr/domain/__init__.py
+++ b/doc/_extensions/zephyr/domain/__init__.py
@@ -12,31 +12,48 @@
----------
- ``zephyr:code-sample::`` - Defines a code sample.
+- ``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.
Roles
-----
- ``:zephyr:code-sample:`` - References a code sample.
+- ``:zephyr:code-sample-category:`` - References a code sample category.
"""
+
+from os import path
+from pathlib import Path
from typing import Any, Dict, Iterator, List, Tuple
from docutils import nodes
-from docutils.nodes import Node
from docutils.parsers.rst import Directive, directives
+from docutils.statemachine import StringList
+
from sphinx import addnodes
+from sphinx.application import Sphinx
from sphinx.domains import Domain, ObjType
+from sphinx.environment import BuildEnvironment
from sphinx.roles import XRefRole
from sphinx.transforms import SphinxTransform
from sphinx.transforms.post_transforms import SphinxPostTransform
from sphinx.util import logging
+from sphinx.util.docutils import SphinxDirective, switch_source_input
from sphinx.util.nodes import NodeMatcher, make_refnode
+from sphinx.util.parsing import nested_parse_to_nodes
+
from zephyr.doxybridge import DoxygenGroupDirective
from zephyr.gh_utils import gh_link_get_url
+
import json
-__version__ = "0.1.0"
+from anytree import Node, Resolver, ChildResolverError, PreOrderIter, search
+
+__version__ = "0.2.0"
+
+RESOURCES_DIR = Path(__file__).parent / "static"
logger = logging.getLogger(__name__)
@@ -49,6 +66,14 @@
pass
+class CodeSampleCategoryNode(nodes.Element):
+ pass
+
+
+class CodeSampleListingNode(nodes.Element):
+ pass
+
+
class ConvertCodeSampleNode(SphinxTransform):
default_priority = 100
@@ -137,6 +162,188 @@
node.document += json_ld
+class ConvertCodeSampleCategoryNode(SphinxTransform):
+ default_priority = 100
+
+ def apply(self):
+ matcher = NodeMatcher(CodeSampleCategoryNode)
+ for node in self.document.traverse(matcher):
+ self.convert_node(node)
+
+ def convert_node(self, node):
+ # move all the siblings of the category node underneath the section it contains
+ parent = node.parent
+ siblings_to_move = []
+ if parent is not None:
+ index = parent.index(node)
+ siblings_to_move = parent.children[index + 1 :]
+
+ node.children[0].extend(siblings_to_move)
+ for sibling in siblings_to_move:
+ parent.remove(sibling)
+
+ # note document as needing toc patching
+ self.document["needs_toc_patch"] = True
+
+ # finally, replace the category node with the section it contains
+ node.replace_self(node.children[0])
+
+
+class CodeSampleCategoriesTocPatching(SphinxPostTransform):
+ default_priority = 5 # needs to run *before* ReferencesResolver
+
+ def output_sample_categories_list_items(self, tree, container: nodes.Node):
+ list_item = nodes.list_item()
+ compact_paragraph = addnodes.compact_paragraph()
+ # find docname for tree.category["id"]
+ docname = self.env.domaindata["zephyr"]["code-samples-categories"][tree.category["id"]][
+ "docname"
+ ]
+ reference = nodes.reference(
+ "",
+ "",
+ internal=True,
+ refuri=docname,
+ anchorname="",
+ *[nodes.Text(tree.category["name"])],
+ classes=["category-link"],
+ )
+ compact_paragraph += reference
+ list_item += compact_paragraph
+
+ sorted_children = sorted(tree.children, key=lambda x: x.category["name"])
+
+ # add bullet list for children (if there are any, i.e. there are subcategories or at least
+ # one code sample in the category)
+ if sorted_children or any(
+ code_sample.get("category") == tree.category["id"]
+ for code_sample in self.env.domaindata["zephyr"]["code-samples"].values()
+ ):
+ bullet_list = nodes.bullet_list()
+ for child in sorted_children:
+ self.output_sample_categories_list_items(child, bullet_list)
+
+ for code_sample in sorted(
+ [
+ code_sample
+ for code_sample in self.env.domaindata["zephyr"]["code-samples"].values()
+ if code_sample.get("category") == tree.category["id"]
+ ],
+ key=lambda x: x["name"].casefold(),
+ ):
+ li = nodes.list_item()
+ sample_xref = nodes.reference(
+ "",
+ "",
+ internal=True,
+ refuri=code_sample["docname"],
+ anchorname="",
+ *[nodes.Text(code_sample["name"])],
+ classes=["code-sample-link"],
+ )
+ sample_xref["reftitle"] = code_sample["description"].astext()
+ compact_paragraph = addnodes.compact_paragraph()
+ compact_paragraph += sample_xref
+ li += compact_paragraph
+ bullet_list += li
+
+ list_item += bullet_list
+
+ container += list_item
+
+ def run(self, **kwargs: Any) -> None:
+ if not self.document.get("needs_toc_patch"):
+ return
+
+ code_samples_categories_tree = self.env.domaindata["zephyr"]["code-samples-categories-tree"]
+
+ category = search.find(
+ code_samples_categories_tree,
+ lambda node: hasattr(node, "category") and node.category["docname"] == self.env.docname,
+ )
+
+ bullet_list = nodes.bullet_list()
+ self.output_sample_categories_list_items(category, bullet_list)
+
+ self.env.tocs[self.env.docname] = bullet_list
+
+
+class ProcessCodeSampleListingNode(SphinxPostTransform):
+ default_priority = 5 # needs to run *before* ReferencesResolver
+
+ def output_sample_categories_sections(self, tree, container: nodes.Node, show_titles=False):
+ if show_titles:
+ section = nodes.section(ids=[tree.category["id"]])
+
+ link = make_refnode(
+ self.env.app.builder,
+ self.env.docname,
+ tree.category["docname"],
+ targetid=None,
+ child=nodes.Text(tree.category["name"]),
+ )
+ title = nodes.title("", "", link)
+ section += title
+ container += section
+ else:
+ section = container
+
+ # list samples from this category
+ list = create_code_sample_list(
+ [
+ code_sample
+ for code_sample in self.env.domaindata["zephyr"]["code-samples"].values()
+ if code_sample.get("category") == tree.category["id"]
+ ]
+ )
+ section += list
+
+ sorted_children = sorted(tree.children, key=lambda x: x.name)
+ for child in sorted_children:
+ self.output_sample_categories_sections(child, section, show_titles=True)
+
+ def run(self, **kwargs: Any) -> None:
+ matcher = NodeMatcher(CodeSampleListingNode)
+
+ for node in self.document.traverse(matcher):
+ code_samples_categories = self.env.domaindata["zephyr"]["code-samples-categories"]
+ code_samples_categories_tree = self.env.domaindata["zephyr"][
+ "code-samples-categories-tree"
+ ]
+
+ container = nodes.container()
+ container["classes"].append("code-sample-listing")
+
+ if self.env.app.builder.format == "html" and node["live-search"]:
+ search_input = nodes.raw(
+ "",
+ """
+ <div class="cs-search-bar">
+ <input type="text" class="cs-search-input" placeholder="Filter code samples..." onkeyup="filterSamples(this)">
+ <i class="fa fa-search"></i>
+ </div>
+ """,
+ format="html",
+ )
+ container += search_input
+
+ for category in node["categories"]:
+ if category not in code_samples_categories:
+ logger.error(
+ f"Category {category} not found in code samples categories",
+ location=(self.env.docname, node.line),
+ )
+ continue
+
+ category_node = search.find(
+ code_samples_categories_tree,
+ lambda node: hasattr(node, "category") and node.category["id"] == category,
+ )
+ self.output_sample_categories_sections(category_node, container)
+
+ node.replace_self(container)
+
+
def create_code_sample_list(code_samples):
"""
Creates a bullet list (`nodes.bullet_list`) of code samples from a list of code samples.
@@ -188,8 +395,8 @@
admonition = nodes.admonition()
admonition += nodes.title(text="Related code samples")
admonition["classes"].append("related-code-samples")
- admonition["classes"].append("dropdown") # used by sphinx-togglebutton extension
- admonition["classes"].append("toggle-shown") # show the content by default
+ admonition["classes"].append("dropdown") # used by sphinx-togglebutton extension
+ admonition["classes"].append("toggle-shown") # show the content by default
sample_list = create_code_sample_list(code_samples)
admonition += sample_list
@@ -251,23 +458,118 @@
return [code_sample_node]
+class CodeSampleCategoryDirective(SphinxDirective):
+ required_arguments = 1 # Category ID
+ optional_arguments = 0
+ option_spec = {
+ "name": directives.unchanged,
+ "show-listing": directives.flag,
+ "live-search": directives.flag,
+ "glob": directives.unchanged,
+ }
+ has_content = True # Category description
+ final_argument_whitespace = True
+
+ def run(self):
+ env = self.state.document.settings.env
+ id = self.arguments[0]
+ name = self.options.get("name", id)
+
+ category_node = CodeSampleCategoryNode()
+ category_node["id"] = id
+ category_node["name"] = name
+ category_node["docname"] = env.docname
+
+ description_node = self.parse_content_to_nodes()
+ category_node["description"] = description_node
+
+ code_sample_category = {
+ "docname": env.docname,
+ "id": id,
+ "name": name,
+ }
+
+ # Add the category to the domain
+ domain = env.get_domain("zephyr")
+ domain.add_code_sample_category(code_sample_category)
+
+ # Fake a toctree directive to ensure the code-sample-category directive implicitly acts as
+ # a toctree and correctly mounts whatever relevant documents under it in the global toc
+ lines = [
+ name,
+ "#" * len(name),
+ "",
+ ".. toctree::",
+ " :titlesonly:",
+ " :glob:",
+ " :hidden:",
+ " :maxdepth: 1",
+ "",
+ f" {self.options['glob']}" if "glob" in self.options else " */*",
+ "",
+ ]
+ stringlist = StringList(lines, source=env.docname)
+
+ with switch_source_input(self.state, stringlist):
+ parsed_section = nested_parse_to_nodes(self.state, stringlist)[0]
+
+ category_node += parsed_section
+
+ parsed_section += description_node
+
+ if "show-listing" in self.options:
+ listing_node = CodeSampleListingNode()
+ listing_node["categories"] = [id]
+ listing_node["live-search"] = "live-search" in self.options
+ parsed_section += listing_node
+
+ return [category_node]
+
+
+class CodeSampleListingDirective(SphinxDirective):
+ has_content = False
+ required_arguments = 0
+ optional_arguments = 0
+ option_spec = {
+ "categories": directives.unchanged_required,
+ "live-search": directives.flag,
+ }
+
+ def run(self):
+ code_sample_listing_node = CodeSampleListingNode()
+ code_sample_listing_node["categories"] = self.options.get("categories").split()
+ code_sample_listing_node["live-search"] = "live-search" in self.options
+
+ return [code_sample_listing_node]
+
+
class ZephyrDomain(Domain):
"""Zephyr domain"""
name = "zephyr"
- label = "Zephyr Project"
+ label = "Zephyr"
roles = {
"code-sample": XRefRole(innernodeclass=nodes.inline, warn_dangling=True),
+ "code-sample-category": XRefRole(innernodeclass=nodes.inline, warn_dangling=True),
}
- directives = {"code-sample": CodeSampleDirective}
+ directives = {
+ "code-sample": CodeSampleDirective,
+ "code-sample-listing": CodeSampleListingDirective,
+ "code-sample-category": CodeSampleCategoryDirective,
+ }
object_types: Dict[str, ObjType] = {
- "code-sample": ObjType("code-sample", "code-sample"),
+ "code-sample": ObjType("code sample", "code-sample"),
+ "code-sample-category": ObjType("code sample category", "code-sample-category"),
}
- initial_data: Dict[str, Any] = {"code-samples": {}}
+ initial_data: Dict[str, Any] = {
+ "code-samples": {}, # id -> code sample data
+ "code-samples-categories": {}, # id -> code sample category data
+ "code-samples-categories-tree": Node("samples"),
+ }
def clear_doc(self, docname: str) -> None:
self.data["code-samples"] = {
@@ -276,8 +578,30 @@
if sample_data["docname"] != docname
}
+ self.data["code-samples-categories"] = {
+ category_id: category_data
+ for category_id, category_data in self.data["code-samples-categories"].items()
+ if category_data["docname"] != docname
+ }
+
+ # TODO clean up the anytree as well
+
def merge_domaindata(self, docnames: List[str], otherdata: Dict) -> None:
self.data["code-samples"].update(otherdata["code-samples"])
+ self.data["code-samples-categories"].update(otherdata["code-samples-categories"])
+
+ # merge category trees by adding all the categories found in the "other" tree that to
+ # self tree
+ other_tree = otherdata["code-samples-categories-tree"]
+ categories = [n for n in PreOrderIter(other_tree) if hasattr(n, "category")]
+ for category in categories:
+ category_path = f"/{'/'.join(n.name for n in category.path)}"
+ self.add_category_to_tree(
+ category_path,
+ category.category["id"],
+ category.category["name"],
+ category.category["docname"],
+ )
def get_objects(self):
for _, code_sample in self.data["code-samples"].items():
@@ -290,6 +614,16 @@
1,
)
+ for _, code_sample_category in self.data["code-samples-categories"].items():
+ yield (
+ code_sample_category["id"],
+ code_sample_category["name"],
+ "code-sample-category",
+ code_sample_category["docname"],
+ code_sample_category["id"],
+ 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():
@@ -300,23 +634,71 @@
def resolve_xref(self, env, fromdocname, builder, type, target, node, contnode):
if type == "code-sample":
- code_sample_info = self.data["code-samples"].get(target)
- if code_sample_info:
- if not node.get("refexplicit"):
- contnode = [nodes.Text(code_sample_info["name"])]
+ elem = self.data["code-samples"].get(target)
+ elif type == "code-sample-category":
+ elem = self.data["code-samples-categories"].get(target)
+ else:
+ return
- return make_refnode(
- builder,
- fromdocname,
- code_sample_info["docname"],
- code_sample_info["id"],
- contnode,
- code_sample_info["description"].astext(),
- )
+ if elem:
+ if not node.get("refexplicit"):
+ contnode = [nodes.Text(elem["name"])]
+
+ return make_refnode(
+ builder,
+ fromdocname,
+ elem["docname"],
+ elem["id"],
+ contnode,
+ elem["description"].astext() if type == "code-sample" else None,
+ )
def add_code_sample(self, code_sample):
self.data["code-samples"][code_sample["id"]] = code_sample
+ def add_code_sample_category(self, code_sample_category):
+ self.data["code-samples-categories"][code_sample_category["id"]] = code_sample_category
+ self.add_category_to_tree(
+ path.dirname(code_sample_category["docname"]),
+ code_sample_category["id"],
+ code_sample_category["name"],
+ code_sample_category["docname"],
+ )
+
+ def add_category_to_tree(
+ self, category_path: str, category_id: str, category_name: str, docname: str
+ ) -> Node:
+ resolver = Resolver("name")
+ tree = self.data["code-samples-categories-tree"]
+
+ if not category_path.startswith("/"):
+ category_path = "/" + category_path
+
+ # node either already exists (and we update it to make it a category node), or we need to
+ # create it
+ try:
+ node = resolver.get(tree, category_path)
+ if hasattr(node, "category") and node.category["id"] != category_id:
+ raise ValueError(
+ f"Can't add code sample category {category_id} as category "
+ f"{node.category['id']} is already defined in {node.category['docname']}. "
+ "You may only have one category per path."
+ )
+ except ChildResolverError as e:
+ path_of_last_existing_node = f"/{'/'.join(n.name for n in e.node.path)}"
+ common_path = path.commonpath([path_of_last_existing_node, category_path])
+ remaining_path = path.relpath(category_path, common_path)
+
+ # Add missing nodes under the last existing node
+ for node_name in remaining_path.split("/"):
+ e.node = Node(node_name, parent=e.node)
+
+ node = e.node
+
+ node.category = {"id": category_id, "name": category_name, "docname": docname}
+
+ return tree
+
class CustomDoxygenGroupDirective(DoxygenGroupDirective):
"""Monkey patch for Breathe's DoxygenGroupDirective."""
@@ -330,14 +712,52 @@
return nodes
+def compute_sample_categories_hierarchy(app: Sphinx, env: BuildEnvironment) -> None:
+ domain = env.get_domain("zephyr")
+ code_samples = domain.data["code-samples"]
+
+ category_tree = env.domaindata["zephyr"]["code-samples-categories-tree"]
+ resolver = Resolver("name")
+ for code_sample in code_samples.values():
+ try:
+ # Try to get the node at the specified path
+ node = resolver.get(category_tree, "/" + path.dirname(code_sample["docname"]))
+ except ChildResolverError as e:
+ # starting with e.node and up, find the first node that has a category
+ node = e.node
+ while not hasattr(node, "category"):
+ node = node.parent
+ code_sample["category"] = node.category["id"]
+
+
+def install_codesample_livesearch(
+ app: Sphinx, pagename: str, templatename: str, context: dict[str, Any], event_arg: Any
+) -> None:
+ # TODO only add the CSS/JS if the page contains a code sample listing
+ # As these resources are really small, it's not a big deal to include them on every page for now
+ app.add_css_file("css/codesample-livesearch.css")
+ app.add_js_file("js/codesample-livesearch.js")
+
+
def setup(app):
app.add_config_value("zephyr_breathe_insert_related_samples", False, "env")
app.add_domain(ZephyrDomain)
app.add_transform(ConvertCodeSampleNode)
+ app.add_transform(ConvertCodeSampleCategoryNode)
+
+ app.add_post_transform(ProcessCodeSampleListingNode)
+ app.add_post_transform(CodeSampleCategoriesTocPatching)
app.add_post_transform(ProcessRelatedCodeSamplesNode)
+ app.connect(
+ "builder-inited",
+ (lambda app: app.config.html_static_path.append(RESOURCES_DIR.as_posix())),
+ )
+ app.connect("html-page-context", install_codesample_livesearch)
+ app.connect("env-updated", compute_sample_categories_hierarchy)
+
# monkey-patching of the DoxygenGroupDirective
app.add_directive("doxygengroup", CustomDoxygenGroupDirective, override=True)
diff --git a/doc/_extensions/zephyr/domain/static/css/codesample-livesearch.css b/doc/_extensions/zephyr/domain/static/css/codesample-livesearch.css
new file mode 100644
index 0000000..36c7818
--- /dev/null
+++ b/doc/_extensions/zephyr/domain/static/css/codesample-livesearch.css
@@ -0,0 +1,42 @@
+/**
+ * Copyright (c) 2024, The Linux Foundation.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+.cs-search-bar {
+ position: relative;
+ display: inline-block;
+ width: 100%;
+ margin-bottom: 2rem;
+ margin-top: 1rem;
+}
+
+.cs-search-bar input {
+ background-color: var(--input-background-color);
+ color: var(--input-text-color);
+ width: 100%;
+ padding: 10px 40px 10px 20px;
+ border: 1px solid #ccc;
+ border-radius: 25px;
+ font-size: 16px;
+ outline: none;
+ box-shadow: none;
+}
+
+.cs-search-bar i {
+ position: absolute;
+ top: 50%;
+ right: 15px;
+ transform: translateY(-50%);
+ color: #ccc;
+ font-size: 18px;
+}
+
+.code-sample-listing mark {
+ background: inherit;
+ font-style: inherit;
+ font-weight: inherit;
+ color: inherit;
+ border-radius: 4px;
+ outline: 4px solid rgba(255, 255, 0, 0.3);
+ outline-offset: 2px;
+}
diff --git a/doc/_extensions/zephyr/domain/static/js/codesample-livesearch.js b/doc/_extensions/zephyr/domain/static/js/codesample-livesearch.js
new file mode 100644
index 0000000..4a5268c
--- /dev/null
+++ b/doc/_extensions/zephyr/domain/static/js/codesample-livesearch.js
@@ -0,0 +1,115 @@
+/**
+ * Copyright (c) 2024, The Linux Foundation.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+function filterSamples(input) {
+ const searchQuery = input.value.toLowerCase();
+ const container = input.closest(".code-sample-listing");
+
+ function removeHighlights(element) {
+ const marks = element.querySelectorAll("mark");
+ marks.forEach((mark) => {
+ const parent = mark.parentNode;
+ while (mark.firstChild) {
+ parent.insertBefore(mark.firstChild, mark);
+ }
+ parent.removeChild(mark);
+ parent.normalize(); // Merge adjacent text nodes
+ });
+ }
+
+ function highlightMatches(node, query) {
+ if (node.nodeType === Node.TEXT_NODE) {
+ const text = node.textContent;
+ const index = text.toLowerCase().indexOf(query);
+ if (index !== -1 && query.length > 0) {
+ const highlightedFragment = document.createDocumentFragment();
+
+ const before = document.createTextNode(text.substring(0, index));
+ const highlight = document.createElement("mark");
+ highlight.textContent = text.substring(index, index + query.length);
+ const after = document.createTextNode(text.substring(index + query.length));
+
+ highlightedFragment.appendChild(before);
+ highlightedFragment.appendChild(highlight);
+ highlightedFragment.appendChild(after);
+
+ node.parentNode.replaceChild(highlightedFragment, node);
+ }
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
+ node.childNodes.forEach((child) => highlightMatches(child, query));
+ }
+ }
+
+ function processSection(section) {
+ let sectionVisible = false;
+ const lists = section.querySelectorAll(":scope > ul.code-sample-list");
+ const childSections = section.querySelectorAll(":scope > section");
+
+ // Process lists directly under this section
+ lists.forEach((list) => {
+ let listVisible = false;
+ const items = list.querySelectorAll("li");
+
+ items.forEach((item) => {
+ const nameElement = item.querySelector(".code-sample-name");
+ const descElement = item.querySelector(".code-sample-description");
+
+ removeHighlights(nameElement);
+ removeHighlights(descElement);
+
+ const sampleName = nameElement.textContent.toLowerCase();
+ const sampleDescription = descElement.textContent.toLowerCase();
+
+ if (
+ sampleName.includes(searchQuery) ||
+ sampleDescription.includes(searchQuery)
+ ) {
+ if (searchQuery) {
+ highlightMatches(nameElement, searchQuery);
+ highlightMatches(descElement, searchQuery);
+ }
+
+ item.style.display = "";
+ listVisible = true;
+ sectionVisible = true;
+ } else {
+ item.style.display = "none";
+ }
+ });
+
+ // Show or hide the list based on whether any items are visible
+ list.style.display = listVisible ? "" : "none";
+ });
+
+ // Recursively process child sections
+ childSections.forEach((childSection) => {
+ const childVisible = processSection(childSection);
+ if (childVisible) {
+ sectionVisible = true;
+ }
+ });
+
+ // Show or hide the section heading based on visibility
+ const heading = section.querySelector(
+ ":scope > h2, :scope > h3, :scope > h4, :scope > h5, :scope > h6"
+ );
+ if (sectionVisible) {
+ if (heading) heading.style.display = "";
+ section.style.display = "";
+ } else {
+ if (heading) heading.style.display = "none";
+ section.style.display = "none";
+ }
+
+ return sectionVisible;
+ }
+
+ // Start processing from the container
+ processSection(container);
+
+ // Ensure the input and its container are always visible
+ input.style.display = "";
+ container.style.display = "";
+}
diff --git a/doc/_static/css/custom.css b/doc/_static/css/custom.css
index f8cbab2..b302ba8 100644
--- a/doc/_static/css/custom.css
+++ b/doc/_static/css/custom.css
@@ -1053,3 +1053,11 @@
font-family: 'FontAwesome';
padding-right: 0.5em;
}
+
+li>a.code-sample-link.reference.internal {
+ font-weight: 100;
+}
+
+li>a.code-sample-link.reference.internal.current {
+ text-decoration: underline;
+}
\ No newline at end of file
diff --git a/doc/contribute/documentation/guidelines.rst b/doc/contribute/documentation/guidelines.rst
index a919443..dbf0f6b 100644
--- a/doc/contribute/documentation/guidelines.rst
+++ b/doc/contribute/documentation/guidelines.rst
@@ -1104,3 +1104,79 @@
Will render as:
Check out :zephyr:code-sample:`blinky code sample <blinky>` for more information.
+
+.. rst:directive:: .. zephyr:code-sample-category:: id
+
+ This directive is used to define a category for grouping code samples.
+
+ For example::
+
+ .. zephyr:code-sample-category:: gpio
+ :name: GPIO
+ :show-listing:
+
+ Samples related to the GPIO subsystem.
+
+ The contents of the directive is used as the description of the category. It can contain any
+ valid reStructuredText content.
+
+ .. rubric:: Options
+
+ .. rst:directive:option:: name
+ :type: text
+
+ Indicates the human-readable name of the category.
+
+ .. rst:directive:option:: show-listing
+ :type: flag
+
+ If set, a listing of code samples in the category will be shown. The listing is automatically
+ generated based on all code samples found in the subdirectories of the current document.
+
+ .. rst:directive:option:: glob
+ :type: text
+
+ A glob pattern to match the files to include in the listing. The default is `*/*` but it can
+ be overridden e.g. when samples may be found in directories not sitting directly under the
+ category directory.
+
+.. rst:role:: zephyr:code-sample-category
+
+ This role is used to reference a code sample category described using
+ :rst:dir:`zephyr:code-sample-category`.
+
+ For example::
+
+ Check out :zephyr:code-sample-category:`cloud` samples for more information.
+
+ Will render as:
+
+ Check out :zephyr:code-sample-category:`cloud` samples for more information.
+
+.. rst:directive:: .. zephyr:code-sample-listing::
+
+ This directive is used to show a listing of all code samples found in one or more categories.
+
+ For example::
+
+ .. zephyr:code-sample-listing::
+ :categories: cloud
+
+ Will render as:
+
+ .. zephyr:code-sample-listing::
+ :categories: cloud
+
+ .. rubric:: Options
+
+ .. rst:directive:option:: categories
+ :type: text
+
+ A space-separated list of category IDs for which to show the listing.
+
+ .. rst:directive:option:: live-search
+ :type: flag
+
+ A flag to include a search box right above the listing. The search box allows users to filter
+ the listing by code sample name/description, which can be useful for categories with a large
+ number of samples. This option is only available in the HTML builder.
diff --git a/doc/requirements.txt b/doc/requirements.txt
index aa77efa..b979eee 100644
--- a/doc/requirements.txt
+++ b/doc/requirements.txt
@@ -21,3 +21,6 @@
# Doxygen doxmlparser
doxmlparser
+
+# Used by the Zephyr domain to organize code samples
+anytree