Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<pre class="mermaid">` 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
Expand Down
41 changes: 41 additions & 0 deletions crates/ndg-config/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use crate::{
error::ConfigError,
index,
markdown,
mermaid,
meta,
options,
postprocess,
Expand Down Expand Up @@ -125,6 +126,10 @@ pub struct Config {
#[config(nested)]
pub markdown: Option<markdown::MarkdownConfig>,

/// Mermaid diagram configuration.
#[config(nested)]
pub mermaid: Option<mermaid::MermaidConfig>,

/// GitHub revision for linking to source files.
#[config(key = "revision")]
pub revision: String,
Expand Down Expand Up @@ -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(),
Expand All @@ -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 {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions crates/ndg-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
98 changes: 98 additions & 0 deletions crates/ndg-config/src/mermaid.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}

/// 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));
}
}
}
21 changes: 20 additions & 1 deletion crates/ndg-html/src/template.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -1644,6 +1644,25 @@ fn generate_custom_scripts(
)?;
}

if let Some(mermaid) = config.enabled_mermaid() {
// `copy_assets` copies a local Mermaid script to `assets/<file name>`.
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,
"<script defer src=\"{root_prefix}assets/mermaid-init.js\" \
data-mermaid-src=\"{}\"></script>",
encode_double_quoted_attribute(&mermaid_src)
)?;
}

Ok(custom_scripts)
}

Expand Down
83 changes: 83 additions & 0 deletions crates/ndg-html/tests/custom_scripts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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,
"<p>Test content</p>",
"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!(
"<script defer src=\"../assets/mermaid-init.js\" \
data-mermaid-src=\"{DEFAULT_MERMAID_SCRIPT}\"></script>"
)),
"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,
"<p>Test content</p>",
"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"
);
}
2 changes: 2 additions & 0 deletions crates/ndg-templates/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand All @@ -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()
Expand Down
Loading
Loading