about summary refs log tree commit diff
path: root/pkgs/tools
diff options
context:
space:
mode:
Diffstat (limited to 'pkgs/tools')
-rw-r--r--pkgs/tools/nix/nixdoc/Cargo.lock309
-rw-r--r--pkgs/tools/nix/nixdoc/default.nix26
-rw-r--r--pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/commonmark.py4
-rw-r--r--pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/html.py106
-rw-r--r--pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/manual.py202
-rw-r--r--pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/manual_structure.py21
-rw-r--r--pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/md.py141
-rw-r--r--pkgs/tools/nix/nixos-render-docs/src/tests/test_commonmark.py6
-rw-r--r--pkgs/tools/nix/nixos-render-docs/src/tests/test_html.py79
-rw-r--r--pkgs/tools/nix/nixos-render-docs/src/tests/test_plugins.py25
10 files changed, 504 insertions, 415 deletions
diff --git a/pkgs/tools/nix/nixdoc/Cargo.lock b/pkgs/tools/nix/nixdoc/Cargo.lock
deleted file mode 100644
index 0f672357cb5f5..0000000000000
--- a/pkgs/tools/nix/nixdoc/Cargo.lock
+++ /dev/null
@@ -1,309 +0,0 @@
-[[package]]
-name = "ansi_term"
-version = "0.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-dependencies = [
- "winapi 0.3.6 (registry+https://github.com/rust-lang/crates.io-index)",
-]
-
-[[package]]
-name = "arenatree"
-version = "0.1.1"
-source = "git+https://gitlab.com/jD91mZM2/arenatree#f9bf7efa9a5ef4c2dd9e2acc5a4cc79a987cb648"
-
-[[package]]
-name = "arrayvec"
-version = "0.4.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-dependencies = [
- "nodrop 0.1.12 (registry+https://github.com/rust-lang/crates.io-index)",
-]
-
-[[package]]
-name = "atty"
-version = "0.2.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-dependencies = [
- "libc 0.2.43 (registry+https://github.com/rust-lang/crates.io-index)",
- "termion 1.5.1 (registry+https://github.com/rust-lang/crates.io-index)",
- "winapi 0.3.6 (registry+https://github.com/rust-lang/crates.io-index)",
-]
-
-[[package]]
-name = "backtrace"
-version = "0.3.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-dependencies = [
- "backtrace-sys 0.1.24 (registry+https://github.com/rust-lang/crates.io-index)",
- "cfg-if 0.1.6 (registry+https://github.com/rust-lang/crates.io-index)",
- "libc 0.2.43 (registry+https://github.com/rust-lang/crates.io-index)",
- "rustc-demangle 0.1.9 (registry+https://github.com/rust-lang/crates.io-index)",
- "winapi 0.3.6 (registry+https://github.com/rust-lang/crates.io-index)",
-]
-
-[[package]]
-name = "backtrace-sys"
-version = "0.1.24"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-dependencies = [
- "cc 1.0.25 (registry+https://github.com/rust-lang/crates.io-index)",
- "libc 0.2.43 (registry+https://github.com/rust-lang/crates.io-index)",
-]
-
-[[package]]
-name = "bitflags"
-version = "1.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-
-[[package]]
-name = "cc"
-version = "1.0.25"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-
-[[package]]
-name = "cfg-if"
-version = "0.1.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-
-[[package]]
-name = "clap"
-version = "2.32.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-dependencies = [
- "ansi_term 0.11.0 (registry+https://github.com/rust-lang/crates.io-index)",
- "atty 0.2.11 (registry+https://github.com/rust-lang/crates.io-index)",
- "bitflags 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)",
- "strsim 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)",
- "textwrap 0.10.0 (registry+https://github.com/rust-lang/crates.io-index)",
- "unicode-width 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)",
- "vec_map 0.8.1 (registry+https://github.com/rust-lang/crates.io-index)",
-]
-
-[[package]]
-name = "failure"
-version = "0.1.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-dependencies = [
- "backtrace 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)",
- "failure_derive 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)",
-]
-
-[[package]]
-name = "failure_derive"
-version = "0.1.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-dependencies = [
- "proc-macro2 0.4.20 (registry+https://github.com/rust-lang/crates.io-index)",
- "quote 0.6.8 (registry+https://github.com/rust-lang/crates.io-index)",
- "syn 0.15.15 (registry+https://github.com/rust-lang/crates.io-index)",
- "synstructure 0.10.1 (registry+https://github.com/rust-lang/crates.io-index)",
-]
-
-[[package]]
-name = "libc"
-version = "0.2.43"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-
-[[package]]
-name = "nixdoc"
-version = "1.0.1"
-dependencies = [
- "failure 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)",
- "rnix 0.4.1 (git+https://gitlab.com/jD91mZM2/rnix.git?rev=10b86c94291b4864470158ef8750de85ddd8d4ba)",
- "structopt 0.2.12 (registry+https://github.com/rust-lang/crates.io-index)",
- "xml-rs 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)",
-]
-
-[[package]]
-name = "nodrop"
-version = "0.1.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-
-[[package]]
-name = "proc-macro2"
-version = "0.4.20"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-dependencies = [
- "unicode-xid 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)",
-]
-
-[[package]]
-name = "quote"
-version = "0.6.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-dependencies = [
- "proc-macro2 0.4.20 (registry+https://github.com/rust-lang/crates.io-index)",
-]
-
-[[package]]
-name = "redox_syscall"
-version = "0.1.40"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-
-[[package]]
-name = "redox_termios"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-dependencies = [
- "redox_syscall 0.1.40 (registry+https://github.com/rust-lang/crates.io-index)",
-]
-
-[[package]]
-name = "rnix"
-version = "0.4.1"
-source = "git+https://gitlab.com/jD91mZM2/rnix.git?rev=10b86c94291b4864470158ef8750de85ddd8d4ba#10b86c94291b4864470158ef8750de85ddd8d4ba"
-dependencies = [
- "arenatree 0.1.1 (git+https://gitlab.com/jD91mZM2/arenatree)",
- "arrayvec 0.4.7 (registry+https://github.com/rust-lang/crates.io-index)",
- "failure 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)",
- "smol_str 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)",
-]
-
-[[package]]
-name = "rustc-demangle"
-version = "0.1.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-
-[[package]]
-name = "smol_str"
-version = "0.1.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-
-[[package]]
-name = "strsim"
-version = "0.7.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-
-[[package]]
-name = "structopt"
-version = "0.2.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-dependencies = [
- "clap 2.32.0 (registry+https://github.com/rust-lang/crates.io-index)",
- "structopt-derive 0.2.12 (registry+https://github.com/rust-lang/crates.io-index)",
-]
-
-[[package]]
-name = "structopt-derive"
-version = "0.2.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-dependencies = [
- "proc-macro2 0.4.20 (registry+https://github.com/rust-lang/crates.io-index)",
- "quote 0.6.8 (registry+https://github.com/rust-lang/crates.io-index)",
- "syn 0.15.15 (registry+https://github.com/rust-lang/crates.io-index)",
-]
-
-[[package]]
-name = "syn"
-version = "0.15.15"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-dependencies = [
- "proc-macro2 0.4.20 (registry+https://github.com/rust-lang/crates.io-index)",
- "quote 0.6.8 (registry+https://github.com/rust-lang/crates.io-index)",
- "unicode-xid 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)",
-]
-
-[[package]]
-name = "synstructure"
-version = "0.10.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-dependencies = [
- "proc-macro2 0.4.20 (registry+https://github.com/rust-lang/crates.io-index)",
- "quote 0.6.8 (registry+https://github.com/rust-lang/crates.io-index)",
- "syn 0.15.15 (registry+https://github.com/rust-lang/crates.io-index)",
- "unicode-xid 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)",
-]
-
-[[package]]
-name = "termion"
-version = "1.5.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-dependencies = [
- "libc 0.2.43 (registry+https://github.com/rust-lang/crates.io-index)",
- "redox_syscall 0.1.40 (registry+https://github.com/rust-lang/crates.io-index)",
- "redox_termios 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)",
-]
-
-[[package]]
-name = "textwrap"
-version = "0.10.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-dependencies = [
- "unicode-width 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)",
-]
-
-[[package]]
-name = "unicode-width"
-version = "0.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-
-[[package]]
-name = "unicode-xid"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-
-[[package]]
-name = "vec_map"
-version = "0.8.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-
-[[package]]
-name = "winapi"
-version = "0.3.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-dependencies = [
- "winapi-i686-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
- "winapi-x86_64-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
-]
-
-[[package]]
-name = "winapi-i686-pc-windows-gnu"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-
-[[package]]
-name = "winapi-x86_64-pc-windows-gnu"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-
-[[package]]
-name = "xml-rs"
-version = "0.8.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-
-[metadata]
-"checksum ansi_term 0.11.0 (registry+https://github.com/rust-lang/crates.io-index)" = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b"
-"checksum arenatree 0.1.1 (git+https://gitlab.com/jD91mZM2/arenatree)" = "<none>"
-"checksum arrayvec 0.4.7 (registry+https://github.com/rust-lang/crates.io-index)" = "a1e964f9e24d588183fcb43503abda40d288c8657dfc27311516ce2f05675aef"
-"checksum atty 0.2.11 (registry+https://github.com/rust-lang/crates.io-index)" = "9a7d5b8723950951411ee34d271d99dddcc2035a16ab25310ea2c8cfd4369652"
-"checksum backtrace 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)" = "89a47830402e9981c5c41223151efcced65a0510c13097c769cede7efb34782a"
-"checksum backtrace-sys 0.1.24 (registry+https://github.com/rust-lang/crates.io-index)" = "c66d56ac8dabd07f6aacdaf633f4b8262f5b3601a810a0dcddffd5c22c69daa0"
-"checksum bitflags 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)" = "228047a76f468627ca71776ecdebd732a3423081fcf5125585bcd7c49886ce12"
-"checksum cc 1.0.25 (registry+https://github.com/rust-lang/crates.io-index)" = "f159dfd43363c4d08055a07703eb7a3406b0dac4d0584d96965a3262db3c9d16"
-"checksum cfg-if 0.1.6 (registry+https://github.com/rust-lang/crates.io-index)" = "082bb9b28e00d3c9d39cc03e64ce4cea0f1bb9b3fde493f0cbc008472d22bdf4"
-"checksum clap 2.32.0 (registry+https://github.com/rust-lang/crates.io-index)" = "b957d88f4b6a63b9d70d5f454ac8011819c6efa7727858f458ab71c756ce2d3e"
-"checksum failure 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)" = "6dd377bcc1b1b7ce911967e3ec24fa19c3224394ec05b54aa7b083d498341ac7"
-"checksum failure_derive 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)" = "64c2d913fe8ed3b6c6518eedf4538255b989945c14c2a7d5cbff62a5e2120596"
-"checksum libc 0.2.43 (registry+https://github.com/rust-lang/crates.io-index)" = "76e3a3ef172f1a0b9a9ff0dd1491ae5e6c948b94479a3021819ba7d860c8645d"
-"checksum nodrop 0.1.12 (registry+https://github.com/rust-lang/crates.io-index)" = "9a2228dca57108069a5262f2ed8bd2e82496d2e074a06d1ccc7ce1687b6ae0a2"
-"checksum proc-macro2 0.4.20 (registry+https://github.com/rust-lang/crates.io-index)" = "3d7b7eaaa90b4a90a932a9ea6666c95a389e424eff347f0f793979289429feee"
-"checksum quote 0.6.8 (registry+https://github.com/rust-lang/crates.io-index)" = "dd636425967c33af890042c483632d33fa7a18f19ad1d7ea72e8998c6ef8dea5"
-"checksum redox_syscall 0.1.40 (registry+https://github.com/rust-lang/crates.io-index)" = "c214e91d3ecf43e9a4e41e578973adeb14b474f2bee858742d127af75a0112b1"
-"checksum redox_termios 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "7e891cfe48e9100a70a3b6eb652fef28920c117d366339687bd5576160db0f76"
-"checksum rnix 0.4.1 (git+https://gitlab.com/jD91mZM2/rnix.git?rev=10b86c94291b4864470158ef8750de85ddd8d4ba)" = "<none>"
-"checksum rustc-demangle 0.1.9 (registry+https://github.com/rust-lang/crates.io-index)" = "bcfe5b13211b4d78e5c2cadfebd7769197d95c639c35a50057eb4c05de811395"
-"checksum smol_str 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)" = "f3ed6f19b800d76574926e458d5f8e2dbea86c2b58c08d33a982448f09ac8d0c"
-"checksum strsim 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)" = "bb4f380125926a99e52bc279241539c018323fab05ad6368b56f93d9369ff550"
-"checksum structopt 0.2.12 (registry+https://github.com/rust-lang/crates.io-index)" = "d77af7242f18c40fd19cb270985930f239ee1646cfb482050bbae9da1d18743b"
-"checksum structopt-derive 0.2.12 (registry+https://github.com/rust-lang/crates.io-index)" = "17ff01fe96de9d16e7372ae5f19dd7ece2c703b51043c3db9ea27f9e393ea311"
-"checksum syn 0.15.15 (registry+https://github.com/rust-lang/crates.io-index)" = "0a9c2bf1e53c21704a7cce1b2a42768f1ae32a6777108a0d7f1faa4bfe7f7c04"
-"checksum synstructure 0.10.1 (registry+https://github.com/rust-lang/crates.io-index)" = "73687139bf99285483c96ac0add482c3776528beac1d97d444f6e91f203a2015"
-"checksum termion 1.5.1 (registry+https://github.com/rust-lang/crates.io-index)" = "689a3bdfaab439fd92bc87df5c4c78417d3cbe537487274e9b0b2dce76e92096"
-"checksum textwrap 0.10.0 (registry+https://github.com/rust-lang/crates.io-index)" = "307686869c93e71f94da64286f9a9524c0f308a9e1c87a583de8e9c9039ad3f6"
-"checksum unicode-width 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)" = "882386231c45df4700b275c7ff55b6f3698780a650026380e72dabe76fa46526"
-"checksum unicode-xid 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "fc72304796d0818e357ead4e000d19c9c174ab23dc11093ac919054d20a6a7fc"
-"checksum vec_map 0.8.1 (registry+https://github.com/rust-lang/crates.io-index)" = "05c78687fb1a80548ae3250346c3db86a80a7cdd77bda190189f2d0a0987c81a"
-"checksum winapi 0.3.6 (registry+https://github.com/rust-lang/crates.io-index)" = "92c1eb33641e276cfa214a0522acad57be5c56b10cb348b3c5117db75f3ac4b0"
-"checksum winapi-i686-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
-"checksum winapi-x86_64-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
-"checksum xml-rs 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)" = "541b12c998c5b56aa2b4e6f18f03664eef9a4fd0a246a55594efae6cc2d964b5"
diff --git a/pkgs/tools/nix/nixdoc/default.nix b/pkgs/tools/nix/nixdoc/default.nix
index 8562ff1c5e56d..785261a8e0925 100644
--- a/pkgs/tools/nix/nixdoc/default.nix
+++ b/pkgs/tools/nix/nixdoc/default.nix
@@ -2,38 +2,24 @@
 
 rustPlatform.buildRustPackage rec {
   pname = "nixdoc";
-  version = "1.0.1";
+  version = "2.3.0";
 
   src = fetchFromGitHub {
-    owner = "tazjin";
+    owner = "nix-community";
     repo  = "nixdoc";
     rev = "v${version}";
-    sha256 = "14d4dq06jdqazxvv7fq5872zy0capxyb0fdkp8qg06gxl1iw201s";
+    sha256 = "sha256-8pp6xlmdb3kZ6unTiO4yRruyEZ//GIHZF1k8f4kQr9Q=";
   };
 
-  patches = [
-    # Support nested identifiers https://github.com/nix-community/nixdoc/pull/27
-    (fetchpatch {
-      url = "https://github.com/nix-community/nixdoc/pull/27/commits/ea542735bf675fe2ccd37edaffb9138d1a8c1b7e.patch";
-      sha256 = "1fmz44jv2r9qsnjxvkkjfb0safy69l4x4vx1g5gisrp8nwdn94rj";
-    })
-  ];
+  cargoSha256 = "sha256-k8/+BBMjQCsrgCi33fTdiSukaAZlg6XU3NwXaJdGYVw=";
 
   buildInputs =  lib.optionals stdenv.isDarwin [ darwin.Security ];
 
-  cargoLock = {
-    lockFile = ./Cargo.lock;
-    outputHashes = {
-      "arenatree-0.1.1" = "sha256-b3VVbYnWsjSjFMxvkfpJt13u+VC6baOIWD4qm1Gco4Q=";
-      "rnix-0.4.1" = "sha256-C1L/qXk6AimH7COrBlqpUA3giftaOYm/qNxs7rQgETA=";
-    };
-  };
-
   meta = with lib; {
     description = "Generate documentation for Nix functions";
-    homepage    = "https://github.com/tazjin/nixdoc";
+    homepage    = "https://github.com/nix-community/nixdoc";
     license     = [ licenses.gpl3 ];
-    maintainers = [ maintainers.tazjin ];
+    maintainers = [ maintainers.asymmetric ];
     platforms   = platforms.unix;
   };
 }
