about summary refs log tree commit diff
diff options
context:
space:
mode:
authorpennae <github@quasiparticle.net>2023-03-08 09:15:48 +0100
committerpennae <82953136+pennae@users.noreply.github.com>2023-05-03 19:58:21 +0200
commit407f6196a23f8b0b42c74c66f2717645aa277bbd (patch)
tree86719c58cfa4ac8e035b49e263238d302df13ba7
parent69259eec2334d066e3a6563ced278c283a618a2d (diff)
nixos-render-docs: add examples support
the nixos manual contains enough examples to support them as a proper
toc entity with specialized rendering, and if in the future the nixpkgs
wants to use nixos-render-docs we will definitely have to support them.
this also allows us to restore some examples that were lost in previous
translation steps because there were too few to add renderer support
back then.
-rw-r--r--nixos/doc/manual/development/freeform-modules.section.md2
-rw-r--r--nixos/doc/manual/development/option-declarations.section.md10
-rw-r--r--nixos/doc/manual/development/option-types.section.md18
-rw-r--r--nixos/doc/manual/development/settings-options.section.md4
-rw-r--r--nixos/doc/manual/development/writing-modules.chapter.md6
-rw-r--r--nixos/doc/manual/installation/installing.chapter.md8
-rw-r--r--pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/docbook.py12
-rw-r--r--pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/html.py12
-rw-r--r--pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/manual.py40
-rw-r--r--pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/manual_structure.py22
-rw-r--r--pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/md.py33
-rw-r--r--pkgs/tools/nix/nixos-render-docs/src/tests/test_plugins.py61
12 files changed, 185 insertions, 43 deletions
diff --git a/nixos/doc/manual/development/freeform-modules.section.md b/nixos/doc/manual/development/freeform-modules.section.md
index 514a06f97ee77..4f344dd804601 100644
--- a/nixos/doc/manual/development/freeform-modules.section.md
+++ b/nixos/doc/manual/development/freeform-modules.section.md
@@ -13,7 +13,7 @@ checking for entire option trees, it is only recommended for use in
 submodules.
 
 ::: {#ex-freeform-module .example}
-**Example: Freeform submodule**
+### Freeform submodule
 
 The following shows a submodule assigning a freeform type that allows
 arbitrary attributes with `str` values below `settings`, but also
diff --git a/nixos/doc/manual/development/option-declarations.section.md b/nixos/doc/manual/development/option-declarations.section.md
index f6fed3e16837f..3448b07722b85 100644
--- a/nixos/doc/manual/development/option-declarations.section.md
+++ b/nixos/doc/manual/development/option-declarations.section.md
@@ -77,6 +77,7 @@ The option's description is "Whether to enable \<name\>.".
 For example:
 
 ::: {#ex-options-declarations-util-mkEnableOption-magic .example}
+### `mkEnableOption` usage
 ```nix
 lib.mkEnableOption (lib.mdDoc "magic")
 # is like
@@ -126,6 +127,7 @@ During the transition to CommonMark documentation `mkPackageOption` creates an o
 Examples:
 
 ::: {#ex-options-declarations-util-mkPackageOption-hello .example}
+### Simple `mkPackageOption` usage
 ```nix
 lib.mkPackageOptionMD pkgs "hello" { }
 # is like
@@ -139,6 +141,7 @@ lib.mkOption {
 :::
 
 ::: {#ex-options-declarations-util-mkPackageOption-ghc .example}
+### `mkPackageOption` with explicit default and example
 ```nix
 lib.mkPackageOptionMD pkgs "GHC" {
   default = [ "ghc" ];
@@ -156,6 +159,7 @@ lib.mkOption {
 :::
 
 ::: {#ex-options-declarations-util-mkPackageOption-extraDescription .example}
+### `mkPackageOption` with additional description text
 ```nix
 mkPackageOption pkgs [ "python39Packages" "pytorch" ] {
   extraDescription = "This is an example and doesn't actually do anything.";
@@ -217,7 +221,7 @@ changing the main service module file and the type system automatically
 enforces that there can only be a single display manager enabled.
 
 ::: {#ex-option-declaration-eot-service .example}
-**Example: Extensible type placeholder in the service module**
+### Extensible type placeholder in the service module
 ```nix
 services.xserver.displayManager.enable = mkOption {
   description = "Display manager to use";
@@ -227,7 +231,7 @@ services.xserver.displayManager.enable = mkOption {
 :::
 
 ::: {#ex-option-declaration-eot-backend-gdm .example}
-**Example: Extending `services.xserver.displayManager.enable` in the `gdm` module**
+### Extending `services.xserver.displayManager.enable` in the `gdm` module
 ```nix
 services.xserver.displayManager.enable = mkOption {
   type = with types; nullOr (enum [ "gdm" ]);
@@ -236,7 +240,7 @@ services.xserver.displayManager.enable = mkOption {
 :::
 
 ::: {#ex-option-declaration-eot-backend-sddm .example}
-**Example: Extending `services.xserver.displayManager.enable` in the `sddm` module**
+### Extending `services.xserver.displayManager.enable` in the `sddm` module
 ```nix
 services.xserver.displayManager.enable = mkOption {
   type = with types; nullOr (enum [ "sddm" ]);
diff --git a/nixos/doc/manual/development/option-types.section.md b/nixos/doc/manual/development/option-types.section.md
index 51977c58333f9..9e2ecb8e35626 100644
--- a/nixos/doc/manual/development/option-types.section.md
+++ b/nixos/doc/manual/development/option-types.section.md
@@ -36,7 +36,7 @@ merging is handled.
     together. This type is recommended when the option type is unknown.
 
     ::: {#ex-types-anything .example}
-    **Example: `types.anything` Example**
+    ### `types.anything`
 
     Two definitions of this type like
 
@@ -356,7 +356,7 @@ you will still need to provide a default value (e.g. an empty attribute set)
 if you want to allow users to leave it undefined.
 
 ::: {#ex-submodule-direct .example}
-**Example: Directly defined submodule**
+### Directly defined submodule
 ```nix
 options.mod = mkOption {
   description = "submodule example";
@@ -375,7 +375,7 @@ options.mod = mkOption {
 :::
 
 ::: {#ex-submodule-reference .example}
-**Example: Submodule defined as a reference**
+### Submodule defined as a reference
 ```nix
 let
   modOptions = {
@@ -403,7 +403,7 @@ multiple definitions of the submodule option set
 ([Example: Definition of a list of submodules](#ex-submodule-listof-definition)).
 
 ::: {#ex-submodule-listof-declaration .example}
-**Example: Declaration of a list of submodules**
+### Declaration of a list of submodules
 ```nix
 options.mod = mkOption {
   description = "submodule example";
@@ -422,7 +422,7 @@ options.mod = mkOption {
 :::
 
 ::: {#ex-submodule-listof-definition .example}
-**Example: Definition of a list of submodules**
+### Definition of a list of submodules
 ```nix
 config.mod = [
   { foo = 1; bar = "one"; }
@@ -437,7 +437,7 @@ multiple named definitions of the submodule option set
 ([Example: Definition of attribute sets of submodules](#ex-submodule-attrsof-definition)).
 
 ::: {#ex-submodule-attrsof-declaration .example}
-**Example: Declaration of attribute sets of submodules**
+### Declaration of attribute sets of submodules
 ```nix
 options.mod = mkOption {
   description = "submodule example";
@@ -456,7 +456,7 @@ options.mod = mkOption {
 :::
 
 ::: {#ex-submodule-attrsof-definition .example}
-**Example: Definition of attribute sets of submodules**
+### Definition of attribute sets of submodules
 ```nix
 config.mod.one = { foo = 1; bar = "one"; };
 config.mod.two = { foo = 2; bar = "two"; };
@@ -476,7 +476,7 @@ Types are mainly characterized by their `check` and `merge` functions.
     ([Example: Overriding a type check](#ex-extending-type-check-2)).
 
     ::: {#ex-extending-type-check-1 .example}
-    **Example: Adding a type check**
+    ### Adding a type check
 
     ```nix
     byte = mkOption {
@@ -487,7 +487,7 @@ Types are mainly characterized by their `check` and `merge` functions.
     :::
 
     ::: {#ex-extending-type-check-2 .example}
-    **Example: Overriding a type check**
+    ### Overriding a type check
 
     ```nix
     nixThings = mkOption {
diff --git a/nixos/doc/manual/development/settings-options.section.md b/nixos/doc/manual/development/settings-options.section.md
index 476ba4b03f9d5..5060dd98f58fc 100644
--- a/nixos/doc/manual/development/settings-options.section.md
+++ b/nixos/doc/manual/development/settings-options.section.md
@@ -143,7 +143,7 @@ These functions all return an attribute set with these values:
     :::
 
 ::: {#ex-settings-nix-representable .example}
-**Example: Module with conventional `settings` option**
+### Module with conventional `settings` option
 
 The following shows a module for an example program that uses a JSON
 configuration file. It demonstrates how above values can be used, along
@@ -218,7 +218,7 @@ the port, which will enforce it to be a valid integer and make it show
 up in the manual.
 
 ::: {#ex-settings-typed-attrs .example}
-**Example: Declaring a type-checked `settings` attribute**
+### Declaring a type-checked `settings` attribute
 ```nix
 settings = lib.mkOption {
   type = lib.types.submodule {
diff --git a/nixos/doc/manual/development/writing-modules.chapter.md b/nixos/doc/manual/development/writing-modules.chapter.md
index ae657458d7680..e07b899e6df7b 100644
--- a/nixos/doc/manual/development/writing-modules.chapter.md
+++ b/nixos/doc/manual/development/writing-modules.chapter.md
@@ -37,7 +37,7 @@ options, but does not declare any. The structure of full NixOS modules
 is shown in [Example: Structure of NixOS Modules](#ex-module-syntax).
 
 ::: {#ex-module-syntax .example}
-**Example: Structure of NixOS Modules**
+### Structure of NixOS Modules
 ```nix
 { config, pkgs, ... }:
 
@@ -100,7 +100,7 @@ Exec directives](#exec-escaping-example) for an example. When using these
 functions system environment substitution should *not* be disabled explicitly.
 
 ::: {#locate-example .example}
-**Example: NixOS Module for the "locate" Service**
+### NixOS Module for the "locate" Service
 ```nix
 { config, lib, pkgs, ... }:
 
@@ -161,7 +161,7 @@ in {
 :::
 
 ::: {#exec-escaping-example .example}
-**Example: Escaping in Exec directives**
+### Escaping in Exec directives
 ```nix
 { config, lib, pkgs, utils, ... }:
 
diff --git a/nixos/doc/manual/installation/installing.chapter.md b/nixos/doc/manual/installation/installing.chapter.md
index 7d67894e59f92..53cf9ed14c33a 100644
--- a/nixos/doc/manual/installation/installing.chapter.md
+++ b/nixos/doc/manual/installation/installing.chapter.md
@@ -538,7 +538,7 @@ drive (here `/dev/sda`). [Example: NixOS Configuration](#ex-config) shows a
 corresponding configuration Nix expression.
 
 ::: {#ex-partition-scheme-MBR .example}
-**Example: Example partition schemes for NixOS on `/dev/sda` (MBR)**
+### Example partition schemes for NixOS on `/dev/sda` (MBR)
 ```ShellSession
 # parted /dev/sda -- mklabel msdos
 # parted /dev/sda -- mkpart primary 1MB -8GB
@@ -547,7 +547,7 @@ corresponding configuration Nix expression.
 :::
 
 ::: {#ex-partition-scheme-UEFI .example}
-**Example: Example partition schemes for NixOS on `/dev/sda` (UEFI)**
+### Example partition schemes for NixOS on `/dev/sda` (UEFI)
 ```ShellSession
 # parted /dev/sda -- mklabel gpt
 # parted /dev/sda -- mkpart primary 512MB -8GB
@@ -558,7 +558,7 @@ corresponding configuration Nix expression.
 :::
 
 ::: {#ex-install-sequence .example}
-**Example: Commands for Installing NixOS on `/dev/sda`**
+### Commands for Installing NixOS on `/dev/sda`
 
 With a partitioned disk.
 
@@ -578,7 +578,7 @@ With a partitioned disk.
 :::
 
 ::: {#ex-config .example}
-**Example: NixOS Configuration**
+### Example: NixOS Configuration
 ```ShellSession
 { config, pkgs, ... }: {
   imports = [
diff --git a/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/docbook.py b/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/docbook.py
index 4c90606ff4558..1c1e95a29ef2c 100644
--- a/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/docbook.py
+++ b/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/docbook.py
@@ -218,11 +218,15 @@ class DocBookRenderer(Renderer):
             result += f"<partintro{maybe_id}>"
         return result
     def example_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
-        if id := token.attrs.get('id'):
-            return f"<anchor xml:id={quoteattr(cast(str, id))} />"
-        return ""
+        if id := cast(str, token.attrs.get('id', '')):
+            id = f'xml:id={quoteattr(id)}' if id else ''
+        return f'<example {id}>'
     def example_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
-        return ""
+        return "</example>"
+    def example_title_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        return "<title>"
+    def example_title_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        return "</title>"
 
     def _close_headings(self, level: Optional[int]) -> str:
         # we rely on markdown-it producing h{1..6} tags in token.tag for this to work
diff --git a/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/html.py b/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/html.py
index 39d2da6adf8c0..ed9cd54855460 100644
--- a/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/html.py
+++ b/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/html.py
@@ -214,11 +214,15 @@ class HTMLRenderer(Renderer):
         self._ordered_list_nesting -= 1;
         return "</ol></div>"
     def example_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
-        if id := token.attrs.get('id'):
-            return f'<a id="{escape(cast(str, id), True)}" />'
-        return ""
+        if id := cast(str, token.attrs.get('id', '')):
+            id = f'id="{escape(id, True)}"' if id else ''
+        return f'<div class="example"><a {id} />'
     def example_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
-        return ""
+        return '</div></div><br class="example-break" />'
+    def example_title_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        return '<p class="title"><strong>'
+    def example_title_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        return '</strong></p><div class="example-contents">'
 
     def _make_hN(self, level: int) -> tuple[str, str]:
         return f"h{min(6, max(1, level + self._hlevel_offset))}", ""
diff --git a/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/manual.py b/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/manual.py
index 40dea3c7d1d85..1963989d53658 100644
--- a/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/manual.py
+++ b/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/manual.py
@@ -402,6 +402,18 @@ class ManualHTMLRenderer(RendererMixin, HTMLRenderer):
         )
         if not (items := walk_and_emit(toc, toc_depth)):
             return ""
+        examples = ""
+        if toc.examples:
+            examples_entries = [
+                f'<dt>{i + 1}. <a href="{ex.target.href()}">{ex.target.toc_html}</a></dt>'
+                for i, ex in enumerate(toc.examples)
+            ]
+            examples = (
+                '<div class="list-of-examples">'
+                '<p><strong>List of Examples</strong><p>'
+                f'<dl>{"".join(examples_entries)}</dl>'
+                '</div>'
+            )
         return (
             f'<div class="toc">'
             f' <p><strong>Table of Contents</strong></p>'
@@ -409,6 +421,7 @@ class ManualHTMLRenderer(RendererMixin, HTMLRenderer):
             f'  {"".join(items)}'
             f' </dl>'
             f'</div>'
+            f'{examples}'
         )
 
     def _make_hN(self, level: int) -> tuple[str, str]:
@@ -513,6 +526,25 @@ class HTMLConverter(BaseConverter[ManualHTMLRenderer]):
             self._redirection_targets.add(into)
         return tokens
 
+    def _number_examples(self, tokens: Sequence[Token], start: int = 1) -> int:
+        for (i, token) in enumerate(tokens):
+            if token.type == "example_title_open":
+                title = tokens[i + 1]
+                assert title.type == 'inline' and title.children
+                # the prefix is split into two tokens because the xref title_html will want
+                # only the first of the two, but both must be rendered into the example itself.
+                title.children = (
+                    [
+                        Token('text', '', 0, content=f'Example {start}'),
+                        Token('text', '', 0, content='. ')
+                    ] + title.children
+                )
+                start += 1
+            elif token.type.startswith('included_') and token.type != 'included_options':
+                for sub, _path in token.meta['included']:
+                    start = self._number_examples(sub, start)
+        return start
+
     # xref | (id, type, heading inlines, file, starts new file)
     def _collect_ids(self, tokens: Sequence[Token], target_file: str, typ: str, file_changed: bool
                      ) -> list[XrefTarget | tuple[str, str, Token, str, bool]]:
@@ -534,6 +566,8 @@ class HTMLConverter(BaseConverter[ManualHTMLRenderer]):
                 subtyp = bt.type.removeprefix('included_').removesuffix('s')
                 for si, (sub, _path) in enumerate(bt.meta['included']):
                     result += self._collect_ids(sub, sub_file, subtyp, si == 0 and sub_file != target_file)
+            elif bt.type == 'example_open' and (id := cast(str, bt.attrs.get('id', ''))):
+                result.append((id, 'example', tokens[i + 2], target_file, False))
             elif bt.type == 'inline':
                 assert bt.children
                 result += self._collect_ids(bt.children, target_file, typ, False)
@@ -558,6 +592,11 @@ class HTMLConverter(BaseConverter[ManualHTMLRenderer]):
             title = prefix + title_html
             toc_html = f"{n}. {title_html}"
             title_html = f"Appendix&nbsp;{n}"
+        elif typ == 'example':
+            # skip the prepended `Example N. ` from _number_examples
+            toc_html, title = self._renderer.renderInline(inlines.children[2:]), title_html
+            # xref title wants only the prepended text, sans the trailing colon and space
+            title_html = self._renderer.renderInline(inlines.children[0:1])
         else:
             toc_html, title = title_html, title_html
             title_html = (
@@ -569,6 +608,7 @@ class HTMLConverter(BaseConverter[ManualHTMLRenderer]):
         return XrefTarget(id, title_html, toc_html, re.sub('<.*?>', '', title), path, drop_fragment)
 
     def _postprocess(self, infile: Path, outfile: Path, tokens: Sequence[Token]) -> None:
+        self._number_examples(tokens)
         xref_queue = self._collect_ids(tokens, outfile.name, 'book', True)
 
         failed = False
diff --git a/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/manual_structure.py b/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/manual_structure.py
index c271ca3c5aa5f..95e6e9474e73f 100644
--- a/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/manual_structure.py
+++ b/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/manual_structure.py
@@ -14,7 +14,7 @@ from .utils import Freezeable
 FragmentType = Literal['preface', 'part', 'chapter', 'section', 'appendix']
 
 # in the TOC all fragments are allowed, plus the all-encompassing book.
-TocEntryType = Literal['book', 'preface', 'part', 'chapter', 'section', 'appendix']
+TocEntryType = Literal['book', 'preface', 'part', 'chapter', 'section', 'appendix', 'example']
 
 def is_include(token: Token) -> bool:
     return token.type == "fence" and token.info.startswith("{=include=} ")
@@ -124,6 +124,7 @@ class TocEntry(Freezeable):
     next: TocEntry | None = None
     children: list[TocEntry] = dc.field(default_factory=list)
     starts_new_chunk: bool = False
+    examples: list[TocEntry] = dc.field(default_factory=list)
 
     @property
     def root(self) -> TocEntry:
@@ -138,13 +139,13 @@ class TocEntry(Freezeable):
 
     @classmethod
     def collect_and_link(cls, xrefs: dict[str, XrefTarget], tokens: Sequence[Token]) -> TocEntry:
-        result = cls._collect_entries(xrefs, tokens, 'book')
+        entries, examples = cls._collect_entries(xrefs, tokens, 'book')
 
         def flatten_with_parent(this: TocEntry, parent: TocEntry | None) -> Iterable[TocEntry]:
             this.parent = parent
             return itertools.chain([this], *[ flatten_with_parent(c, this) for c in this.children ])
 
-        flat = list(flatten_with_parent(result, None))
+        flat = list(flatten_with_parent(entries, None))
         prev = flat[0]
         prev.starts_new_chunk = True
         paths_seen = set([prev.target.path])
@@ -155,32 +156,39 @@ class TocEntry(Freezeable):
                 prev = c
             paths_seen.add(c.target.path)
 
+        flat[0].examples = examples
+
         for c in flat:
             c.freeze()
 
-        return result
+        return entries
 
     @classmethod
     def _collect_entries(cls, xrefs: dict[str, XrefTarget], tokens: Sequence[Token],
-                         kind: TocEntryType) -> TocEntry:
+                         kind: TocEntryType) -> tuple[TocEntry, list[TocEntry]]:
         # we assume that check_structure has been run recursively over the entire input.
         # list contains (tag, entry) pairs that will collapse to a single entry for
         # the full sequence.
         entries: list[tuple[str, TocEntry]] = []
+        examples: list[TocEntry] = []
         for token in tokens:
             if token.type.startswith('included_') and (included := token.meta.get('included')):
                 fragment_type_str = token.type[9:].removesuffix('s')
                 assert fragment_type_str in get_args(TocEntryType)
                 fragment_type = cast(TocEntryType, fragment_type_str)
                 for fragment, _path in included:
-                    entries[-1][1].children.append(cls._collect_entries(xrefs, fragment, fragment_type))
+                    subentries, subexamples = cls._collect_entries(xrefs, fragment, fragment_type)
+                    entries[-1][1].children.append(subentries)
+                    examples += subexamples
             elif token.type == 'heading_open' and (id := cast(str, token.attrs.get('id', ''))):
                 while len(entries) > 1 and entries[-1][0] >= token.tag:
                     entries[-2][1].children.append(entries.pop()[1])
                 entries.append((token.tag,
                                 TocEntry(kind if token.tag == 'h1' else 'section', xrefs[id])))
                 token.meta['TocEntry'] = entries[-1][1]
+            elif token.type == 'example_open' and (id := cast(str, token.attrs.get('id', ''))):
+                examples.append(TocEntry('example', xrefs[id]))
 
         while len(entries) > 1:
             entries[-2][1].children.append(entries.pop()[1])
-        return entries[0][1]
+        return (entries[0][1], examples)
diff --git a/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/md.py b/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/md.py
index e8fee1b713282..ce79b0dee794d 100644
--- a/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/md.py
+++ b/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/md.py
@@ -88,6 +88,8 @@ class Renderer:
             "ordered_list_close": self.ordered_list_close,
             "example_open": self.example_open,
             "example_close": self.example_close,
+            "example_title_open": self.example_title_open,
+            "example_title_close": self.example_title_close,
         }
 
         self._admonitions = {
@@ -219,6 +221,10 @@ class Renderer:
         raise RuntimeError("md token not supported", token)
     def example_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
         raise RuntimeError("md token not supported", token)
+    def example_title_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        raise RuntimeError("md token not supported", token)
+    def example_title_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        raise RuntimeError("md token not supported", token)
 
 def _is_escaped(src: str, pos: int) -> bool:
     found = 0
@@ -417,6 +423,32 @@ def _block_attr(md: markdown_it.MarkdownIt) -> None:
 
     md.core.ruler.push("block_attr", block_attr)
 
+def _example_titles(md: markdown_it.MarkdownIt) -> None:
+    """
+    find title headings of examples and stick them into meta for renderers, then
+    remove them from the token stream. also checks whether any example contains a
+    non-title heading since those would make toc generation extremely complicated.
+    """
+    def example_titles(state: markdown_it.rules_core.StateCore) -> None:
+        in_example = [False]
+        for i, token in enumerate(state.tokens):
+            if token.type == 'example_open':
+                if state.tokens[i + 1].type == 'heading_open':
+                    assert state.tokens[i + 3].type == 'heading_close'
+                    state.tokens[i + 1].type = 'example_title_open'
+                    state.tokens[i + 3].type = 'example_title_close'
+                else:
+                    assert token.map
+                    raise RuntimeError(f"found example without title in line {token.map[0] + 1}")
+                in_example.append(True)
+            elif token.type == 'example_close':
+                in_example.pop()
+            elif token.type == 'heading_open' and in_example[-1]:
+                assert token.map
+                raise RuntimeError(f"unexpected non-title heading in example in line {token.map[0] + 1}")
+
+    md.core.ruler.push("example_titles", example_titles)
+
 TR = TypeVar('TR', bound='Renderer')
 
 class Converter(ABC, Generic[TR]):
@@ -459,6 +491,7 @@ class Converter(ABC, Generic[TR]):
         self._md.use(_heading_ids)
         self._md.use(_compact_list_attr)
         self._md.use(_block_attr)
+        self._md.use(_example_titles)
         self._md.enable(["smartquotes", "replacements"])
 
     def _parse(self, src: str) -> list[Token]:
diff --git a/pkgs/tools/nix/nixos-render-docs/src/tests/test_plugins.py b/pkgs/tools/nix/nixos-render-docs/src/tests/test_plugins.py
index f94ede6382bf0..fb7a4ab0117f7 100644
--- a/pkgs/tools/nix/nixos-render-docs/src/tests/test_plugins.py
+++ b/pkgs/tools/nix/nixos-render-docs/src/tests/test_plugins.py
@@ -1,4 +1,5 @@
 import nixos_render_docs as nrd
+import pytest
 
 from markdown_it.token import Token
 
@@ -427,18 +428,38 @@ def test_admonitions() -> None:
 
 def test_example() -> None:
     c = Converter({})
-    assert c._parse("::: {.example}") == [
-        Token(type='example_open', tag='div', nesting=1, attrs={}, map=[0, 1], level=0, children=None,
+    assert c._parse("::: {.example}\n# foo") == [
+        Token(type='example_open', tag='div', nesting=1, attrs={}, map=[0, 2], level=0, children=None,
               content='', markup=':::', info=' {.example}', meta={}, block=True, hidden=False),
+        Token(type='example_title_open', tag='h1', nesting=1, attrs={}, map=[1, 2], level=1, children=None,
+              content='', markup='#', info='', meta={}, block=True, hidden=False),
+        Token(type='inline', tag='', nesting=0, attrs={}, map=[1, 2], level=2,
+              content='foo', markup='', info='', meta={}, block=True, hidden=False,
+              children=[
+                  Token(type='text', tag='', nesting=0, attrs={}, map=None, level=0, children=None,
+                        content='foo', markup='', info='', meta={}, block=False, hidden=False)
+              ]),
+        Token(type='example_title_close', tag='h1', nesting=-1, attrs={}, map=None, level=1, children=None,
+              content='', markup='#', info='', meta={}, block=True, hidden=False),
         Token(type='example_close', tag='div', nesting=-1, attrs={}, map=None, level=0, children=None,
-              content='', markup=':::', info='', meta={}, block=True, hidden=False)
+              content='', markup='', info='', meta={}, block=True, hidden=False)
     ]
-    assert c._parse("::: {#eid .example}") == [
-        Token(type='example_open', tag='div', nesting=1, attrs={'id': 'eid'}, map=[0, 1], level=0,
+    assert c._parse("::: {#eid .example}\n# foo") == [
+        Token(type='example_open', tag='div', nesting=1, attrs={'id': 'eid'}, map=[0, 2], level=0,
               children=None, content='', markup=':::', info=' {#eid .example}', meta={}, block=True,
               hidden=False),
+        Token(type='example_title_open', tag='h1', nesting=1, attrs={}, map=[1, 2], level=1, children=None,
+              content='', markup='#', info='', meta={}, block=True, hidden=False),
+        Token(type='inline', tag='', nesting=0, attrs={}, map=[1, 2], level=2,
+              content='foo', markup='', info='', meta={}, block=True, hidden=False,
+              children=[
+                  Token(type='text', tag='', nesting=0, attrs={}, map=None, level=0, children=None,
+                        content='foo', markup='', info='', meta={}, block=False, hidden=False)
+              ]),
+        Token(type='example_title_close', tag='h1', nesting=-1, attrs={}, map=None, level=1, children=None,
+              content='', markup='#', info='', meta={}, block=True, hidden=False),
         Token(type='example_close', tag='div', nesting=-1, attrs={}, map=None, level=0, children=None,
-              content='', markup=':::', info='', meta={}, block=True, hidden=False)
+              content='', markup='', info='', meta={}, block=True, hidden=False)
     ]
     assert c._parse("::: {.example .note}") == [
         Token(type='paragraph_open', tag='p', nesting=1, attrs={}, map=[0, 1], level=0, children=None,
@@ -452,3 +473,31 @@ def test_example() -> None:
         Token(type='paragraph_close', tag='p', nesting=-1, attrs={}, map=None, level=0, children=None,
               content='', markup='', info='', meta={}, block=True, hidden=False)
     ]
+    assert c._parse("::: {.example}\n### foo: `code`\nbar\n:::\nbaz") == [
+        Token(type='example_open', tag='div', nesting=1, map=[0, 3], markup=':::', info=' {.example}',
+              block=True),
+        Token(type='example_title_open', tag='h3', nesting=1, map=[1, 2], level=1, markup='###', block=True),
+        Token(type='inline', tag='', nesting=0, map=[1, 2], level=2, content='foo: `code`', block=True,
+              children=[
+                  Token(type='text', tag='', nesting=0, content='foo: '),
+                  Token(type='code_inline', tag='code', nesting=0, content='code', markup='`')
+              ]),
+        Token(type='example_title_close', tag='h3', nesting=-1, level=1, markup='###', block=True),
+        Token(type='paragraph_open', tag='p', nesting=1, map=[2, 3], level=1, block=True),
+        Token(type='inline', tag='', nesting=0, map=[2, 3], level=2, content='bar', block=True,
+              children=[
+                  Token(type='text', tag='', nesting=0, content='bar')
+              ]),
+        Token(type='paragraph_close', tag='p', nesting=-1, level=1, block=True),
+        Token(type='example_close', tag='div', nesting=-1, markup=':::', block=True),
+        Token(type='paragraph_open', tag='p', nesting=1, map=[4, 5], block=True),
+        Token(type='inline', tag='', nesting=0, map=[4, 5], level=1, content='baz', block=True,
+              children=[
+                  Token(type='text', tag='', nesting=0, content='baz')
+              ]),
+        Token(type='paragraph_close', tag='p', nesting=-1, block=True)
+    ]
+
+    with pytest.raises(RuntimeError) as exc:
+        c._parse("::: {.example}\n### foo\n### bar\n:::")
+    assert exc.value.args[0] == 'unexpected non-title heading in example in line 3'