diff --git a/conf.py b/conf.py
index 5a019a98b..3c28bed4c 100644
--- a/conf.py
+++ b/conf.py
@@ -165,6 +165,9 @@ extensions = [
# Content tabs
'sphinx_tabs.tabs',
+ # Cards
+ 'cards',
+
# Spoilers
'spoilers',
diff --git a/content/contributing/documentation/rst_cheat_sheet.rst b/content/contributing/documentation/rst_cheat_sheet.rst
index 63f6d0fee..7239d0b3d 100644
--- a/content/contributing/documentation/rst_cheat_sheet.rst
+++ b/content/contributing/documentation/rst_cheat_sheet.rst
@@ -1003,6 +1003,54 @@ set, the label is used instead of the language for grouping tabs.
console.log("Hello World");
+.. _contributing/cards:
+
+Cards
+=====
+
+.. list-table::
+ :class: o-showcase-table
+
+ * - .. cards::
+
+ .. card:: Documentation
+ :target: ../documentation
+ :tag: Step-by-step guide
+ :large:
+
+ Use this guide to acquire the tools and knowledge you need to write documentation.
+
+ .. card:: Content guidelines
+ :target: content_guidelines
+
+ List of guidelines and trips and tricks to make your content shine at its brightest!
+
+ .. card:: RST guidelines
+ :target: rst_guidelines
+
+ List of technical guidelines to observe when writing with reStructuredText.
+
+ * - .. code-block:: text
+
+ .. cards::
+
+ .. card:: Documentation
+ :target: ../documentation
+ :tag: Step-by-step guide
+ :large:
+
+ Use this guide to acquire the tools and knowledge you need to write documentation.
+
+ .. card:: Content guidelines
+ :target: content_guidelines
+
+ List of guidelines and trips and tricks to make your content shine at its brightest!
+
+ .. card:: RST guidelines
+ :target: rst_guidelines
+
+ List of technical guidelines to observe when writing with reStructuredText.
+
.. _contributing/document-metadata:
Document metadata
diff --git a/content/developer/howtos.rst b/content/developer/howtos.rst
index e3415ea7e..8a2d04a08 100644
--- a/content/developer/howtos.rst
+++ b/content/developer/howtos.rst
@@ -16,95 +16,40 @@ How-to guides
howtos/provide_iap_services
howtos/connect_device
-.. raw:: html
+.. cards::
-
+ .. card:: Translating modules
+ :target: howtos/translations
+
+ Learn how to provide translation abilities to your module.
+
+ .. card:: Provide IAP services
+ :target: howtos/provide_iap_services
+
+ Learn how to provide ongoing services with Odoo's In-App Purchase (IAP).
+
+ .. card:: Connect with a device
+ :target: howtos/connect_device
+
+ Learn how to enable a module to detect and communicate with an IoT device.
diff --git a/content/developer/tutorials.rst b/content/developer/tutorials.rst
index 2e0999ad1..1a2d3088d 100644
--- a/content/developer/tutorials.rst
+++ b/content/developer/tutorials.rst
@@ -17,124 +17,58 @@ Tutorials
tutorials/pdf_reports
tutorials/dashboards
-.. raw:: html
+.. cards::
-
-
-
-
-
-
-
Getting started
-
- Develop your own module with the Odoo framework. This step-by-step tutorial
- is crafted for newcomers and any other individual curious about Odoo
- development.
-
-
-
-
-
+ .. card:: Getting started
+ :target: tutorials/getting_started
+ :tag: Beginner
+ :large:
-
-
-
-
Discover the JavaScript Framework
-
- Learn everything you need to know about the JavaScript framework of Odoo.
- This tutorial will teach you how to build custom components and views, give
- life to your application, and even re-introduce the kitten mode.
-
-
-
-
-
+ Develop your own module with the Odoo framework. This step-by-step tutorial is crafted for
+ newcomers and any other individual curious about Odoo development.
-
-
-
-
Define module data
-
- Define master and demo data for an Odoo module, leveraging the strengths of
- the CSV and XML file formats to accommodate specific data requirements.
-
-
-
-
-
+ .. card:: Discover the JavaScript Framework
+ :target: tutorials/discover_js_framework
+ :tag: Beginner
+ :large:
-
-
-
-
Restrict access to data
-
- Implement security measures to restrict access to sensitive data with the
- help of groups, access rights, and record rules.
-
-
-
-
-
+ Learn everything you need to know about the JavaScript framework of Odoo. This tutorial will
+ teach you how to build custom components and views, give life to your application, and even
+ re-introduce the kitten mode.
-
-
-
-
Safeguard your code with unit tests
-
- Write effective unit tests in Python to ensure the resilience of your code
- and safeguard it against unexpected behaviors and regressions.
-
-
-
-
-
+ .. card:: Define module data
+ :target: tutorials/define_module_data
+ :tag: Beginner
-
-
-
-
Reuse code with mixins
-
- Create mixins to code features once and reuse them in multiple models.
-
-
-
-
-
+ Define master and demo data for an Odoo module, leveraging the strengths of the CSV and XML
+ file formats to accommodate specific data requirements.
-
-
-
-
Build PDF reports
-
- Use QWeb, Odoo's powerful templating engine, to create custom PDF reports for
- your documents.
-
-
-
-
-
+ .. card:: Restrict access to data
+ :target: tutorials/restrict_data_access
+ :tag: Beginner
-
-
-
-
Visualize data in dashboards
-
- Create data visualization dashboards using the enterprise edition "Dashboard"
- view and so-called "SQL views".
-
-
-
-
-
+ Implement security measures to restrict access to sensitive data with the help of groups,
+ access rights, and record rules.
-
+ .. card:: Safeguard your code with unit tests
+ :target: tutorials/unit_tests
+ :tag: Beginner
+
+ Write effective unit tests in Python to ensure the resilience of your code and safeguard it
+ against unexpected behaviors and regressions.
+
+ .. card:: Reuse code with mixins
+ :target: tutorials/mixins
+
+ Create mixins to code features once and reuse them in multiple models.
+
+ .. card:: Build PDF reports
+ :target: tutorials/pdf_reports
+
+ Use QWeb, Odoo's powerful templating engine, to create custom PDF reports for your documents.
+
+ .. card:: Visualize data in dashboards
+ :target: tutorials/dashboards
+
+ Create data visualization dashboards using the enterprise edition "Dashboard" view and
+ so-called "SQL views".
diff --git a/extensions/cards/__init__.py b/extensions/cards/__init__.py
new file mode 100644
index 000000000..a06f37d8f
--- /dev/null
+++ b/extensions/cards/__init__.py
@@ -0,0 +1,125 @@
+from pathlib import Path
+
+from docutils import nodes
+from docutils.parsers.rst import directives
+from sphinx.util.docutils import SphinxDirective
+
+
+class Cards(SphinxDirective):
+ """ Implement a `cards` directive as a Bootstrap `row`. """
+ has_content = True
+
+ def run(self):
+ """ Process the content of the directive.
+
+ We use custom node classes to represent HTML elements (e.g., 'div') rather than the
+ corresponding sphinx.nodes.* class (e.g., sphinx.nodes.container) to prevent automatically
+ setting the name of the node as class (e.g., "container") on the element.
+ """
+ self.assert_has_content()
+
+ div_row = Div(classes=[
+ 'row', 'row-cols-1', 'row-cols-md-2', 'row-cols-xl-3', 'row-cols-xxl-4', 'g-4', 'mb-4'
+ ])
+ self.state.nested_parse(self.content, self.content_offset, div_row)
+ return [div_row]
+
+
+class Card(SphinxDirective):
+ """ Implement a `card` directive with Bootstrap's card component. """
+ required_arguments = 1
+ final_argument_whitespace = True
+ option_spec = {
+ 'target': directives.unchanged_required,
+ 'tag': directives.unchanged,
+ 'large': directives.flag,
+ }
+ has_content = True
+
+ def run(self):
+ """ Process the content of the directive.
+
+ We use custom node classes to represent HTML elements (e.g., 'div') rather than the
+ corresponding sphinx.nodes.* class (e.g., sphinx.nodes.container) to prevent automatically
+ setting the name of the node as class (e.g., "container") on the element.
+ """
+ self.assert_has_content()
+
+ current_document = f'{self.env.docname}.rst'
+ target_document = f'{self.options["target"]}.rst'
+ if target_document.startswith('/'):
+ raise self.warning(f"card directive's target starts with a '/'")
+ target_file = Path(self.env.srcdir) / Path(current_document).parent / target_document
+ if not target_file.exists():
+ raise self.warning(f"card directive targets nonexisting document '{target_document}'")
+
+ a_col_href = target_document.replace('.rst', '.html')
+ a_col_classes = ['o_toctree_card']
+ if 'large' in self.options:
+ a_col_classes += ['col-md-12', 'col-xl-8', 'col-xxl-6']
+ else:
+ a_col_classes += ['col']
+ a_col = A(href=a_col_href, classes=a_col_classes)
+
+ div_card = Div(classes=['card', 'h-100'])
+ a_col += div_card
+
+ div_card_body = Div(classes=['card-body', 'pb-0'])
+ div_card += div_card_body
+
+ h4_title = H4(classes=['card-title', 'text-primary', 'mb-1'])
+ h4_title += nodes.Text(self.arguments[0])
+ div_card_body += h4_title
+
+ p_card_text = nodes.paragraph(classes=['card-text', 'text-dark', 'fw-normal'])
+ p_card_text += nodes.Text('\n'.join(self.content))
+ div_card_body += p_card_text
+
+ div_card_footer = Div(classes=['card-footer', 'border-0'])
+ div_card += div_card_footer
+
+ if 'tag' in self.options:
+ span_badge = Span(classes=['badge', 'rounded-pill', 'bg-dark', 'mt-auto', 'mb-2'])
+ div_card_footer += span_badge
+ span_badge += nodes.Text(self.options['tag'])
+
+ return [a_col]
+
+
+class Div(nodes.General, nodes.Element):
+ custom_tag_name = 'div'
+
+
+class A(nodes.General, nodes.Element):
+ custom_tag_name = 'a'
+
+
+class Span(nodes.General, nodes.Element):
+ custom_tag_name = 'span'
+
+
+class H4(nodes.General, nodes.Element):
+ custom_tag_name = 'h4'
+
+
+def visit_node(translator, node):
+ custom_attr = {k: v for k, v in node.attributes.items() if k not in node.known_attributes}
+ translator.body.append(translator.starttag(node, node.custom_tag_name, **custom_attr).rstrip())
+
+
+def depart_node(translator, node):
+ translator.body.append(f'{node.custom_tag_name}>')
+
+
+def setup(app):
+ app.add_directive('cards', Cards)
+ app.add_directive('card', Card)
+ app.add_node(Div, html=(visit_node, depart_node))
+ app.add_node(A, html=(visit_node, depart_node))
+ app.add_node(Span, html=(visit_node, depart_node))
+ app.add_node(H4, html=(visit_node, depart_node))
+
+ return {
+ 'parallel_read_safe': True,
+ 'parallel_write_safe': True,
+ }