diff --git a/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/commonmark.py b/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/commonmark.py
index 440cf35f0d387..6287b60f0a51d 100644
--- a/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/commonmark.py
+++ b/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/commonmark.py
@@ -184,3 +184,7 @@ class CommonMarkRenderer(Renderer):
     def ordered_list_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
         self._list_stack.pop()
         return ""
+    def image(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        if title := cast(str, token.attrs.get('title', '')):
+            title = ' "' + title.replace('"', '\\"') + '"'
+        return f'![{token.content}]({token.attrs["src"]}{title})'
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 d25253d1a2f92..ffe64cde4d345 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
@@ -44,6 +44,9 @@ class HTMLRenderer(Renderer):
         result += self._close_headings(None)
         return result
 
+    def _pull_image(self, path: str) -> str:
+        raise NotImplementedError()
+
     def text(self, token: Token, tokens: Sequence[Token], i: int) -> str:
         return escape(token.content)
     def paragraph_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
@@ -67,7 +70,8 @@ class HTMLRenderer(Renderer):
             if tokens[i + 1].type == 'link_close':
                 tag, text = "xref", xref.title_html
             if xref.title:
-                title = f'title="{escape(xref.title, True)}"'
+                # titles are not attribute-safe on their own, so we need to replace quotes.
+                title = 'title="{}"'.format(xref.title.replace('"', '&quot;'))
             target, href = "", xref.href()
         return f'<a class="{tag}" href="{href}" {title} {target}>{text}'
     def link_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
@@ -223,6 +227,106 @@ class HTMLRenderer(Renderer):
         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 image(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        src = self._pull_image(cast(str, token.attrs['src']))
+        alt = f'alt="{escape(token.content, True)}"' if token.content else ""
+        if title := cast(str, token.attrs.get('title', '')):
+            title = f'title="{escape(title, True)}"'
+        return (
+            '<div class="mediaobject">'
+            f'<img src="{escape(src, True)}" {alt} {title} />'
+            '</div>'
+        )
+    def figure_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        if anchor := cast(str, token.attrs.get('id', '')):
+            anchor = f'<a id="{escape(anchor, True)}"></a>'
+        return f'<div class="figure">{anchor}'
+    def figure_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        return (
+            ' </div>'
+            '</div><br class="figure-break" />'
+        )
+    def figure_title_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        return (
+            '<p class="title">'
+            ' <strong>'
+        )
+    def figure_title_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        return (
+            ' </strong>'
+            '</p>'
+            '<div class="figure-contents">'
+        )
+    def table_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        return (
+            '<div class="informaltable">'
+            '<table class="informaltable" border="1">'
+        )
+    def table_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        return (
+            '</table>'
+            '</div>'
+        )
+    def thead_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        cols = []
+        for j in range(i + 1, len(tokens)):
+            if tokens[j].type == 'thead_close':
+                break
+            elif tokens[j].type == 'th_open':
+                cols.append(cast(str, tokens[j].attrs.get('style', 'left')).removeprefix('text-align:'))
+        return "".join([
+            "<colgroup>",
+            "".join([ f'<col align="{col}" />' for col in cols ]),
+            "</colgroup>",
+            "<thead>",
+        ])
+    def thead_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        return "</thead>"
+    def tr_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        return "<tr>"
+    def tr_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        return "</tr>"
+    def th_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        return f'<th align="{cast(str, token.attrs.get("style", "left")).removeprefix("text-align:")}">'
+    def th_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        return "</th>"
+    def tbody_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        return "<tbody>"
+    def tbody_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        return "</tbody>"
+    def td_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        return f'<td align="{cast(str, token.attrs.get("style", "left")).removeprefix("text-align:")}">'
+    def td_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        return "</td>"
+    def footnote_ref(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        href = self._xref_targets[token.meta['target']].href()
+        id = escape(cast(str, token.attrs["id"]), True)
+        return (
+            f'<a href="{href}" class="footnote" id="{id}">'
+            f'<sup class="footnote">[{token.meta["id"] + 1}]</sup>'
+            '</a>'
+        )
+    def footnote_block_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        return (
+            '<div class="footnotes">'
+            '<br />'
+            '<hr style="width:100; text-align:left;margin-left: 0" />'
+        )
+    def footnote_block_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        return "</div>"
+    def footnote_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        # meta id,label
+        id = escape(self._xref_targets[token.meta["label"]].id, True)
+        return f'<div id="{id}" class="footnote">'
+    def footnote_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        return "</div>"
+    def footnote_anchor(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        href = self._xref_targets[token.meta['target']].href()
+        return (
+            f'<a href="{href}" class="para">'
+            f'<sup class="para">[{token.meta["id"] + 1}]</sup>'
+            '</a>'
+        )
 
     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 ef81e4d7e1ab8..03c5a5dd39604 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
@@ -1,4 +1,5 @@
 import argparse
+import hashlib
 import html
 import json
 import re
@@ -235,27 +236,48 @@ class HTMLParameters(NamedTuple):
     generator: str
     stylesheets: Sequence[str]
     scripts: Sequence[str]
+    # number of levels in the rendered table of contents. tables are prepended to
+    # the content they apply to (entire document / document chunk / top-level section
+    # of a chapter), setting a depth of 0 omits the respective table.
     toc_depth: int
     chunk_toc_depth: int
+    section_toc_depth: int
+    media_dir: Path
 
 class ManualHTMLRenderer(RendererMixin, HTMLRenderer):
     _base_path: Path
+    _in_dir: Path
     _html_params: HTMLParameters
 
     def __init__(self, toplevel_tag: str, revision: str, html_params: HTMLParameters,
                  manpage_urls: Mapping[str, str], xref_targets: dict[str, XrefTarget],
-                 base_path: Path):
+                 in_dir: Path, base_path: Path):
         super().__init__(toplevel_tag, revision, manpage_urls, xref_targets)
-        self._base_path, self._html_params = base_path, html_params
+        self._in_dir = in_dir
+        self._base_path = base_path.absolute()
+        self._html_params = html_params
+
+    def _pull_image(self, src: str) -> str:
+        src_path = Path(src)
+        content = (self._in_dir / src_path).read_bytes()
+        # images may be used more than once, but we want to store them only once and
+        # in an easily accessible (ie, not input-file-path-dependent) location without
+        # having to maintain a mapping structure. hashing the file and using the hash
+        # as both the path of the final image provides both.
+        content_hash = hashlib.sha3_256(content).hexdigest()
+        target_name = f"{content_hash}{src_path.suffix}"
+        target_path = self._base_path / self._html_params.media_dir / target_name
+        target_path.write_bytes(content)
+        return f"./{self._html_params.media_dir}/{target_name}"
 
     def _push(self, tag: str, hlevel_offset: int) -> Any:
-        result = (self._toplevel_tag, self._headings, self._attrspans, self._hlevel_offset)
+        result = (self._toplevel_tag, self._headings, self._attrspans, self._hlevel_offset, self._in_dir)
         self._hlevel_offset += hlevel_offset
         self._toplevel_tag, self._headings, self._attrspans = tag, [], []
         return result
 
     def _pop(self, state: Any) -> None:
-        (self._toplevel_tag, self._headings, self._attrspans, self._hlevel_offset) = state
+        (self._toplevel_tag, self._headings, self._attrspans, self._hlevel_offset, self._in_dir) = state
 
     def _render_book(self, tokens: Sequence[Token]) -> str:
         assert tokens[4].children
@@ -284,6 +306,7 @@ class ManualHTMLRenderer(RendererMixin, HTMLRenderer):
     def _file_header(self, toc: TocEntry) -> str:
         prev_link, up_link, next_link = "", "", ""
         prev_a, next_a, parent_title = "", "", "&nbsp;"
+        nav_html = ""
         home = toc.root
         if toc.prev:
             prev_link = f'<link rel="prev" href="{toc.prev.target.href()}" title="{toc.prev.target.title}" />'
@@ -299,6 +322,22 @@ class ManualHTMLRenderer(RendererMixin, HTMLRenderer):
         if toc.next:
             next_link = f'<link rel="next" href="{toc.next.target.href()}" title="{toc.next.target.title}" />'
             next_a = f'<a accesskey="n" href="{toc.next.target.href()}">Next</a>'
+        if toc.prev or toc.parent or toc.next:
+            nav_html = "\n".join([
+                '  <div class="navheader">',
+                '   <table width="100%" summary="Navigation header">',
+                '    <tr>',
+                f'    <th colspan="3" align="center">{toc.target.title}</th>',
+                '    </tr>',
+                '    <tr>',
+                f'    <td width="20%" align="left">{prev_a}&nbsp;</td>',
+                f'    <th width="60%" align="center">{parent_title}</th>',
+                f'    <td width="20%" align="right">&nbsp;{next_a}</td>',
+                '    </tr>',
+                '   </table>',
+                '   <hr />',
+                '  </div>',
+            ])
         return "\n".join([
             '<?xml version="1.0" encoding="utf-8" standalone="no"?>',
             '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"',
@@ -312,29 +351,18 @@ class ManualHTMLRenderer(RendererMixin, HTMLRenderer):
             "".join((f'<script src="{html.escape(script, True)}" type="text/javascript"></script>'
                      for script in self._html_params.scripts)),
             f' <meta name="generator" content="{html.escape(self._html_params.generator, True)}" />',
-            f' <link rel="home" href="{home.target.href()}" title="{home.target.title}" />',
+            f' <link rel="home" href="{home.target.href()}" title="{home.target.title}" />' if home.target.href() else "",
             f' {up_link}{prev_link}{next_link}',
             ' </head>',
             ' <body>',
-            '  <div class="navheader">',
-            '   <table width="100%" summary="Navigation header">',
-            '    <tr>',
-            f'    <th colspan="3" align="center">{toc.target.title}</th>',
-            '    </tr>',
-            '    <tr>',
-            f'    <td width="20%" align="left">{prev_a}&nbsp;</td>',
-            f'    <th width="60%" align="center">{parent_title}</th>',
-            f'    <td width="20%" align="right">&nbsp;{next_a}</td>',
-            '    </tr>',
-            '   </table>',
-            '   <hr />',
-            '  </div>',
+            nav_html,
         ])
 
     def _file_footer(self, toc: TocEntry) -> str:
         # prev, next = self._get_prev_and_next()
         prev_a, up_a, home_a, next_a = "", "&nbsp;", "&nbsp;", ""
         prev_text, up_text, next_text = "", "", ""
+        nav_html = ""
         home = toc.root
         if toc.prev:
             prev_a = f'<a accesskey="p" href="{toc.prev.target.href()}">Prev</a>'
@@ -348,22 +376,26 @@ class ManualHTMLRenderer(RendererMixin, HTMLRenderer):
             next_a = f'<a accesskey="n" href="{toc.next.target.href()}">Next</a>'
             assert toc.next.target.title
             next_text = toc.next.target.title
+        if toc.prev or toc.parent or toc.next:
+            nav_html = "\n".join([
+                '  <div class="navfooter">',
+                '   <hr />',
+                '   <table width="100%" summary="Navigation footer">',
+                '    <tr>',
+                f'    <td width="40%" align="left">{prev_a}&nbsp;</td>',
+                f'    <td width="20%" align="center">{up_a}</td>',
+                f'    <td width="40%" align="right">&nbsp;{next_a}</td>',
+                '    </tr>',
+                '    <tr>',
+                f'     <td width="40%" align="left" valign="top">{prev_text}&nbsp;</td>',
+                f'     <td width="20%" align="center">{home_a}</td>',
+                f'     <td width="40%" align="right" valign="top">&nbsp;{next_text}</td>',
+                '    </tr>',
+                '   </table>',
+                '  </div>',
+            ])
         return "\n".join([
-            '  <div class="navfooter">',
-            '   <hr />',
-            '   <table width="100%" summary="Navigation footer">',
-            '    <tr>',
-            f'    <td width="40%" align="left">{prev_a}&nbsp;</td>',
-            f'    <td width="20%" align="center">{up_a}</td>',
-            f'    <td width="40%" align="right">&nbsp;{next_a}</td>',
-            '    </tr>',
-            '    <tr>',
-            f'     <td width="40%" align="left" valign="top">{prev_text}&nbsp;</td>',
-            f'     <td width="20%" align="center">{home_a}</td>',
-            f'     <td width="40%" align="right" valign="top">&nbsp;{next_text}</td>',
-            '    </tr>',
-            '   </table>',
-            '  </div>',
+            nav_html,
             ' </body>',
             '</html>',
         ])
@@ -374,7 +406,7 @@ class ManualHTMLRenderer(RendererMixin, HTMLRenderer):
         return super()._heading_tag(token, tokens, i)
     def _build_toc(self, tokens: Sequence[Token], i: int) -> str:
         toc = TocEntry.of(tokens[i])
-        if toc.kind == 'section':
+        if toc.kind == 'section' and self._html_params.section_toc_depth < 1:
             return ""
         def walk_and_emit(toc: TocEntry, depth: int) -> list[str]:
             if depth <= 0:
@@ -394,34 +426,47 @@ class ManualHTMLRenderer(RendererMixin, HTMLRenderer):
                 if next_level:
                     result.append(f'<dd><dl>{"".join(next_level)}</dl></dd>')
             return result
-        toc_depth = (
-            self._html_params.chunk_toc_depth
-            if toc.starts_new_chunk and toc.kind != 'book'
-            else self._html_params.toc_depth
-        )
-        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)
+        def build_list(kind: str, id: str, lst: Sequence[TocEntry]) -> str:
+            if not lst:
+                return ""
+            entries = [
+                f'<dt>{i}. <a href="{e.target.href()}">{e.target.toc_html}</a></dt>'
+                for i, e in enumerate(lst, start=1)
             ]
-            examples = (
-                '<div class="list-of-examples">'
-                '<p><strong>List of Examples</strong></p>'
-                f'<dl>{"".join(examples_entries)}</dl>'
+            return (
+                f'<div class="{id}">'
+                f'<p><strong>List of {kind}</strong></p>'
+                f'<dl>{"".join(entries)}</dl>'
                 '</div>'
             )
-        return (
-            f'<div class="toc">'
-            f' <p><strong>Table of Contents</strong></p>'
+        # we don't want to generate the "Title of Contents" header for sections,
+        # docbook doesn't and it's only distracting clutter unless it's the main table.
+        # we also want to generate tocs only for a top-level section (ie, one that is
+        # not itself contained in another section)
+        print_title = toc.kind != 'section'
+        if toc.kind == 'section':
+            if toc.parent and toc.parent.kind == 'section':
+                toc_depth = 0
+            else:
+                toc_depth = self._html_params.section_toc_depth
+        elif toc.starts_new_chunk and toc.kind != 'book':
+            toc_depth = self._html_params.chunk_toc_depth
+        else:
+            toc_depth = self._html_params.toc_depth
+        if not (items := walk_and_emit(toc, toc_depth)):
+            return ""
+        figures = build_list("Figures", "list-of-figures", toc.figures)
+        examples = build_list("Examples", "list-of-examples", toc.examples)
+        return "".join([
+            f'<div class="toc">',
+            ' <p><strong>Table of Contents</strong></p>' if print_title else "",
             f' <dl class="toc">'
             f'  {"".join(items)}'
             f' </dl>'
             f'</div>'
+            f'{figures}'
             f'{examples}'
-        )
+        ])
 
     def _make_hN(self, level: int) -> tuple[str, str]:
         # for some reason chapters don't increase the hN nesting count in docbook xslts. duplicate
@@ -458,8 +503,10 @@ class ManualHTMLRenderer(RendererMixin, HTMLRenderer):
             # we do not set _hlevel_offset=0 because docbook doesn't either.
         else:
             inner = outer
+        in_dir = self._in_dir
         for included, path in fragments:
             try:
+                self._in_dir = (in_dir / path).parent
                 inner.append(self.render(included))
             except Exception as e:
                 raise RuntimeError(f"rendering {path}") from e
@@ -502,8 +549,9 @@ class HTMLConverter(BaseConverter[ManualHTMLRenderer]):
         # renderer not set on purpose since it has a dependency on the output path!
 
     def convert(self, infile: Path, outfile: Path) -> None:
-        self._renderer = ManualHTMLRenderer('book', self._revision, self._html_params,
-                                            self._manpage_urls, self._xref_targets, outfile.parent)
+        self._renderer = ManualHTMLRenderer(
+            'book', self._revision, self._html_params, self._manpage_urls, self._xref_targets,
+            infile.parent, outfile.parent)
         super().convert(infile, outfile)
 
     def _parse(self, src: str) -> list[Token]:
@@ -525,23 +573,24 @@ class HTMLConverter(BaseConverter[ManualHTMLRenderer]):
             self._redirection_targets.add(into)
         return tokens
 
-    def _number_examples(self, tokens: Sequence[Token], start: int = 1) -> int:
+    def _number_block(self, block: str, prefix: str, tokens: Sequence[Token], start: int = 1) -> int:
+        title_open, title_close = f'{block}_title_open', f'{block}_title_close'
         for (i, token) in enumerate(tokens):
-            if token.type == "example_title_open":
+            if token.type == 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=f'{prefix} {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)
+                    start = self._number_block(block, prefix, sub, start)
         return start
 
     # xref | (id, type, heading inlines, file, starts new file)
@@ -567,6 +616,12 @@ class HTMLConverter(BaseConverter[ManualHTMLRenderer]):
                     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 == 'figure_open' and (id := cast(str, bt.attrs.get('id', ''))):
+                result.append((id, 'figure', tokens[i + 2], target_file, False))
+            elif bt.type == 'footnote_open' and (id := cast(str, bt.attrs.get('id', ''))):
+                result.append(XrefTarget(id, "???", None, None, target_file))
+            elif bt.type == 'footnote_ref' and (id := cast(str, bt.attrs.get('id', ''))):
+                result.append(XrefTarget(id, "???", None, None, target_file))
             elif bt.type == 'inline':
                 assert bt.children
                 result += self._collect_ids(bt.children, target_file, typ, False)
@@ -591,8 +646,8 @@ 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
+        elif typ in ['example', 'figure']:
+            # skip the prepended `{Example,Figure} N. ` from numbering
             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])
@@ -607,7 +662,8 @@ 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)
+        self._number_block('example', "Example", tokens)
+        self._number_block('figure', "Figure", tokens)
         xref_queue = self._collect_ids(tokens, outfile.name, 'book', True)
 
         failed = False
