use std::{fmt::Write, fs, path::Path};
use html_escape::{encode_double_quoted_attribute, encode_text};
use super::{dom::safe_select, process::process_safe};
fn sanitize_option_id(name: &str) -> String {
let sanitized: String = name
.chars()
.map(|c| {
match c {
'*' | '<' | '>' | '[' | ']' | ':' | '"' | ' ' => '_',
c => c,
}
})
.collect();
format!("option-{sanitized}")
}
#[cfg(feature = "gfm")]
#[must_use]
pub fn apply_gfm_extensions(markdown: &str) -> String {
markdown.to_owned()
}
const MAX_INCLUDE_DEPTH: usize = 8;
const INCLUDE_BOUNDARY_MARKER: &str = "<!-- ndg:include-boundary -->";
#[cfg(feature = "nixpkgs")]
fn is_safe_path(path: &str, _base_dir: &Path) -> bool {
let p = Path::new(path);
if path.contains('\\') {
return false;
}
for component in p.components() {
if matches!(component, std::path::Component::ParentDir) {
return false;
}
}
true
}
#[cfg(feature = "nixpkgs")]
struct IncludeDirective {
custom_output: Option<String>,
include_type: Option<String>,
auto_id_prefix: Option<String>,
}
#[cfg(feature = "nixpkgs")]
fn parse_include_directive(line: &str) -> IncludeDirective {
let after_marker = line.strip_prefix("```{=include=}").unwrap_or(line).trim();
let include_type = after_marker
.split_whitespace()
.find(|part| {
!part.starts_with("html:into-file=")
&& !part.starts_with("auto-id-prefix=")
})
.map(str::to_string);
let custom_output = directive_value(line, "html:into-file=");
let auto_id_prefix = directive_value(line, "auto-id-prefix=");
IncludeDirective {
custom_output,
include_type,
auto_id_prefix,
}
}
#[cfg(feature = "nixpkgs")]
fn directive_value(line: &str, marker: &str) -> Option<String> {
line.find(marker).map(|start| {
let start = start + marker.len();
line[start..].find(' ').map_or_else(
|| line[start..].trim().to_string(),
|end| line[start..start + end].to_string(),
)
})
}
#[cfg(feature = "nixpkgs")]
fn apply_auto_id_prefix(content: &str, prefix: &str) -> String {
if prefix.is_empty() {
return content.to_string();
}
let mut result = String::with_capacity(content.len());
let mut fence_tracker = crate::utils::codeblock::FenceTracker::new();
let mut heading_numbers = Vec::new();
for line in content.lines() {
fence_tracker = fence_tracker.process_line(line);
if fence_tracker.in_code_block() {
result.push_str(line);
} else if let Some(line) =
add_auto_id_to_heading(line, prefix, &mut heading_numbers)
{
result.push_str(&line);
} else {
result.push_str(line);
}
result.push('\n');
}
result
}
#[cfg(feature = "nixpkgs")]
fn add_auto_id_to_heading(
line: &str,
prefix: &str,
heading_numbers: &mut Vec<usize>,
) -> Option<String> {
let leading_len = line.len() - line.trim_start().len();
if leading_len > 3 {
return None;
}
let trimmed = line.trim_start();
let level = trimmed.chars().take_while(|&ch| ch == '#').count();
if !(1..=6).contains(&level) {
return None;
}
let after_hashes = &trimmed[level..];
if !after_hashes.is_empty() && !after_hashes.starts_with(char::is_whitespace)
{
return None;
}
let heading = after_hashes.trim();
if heading.is_empty() {
return None;
}
if level > heading_numbers.len() {
heading_numbers.resize(level, 0);
}
heading_numbers.truncate(level);
heading_numbers[level - 1] += 1;
if heading.contains("{#") {
return None;
}
let id = heading_numbers
.iter()
.map(usize::to_string)
.collect::<Vec<_>>()
.join(".");
Some(format!(
"{}{} {{#{}-{}}}",
&line[..leading_len],
trimmed,
prefix,
id
))
}
#[cfg(feature = "nixpkgs")]
fn render_options_include(content: &str) -> Option<String> {
let data: serde_json::Value = serde_json::from_str(content).ok()?;
let options = data.as_object()?;
let mut result = String::new();
for (name, value) in options {
let option_data = value.as_object()?;
let option_id = sanitize_option_id(name);
let _ = writeln!(
result,
"<div class=\"option\" id=\"{}\">",
encode_double_quoted_attribute(&option_id)
);
let _ = writeln!(
result,
" <h3 class=\"option-name\"><a href=\"#{}\" \
class=\"option-anchor\">{}</a></h3>",
encode_double_quoted_attribute(&option_id),
encode_text(name)
);
if let Some(type_name) = option_data.get("type").and_then(|v| v.as_str()) {
let _ = writeln!(
result,
" <div class=\"option-type\">Type: <code>{}</code></div>",
encode_text(type_name)
);
}
if let Some(description) = option_data.get("description") {
let description = match description {
serde_json::Value::String(value) => value.as_str(),
serde_json::Value::Object(object)
if object.get("_type").and_then(|v| v.as_str())
== Some("literalMD") =>
{
object.get("text").and_then(|v| v.as_str()).unwrap_or("")
},
_ => "",
};
if !description.is_empty() {
let _ = writeln!(
result,
" <div class=\"option-description\">{}</div>",
encode_text(description)
);
}
}
result.push_str("</div>\n");
}
Some(result)
}
#[cfg(feature = "nixpkgs")]
fn read_options_includes(
listing: &str,
base_dir: &Path,
included_files: &mut Vec<crate::types::IncludedFile>,
) -> String {
if let Some(source) = parse_options_source(listing) {
return read_options_file(&source, base_dir, included_files);
}
let mut result = String::new();
for line in listing.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || !is_safe_path(trimmed, base_dir) {
continue;
}
let full_path = base_dir.join(trimmed);
match fs::read_to_string(&full_path) {
Ok(content) => {
if let Some(rendered) = render_options_include(&content) {
result.push_str(&rendered);
} else {
let _ = writeln!(
result,
"<!-- ndg: could not parse options include: {} -->",
full_path.display()
);
}
included_files.push(crate::types::IncludedFile {
path: trimmed.to_string(),
custom_output: None,
});
},
Err(_) => {
let _ = writeln!(
result,
"<!-- ndg: could not include file: {} -->",
full_path.display()
);
},
}
}
result
}
#[cfg(feature = "nixpkgs")]
fn parse_options_source(listing: &str) -> Option<String> {
let mut source = None;
for line in listing.lines() {
let (key, value) = line.split_once(':')?;
if key.trim() == "source" {
source = Some(value.trim().to_string());
}
}
source
}
#[cfg(feature = "nixpkgs")]
fn read_options_file(
source: &str,
base_dir: &Path,
included_files: &mut Vec<crate::types::IncludedFile>,
) -> String {
let mut result = String::new();
if !is_safe_path(source, base_dir) {
return result;
}
let full_path = base_dir.join(source);
match fs::read_to_string(&full_path) {
Ok(content) => {
if let Some(rendered) = render_options_include(&content) {
result.push_str(&rendered);
} else {
let _ = writeln!(
result,
"<!-- ndg: could not parse options include: {} -->",
full_path.display()
);
}
included_files.push(crate::types::IncludedFile {
path: source.to_string(),
custom_output: None,
});
},
Err(_) => {
let _ = writeln!(
result,
"<!-- ndg: could not include file: {} -->",
full_path.display()
);
},
}
result
}
#[cfg(feature = "nixpkgs")]
#[allow(
clippy::needless_pass_by_value,
reason = "Owned value needed for cloning in loop"
)]
fn read_includes(
listing: &str,
base_dir: &Path,
custom_output: Option<String>,
auto_id_prefix: Option<String>,
included_files: &mut Vec<crate::types::IncludedFile>,
depth: usize,
) -> Result<String, String> {
let mut result = String::new();
for (line_index, line) in listing.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() || !is_safe_path(trimmed, base_dir) {
continue;
}
let full_path = base_dir.join(trimmed);
log::info!("Including file: {}", full_path.display());
match fs::read_to_string(&full_path) {
Ok(content) => {
let file_dir = full_path.parent().unwrap_or(base_dir);
let (processed_content, nested_includes) =
process_file_includes(&content, file_dir, depth + 1)?;
let processed_content = if let Some(prefix) = auto_id_prefix.as_deref()
{
apply_auto_id_prefix(
&processed_content,
&format!("{}-{}", prefix, line_index + 1),
)
} else {
processed_content
};
if custom_output.is_none() {
result.push_str(&processed_content);
if !processed_content.ends_with('\n') {
result.push('\n');
}
result.push_str(INCLUDE_BOUNDARY_MARKER);
result.push('\n');
}
included_files.push(crate::types::IncludedFile {
path: trimmed.to_string(),
custom_output: custom_output.clone(),
});
for nested in nested_includes {
let nested_full_path = file_dir.join(&nested.path);
if let Ok(normalized_path) = nested_full_path.strip_prefix(base_dir) {
included_files.push(crate::types::IncludedFile {
path: normalized_path.to_string_lossy().to_string(),
custom_output: nested.custom_output,
});
}
}
},
Err(_) => {
let _ = writeln!(
result,
"<!-- ndg: could not include file: {} -->",
full_path.display()
);
},
}
}
Ok(result)
}
#[cfg(feature = "nixpkgs")]
pub fn process_file_includes(
markdown: &str,
base_dir: &std::path::Path,
depth: usize,
) -> Result<(String, Vec<crate::types::IncludedFile>), String> {
if depth >= MAX_INCLUDE_DEPTH {
return Err(format!(
"Maximum include recursion depth ({MAX_INCLUDE_DEPTH}) exceeded. This \
likely indicates a cycle in file includes."
));
}
let mut output = String::new();
let mut lines = markdown.lines();
let mut fence_tracker = crate::utils::codeblock::FenceTracker::new();
let mut all_included_files: Vec<crate::types::IncludedFile> = Vec::new();
while let Some(line) = lines.next() {
if line.trim() == INCLUDE_BOUNDARY_MARKER {
continue;
}
let trimmed = line.trim_start();
if !fence_tracker.in_code_block() && trimmed.starts_with("```{=include=}") {
let directive = parse_include_directive(trimmed);
let mut include_listing = String::new();
for next_line in lines.by_ref() {
if next_line.trim_start().starts_with("```") {
break;
}
include_listing.push_str(next_line);
include_listing.push('\n');
}
let included = if directive.include_type.as_deref() == Some("options") {
read_options_includes(
&include_listing,
base_dir,
&mut all_included_files,
)
} else {
read_includes(
&include_listing,
base_dir,
directive.custom_output,
directive.auto_id_prefix,
&mut all_included_files,
depth,
)?
};
output.push_str(&included);
continue;
}
fence_tracker = fence_tracker.process_line(line);
output.push_str(line);
output.push('\n');
}
Ok((output, all_included_files))
}
#[cfg(any(feature = "nixpkgs", feature = "ndg-flavored"))]
#[must_use]
#[allow(
clippy::implicit_hasher,
reason = "Standard HashMap/HashSet sufficient for this use case"
)]
pub fn process_role_markup(
content: &str,
manpage_urls: Option<&std::collections::HashMap<String, String>>,
auto_link_options: bool,
valid_options: Option<&std::collections::HashSet<String>>,
) -> String {
let mut result = String::new();
let mut chars = content.chars().peekable();
let mut tracker = crate::utils::codeblock::InlineTracker::new();
while let Some(ch) = chars.next() {
if ch == '`' {
let (new_tracker, tick_count) = tracker.process_backticks(&mut chars);
tracker = new_tracker;
result.push_str(&"`".repeat(tick_count));
continue;
}
if ch == '~' && chars.peek() == Some(&'~') {
let (new_tracker, tilde_count) = tracker.process_tildes(&mut chars);
tracker = new_tracker;
result.push_str(&"~".repeat(tilde_count));
continue;
}
if ch == '\n' {
tracker = tracker.process_newline();
result.push(ch);
continue;
}
if ch == '{' && !tracker.in_any_code() {
let remaining: Vec<char> = chars.clone().collect();
let remaining_str: String = remaining.iter().collect();
let mut temp_chars = remaining_str.chars().peekable();
if let Some(role_markup) = parse_role_markup(
&mut temp_chars,
manpage_urls,
auto_link_options,
valid_options,
) {
let remaining_after_parse: String = temp_chars.collect();
let consumed = remaining_str.len() - remaining_after_parse.len();
for _ in 0..consumed {
chars.next();
}
result.push_str(&role_markup);
} else {
result.push(ch);
}
} else {
result.push(ch);
}
}
result
}
fn parse_role_markup(
chars: &mut std::iter::Peekable<std::str::Chars>,
manpage_urls: Option<&std::collections::HashMap<String, String>>,
auto_link_options: bool,
valid_options: Option<&std::collections::HashSet<String>>,
) -> Option<String> {
let mut role_name = String::new();
while let Some(&ch) = chars.peek() {
if ch.is_ascii_lowercase() {
role_name.push(ch);
chars.next();
} else {
break;
}
}
if role_name.is_empty() {
return None;
}
if chars.peek() != Some(&'}') {
return None;
}
chars.next();
if chars.peek() != Some(&'`') {
return None;
}
chars.next();
let mut content = String::new();
for ch in chars.by_ref() {
if ch == '`' {
if content.is_empty() && !matches!(role_name.as_str(), "manpage") {
return None; }
return Some(format_role_markup(
&role_name,
&content,
manpage_urls,
auto_link_options,
valid_options,
));
}
content.push(ch);
}
None
}
#[must_use]
#[allow(
clippy::option_if_let_else,
reason = "Nested options clearer with if-let"
)]
#[allow(
clippy::implicit_hasher,
reason = "Standard HashMap/HashSet sufficient for this use case"
)]
pub fn format_role_markup(
role_type: &str,
content: &str,
manpage_urls: Option<&std::collections::HashMap<String, String>>,
auto_link_options: bool,
valid_options: Option<&std::collections::HashSet<String>>,
) -> String {
let escaped_content = encode_text(content);
match role_type {
"manpage" => {
if let Some(urls) = manpage_urls {
if let Some(url) = urls.get(content) {
format!(
"<a href=\"{url}\" \
class=\"manpage-reference\">{escaped_content}</a>"
)
} else {
format!("<span class=\"manpage-reference\">{escaped_content}</span>")
}
} else {
format!("<span class=\"manpage-reference\">{escaped_content}</span>")
}
},
"command" => format!("<code class=\"command\">{escaped_content}</code>"),
"env" => format!("<code class=\"env-var\">{escaped_content}</code>"),
"file" => format!("<code class=\"file-path\">{escaped_content}</code>"),
"option" => {
if cfg!(feature = "ndg-flavored") && auto_link_options {
let should_link =
valid_options.is_none_or(|opts| opts.contains(content));
if should_link {
let option_id = sanitize_option_id(content);
format!(
"<a class=\"option-reference\" \
href=\"options.html#{option_id}\"><code \
class=\"nixos-option\">{escaped_content}</code></a>"
)
} else {
format!("<code class=\"nixos-option\">{escaped_content}</code>")
}
} else {
format!("<code class=\"nixos-option\">{escaped_content}</code>")
}
},
"var" => format!("<code class=\"nix-var\">{escaped_content}</code>"),
_ => format!("<span class=\"{role_type}-markup\">{escaped_content}</span>"),
}
}
#[must_use]
pub fn process_myst_autolinks(content: &str) -> String {
let mut result = String::with_capacity(content.len());
let mut fence_tracker = crate::utils::codeblock::FenceTracker::new();
for line in content.lines() {
fence_tracker = fence_tracker.process_line(line);
if fence_tracker.in_code_block() {
result.push_str(line);
} else {
result.push_str(&process_line_myst_autolinks(line));
}
result.push('\n');
}
result
}
fn process_line_myst_autolinks(line: &str) -> String {
let mut result = String::with_capacity(line.len());
let mut chars = line.chars().peekable();
let mut tracker = crate::utils::codeblock::InlineTracker::new();
while let Some(ch) = chars.next() {
if ch == '`' {
let (new_tracker, tick_count) = tracker.process_backticks(&mut chars);
tracker = new_tracker;
result.push_str(&"`".repeat(tick_count));
continue;
}
if ch == '[' && chars.peek() == Some(&']') && !tracker.in_any_code() {
chars.next();
if chars.peek() == Some(&'{') {
result.push_str("[]");
continue;
}
if chars.peek() == Some(&'(') {
chars.next();
let mut url = String::new();
let mut found_closing = false;
while let Some(&next_ch) = chars.peek() {
if next_ch == ')' {
chars.next(); found_closing = true;
break;
}
url.push(next_ch);
chars.next();
}
if found_closing && !url.is_empty() {
if url.starts_with('#') {
let _ = write!(result, "[{{{{ANCHOR}}}}]({url})");
} else if url.starts_with("http://") || url.starts_with("https://") {
let _ = write!(result, "<{url}>");
} else {
let _ = write!(result, "[]({url})");
}
} else {
result.push_str("](");
result.push_str(&url);
}
} else {
result.push(']');
}
} else {
result.push(ch);
}
}
result
}
#[cfg(feature = "nixpkgs")]
#[must_use]
pub fn process_inline_anchors(content: &str) -> String {
let mut result = String::with_capacity(content.len() + 100);
let mut fence_tracker = crate::utils::codeblock::FenceTracker::new();
for line in content.lines() {
let trimmed = line.trim_start();
fence_tracker = fence_tracker.process_line(line);
if fence_tracker.in_code_block() {
result.push_str(line);
} else {
if let Some(anchor_start) = find_list_item_anchor(trimmed)
&& let Some(processed_line) =
process_list_item_anchor(line, anchor_start)
{
result.push_str(&processed_line);
result.push('\n');
continue;
}
result.push_str(&process_line_anchors(line));
}
result.push('\n');
}
result
}
#[cfg(feature = "nixpkgs")]
#[must_use]
pub fn process_bracketed_spans(content: &str) -> String {
let mut result = String::with_capacity(content.len());
let mut fence_tracker = crate::utils::codeblock::FenceTracker::new();
for line in content.lines() {
fence_tracker = fence_tracker.process_line(line);
if fence_tracker.in_code_block() {
result.push_str(line);
} else {
result.push_str(&process_line_bracketed_spans(line));
}
result.push('\n');
}
result
}
#[cfg(feature = "nixpkgs")]
fn process_line_bracketed_spans(line: &str) -> String {
let mut result = String::with_capacity(line.len());
let mut chars = line.chars().peekable();
let mut tracker = crate::utils::codeblock::InlineTracker::new();
let mut previous = None;
while let Some(ch) = chars.next() {
if ch == '`' {
let (new_tracker, tick_count) = tracker.process_backticks(&mut chars);
tracker = new_tracker;
result.push_str(&"`".repeat(tick_count));
previous = Some('`');
continue;
}
if ch == '[' && previous != Some('!') && !tracker.in_any_code() {
let remaining: String = chars.clone().collect();
if let Some((html, consumed)) = parse_bracketed_span(&remaining) {
for _ in 0..consumed {
chars.next();
}
result.push_str(&html);
previous = Some('>');
continue;
}
}
result.push(ch);
previous = Some(ch);
}
result
}
#[cfg(feature = "nixpkgs")]
fn parse_bracketed_span(input: &str) -> Option<(String, usize)> {
let close_text = input.find(']')?;
if close_text == 0 {
return None;
}
let text = &input[..close_text];
let after_text = &input[close_text + 1..];
if !after_text.starts_with('{') {
return None;
}
let close_attrs = after_text.find('}')?;
let attrs = &after_text[1..close_attrs];
let html_attrs = render_span_attrs(attrs)?;
let html = format!("<span{html_attrs}>{}</span>", encode_text(text));
Some((html, close_text + 1 + close_attrs + 1))
}
#[cfg(feature = "nixpkgs")]
fn render_span_attrs(attrs: &str) -> Option<String> {
let mut id = None;
let mut classes = Vec::new();
let mut pairs = Vec::new();
for attr in attrs.split_whitespace() {
if let Some(value) = attr.strip_prefix('#') {
if !value.is_empty() {
id = Some(value);
}
} else if let Some(value) = attr.strip_prefix('.') {
if !value.is_empty() {
classes.push(value);
}
} else if let Some((key, value)) = attr.split_once('=')
&& key
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
{
pairs.push((key, value.trim_matches('"')));
}
}
if id.is_none() && classes.is_empty() && pairs.is_empty() {
return None;
}
let mut rendered = String::new();
if let Some(id) = id {
let _ = write!(rendered, " id=\"{}\"", encode_double_quoted_attribute(id));
}
if !classes.is_empty() {
let _ = write!(
rendered,
" class=\"{}\"",
encode_double_quoted_attribute(&classes.join(" "))
);
}
for (key, value) in pairs {
let _ = write!(
rendered,
" {key}=\"{}\"",
encode_double_quoted_attribute(value)
);
}
Some(rendered)
}
fn find_list_item_anchor(trimmed: &str) -> Option<usize> {
if (trimmed.starts_with("- ")
|| trimmed.starts_with("* ")
|| trimmed.starts_with("+ "))
&& trimmed.len() > 2
{
let after_marker = &trimmed[2..];
if after_marker.starts_with("[]{#") {
return Some(2);
}
}
let digit_end = trimmed
.char_indices()
.find(|(_, c)| !c.is_ascii_digit())
.map_or(trimmed.len(), |(i, _)| i);
if digit_end > 0
&& digit_end < trimmed.len() - 1
&& trimmed.as_bytes().get(digit_end) == Some(&b'.')
{
let after_marker = &trimmed[digit_end + 1..];
if after_marker.starts_with(" []{#") {
return Some(digit_end + 2);
}
}
None
}
fn process_list_item_anchor(line: &str, anchor_start: usize) -> Option<String> {
let before_anchor = &line[..anchor_start];
let after_marker = &line[anchor_start..];
if !after_marker.starts_with("[]{#") {
return None;
}
if let Some(anchor_end) = after_marker.find('}') {
let id = &after_marker[4..anchor_end]; let remaining_content = &after_marker[anchor_end + 1..];
if id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
&& !id.is_empty()
{
return Some(format!(
"{before_anchor}<span id=\"{id}\" \
class=\"nixos-anchor\"></span>{remaining_content}"
));
}
}
None
}
fn process_line_anchors(line: &str) -> String {
let mut result = String::with_capacity(line.len());
let mut chars = line.chars().peekable();
let mut tracker = crate::utils::codeblock::InlineTracker::new();
while let Some(ch) = chars.next() {
if ch == '`' {
let (new_tracker, tick_count) = tracker.process_backticks(&mut chars);
tracker = new_tracker;
result.push_str(&"`".repeat(tick_count));
continue;
}
if ch == '[' && chars.peek() == Some(&']') && !tracker.in_any_code() {
chars.next();
if chars.peek() == Some(&'{') {
chars.next(); if chars.peek() == Some(&'#') {
chars.next();
let mut id = String::new();
while let Some(&next_ch) = chars.peek() {
if next_ch == '}' {
chars.next();
if !id.is_empty()
&& id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
let _ = write!(
result,
"<span id=\"{id}\" class=\"nixos-anchor\"></span>"
);
} else {
let _ = write!(result, "[]{{{{#{id}}}}}");
}
break;
} else if next_ch.is_ascii_alphanumeric()
|| next_ch == '-'
|| next_ch == '_'
{
id.push(next_ch);
chars.next();
} else {
let _ = write!(result, "[]{{{{#{id}");
break;
}
}
} else {
result.push_str("]{");
}
} else {
result.push(']');
}
} else {
result.push(ch);
}
}
result
}
#[cfg(feature = "nixpkgs")]
#[must_use]
pub fn process_block_elements(content: &str) -> String {
let mut result = Vec::new();
let mut lines = content.lines().peekable();
let mut fence_tracker = crate::utils::codeblock::FenceTracker::new();
while let Some(line) = lines.next() {
if line.trim() == INCLUDE_BOUNDARY_MARKER {
continue;
}
fence_tracker = fence_tracker.process_line(line);
if !fence_tracker.in_code_block() {
if let Some((callout_type, initial_content)) = parse_github_callout(line)
{
let content =
collect_github_callout_content(&mut lines, &initial_content);
let admonition = render_admonition(&callout_type, None, &content);
result.push(admonition);
continue;
}
if let Some(admonition_start) = parse_fenced_admonition_start(line) {
let indent = leading_whitespace(line);
let (content, trailing) = collect_fenced_content(
&mut lines,
indent,
admonition_start.fence_len,
);
let content = process_block_elements(&content);
let admonition = indent_block(
&render_admonition(
&admonition_start.adm_type,
admonition_start.id.as_deref(),
&content,
),
indent,
);
result.push(admonition);
if let Some(trailing_content) = trailing {
result.push(trailing_content);
}
continue;
}
if let Some((id, title, content)) = parse_figure_block(line, &mut lines) {
let figure = render_figure(id.as_deref(), &title, &content);
result.push(figure);
continue;
}
}
result.push(line.to_string());
}
result.join("\n")
}
fn parse_github_callout(line: &str) -> Option<(String, String)> {
let trimmed = line.trim_start();
if !trimmed.starts_with("> [!") {
return None;
}
if let Some(close_bracket) = trimmed.find(']')
&& close_bracket > 4
{
let callout_type = &trimmed[4..close_bracket];
match callout_type {
"NOTE" | "TIP" | "IMPORTANT" | "WARNING" | "CAUTION" | "DANGER" => {
let content = trimmed[close_bracket + 1..].trim();
return Some((callout_type.to_lowercase(), content.to_string()));
},
_ => return None,
}
}
None
}
fn is_atx_header(line: &str) -> bool {
let mut chars = line.chars();
let mut hash_count = 0;
while let Some(c) = chars.next() {
if c == '#' {
hash_count += 1;
if hash_count > 6 {
return false;
}
} else {
return (1..=6).contains(&hash_count)
&& (c.is_whitespace() || chars.as_str().is_empty());
}
}
(1..=6).contains(&hash_count)
}
fn collect_github_callout_content(
lines: &mut std::iter::Peekable<std::str::Lines>,
initial_content: &str,
) -> String {
let mut content = String::new();
if !initial_content.is_empty() {
content.push_str(initial_content);
content.push('\n');
}
while let Some(line) = lines.peek() {
let trimmed = line.trim_start();
if trimmed.is_empty() {
break;
}
let content_part = if trimmed.starts_with('>') {
trimmed.strip_prefix('>').unwrap_or("").trim_start()
} else {
let starts_new_block = is_atx_header(trimmed)
|| trimmed.starts_with("```")
|| trimmed.starts_with("~~~")
|| (trimmed.starts_with("---")
&& trimmed.chars().all(|c| c == '-' || c.is_whitespace()))
|| (trimmed.starts_with("===")
&& trimmed.chars().all(|c| c == '=' || c.is_whitespace()))
|| (trimmed.starts_with("***")
&& trimmed.chars().all(|c| c == '*' || c.is_whitespace()));
if starts_new_block {
break;
}
trimmed
};
content.push_str(content_part);
content.push('\n');
lines.next(); }
content.trim().to_string()
}
struct AdmonitionStart {
adm_type: String,
id: Option<String>,
fence_len: usize,
}
fn parse_fenced_admonition_start(line: &str) -> Option<AdmonitionStart> {
let trimmed = line.trim();
if !trimmed.starts_with(":::") {
return None;
}
let fence_len = trimmed.chars().take_while(|&ch| ch == ':').count();
if fence_len < 3 {
return None;
}
let after_colons = trimmed[fence_len..].trim_start();
if !after_colons.starts_with('{') {
return None;
}
if let Some(close_brace) = after_colons.find('}') {
let content = &after_colons[1..close_brace];
let mut first_class = None;
let mut adm_type = None;
let mut id = None;
for part in content.split_whitespace() {
if let Some(value) = part.strip_prefix('.') {
let value = value.to_ascii_lowercase();
first_class.get_or_insert_with(|| value.clone());
if matches!(
value.as_str(),
"note" | "tip" | "important" | "warning" | "caution" | "danger"
) {
adm_type.get_or_insert(value);
}
} else if let Some(value) = part.strip_prefix('#') {
id.get_or_insert_with(|| value.to_string());
}
}
if let Some(adm_type) = adm_type.or(first_class) {
return Some(AdmonitionStart {
adm_type,
id,
fence_len,
});
}
}
None
}
fn leading_whitespace(line: &str) -> &str {
let end = line
.char_indices()
.find_map(|(idx, ch)| (!ch.is_whitespace()).then_some(idx))
.unwrap_or(line.len());
&line[..end]
}
fn strip_indent<'a>(line: &'a str, indent: &str) -> &'a str {
line.strip_prefix(indent).unwrap_or(line)
}
fn indent_block(block: &str, indent: &str) -> String {
if indent.is_empty() {
return block.to_string();
}
block
.lines()
.map(|line| format!("{indent}{line}"))
.collect::<Vec<_>>()
.join("\n")
}
fn collect_fenced_content(
lines: &mut std::iter::Peekable<std::str::Lines>,
indent: &str,
fence_len: usize,
) -> (String, Option<String>) {
let mut content = String::new();
for line in lines.by_ref() {
let line = strip_indent(line, indent);
let trimmed = line.trim();
if trimmed == INCLUDE_BOUNDARY_MARKER {
return (content.trim().to_string(), None);
}
let closing_len = trimmed.chars().take_while(|&ch| ch == ':').count();
if closing_len >= fence_len {
let after_colons = &trimmed[closing_len..];
if !after_colons.is_empty() {
return (content.trim().to_string(), Some(after_colons.to_string()));
}
break;
}
content.push_str(line);
content.push('\n');
}
(content.trim().to_string(), None)
}
#[allow(
clippy::option_if_let_else,
reason = "Nested options clearer with if-let"
)]
fn parse_figure_block(
line: &str,
lines: &mut std::iter::Peekable<std::str::Lines>,
) -> Option<(Option<String>, String, String)> {
let trimmed = line.trim();
if !trimmed.starts_with(":::") {
return None;
}
let after_colons = trimmed[3..].trim_start();
if !after_colons.starts_with("{.figure") {
return None;
}
let id = if let Some(hash_pos) = after_colons.find('#') {
if let Some(close_brace) = after_colons.find('}') {
if hash_pos < close_brace {
Some(after_colons[hash_pos + 1..close_brace].trim().to_string())
} else {
None
}
} else {
None
}
} else {
None
};
let title = if let Some(title_line) = lines.next() {
let trimmed_title = title_line.trim();
if let Some(this) = trimmed_title.strip_prefix('#') {
{ this.trim_matches(char::is_whitespace) }.to_string()
} else {
return None;
}
} else {
return None;
};
let mut content = String::new();
for line in lines.by_ref() {
let trimmed = line.trim();
if trimmed == INCLUDE_BOUNDARY_MARKER || trimmed.starts_with(":::") {
break;
}
content.push_str(line);
content.push('\n');
}
Some((id, title, content.trim().to_string()))
}
fn render_admonition(
adm_type: &str,
id: Option<&str>,
content: &str,
) -> String {
let capitalized_type = crate::utils::capitalize_first(adm_type);
let id_attr = id.map_or(String::new(), |id| format!(" id=\"{id}\""));
let opening = format!(
"<div class=\"admonition {adm_type}\"{id_attr}>\n<p \
class=\"admonition-title\">{capitalized_type}</p>"
);
format!("{opening}\n\n{content}\n\n</div>\n")
}
fn render_figure(id: Option<&str>, title: &str, content: &str) -> String {
let id_attr = id.map_or(String::new(), |id| format!(" id=\"{id}\""));
format!(
"<figure{id_attr}>\n<figcaption>{title}</figcaption>\n{content}\n</figure>"
)
}
#[cfg(feature = "nixpkgs")]
#[must_use]
#[allow(
clippy::implicit_hasher,
reason = "Standard HashMap sufficient for this use case"
)]
pub fn process_manpage_references(
html: &str,
manpage_urls: Option<&std::collections::HashMap<String, String>>,
) -> String {
process_safe(
html,
|html| {
use kuchikikiki::NodeRef;
use tendril::TendrilSink;
let document = kuchikikiki::parse_html().one(html);
let mut to_replace = Vec::new();
for span_node in safe_select(&document, "span.manpage-reference") {
let span_el = span_node;
let span_text = span_el.text_contents();
if let Some(urls) = manpage_urls {
if let Some(url) = urls.get(&span_text) {
let clean_url = extract_url_from_html(url);
let link = NodeRef::new_element(
markup5ever::QualName::new(
None,
markup5ever::ns!(html),
markup5ever::local_name!("a"),
),
vec![
(
kuchikikiki::ExpandedName::new("", "href"),
kuchikikiki::Attribute {
prefix: None,
value: clean_url.into(),
},
),
(
kuchikikiki::ExpandedName::new("", "class"),
kuchikikiki::Attribute {
prefix: None,
value: "manpage-reference".into(),
},
),
],
);
link.append(NodeRef::new_text(span_text.clone()));
to_replace.push((span_el.clone(), link));
}
}
}
for (old, new) in to_replace {
old.insert_before(new);
old.detach();
}
let mut out = Vec::new();
let _ = document.serialize(&mut out);
String::from_utf8(out).unwrap_or_else(|_| html.to_string())
},
"",
)
}
#[cfg(feature = "ndg-flavored")]
#[must_use]
#[allow(
clippy::implicit_hasher,
reason = "Standard HashSet sufficient for this use case"
)]
pub fn process_option_references(
html: &str,
valid_options: Option<&std::collections::HashSet<String>>,
) -> String {
use kuchikikiki::{Attribute, ExpandedName, NodeRef};
use markup5ever::{QualName, local_name, ns};
use tendril::TendrilSink;
process_safe(
html,
|html| {
let document = kuchikikiki::parse_html().one(html);
let mut to_replace = vec![];
for code_node in safe_select(&document, "code.nixos-option") {
let code_el = code_node;
let code_text = code_el.text_contents();
let mut is_already_option_ref = false;
let mut current = code_el.parent();
while let Some(parent) = current {
if let Some(element) = parent.as_element()
&& element.name.local == local_name!("a")
&& let Some(class_attr) =
element.attributes.borrow().get(local_name!("class"))
&& class_attr.contains("option-reference")
{
is_already_option_ref = true;
break;
}
current = parent.parent();
}
if !is_already_option_ref {
let should_link =
valid_options.is_none_or(|opts| opts.contains(code_text.as_str()));
if should_link {
let option_id = sanitize_option_id(code_text.as_str());
let attrs = vec![
(ExpandedName::new("", "href"), Attribute {
prefix: None,
value: format!("options.html#{option_id}"),
}),
(ExpandedName::new("", "class"), Attribute {
prefix: None,
value: "option-reference".into(),
}),
];
let a = NodeRef::new_element(
QualName::new(None, ns!(html), local_name!("a")),
attrs,
);
let code = NodeRef::new_element(
QualName::new(None, ns!(html), local_name!("code")),
vec![],
);
code.append(NodeRef::new_text(code_text.clone()));
a.append(code);
to_replace.push((code_el.clone(), a));
}
}
}
for (old, new) in to_replace {
old.insert_before(new);
old.detach();
}
let mut out = Vec::new();
let _ = document.serialize(&mut out);
String::from_utf8(out).unwrap_or_else(|_| html.to_string())
},
"",
)
}
fn extract_url_from_html(url_or_html: &str) -> &str {
if url_or_html.starts_with("<a href=\"") {
if let Some(start) = url_or_html.find("href=\"") {
let start = start + 6; if let Some(end) = url_or_html[start..].find('"') {
return &url_or_html[start..start + end];
}
}
}
url_or_html
}
#[cfg(feature = "wiki")]
#[must_use]
pub fn process_wikilinks(content: &str) -> String {
use crate::utils::codeblock::FenceTracker;
let mut result = String::with_capacity(content.len());
let lines = content.lines();
let mut tracker = FenceTracker::new();
for line in lines {
tracker = tracker.process_line(line);
if tracker.in_code_block() {
result.push_str(line);
} else {
result.push_str(&process_line_wikilinks(line));
}
result.push('\n');
}
result.trim_end().to_string()
}
#[cfg(feature = "wiki")]
fn process_line_wikilinks(line: &str) -> String {
let mut result = String::with_capacity(line.len());
let mut chars = line.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '[' && chars.peek() == Some(&'[') {
chars.next();
let mut inner = String::new();
let mut found_double_close = false;
while let Some(&next_ch) = chars.peek() {
chars.next();
if next_ch == ']' && chars.peek() == Some(&']') {
chars.next();
found_double_close = true;
break;
}
inner.push(next_ch);
}
if found_double_close {
if inner.is_empty() {
result.push_str("[[]]");
} else if inner.contains('|') {
let parts: Vec<&str> = inner.splitn(2, '|').collect();
let name = parts[0].trim();
let url = parts.get(1).unwrap_or(&name).trim();
let escaped_name = encode_text(name);
let escaped_url = encode_text(url);
let _ = write!(
result,
"<a href=\"{escaped_url}\" class=\"wikilink\">{escaped_name}</a>"
);
} else {
let page = inner.trim();
let escaped_page = encode_text(page);
let link_target = format!("{page}.html");
let _ = write!(
result,
"<a href=\"{link_target}\" \
class=\"obsidian-link\">{escaped_page}</a>"
);
}
} else {
result.push_str("[[");
result.push_str(&inner);
}
} else {
result.push(ch);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_atx_header_valid_headers() {
assert!(is_atx_header("# Header"));
assert!(is_atx_header("## Header"));
assert!(is_atx_header("### Header"));
assert!(is_atx_header("#### Header"));
assert!(is_atx_header("##### Header"));
assert!(is_atx_header("###### Header"));
assert!(is_atx_header("#\tHeader"));
assert!(is_atx_header("##\tHeader"));
assert!(is_atx_header("#"));
assert!(is_atx_header("##"));
assert!(is_atx_header("###"));
assert!(is_atx_header("####"));
assert!(is_atx_header("#####"));
assert!(is_atx_header("######"));
assert!(is_atx_header("# Header with multiple spaces"));
assert!(is_atx_header("## Header"));
}
#[test]
fn test_is_atx_header_invalid_headers() {
assert!(!is_atx_header("####### Too many hashes"));
assert!(!is_atx_header("######## Even more"));
assert!(!is_atx_header("#NoSpace"));
assert!(!is_atx_header("##NoSpace"));
assert!(!is_atx_header("Not # a header"));
assert!(!is_atx_header(""));
assert!(!is_atx_header("Regular text"));
assert!(!is_atx_header("#hashtag"));
assert!(!is_atx_header("##hashtag"));
assert!(!is_atx_header("#123"));
assert!(!is_atx_header("##abc"));
assert!(!is_atx_header("#!important"));
assert!(!is_atx_header("#@mention"));
assert!(!is_atx_header("#$variable"));
}
#[test]
fn test_is_atx_header_edge_cases() {
assert!(!is_atx_header(" # Header"));
assert!(!is_atx_header(" ## Header"));
assert!(is_atx_header("# "));
assert!(is_atx_header("## "));
assert!(is_atx_header("# Header\n"));
assert!(is_atx_header("## Header\n"));
assert!(is_atx_header("# \t Header"));
assert!(is_atx_header("## \tHeader"));
}
#[test]
fn test_is_atx_header_blockquote_context() {
assert!(is_atx_header("# New Section"));
assert!(is_atx_header("## Subsection"));
assert!(!is_atx_header("#tag"));
assert!(!is_atx_header("##issue-123"));
assert!(!is_atx_header("###no-space"));
assert!(is_atx_header("###### Level 6"));
assert!(!is_atx_header("####### Not valid"));
}
#[cfg(feature = "wiki")]
#[test]
fn test_wikilink_obsidian_basic() {
let input = "Check out [[Some Page]] for details.";
let result = process_wikilinks(input);
assert!(result.contains("href=\"Some Page.html\""));
assert!(result.contains("class=\"obsidian-link\""));
assert!(result.contains(">Some Page<"));
}
#[cfg(feature = "wiki")]
#[test]
fn test_wikilink_with_url() {
let input = "See [[Custom Name|https://example.com]]";
let result = process_wikilinks(input);
assert!(result.contains("href=\"https://example.com\""));
assert!(result.contains("class=\"wikilink\""));
assert!(result.contains(">Custom Name<"));
}
#[cfg(feature = "wiki")]
#[test]
fn test_wikilink_with_spaces() {
let input = "[[My Page Name]]";
let result = process_wikilinks(input);
assert!(result.contains("href=\"My Page Name.html\""));
}
#[cfg(feature = "wiki")]
#[test]
fn test_wikilink_in_code_block() {
let input = "```\n[[Wiki Link]]\n```\nThen [[Another]]";
let result = process_wikilinks(input);
assert!(result.contains("[[Wiki Link]]"));
assert!(result.contains("href=\"Another.html\""));
}
#[cfg(feature = "wiki")]
#[test]
fn test_wikilink_empty() {
let input = "[[]]";
let result = process_wikilinks(input);
assert!(result.contains("[[]]"));
}
#[cfg(feature = "wiki")]
#[test]
fn test_wikilink_malformed() {
let input = "[[ incomplete";
let result = process_wikilinks(input);
assert!(result.contains("[[ incomplete"));
}
#[cfg(feature = "wiki")]
#[test]
fn test_wikilink_html_escaping() {
let input = "See [[Page With <script>]] for info";
let result = process_wikilinks(input);
assert!(result.contains("<script>"));
assert!(!result.contains(">Page With <script><"));
}
}