diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f27efcdd..4efa4ba5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,14 @@ changes. `sidebar.group_by_dir` report errors ([#290](https://github.com/feel-co/ndg/issues/290)). +- Mermaid diagrams. With `mermaid.enable = true`, NDG renders `mermaid` code + blocks as `
` elements and adds `assets/mermaid-init.js`
+ to each page. The script loads the Mermaid library only on pages with a
+ diagram. `mermaid.script` sets the library as a URL or a local file, which NDG
+ copies to `assets/`. It defaults to Mermaid 11 from jsDelivr. The
+ `ndg-builder` Nix package has a `mermaid` option that uses the library from
+ `mdbook-mermaid`.
+
- A theme toggle in the page header. A click selects the light or the dark
theme, and the page keeps the selection in `localStorage`. When the selected
theme is the same as the system theme, the page follows the system theme
diff --git a/crates/ndg-config/src/config.rs b/crates/ndg-config/src/config.rs
index d51fa6f6e..8077322f8 100644
--- a/crates/ndg-config/src/config.rs
+++ b/crates/ndg-config/src/config.rs
@@ -13,6 +13,7 @@ use crate::{
error::ConfigError,
index,
markdown,
+ mermaid,
meta,
options,
postprocess,
@@ -125,6 +126,10 @@ pub struct Config {
#[config(nested)]
pub markdown: Option,
+ /// Mermaid diagram configuration.
+ #[config(nested)]
+ pub mermaid: Option,
+
/// GitHub revision for linking to source files.
#[config(key = "revision")]
pub revision: String,
@@ -221,6 +226,7 @@ impl Default for Config {
footer_text: DEFAULT_FOOTER_TEXT.to_string(),
highlight_code: true,
markdown: None,
+ mermaid: None,
revision: DEFAULT_REVISION.to_string(),
included_files: FxHashMap::default(),
included_output_files: FxHashMap::default(),
@@ -246,6 +252,20 @@ impl Config {
self.search.as_ref().is_none_or(|s| s.enable)
}
+ /// Returns the Mermaid configuration when Mermaid diagrams are enabled.
+ ///
+ /// Mermaid diagrams are disabled when no `mermaid` configuration is present.
+ #[must_use]
+ pub fn enabled_mermaid(&self) -> Option<&mermaid::MermaidConfig> {
+ self.mermaid.as_ref().filter(|m| m.enable)
+ }
+
+ /// Returns whether Mermaid diagrams are enabled.
+ #[must_use]
+ pub fn is_mermaid_enabled(&self) -> bool {
+ self.enabled_mermaid().is_some()
+ }
+
/// Returns the maximum heading level to index for search.
#[must_use]
pub fn search_max_heading_level(&self) -> u8 {
@@ -842,6 +862,18 @@ impl Config {
}
}
+ // A local Mermaid library file should exist if Mermaid is enabled
+ if let Some(mermaid::MermaidScript::File(script)) = self
+ .enabled_mermaid()
+ .map(mermaid::MermaidConfig::script_source)
+ && !script.is_file()
+ {
+ errors.push(format!(
+ "Mermaid script file does not exist: {}",
+ script.display()
+ ));
+ }
+
// Nixdoc input paths should exist if specified
for (index, nixdoc_input) in self.nixdoc_inputs.iter().enumerate() {
if !nixdoc_input.exists() {
@@ -1196,6 +1228,15 @@ mod tests {
assert_eq!(config.input_dir, Some(PathBuf::from("/new/path")));
}
+ #[test]
+ fn test_apply_override_mermaid() {
+ let mut config = Config::default();
+ assert!(!config.is_mermaid_enabled());
+
+ config.apply_override("mermaid.enable", "true").unwrap();
+ assert!(config.is_mermaid_enabled());
+ }
+
#[test]
fn test_apply_override_nested_config() {
let mut config = Config::default();
diff --git a/crates/ndg-config/src/lib.rs b/crates/ndg-config/src/lib.rs
index 66080aa7b..fd11e88f4 100644
--- a/crates/ndg-config/src/lib.rs
+++ b/crates/ndg-config/src/lib.rs
@@ -5,6 +5,7 @@ pub mod error;
pub mod index;
pub mod markdown;
pub mod matchers;
+pub mod mermaid;
pub mod meta;
pub mod options;
pub mod postprocess;
diff --git a/crates/ndg-config/src/mermaid.rs b/crates/ndg-config/src/mermaid.rs
new file mode 100644
index 000000000..60dd4c627
--- /dev/null
+++ b/crates/ndg-config/src/mermaid.rs
@@ -0,0 +1,98 @@
+use std::path::Path;
+
+use ndg_macros::{ConfigTemplate, Configurable};
+use serde::{Deserialize, Serialize};
+
+/// The Mermaid library that ndg loads when `mermaid.script` is not set.
+pub const DEFAULT_MERMAID_SCRIPT: &str =
+ "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js";
+
+/// Configuration for Mermaid diagrams
+#[derive(
+ Debug, Clone, Default, Serialize, Deserialize, Configurable, ConfigTemplate,
+)]
+#[serde(default, deny_unknown_fields)]
+pub struct MermaidConfig {
+ /// Whether to render `mermaid` fenced code blocks as diagrams
+ #[config(key = "enable")]
+ pub enable: bool,
+
+ /// Where to load the Mermaid library from
+ ///
+ /// A URL (`https://`, `http://` or `//`) is linked as-is. Any other value
+ /// is a path to a local file, which ndg copies to the `assets` directory.
+ /// Defaults to Mermaid 11 from jsDelivr.
+ #[config(key = "script", allow_empty)]
+ #[template(example = DEFAULT_MERMAID_SCRIPT.to_string())]
+ pub script: Option,
+}
+
+/// The source of the Mermaid library.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum MermaidScript<'a> {
+ /// A URL that pages link to as-is.
+ Url(&'a str),
+ /// A local file that ndg copies to the `assets` directory.
+ File(&'a Path),
+}
+
+impl MermaidConfig {
+ /// Returns the configured source of the Mermaid library.
+ #[must_use]
+ pub fn script_source(&self) -> MermaidScript<'_> {
+ let script = self.script.as_deref().unwrap_or(DEFAULT_MERMAID_SCRIPT);
+ if ["https://", "http://", "//"]
+ .iter()
+ .any(|prefix| script.starts_with(prefix))
+ {
+ MermaidScript::Url(script)
+ } else {
+ MermaidScript::File(Path::new(script))
+ }
+ }
+}
+
+#[cfg(test)]
+#[expect(clippy::unwrap_used, reason = "Fine in tests")]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_mermaid_config_defaults_to_disabled_cdn_script() {
+ let config = MermaidConfig::default();
+ assert!(!config.enable);
+ assert_eq!(
+ config.script_source(),
+ MermaidScript::Url(DEFAULT_MERMAID_SCRIPT)
+ );
+ }
+
+ #[test]
+ fn test_mermaid_config_apply_override() {
+ let mut config = MermaidConfig::default();
+ config.apply_override("enable", "true").unwrap();
+ config
+ .apply_override("script", "vendor/mermaid.min.js")
+ .unwrap();
+ assert!(config.enable);
+ assert_eq!(
+ config.script_source(),
+ MermaidScript::File(Path::new("vendor/mermaid.min.js"))
+ );
+ }
+
+ #[test]
+ fn test_mermaid_config_script_source_url() {
+ for url in [
+ "https://example.com/mermaid.min.js",
+ "http://example.com/mermaid.min.js",
+ "//example.com/mermaid.min.js",
+ ] {
+ let config = MermaidConfig {
+ script: Some(url.to_string()),
+ ..Default::default()
+ };
+ assert_eq!(config.script_source(), MermaidScript::Url(url));
+ }
+ }
+}
diff --git a/crates/ndg-html/src/template.rs b/crates/ndg-html/src/template.rs
index 19b4ed9a7..75564b630 100644
--- a/crates/ndg-html/src/template.rs
+++ b/crates/ndg-html/src/template.rs
@@ -11,7 +11,7 @@ use color_eyre::eyre::{Context, Result, bail};
use html_escape::{encode_double_quoted_attribute, encode_text};
use indexmap::IndexMap;
use ndg_commonmark::{Header, MarkdownProcessor};
-use ndg_config::{Config, sidebar::SidebarOrdering};
+use ndg_config::{Config, mermaid::MermaidScript, sidebar::SidebarOrdering};
use ndg_manpage::types::NixOption;
use ndg_templates as templates;
use ndg_utils::{
@@ -1644,6 +1644,25 @@ fn generate_custom_scripts(
)?;
}
+ if let Some(mermaid) = config.enabled_mermaid() {
+ // `copy_assets` copies a local Mermaid script to `assets/`.
+ let mermaid_src = match mermaid.script_source() {
+ MermaidScript::Url(url) => url.to_owned(),
+ MermaidScript::File(path) => {
+ let Some(file_name) = path.file_name() else {
+ return Ok(custom_scripts);
+ };
+ format!("{}assets/{}", root_prefix, file_name.to_string_lossy())
+ },
+ };
+ write!(
+ custom_scripts,
+ "",
+ encode_double_quoted_attribute(&mermaid_src)
+ )?;
+ }
+
Ok(custom_scripts)
}
diff --git a/crates/ndg-html/tests/custom_scripts.rs b/crates/ndg-html/tests/custom_scripts.rs
index 8f2d7724f..69d145c84 100644
--- a/crates/ndg-html/tests/custom_scripts.rs
+++ b/crates/ndg-html/tests/custom_scripts.rs
@@ -7,6 +7,7 @@ use std::{
mod common;
use common::test_config;
+use ndg_config::mermaid::{DEFAULT_MERMAID_SCRIPT, MermaidConfig};
use ndg_html::template::render;
use ndg_utils::assets::copy_assets;
use tempfile::{TempDir, tempdir_in};
@@ -124,3 +125,85 @@ fn test_custom_scripts_reject_duplicate_destination_filenames() {
"previous script"
);
}
+
+fn render_with_mermaid(
+ script: Option<&str>,
+ output_dir: &std::path::Path,
+) -> String {
+ let config = ndg_config::Config {
+ mermaid: Some(MermaidConfig {
+ enable: true,
+ script: script.map(str::to_owned),
+ }),
+ ..test_config(output_dir)
+ };
+
+ render(
+ &config,
+ "Test content
",
+ "Test Page",
+ &[],
+ &PathBuf::from("docs/test.html"),
+ None,
+ )
+ .expect("Failed to render page")
+}
+
+#[test]
+fn test_mermaid_loader_uses_default_cdn() {
+ let temp_dir = TempDir::new().expect("Failed to create temp dir");
+ let html = render_with_mermaid(None, temp_dir.path());
+
+ assert!(
+ html.contains(&format!(
+ ""
+ )),
+ "Loader should point to the default Mermaid script"
+ );
+}
+
+#[test]
+fn test_mermaid_loader_links_to_copied_script() {
+ let temp_dir = TempDir::new().expect("Failed to create temp dir");
+ let html = render_with_mermaid(
+ Some("/nix/store/abc-mermaid/mermaid.min.js"),
+ temp_dir.path(),
+ );
+
+ assert!(
+ html.contains("data-mermaid-src=\"../assets/mermaid.min.js\""),
+ "Loader should point to the copied Mermaid script"
+ );
+ assert!(
+ !html.contains("/nix/store/abc-mermaid"),
+ "Loader should not point to the source path of the script"
+ );
+}
+
+#[test]
+fn test_mermaid_loader_absent_when_disabled() {
+ let temp_dir = TempDir::new().expect("Failed to create temp dir");
+ let config = ndg_config::Config {
+ mermaid: Some(MermaidConfig {
+ enable: false,
+ script: None,
+ }),
+ ..test_config(temp_dir.path())
+ };
+
+ let html = render(
+ &config,
+ "Test content
",
+ "Test Page",
+ &[],
+ &PathBuf::from("docs/test.html"),
+ None,
+ )
+ .expect("Failed to render page");
+
+ assert!(
+ !html.contains("mermaid-init.js"),
+ "Loader should not be added when Mermaid is disabled"
+ );
+}
diff --git a/crates/ndg-templates/src/lib.rs b/crates/ndg-templates/src/lib.rs
index deca59685..b41c98c1c 100644
--- a/crates/ndg-templates/src/lib.rs
+++ b/crates/ndg-templates/src/lib.rs
@@ -23,6 +23,7 @@ pub const SEARCH_JS: &str = include_str!("../templates/search.js");
pub const SEARCH_WORKER_JS: &str =
include_str!("../templates/search-worker.js");
pub const MAIN_JS: &str = include_str!("../templates/main.js");
+pub const MERMAID_INIT_JS: &str = include_str!("../templates/mermaid-init.js");
#[must_use]
pub fn all_templates() -> HashMap<&'static str, &'static str> {
@@ -42,6 +43,7 @@ pub fn all_templates() -> HashMap<&'static str, &'static str> {
templates.insert("search.js", SEARCH_JS);
templates.insert("search-worker.js", SEARCH_WORKER_JS);
templates.insert("main.js", MAIN_JS);
+ templates.insert("mermaid-init.js", MERMAID_INIT_JS);
templates
})
.clone()
diff --git a/crates/ndg-templates/templates/mermaid-init.js b/crates/ndg-templates/templates/mermaid-init.js
new file mode 100644
index 000000000..e382245cf
--- /dev/null
+++ b/crates/ndg-templates/templates/mermaid-init.js
@@ -0,0 +1,92 @@
+/*
+ * Draws Mermaid diagrams. When `mermaid.enable` is set, ndg renders each
+ * `mermaid` fenced code block as a `` element and adds
+ * this script to each page. The `data-mermaid-src` attribute of the script
+ * element gives the location of the Mermaid library.
+ *
+ * The script loads the library only when the page has a diagram, because the
+ * library is large. It also draws the diagrams that the page adds later, for
+ * example when the options page loads more options.
+ *
+ * The diagrams use the theme of ``, or the color scheme of the
+ * system when the page has no `data-theme`. On an `ndg:themechange` event, the
+ * script draws the diagrams again in the new theme.
+ */
+(() => {
+ const script = document.currentScript;
+ // Resolve the location now: client-side navigation can change the page URL.
+ const source = new URL(script.dataset.mermaidSrc, document.baseURI).href;
+ const selector = "pre.mermaid:not([data-processed])";
+ // The text of each diagram, because Mermaid replaces it with the drawing.
+ const sources = new WeakMap();
+ let library;
+ let queue = Promise.resolve();
+
+ function initialize(mermaid) {
+ const theme = document.documentElement.dataset.theme;
+ const dark = theme
+ ? theme === "dark"
+ : window.matchMedia("(prefers-color-scheme: dark)").matches;
+ mermaid.initialize({
+ startOnLoad: false,
+ securityLevel: "strict",
+ theme: dark ? "dark" : "default",
+ });
+ }
+
+ function loadLibrary() {
+ library ??= new Promise((resolve, reject) => {
+ const element = document.createElement("script");
+ element.src = source;
+ element.addEventListener("load", () => {
+ initialize(globalThis.mermaid);
+ resolve(globalThis.mermaid);
+ });
+ element.addEventListener("error", () =>
+ reject(new Error(`Failed to load Mermaid from ${source}`)),
+ );
+ document.head.append(element);
+ });
+ return library;
+ }
+
+ function draw() {
+ if (!document.querySelector(selector)) return;
+ // Draw one batch at a time, so that no diagram is drawn twice.
+ queue = queue
+ .then(async () => {
+ const mermaid = await loadLibrary();
+ for (const element of document.querySelectorAll(selector)) {
+ if (!sources.has(element)) sources.set(element, element.textContent);
+ }
+ await mermaid.run({ querySelector: selector });
+ })
+ .catch((error) => console.error(error));
+ }
+
+ function redraw() {
+ // Without the library, no diagram is drawn yet.
+ if (!library) return;
+ queue = queue
+ .then(async () => {
+ const mermaid = await library;
+ initialize(mermaid);
+ for (const element of document.querySelectorAll(
+ "pre.mermaid[data-processed]",
+ )) {
+ if (!sources.has(element)) continue;
+ element.textContent = sources.get(element);
+ element.removeAttribute("data-processed");
+ }
+ await mermaid.run({ querySelector: selector });
+ })
+ .catch((error) => console.error(error));
+ }
+
+ draw();
+ document.addEventListener("ndg:themechange", redraw);
+ new MutationObserver(draw).observe(document.body, {
+ childList: true,
+ subtree: true,
+ });
+})();
diff --git a/crates/ndg-templates/templates/mermaid-init.test.js b/crates/ndg-templates/templates/mermaid-init.test.js
new file mode 100644
index 000000000..926e5ee1b
--- /dev/null
+++ b/crates/ndg-templates/templates/mermaid-init.test.js
@@ -0,0 +1,148 @@
+import source from "./mermaid-init.js" with { type: "text" };
+
+function assert(condition, message) {
+ if (!condition) throw new Error(message);
+}
+
+function loadMermaidInit({ diagrams, dark = false, theme }) {
+ const appended = [];
+ const runs = [];
+ const initialized = [];
+ const listeners = {};
+ let observe;
+ const mermaid = {
+ initialize(config) {
+ initialized.push(config);
+ },
+ run({ querySelector }) {
+ runs.push({ querySelector, text: diagrams.text });
+ diagrams.text = "";
+ diagrams.processed = true;
+ return Promise.resolve();
+ },
+ };
+ const globals = {};
+ const element = {
+ get textContent() {
+ return diagrams.text;
+ },
+ set textContent(text) {
+ diagrams.text = text;
+ },
+ removeAttribute(name) {
+ if (name === "data-processed") diagrams.processed = false;
+ },
+ };
+ const document = {
+ baseURI: "https://example.com/docs/page.html",
+ currentScript: { dataset: { mermaidSrc: "../assets/mermaid.min.js" } },
+ documentElement: { dataset: theme ? { theme } : {} },
+ body: {},
+ addEventListener(event, listener) {
+ listeners[event] = listener;
+ },
+ head: {
+ append(element) {
+ appended.push(element);
+ globals.mermaid = mermaid;
+ element.listeners.load();
+ },
+ },
+ createElement: () => ({
+ listeners: {},
+ addEventListener(event, listener) {
+ this.listeners[event] = listener;
+ },
+ }),
+ querySelector: () =>
+ diagrams.count > 0 && !diagrams.processed ? {} : null,
+ // One element stands for all the diagrams.
+ querySelectorAll(selector) {
+ if (diagrams.count === 0) return [];
+ if (diagrams.processed === selector.includes(":not(")) return [];
+ return [element];
+ },
+ };
+ const window = { matchMedia: () => ({ matches: dark }) };
+ class MutationObserver {
+ constructor(callback) {
+ observe = callback;
+ }
+ observe() {}
+ }
+ new Function(
+ "document",
+ "window",
+ "MutationObserver",
+ "globalThis",
+ source,
+ )(document, window, MutationObserver, globals);
+ return {
+ appended,
+ runs,
+ initialized,
+ mutate: () => observe(),
+ changeTheme(theme) {
+ document.documentElement.dataset.theme = theme;
+ listeners["ndg:themechange"]();
+ },
+ };
+}
+
+const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
+
+Deno.test("a page without diagrams does not load Mermaid", async () => {
+ const page = loadMermaidInit({ diagrams: { count: 0 } });
+ await settle();
+ assert(page.appended.length === 0, "the library is not loaded");
+});
+
+Deno.test("a page with diagrams loads Mermaid once and draws", async () => {
+ const diagrams = { count: 1 };
+ const page = loadMermaidInit({ diagrams, dark: true });
+ await settle();
+ assert(page.appended.length === 1, "the library is loaded once");
+ assert(
+ page.appended[0].src === "https://example.com/assets/mermaid.min.js",
+ "the library location is resolved against the page",
+ );
+ assert(page.initialized[0].theme === "dark", "the dark theme is selected");
+ assert(page.initialized[0].startOnLoad === false, "Mermaid does not start");
+ assert(page.runs.length === 1, "the diagrams are drawn");
+
+ diagrams.processed = false;
+ diagrams.text = "graph TD; C-->D";
+ page.mutate();
+ await settle();
+ assert(page.appended.length === 1, "the library is not loaded again");
+ assert(page.runs.length === 2, "new diagrams are drawn");
+});
+
+Deno.test("a selected theme overrides the system", async () => {
+ const page = loadMermaidInit({ diagrams: { count: 1 }, theme: "light" });
+ await settle();
+ assert(page.initialized[0].theme === "default", "the light theme is used");
+});
+
+Deno.test("a theme change draws the diagrams again", async () => {
+ const diagrams = { count: 1, text: "graph TD; A-->B" };
+ const page = loadMermaidInit({ diagrams });
+ await settle();
+ assert(page.initialized[0].theme === "default", "the light theme is used");
+
+ page.changeTheme("dark");
+ await settle();
+ assert(page.initialized[1].theme === "dark", "the dark theme is selected");
+ assert(page.runs.length === 2, "the diagrams are drawn again");
+ assert(
+ page.runs[1].text === "graph TD; A-->B",
+ "the diagrams are drawn from their text, not the old drawing",
+ );
+});
+
+Deno.test("a theme change before the first drawing does nothing", async () => {
+ const page = loadMermaidInit({ diagrams: { count: 0 } });
+ page.changeTheme("dark");
+ await settle();
+ assert(page.appended.length === 0, "the library is not loaded");
+});
diff --git a/crates/ndg-utils/src/assets.rs b/crates/ndg-utils/src/assets.rs
index c05643851..09a3547ee 100644
--- a/crates/ndg-utils/src/assets.rs
+++ b/crates/ndg-utils/src/assets.rs
@@ -2,7 +2,7 @@ use std::{fs, path::Path};
use color_eyre::eyre::{self, Context, Result};
use log::debug;
-use ndg_config::Config;
+use ndg_config::{Config, mermaid::MermaidScript};
use ndg_templates as templates;
use walkdir::WalkDir;
@@ -13,6 +13,7 @@ const SEARCH_CORE_JS: &str = templates::SEARCH_CORE_JS;
const SEARCH_JS: &str = templates::SEARCH_JS;
const SEARCH_WORKER_JS: &str = templates::SEARCH_WORKER_JS;
const MAIN_JS: &str = templates::MAIN_JS;
+const MERMAID_INIT_JS: &str = templates::MERMAID_INIT_JS;
/// Copies all required assets (CSS, JS, custom assets, scripts) to the output
/// directory.
@@ -20,6 +21,7 @@ const MAIN_JS: &str = templates::MAIN_JS;
/// This includes:
/// - The main stylesheet (default or template/custom)
/// - Main JavaScript files (main.js, search.js only if enabled)
+/// - The Mermaid loader and a local Mermaid library, if Mermaid is enabled
/// - Any custom assets from the configured assets directory
/// - Any custom script files specified in the configuration
/// - The favicon file if configured
@@ -73,6 +75,8 @@ pub fn copy_assets(config: &Config) -> Result<()> {
// Copy script files to assets directory
copy_script_files(config, &assets_dir)?;
+ copy_mermaid_assets(config, &assets_dir)?;
+
// Create search.js for search functionality
if config.is_search_enabled() {
copy_template_asset(config, &assets_dir, "search-core.js", SEARCH_CORE_JS)?;
@@ -97,6 +101,38 @@ pub fn copy_assets(config: &Config) -> Result<()> {
Ok(())
}
+/// Copies the Mermaid loader and, if the Mermaid script is a local file, the
+/// Mermaid library to the assets directory.
+///
+/// The library is copied as is, because it is already minified.
+///
+/// # Errors
+///
+/// Returns an error if an asset cannot be read or written.
+fn copy_mermaid_assets(config: &Config, assets_dir: &Path) -> Result<()> {
+ let Some(mermaid) = config.enabled_mermaid() else {
+ return Ok(());
+ };
+
+ copy_template_asset(config, assets_dir, "mermaid-init.js", MERMAID_INIT_JS)?;
+
+ if let MermaidScript::File(path) = mermaid.script_source() {
+ let file_name = path.file_name().ok_or_else(|| {
+ eyre::eyre!("Mermaid script has no file name: {}", path.display())
+ })?;
+ let dest_path = assets_dir.join(file_name);
+ fs::copy(path, &dest_path).wrap_err_with(|| {
+ format!(
+ "Failed to copy Mermaid script from {} to {}",
+ path.display(),
+ dest_path.display()
+ )
+ })?;
+ }
+
+ Ok(())
+}
+
/// Copies a template asset to the assets directory, allowing user override if
/// present.
///
diff --git a/crates/ndg-utils/src/markdown.rs b/crates/ndg-utils/src/markdown.rs
index 6d0a6cb95..2bf2022ef 100644
--- a/crates/ndg-utils/src/markdown.rs
+++ b/crates/ndg-utils/src/markdown.rs
@@ -662,6 +662,7 @@ pub fn create_processor(
let mut builder = MarkdownOptionsBuilder::new()
.gfm(true)
.highlight_code(config.highlight_code)
+ .mermaid(config.is_mermaid_enabled())
.extensions(
config
.markdown
diff --git a/ndg-commonmark/src/processor/core.rs b/ndg-commonmark/src/processor/core.rs
index ef9252e3b..f8edba5a9 100644
--- a/ndg-commonmark/src/processor/core.rs
+++ b/ndg-commonmark/src/processor/core.rs
@@ -641,11 +641,46 @@ impl MarkdownProcessor {
fn kuchiki_postprocess(&self, html: &str) -> String {
// Use a standalone function to avoid borrowing issues
kuchiki_postprocess_html(html, |document| {
+ if self.options.mermaid {
+ Self::process_mermaid_blocks(document);
+ }
self.highlight_codeblocks_document(document);
Self::apply_dom_transformations(document);
})
}
+ /// Replace each `mermaid` code block with a `` element
+ /// that holds the diagram source as text. The Mermaid library draws these
+ /// elements on the client.
+ fn process_mermaid_blocks(document: &kuchikikiki::NodeRef) {
+ let mut to_modify = Vec::new();
+ for code_node in safe_select(document, "pre > code.language-mermaid") {
+ if let Some(pre_node) = code_node.parent() {
+ to_modify.push((pre_node, code_node.text_contents()));
+ }
+ }
+
+ for (pre_node, source) in to_modify {
+ let diagram = kuchikikiki::NodeRef::new_element(
+ markup5ever::QualName::new(
+ None,
+ markup5ever::ns!(html),
+ local_name!("pre"),
+ ),
+ vec![(
+ kuchikikiki::ExpandedName::new("", "class"),
+ kuchikikiki::Attribute {
+ prefix: None,
+ value: "mermaid".into(),
+ },
+ )],
+ );
+ diagram.append(kuchikikiki::NodeRef::new_text(source));
+ pre_node.insert_after(diagram);
+ pre_node.detach();
+ }
+ }
+
fn highlight_codeblocks_document(&self, document: &kuchikikiki::NodeRef) {
if !self.options.highlight_code || self.syntax_manager.is_none() {
return;
diff --git a/ndg-commonmark/src/processor/process.rs b/ndg-commonmark/src/processor/process.rs
index 5d43db829..6a03f15e3 100644
--- a/ndg-commonmark/src/processor/process.rs
+++ b/ndg-commonmark/src/processor/process.rs
@@ -163,6 +163,7 @@ fn options_for_preset(preset: ProcessorPreset) -> MarkdownOptions {
auto_link_options: true,
tab_style: TabStyle::None,
valid_options: None,
+ mermaid: false,
}
},
ProcessorPreset::Ndg => {
@@ -177,6 +178,7 @@ fn options_for_preset(preset: ProcessorPreset) -> MarkdownOptions {
auto_link_options: true,
tab_style: TabStyle::None,
valid_options: None,
+ mermaid: false,
}
},
ProcessorPreset::Nixpkgs => {
@@ -191,6 +193,7 @@ fn options_for_preset(preset: ProcessorPreset) -> MarkdownOptions {
auto_link_options: true,
tab_style: TabStyle::None,
valid_options: None,
+ mermaid: false,
}
},
}
diff --git a/ndg-commonmark/src/processor/types.rs b/ndg-commonmark/src/processor/types.rs
index 77b7c2203..265fc8edc 100644
--- a/ndg-commonmark/src/processor/types.rs
+++ b/ndg-commonmark/src/processor/types.rs
@@ -104,6 +104,14 @@ pub struct MarkdownOptions {
/// How to handle hard tabs in code blocks.
pub tab_style: TabStyle,
+
+ /// Render `mermaid` fenced code blocks as Mermaid diagrams.
+ ///
+ /// When `true`, each `mermaid` fenced code block becomes a
+ /// `` element that holds the diagram source, without
+ /// syntax highlighting. The page must load the Mermaid library to draw the
+ /// diagrams.
+ pub mermaid: bool,
}
/// Configuration for handling hard tabs in code blocks.
@@ -135,6 +143,7 @@ impl MarkdownOptions {
auto_link_options: true,
valid_options: None,
tab_style: TabStyle::None,
+ mermaid: false,
}
}
@@ -156,6 +165,7 @@ impl MarkdownOptions {
auto_link_options: true,
valid_options: None,
tab_style: TabStyle::None,
+ mermaid: false,
}
}
}
@@ -173,6 +183,7 @@ impl Default for MarkdownOptions {
auto_link_options: true,
valid_options: None,
tab_style: TabStyle::None,
+ mermaid: false,
}
}
}
@@ -424,6 +435,13 @@ impl MarkdownOptionsBuilder {
self
}
+ /// Enable or disable Mermaid diagram blocks.
+ #[must_use]
+ pub const fn mermaid(mut self, enabled: bool) -> Self {
+ self.options.mermaid = enabled;
+ self
+ }
+
/// Build the final `MarkdownOptions`.
#[must_use]
pub fn build(self) -> MarkdownOptions {
diff --git a/ndg-commonmark/tests/mermaid.rs b/ndg-commonmark/tests/mermaid.rs
new file mode 100644
index 000000000..085bcdd3c
--- /dev/null
+++ b/ndg-commonmark/tests/mermaid.rs
@@ -0,0 +1,70 @@
+#![allow(clippy::expect_used, clippy::unwrap_used, reason = "Fine in tests")]
+use ndg_commonmark::{MarkdownOptions, MarkdownProcessor};
+
+const DIAGRAM: &str = "```mermaid\nflowchart LR\n a --> b\n```\n";
+
+fn render(options: MarkdownOptions, md: &str) -> String {
+ MarkdownProcessor::new(options).render(md).html
+}
+
+#[test]
+fn mermaid_disabled_keeps_code_block() {
+ let html = render(
+ MarkdownOptions {
+ highlight_code: false,
+ ..Default::default()
+ },
+ DIAGRAM,
+ );
+ assert!(html.contains("language-mermaid"), "{html}");
+ assert!(!html.contains(r#""#), "{html}");
+}
+
+#[test]
+fn mermaid_enabled_renders_diagram_element() {
+ let html = render(
+ MarkdownOptions {
+ highlight_code: false,
+ mermaid: true,
+ ..Default::default()
+ },
+ DIAGRAM,
+ );
+ assert!(
+ html.contains("flowchart LR\n a --> b\n
"),
+ "{html}"
+ );
+ assert!(!html.contains("language-mermaid"), "{html}");
+}
+
+#[test]
+fn mermaid_enabled_skips_highlighting() {
+ let options = MarkdownOptions {
+ mermaid: true,
+ ..Default::default()
+ };
+ let highlight_code = options.highlight_code;
+ let html =
+ render(options, &format!("{DIAGRAM}\n```nix\n{{ a = 1; }}\n```\n"));
+ assert!(
+ html.contains("flowchart LR\n a --> b\n
"),
+ "{html}"
+ );
+ if highlight_code {
+ // Other code blocks are still highlighted.
+ assert!(html.contains(r#""#), "{html}");
+ }
+}
+
+#[test]
+fn mermaid_enabled_leaves_other_languages() {
+ let html = render(
+ MarkdownOptions {
+ highlight_code: false,
+ mermaid: true,
+ ..Default::default()
+ },
+ "```text\nflowchart LR\n```\n",
+ );
+ assert!(!html.contains(r#""#), "{html}");
+}
diff --git a/ndg/README.md b/ndg/README.md
index 3e833969f..d7f8a67ca 100644
--- a/ndg/README.md
+++ b/ndg/README.md
@@ -489,6 +489,45 @@ download KaTeX's prebuilt distribution and serve its JavaScript, stylesheet, and
`fonts/` directory from `assets/` instead. See the [KaTeX browser setup] and
[rendering API] documentation.
+### Rendering Mermaid diagrams
+
+NDG can render [Mermaid] diagrams from fenced code blocks with the `mermaid`
+language:
+
+````markdown
+```mermaid
+flowchart LR
+ config["ndg.toml"] --> ndg --> html["HTML"]
+```
+````
+
+Enable this in `ndg.toml`:
+
+```toml
+[mermaid]
+enable = true
+```
+
+NDG then renders each `mermaid` code block as a `` element
+without syntax highlighting, and adds `assets/mermaid-init.js` to each page. The
+script loads the Mermaid library only on pages that have a diagram, and selects
+the dark Mermaid theme when the browser prefers a dark color scheme.
+
+By default, the library is Mermaid 11 from jsDelivr. To use a different version
+or to serve the library from your site, set `script` to a URL or a local path:
+
+```toml
+[mermaid]
+enable = true
+script = "vendor/mermaid.min.js"
+```
+
+NDG copies a local file to the generated `assets/` directory. The Nix builder
+`ndg-builder` has a `mermaid` option, which uses the library from the
+`mdbook-mermaid` package, so that the site does not need a CDN.
+
+[Mermaid]: https://mermaid.js.org
+
#### Advanced Configuration Details
**Parallel Processing (`jobs`):**
diff --git a/ndg/tests/integration.rs b/ndg/tests/integration.rs
index a7d7c78fa..31ec1b2f2 100644
--- a/ndg/tests/integration.rs
+++ b/ndg/tests/integration.rs
@@ -2,7 +2,7 @@
use std::{fs, process::Command};
use ndg::{
- config::{Config, postprocess::PostprocessConfig},
+ config::{Config, mermaid::MermaidConfig, postprocess::PostprocessConfig},
utils::assets::copy_assets,
};
use ndg_commonmark::MarkdownExtension;
@@ -1243,3 +1243,58 @@ fn test_readme_as_homepage_disabled() {
disabled"
);
}
+
+#[test]
+fn test_mermaid_assets_copied_when_enabled() {
+ let temp_dir = tempdir().expect("Failed to create temp dir in test");
+ let output_dir = temp_dir.path().join("output");
+ let library = temp_dir.path().join("mermaid.min.js");
+ let library_content = "globalThis.mermaid = {};";
+ fs::write(&library, library_content)
+ .expect("Failed to write mermaid.min.js in test");
+
+ let config = Config {
+ output_dir: output_dir.clone(),
+ mermaid: Some(MermaidConfig {
+ enable: true,
+ script: Some(library.to_string_lossy().into_owned()),
+ }),
+ postprocess: Some(PostprocessConfig {
+ minify_js: true,
+ ..Default::default()
+ }),
+ ..Default::default()
+ };
+
+ copy_assets(&config).expect("Failed to copy assets in test");
+
+ let assets_dir = output_dir.join("assets");
+ assert!(
+ assets_dir.join("mermaid-init.js").is_file(),
+ "Mermaid loader should be copied"
+ );
+ assert_eq!(
+ fs::read_to_string(assets_dir.join("mermaid.min.js"))
+ .expect("Failed to read copied Mermaid script in test"),
+ library_content,
+ "Mermaid script should be copied without postprocessing"
+ );
+}
+
+#[test]
+fn test_mermaid_assets_absent_when_disabled() {
+ let temp_dir = tempdir().expect("Failed to create temp dir in test");
+ let output_dir = temp_dir.path().join("output");
+
+ let config = Config {
+ output_dir: output_dir.clone(),
+ ..Default::default()
+ };
+
+ copy_assets(&config).expect("Failed to copy assets in test");
+
+ assert!(
+ !output_dir.join("assets").join("mermaid-init.js").exists(),
+ "Mermaid loader should not be copied when Mermaid is disabled"
+ );
+}
diff --git a/nix/internal/checks.nix b/nix/internal/checks.nix
index bd70d7010..13c94aaa6 100644
--- a/nix/internal/checks.nix
+++ b/nix/internal/checks.nix
@@ -31,6 +31,27 @@ in {
};
};
+ mermaid = let
+ site = ndg-builder.override {
+ mermaid = true;
+ inputDir = pkgs.writeTextDir "diagram.md" ''
+ # Diagram
+
+ ```mermaid
+ flowchart LR
+ a --> b
+ ```
+ '';
+ };
+ in
+ pkgs.runCommandLocal "ndg-mermaid" {} ''
+ grep -F 'flowchart LR' ${site}/diagram.html
+ grep -F 'data-mermaid-src="assets/mermaid.min.js"' ${site}/diagram.html
+ test -s ${site}/assets/mermaid.min.js
+ test -s ${site}/assets/mermaid-init.js
+ touch $out
+ '';
+
nrd-options-commonmark = pkgs.testers.runNixOSTest {
name = "ndg-nrd-options-commonmark";
diff --git a/nix/packages/ndg-builder/package.nix b/nix/packages/ndg-builder/package.nix
index 967dd3c1d..45ee6487b 100644
--- a/nix/packages/ndg-builder/package.nix
+++ b/nix/packages/ndg-builder/package.nix
@@ -3,6 +3,7 @@
lib,
# Build Dependencies
ndg,
+ mdbook-mermaid,
runCommandLocal,
nixosOptionsDoc,
writers,
@@ -106,6 +107,9 @@
optionsDepth ? 2,
generateSearch ? true,
highlightCode ? true,
+ # Render `mermaid` code blocks as diagrams, with the Mermaid library from
+ # `mdbook-mermaid`, so that the site does not need a CDN.
+ mermaid ? false,
extraConfig ? {},
# ZIM archive generation
buildZim ? false,
@@ -155,6 +159,12 @@ in
(optionalAttrs (manpageUrls != null) {manpage_urls_path = manpageUrls;})
(optionalAttrs (stylesheets != []) {stylesheet_paths = stylesheets;})
(optionalAttrs (scripts != []) {script_paths = scripts;})
+ (optionalAttrs mermaid {
+ mermaid = {
+ enable = true;
+ script = "${mdbook-mermaid.src}/src/bin/assets/mermaid.min.js";
+ };
+ })
(optionalAttrs (extraConfig != {}) extraConfig)
]);
in