@@ -629,6 +685,22 @@ class HTMLConverter(BaseConverter[ManualHTMLRenderer]):
                 failed = True # do another round and report the first error
             xref_queue = deferred
 
+        paths_seen = set()
+        for t in self._xref_targets.values():
+            paths_seen.add(t.path)
+
+        if len(paths_seen) == 1:
+            for (k, t) in self._xref_targets.items():
+                self._xref_targets[k] = XrefTarget(
+                    t.id,
+                    t.title_html,
+                    t.toc_html,
+                    t.title,
+                    t.path,
+                    t.drop_fragment,
+                    drop_target=True
+                )
+
         TocEntry.collect_and_link(self._xref_targets, tokens)
 
 
@@ -647,6 +719,8 @@ def _build_cli_html(p: argparse.ArgumentParser) -> None:
     p.add_argument('--script', default=[], action='append')
     p.add_argument('--toc-depth', default=1, type=int)
     p.add_argument('--chunk-toc-depth', default=1, type=int)
+    p.add_argument('--section-toc-depth', default=0, type=int)
+    p.add_argument('--media-dir', default="media", type=Path)
     p.add_argument('infile', type=Path)
     p.add_argument('outfile', type=Path)
 
@@ -660,7 +734,7 @@ def _run_cli_html(args: argparse.Namespace) -> None:
         md = HTMLConverter(
             args.revision,
             HTMLParameters(args.generator, args.stylesheet, args.script, args.toc_depth,
-                           args.chunk_toc_depth),
+                           args.chunk_toc_depth, args.section_toc_depth, args.media_dir),
             json.load(manpage_urls))
         md.convert(args.infile, args.outfile)
 
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 95e6e9474e73f..c6e6bf4293706 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', 'example']
+TocEntryType = Literal['book', 'preface', 'part', 'chapter', 'section', 'appendix', 'example', 'figure']
 
 def is_include(token: Token) -> bool:
     return token.type == "fence" and token.info.startswith("{=include=} ")
@@ -110,9 +110,12 @@ class XrefTarget:
     path: str
     """whether to drop the `#anchor` from links when expanding xrefs"""
     drop_fragment: bool = False
+    """whether to drop the `path.html` from links when expanding xrefs.
+       mostly useful for docbook compatibility"""
+    drop_target: bool = False
 
     def href(self) -> str:
-        path = html.escape(self.path, True)
+        path = "" if self.drop_target else html.escape(self.path, True)
         return path if self.drop_fragment else f"{path}#{html.escape(self.id, True)}"
 
 @dc.dataclass
@@ -125,6 +128,7 @@ class TocEntry(Freezeable):
     children: list[TocEntry] = dc.field(default_factory=list)
     starts_new_chunk: bool = False
     examples: list[TocEntry] = dc.field(default_factory=list)
+    figures: list[TocEntry] = dc.field(default_factory=list)
 
     @property
     def root(self) -> TocEntry:
@@ -139,7 +143,7 @@ class TocEntry(Freezeable):
 
     @classmethod
     def collect_and_link(cls, xrefs: dict[str, XrefTarget], tokens: Sequence[Token]) -> TocEntry:
-        entries, examples = cls._collect_entries(xrefs, tokens, 'book')
+        entries, examples, figures = cls._collect_entries(xrefs, tokens, 'book')
 
         def flatten_with_parent(this: TocEntry, parent: TocEntry | None) -> Iterable[TocEntry]:
             this.parent = parent
@@ -157,6 +161,7 @@ class TocEntry(Freezeable):
             paths_seen.add(c.target.path)
 
         flat[0].examples = examples
+        flat[0].figures = figures
 
         for c in flat:
             c.freeze()
@@ -165,21 +170,23 @@ class TocEntry(Freezeable):
 
     @classmethod
     def _collect_entries(cls, xrefs: dict[str, XrefTarget], tokens: Sequence[Token],
-                         kind: TocEntryType) -> tuple[TocEntry, list[TocEntry]]:
+                         kind: TocEntryType) -> tuple[TocEntry, list[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] = []
+        figures: 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:
-                    subentries, subexamples = cls._collect_entries(xrefs, fragment, fragment_type)
+                    subentries, subexamples, subfigures = cls._collect_entries(xrefs, fragment, fragment_type)
                     entries[-1][1].children.append(subentries)
                     examples += subexamples
+                    figures += subfigures
             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])
@@ -188,7 +195,9 @@ class TocEntry(Freezeable):
                 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]))
+            elif token.type == 'figure_open' and (id := cast(str, token.attrs.get('id', ''))):
+                figures.append(TocEntry('figure', xrefs[id]))
 
         while len(entries) > 1:
             entries[-2][1].children.append(entries.pop()[1])
-        return (entries[0][1], examples)
+        return (entries[0][1], examples, figures)
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 78e05642552b0..f754b61b44393 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
@@ -1,6 +1,6 @@
 from abc import ABC
 from collections.abc import Mapping, MutableMapping, Sequence
-from typing import Any, cast, Generic, get_args, Iterable, Literal, NoReturn, Optional, TypeVar
+from typing import Any, Callable, cast, Generic, get_args, Iterable, Literal, NoReturn, Optional, TypeVar
 
 import dataclasses
 import re
@@ -12,6 +12,7 @@ from markdown_it.token import Token
 from markdown_it.utils import OptionsDict
 from mdit_py_plugins.container import container_plugin # type: ignore[attr-defined]
 from mdit_py_plugins.deflist import deflist_plugin # type: ignore[attr-defined]
+from mdit_py_plugins.footnote import footnote_plugin # type: ignore[attr-defined]
 from mdit_py_plugins.myst_role import myst_role_plugin # type: ignore[attr-defined]
 
 _md_escape_table = {
@@ -40,7 +41,7 @@ def md_make_code(code: str, info: str = "", multiline: Optional[bool] = None) ->
     ticks, sep = ('`' * (longest + (3 if multiline else 1)), '\n' if multiline else ' ')
     return f"{ticks}{info}{sep}{code}{sep}{ticks}"
 
-AttrBlockKind = Literal['admonition', 'example']
+AttrBlockKind = Literal['admonition', 'example', 'figure']
 
 AdmonitionKind = Literal["note", "caution", "tip", "important", "warning"]
 
@@ -90,6 +91,29 @@ class Renderer:
             "example_close": self.example_close,
             "example_title_open": self.example_title_open,
             "example_title_close": self.example_title_close,
+            "image": self.image,
+            "figure_open": self.figure_open,
+            "figure_close": self.figure_close,
+            "figure_title_open": self.figure_title_open,
+            "figure_title_close": self.figure_title_close,
+            "table_open": self.table_open,
+            "table_close": self.table_close,
+            "thead_open": self.thead_open,
+            "thead_close": self.thead_close,
+            "tr_open": self.tr_open,
+            "tr_close": self.tr_close,
+            "th_open": self.th_open,
+            "th_close": self.th_close,
+            "tbody_open": self.tbody_open,
+            "tbody_close": self.tbody_close,
+            "td_open": self.td_open,
+            "td_close": self.td_close,
+            "footnote_ref": self.footnote_ref,
+            "footnote_block_open": self.footnote_block_open,
+            "footnote_block_close": self.footnote_block_close,
+            "footnote_open": self.footnote_open,
+            "footnote_close": self.footnote_close,
+            "footnote_anchor": self.footnote_anchor,
         }
 
         self._admonitions = {
@@ -225,6 +249,52 @@ class Renderer:
         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 image(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        raise RuntimeError("md token not supported", token)
+    def figure_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        raise RuntimeError("md token not supported", token)
+    def figure_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        raise RuntimeError("md token not supported", token)
+    def figure_title_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        raise RuntimeError("md token not supported", token)
+    def figure_title_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        raise RuntimeError("md token not supported", token)
+    def table_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        raise RuntimeError("md token not supported", token)
+    def table_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        raise RuntimeError("md token not supported", token)
+    def thead_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        raise RuntimeError("md token not supported", token)
+    def thead_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        raise RuntimeError("md token not supported", token)
+    def tr_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        raise RuntimeError("md token not supported", token)
+    def tr_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        raise RuntimeError("md token not supported", token)
+    def th_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        raise RuntimeError("md token not supported", token)
+    def th_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        raise RuntimeError("md token not supported", token)
+    def tbody_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        raise RuntimeError("md token not supported", token)
+    def tbody_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        raise RuntimeError("md token not supported", token)
+    def td_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        raise RuntimeError("md token not supported", token)
+    def td_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        raise RuntimeError("md token not supported", token)
+    def footnote_ref(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        raise RuntimeError("md token not supported", token)
+    def footnote_block_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        raise RuntimeError("md token not supported", token)
+    def footnote_block_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        raise RuntimeError("md token not supported", token)
+    def footnote_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        raise RuntimeError("md token not supported", token)
+    def footnote_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
+        raise RuntimeError("md token not supported", token)
+    def footnote_anchor(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
@@ -267,6 +337,8 @@ def _parse_blockattrs(info: str) -> Optional[tuple[AttrBlockKind, Optional[str],
         return ('admonition', id, classes)
     if classes == ['example']:
         return ('example', id, classes)
+    elif classes == ['figure']:
+        return ('figure', id, classes)
     return None
 
 def _attr_span_plugin(md: markdown_it.MarkdownIt) -> None:
@@ -368,6 +440,32 @@ def _heading_ids(md: markdown_it.MarkdownIt) -> None:
 
     md.core.ruler.before("replacements", "heading_ids", heading_ids)
 
+def _footnote_ids(md: markdown_it.MarkdownIt) -> None:
+    """generate ids for footnotes, their refs, and their backlinks. the ids we
+       generate here are derived from the footnote label, making numeric footnote
+       labels invalid.
+    """
+    def generate_ids(tokens: Sequence[Token]) -> None:
+        for token in tokens:
+            if token.type == 'footnote_open':
+                if token.meta["label"][:1].isdigit():
+                    assert token.map
+                    raise RuntimeError(f"invalid footnote label in line {token.map[0] + 1}")
+                token.attrs['id'] = token.meta["label"]
+            elif token.type == 'footnote_anchor':
+                token.meta['target'] = f'{token.meta["label"]}.__back.{token.meta["subId"]}'
+            elif token.type == 'footnote_ref':
+                token.attrs['id'] = f'{token.meta["label"]}.__back.{token.meta["subId"]}'
+                token.meta['target'] = token.meta["label"]
+            elif token.type == 'inline':
+                assert token.children
+                generate_ids(token.children)
+
+    def footnote_ids(state: markdown_it.rules_core.StateCore) -> None:
+        generate_ids(state.tokens)
+
+    md.core.ruler.after("footnote_tail", "footnote_ids", footnote_ids)
+
 def _compact_list_attr(md: markdown_it.MarkdownIt) -> None:
     @dataclasses.dataclass
     class Entry:
@@ -416,6 +514,11 @@ def _block_attr(md: markdown_it.MarkdownIt) -> None:
                     if id is not None:
                         token.attrs['id'] = id
                     stack.append('example_close')
+                elif kind == 'figure':
+                    token.type = 'figure_open'
+                    if id is not None:
+                        token.attrs['id'] = id
+                    stack.append('figure_close')
                 else:
                     assert_never(kind)
             elif token.type == 'container_blockattr_close':
@@ -423,31 +526,37 @@ def _block_attr(md: markdown_it.MarkdownIt) -> None:
 
     md.core.ruler.push("block_attr", block_attr)
 
-def _example_titles(md: markdown_it.MarkdownIt) -> None:
+def _block_titles(block: str) -> Callable[[markdown_it.MarkdownIt], None]:
+    open, close = f'{block}_open', f'{block}_close'
+    title_open, title_close = f'{block}_title_open', f'{block}_title_close'
+
     """
-    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
+    find title headings of blocks and stick them into meta for renderers, then
+    remove them from the token stream. also checks whether any block contains a
     non-title heading since those would make toc generation extremely complicated.
     """
-    def example_titles(state: markdown_it.rules_core.StateCore) -> None:
+    def block_titles(state: markdown_it.rules_core.StateCore) -> None:
         in_example = [False]
         for i, token in enumerate(state.tokens):
-            if token.type == 'example_open':
+            if token.type == 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'
+                    state.tokens[i + 1].type = title_open
+                    state.tokens[i + 3].type = title_close
                 else:
                     assert token.map
-                    raise RuntimeError(f"found example without title in line {token.map[0] + 1}")
+                    raise RuntimeError(f"found {block} without title in line {token.map[0] + 1}")
                 in_example.append(True)
-            elif token.type == 'example_close':
+            elif token.type == 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}")
+                raise RuntimeError(f"unexpected non-title heading in {block} in line {token.map[0] + 1}")
+
+    def do_add(md: markdown_it.MarkdownIt) -> None:
+        md.core.ruler.push(f"{block}_titles", block_titles)
 
-    md.core.ruler.push("example_titles", example_titles)
+    return do_add
 
 TR = TypeVar('TR', bound='Renderer')
 
@@ -478,20 +587,24 @@ class Converter(ABC, Generic[TR]):
             },
             renderer_cls=self.ForbiddenRenderer
         )
+        self._md.enable('table')
         self._md.use(
             container_plugin,
             name="blockattr",
             validate=lambda name, *args: _parse_blockattrs(name),
         )
         self._md.use(deflist_plugin)
+        self._md.use(footnote_plugin)
         self._md.use(myst_role_plugin)
         self._md.use(_attr_span_plugin)
         self._md.use(_inline_comment_plugin)
         self._md.use(_block_comment_plugin)
         self._md.use(_heading_ids)
+        self._md.use(_footnote_ids)
         self._md.use(_compact_list_attr)
         self._md.use(_block_attr)
-        self._md.use(_example_titles)
+        self._md.use(_block_titles("example"))
+        self._md.use(_block_titles("figure"))
         self._md.enable(["smartquotes", "replacements"])
 
     def _parse(self, src: str) -> list[Token]:
diff --git a/pkgs/tools/nix/nixos-render-docs/src/tests/test_commonmark.py b/pkgs/tools/nix/nixos-render-docs/src/tests/test_commonmark.py
index d808c5b50c345..4ff0bc3095c3d 100644
--- a/pkgs/tools/nix/nixos-render-docs/src/tests/test_commonmark.py
+++ b/pkgs/tools/nix/nixos-render-docs/src/tests/test_commonmark.py
@@ -91,3 +91,9 @@ some nested anchors
  - *‌more stuff in same deflist‌*
    
    foo""".replace(' ', ' ')
+
+def test_images() -> None:
+    c = Converter({})
+    assert c._render("![*alt text*](foo \"title \\\"quoted\\\" text\")") == (
+        "![*alt text*](foo \"title \\\"quoted\\\" text\")"
+    )
diff --git a/pkgs/tools/nix/nixos-render-docs/src/tests/test_html.py b/pkgs/tools/nix/nixos-render-docs/src/tests/test_html.py
index df366a8babd7e..96cf8d0b7dff1 100644
--- a/pkgs/tools/nix/nixos-render-docs/src/tests/test_html.py
+++ b/pkgs/tools/nix/nixos-render-docs/src/tests/test_html.py
@@ -1,12 +1,17 @@
 import nixos_render_docs as nrd
 import pytest
+import textwrap
 
 from sample_md import sample1
 
+class Renderer(nrd.html.HTMLRenderer):
+    def _pull_image(self, src: str) -> str:
+        return src
+
 class Converter(nrd.md.Converter[nrd.html.HTMLRenderer]):
     def __init__(self, manpage_urls: dict[str, str], xrefs: dict[str, nrd.manual_structure.XrefTarget]):
         super().__init__()
-        self._renderer = nrd.html.HTMLRenderer(manpage_urls, xrefs)
+        self._renderer = Renderer(manpage_urls, xrefs)
 
 def unpretty(s: str) -> str:
     return "".join(map(str.strip, s.splitlines())).replace('␣', ' ').replace('↵', '\n')
@@ -69,6 +74,78 @@ def test_xrefs() -> None:
         c._render("[](#baz)")
     assert exc.value.args[0] == 'bad local reference, id #baz not known'
 
+def test_images() -> None:
+    c = Converter({}, {})
+    assert c._render("![*alt text*](foo \"title text\")") == unpretty("""
+      <p>
+       <div class="mediaobject">
+        <img src="foo" alt="*alt text*" title="title text" />
+       </div>
+      </p>
+    """)
+
+def test_tables() -> None:
+    c = Converter({}, {})
+    assert c._render(textwrap.dedent("""
+      | d | l | m | r |
+      |---|:--|:-:|--:|
+      | a | b | c | d |
+    """)) == unpretty("""
+      <div class="informaltable">
+       <table class="informaltable" border="1">
+        <colgroup>
+         <col align="left" />
+         <col align="left" />
+         <col align="center" />
+         <col align="right" />
+        </colgroup>
+        <thead>
+         <tr>
+          <th align="left">d</th>
+          <th align="left">l</th>
+          <th align="center">m</th>
+          <th align="right">r</th>
+         </tr>
+        </thead>
+        <tbody>
+         <tr>
+          <td align="left">a</td>
+          <td align="left">b</td>
+          <td align="center">c</td>
+          <td align="right">d</td>
+         </tr>
+        </tbody>
+       </table>
+      </div>
+    """)
+
+def test_footnotes() -> None:
+    c = Converter({}, {
+        "bar": nrd.manual_structure.XrefTarget("bar", "", None, None, ""),
+        "bar.__back.0": nrd.manual_structure.XrefTarget("bar.__back.0", "", None, None, ""),
+        "bar.__back.1": nrd.manual_structure.XrefTarget("bar.__back.1", "", None, None, ""),
+    })
+    assert c._render(textwrap.dedent("""
+      foo [^bar] baz [^bar]
+
+      [^bar]: note
+    """)) == unpretty("""
+      <p>
+       foo <a href="#bar" class="footnote" id="bar.__back.0"><sup class="footnote">[1]</sup></a>␣
+       baz <a href="#bar" class="footnote" id="bar.__back.1"><sup class="footnote">[1]</sup></a>
+      </p>
+      <div class="footnotes">
+       <br />
+       <hr style="width:100; text-align:left;margin-left: 0" />
+       <div id="bar" class="footnote">
+         <p>
+          note<a href="#bar.__back.0" class="para"><sup class="para">[1]</sup></a>
+          <a href="#bar.__back.1" class="para"><sup class="para">[1]</sup></a>
+         </p>
+        </div>
+       </div>
+    """)
+
 def test_full() -> None:
     c = Converter({ 'man(1)': 'http://example.org' }, {})
     assert c._render(sample1) == unpretty("""
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 fb7a4ab0117f7..8564297efdd3a 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
@@ -501,3 +501,28 @@ def test_example() -> None:
     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'
+
+def test_footnotes() -> None:
+    c = Converter({})
+    assert c._parse("text [^foo]\n\n[^foo]: bar") == [
+        Token(type='paragraph_open', tag='p', nesting=1, map=[0, 1], block=True),
+        Token(type='inline', tag='', nesting=0, map=[0, 1], level=1, content='text [^foo]', block=True,
+              children=[
+                  Token(type='text', tag='', nesting=0, content='text '),
+                  Token(type='footnote_ref', tag='', nesting=0, attrs={'id': 'foo.__back.0'},
+                        meta={'id': 0, 'subId': 0, 'label': 'foo', 'target': 'foo'})
+              ]),
+        Token(type='paragraph_close', tag='p', nesting=-1, block=True),
+        Token(type='footnote_block_open', tag='', nesting=1),
+        Token(type='footnote_open', tag='', nesting=1, attrs={'id': 'foo'}, meta={'id': 0, 'label': 'foo'}),
+        Token(type='paragraph_open', tag='p', nesting=1, map=[2, 3], level=1, block=True, hidden=False),
+        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='footnote_anchor', tag='', nesting=0,
+              meta={'id': 0, 'label': 'foo', 'subId': 0, 'target': 'foo.__back.0'}),
+        Token(type='paragraph_close', tag='p', nesting=-1, level=1, block=True),
+        Token(type='footnote_close', tag='', nesting=-1),
+        Token(type='footnote_block_close', tag='', nesting=-1),
+    ]