use pulldown_cmark::{html, CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use std::path::Path;
#[derive(Debug, Clone)]
pub struct TocItem {
pub level: u8,
pub text: String,
pub id: String,
}
pub fn extract_headings(content: &str) -> Vec<TocItem> {
let content = fix_fullwidth_heading_spaces(content);
let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_FOOTNOTES);
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TASKLISTS);
options.insert(Options::ENABLE_HEADING_ATTRIBUTES);
let parser = Parser::new_ext(&content, options);
let mut headings = Vec::new();
let mut in_heading: Option<HeadingLevel> = None;
let mut heading_text = String::new();
let mut custom_heading_id: Option<String> = None;
let mut used_slugs: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for event in parser {
match &event {
Event::Start(Tag::Heading { level, id, .. }) => {
in_heading = Some(*level);
heading_text.clear();
custom_heading_id = id.as_ref().map(|s| s.to_string());
}
Event::Text(text) if in_heading.is_some() => {
heading_text.push_str(text);
}
Event::End(TagEnd::Heading(level)) if in_heading.is_some() => {
let level_num = heading_level_to_num(*level);
let id = custom_heading_id
.take()
.unwrap_or_else(|| dedupe_slug(slugify(&heading_text), &mut used_slugs));
if (2..=4).contains(&level_num) {
headings.push(TocItem {
level: level_num,
text: heading_text.clone(),
id,
});
}
in_heading = None;
}
_ => {}
}
}
headings
}
pub fn render_markdown_with_path(
content: &str,
current_path: Option<&str>,
hardbreaks: bool,
) -> String {
let content = content.replace("\r\n", "\n").replace("\r", "\n");
let html = render_markdown_internal(&content, hardbreaks);
if let Some(path) = current_path {
convert_relative_links_to_absolute(&html, path)
} else {
html
}
}
pub fn render_markdown(content: &str) -> String {
let content = content.replace("\r\n", "\n").replace("\r", "\n");
render_markdown_internal(&content, false)
}
pub fn render_markdown_with_hardbreaks(content: &str, hardbreaks: bool) -> String {
let content = content.replace("\r\n", "\n").replace("\r", "\n");
render_markdown_internal(&content, hardbreaks)
}
fn render_markdown_internal(content: &str, hardbreaks: bool) -> String {
let content = content.replace('\u{FEFF}', "");
let content = fix_fullwidth_heading_spaces(&content);
let content = fix_image_paths_with_spaces(&content);
let content = fix_multiline_footnotes(&content);
let content = fix_table_separator_columns(&content);
let content = convert_footnote_definitions_inline(&content, hardbreaks);
let content = convert_footnote_references_to_placeholder(&content);
let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TASKLISTS);
options.insert(Options::ENABLE_HEADING_ATTRIBUTES);
let parser = Parser::new_ext(&content, options);
let mut in_mermaid = false;
let mut mermaid_content = String::new();
let mut in_heading: Option<HeadingLevel> = None;
let mut heading_text = String::new();
let mut custom_heading_id: Option<String> = None; let mut used_slugs: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
let mut events: Vec<Event> = Vec::new();
for event in parser {
match &event {
Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(lang))) => {
let lang_str = lang.as_ref();
if lang_str == "mermaid" || lang_str.starts_with("mermaid") {
in_mermaid = true;
mermaid_content.clear();
continue;
}
}
Event::End(TagEnd::CodeBlock) if in_mermaid => {
let mermaid_html = format!(
r#"<div class="mermaid">{}</div>"#,
html_escape(&mermaid_content)
);
events.push(Event::Html(mermaid_html.into()));
in_mermaid = false;
continue;
}
Event::Text(text) if in_mermaid => {
mermaid_content.push_str(text);
continue;
}
Event::Start(Tag::Heading { level, id, .. }) => {
in_heading = Some(*level);
heading_text.clear();
custom_heading_id = id.as_ref().map(|s| s.to_string());
events.push(event.clone());
continue;
}
Event::Text(text) if in_heading.is_some() => {
heading_text.push_str(text);
events.push(event.clone());
continue;
}
Event::End(TagEnd::Heading(level)) if in_heading.is_some() => {
let id = custom_heading_id
.take()
.unwrap_or_else(|| dedupe_slug(slugify(&heading_text), &mut used_slugs));
let level_num = heading_level_to_num(*level);
let mut heading_events = Vec::new();
while let Some(ev) = events.pop() {
if matches!(ev, Event::Start(Tag::Heading { .. })) {
break;
}
heading_events.push(ev);
}
heading_events.reverse();
let open_tag = format!(r#"<h{} id="{}">"#, level_num, id);
events.push(Event::Html(open_tag.into()));
events.extend(heading_events);
events.push(Event::Html(format!("</h{}>", level_num).into()));
in_heading = None;
continue;
}
Event::SoftBreak if hardbreaks => {
events.push(Event::HardBreak);
continue;
}
_ => {}
}
events.push(event);
}
let mut html_output = String::new();
html::push_html(&mut html_output, events.into_iter());
html_output = fix_relative_links(&html_output);
html_output = autolink_urls(&html_output);
html_output = add_target_blank_to_external_links(&html_output);
html_output = convert_remaining_markdown_images(&html_output);
html_output = convert_footnote_placeholders_to_html(&html_output);
html_output
}
fn heading_level_to_num(level: HeadingLevel) -> u8 {
match level {
HeadingLevel::H1 => 1,
HeadingLevel::H2 => 2,
HeadingLevel::H3 => 3,
HeadingLevel::H4 => 4,
HeadingLevel::H5 => 5,
HeadingLevel::H6 => 6,
}
}
fn slugify(text: &str) -> String {
text.to_lowercase()
.chars()
.filter_map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
Some(c)
} else if c.is_whitespace() {
Some('-')
} else if c > '\x7F' {
Some(c)
} else {
None
}
})
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
fn dedupe_slug(slug: String, used: &mut std::collections::HashMap<String, usize>) -> String {
if !used.contains_key(&slug) {
used.insert(slug.clone(), 0);
return slug;
}
let mut n = used[&slug] + 1;
let mut candidate = format!("{}-{}", slug, n);
while used.contains_key(&candidate) {
n += 1;
candidate = format!("{}-{}", slug, n);
}
used.insert(slug, n);
used.insert(candidate.clone(), 0);
candidate
}
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
fn collect_reference_links(content: &str) -> std::collections::HashMap<String, String> {
let mut links = std::collections::HashMap::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') && !trimmed.starts_with("[^") {
if let Some(bracket_end) = trimmed.find("]:") {
let label = &trimmed[1..bracket_end];
let url = trimmed[bracket_end + 2..].trim();
if !label.is_empty() && !url.is_empty() {
let url = url.trim_start_matches('<').trim_end_matches('>');
links.insert(label.to_lowercase(), url.to_string());
}
}
}
}
links
}
fn resolve_reference_links(
text: &str,
reference_links: &std::collections::HashMap<String, String>,
) -> String {
let mut result = String::new();
let mut chars = text.char_indices().peekable();
while let Some((i, c)) = chars.next() {
if c == '[' {
let rest = &text[i + c.len_utf8()..];
if let Some(end_byte) = rest.find(']') {
let first_label = &rest[..end_byte];
let after_bracket = &rest[end_byte + 1..];
if let Some(after_second_open) = after_bracket.strip_prefix('[') {
if let Some(second_end_byte) = after_second_open.find(']') {
let ref_label = &after_second_open[..second_end_byte];
let lookup_key = if ref_label.is_empty() {
first_label.to_lowercase()
} else {
ref_label.to_lowercase()
};
if let Some(url) = reference_links.get(&lookup_key) {
result.push_str(&format!("<a href=\"{}\">{}</a>", url, first_label));
let chars_to_skip =
first_label.chars().count() + 1 + 1 + ref_label.chars().count() + 1;
for _ in 0..chars_to_skip {
chars.next();
}
continue;
}
}
}
if after_bracket.starts_with('(') {
result.push(c);
continue;
}
if let Some(url) = reference_links.get(&first_label.to_lowercase()) {
result.push_str(&format!("<a href=\"{}\">{}</a>", url, first_label));
let chars_to_skip = first_label.chars().count() + 1;
for _ in 0..chars_to_skip {
chars.next();
}
continue;
}
}
}
result.push(c);
}
result
}
fn convert_footnote_definitions_inline(content: &str, hardbreaks: bool) -> String {
let reference_links = collect_reference_links(content);
let mut result_lines = Vec::new();
let lines: Vec<&str> = content.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i];
if let Some(captures) = parse_footnote_def_start(line) {
let (number, first_line_content) = captures;
let first_line_content = first_line_content.trim_end();
let first_line_resolved = resolve_reference_links(first_line_content, &reference_links);
let mut continuation_lines: Vec<String> = Vec::new();
i += 1;
while i < lines.len() {
let next_line = lines[i];
let trimmed = next_line.trim_start();
if trimmed.is_empty() {
break;
}
if trimmed.starts_with("[^") && trimmed.contains("]:") {
break;
}
if trimmed.starts_with('#') {
break;
}
let resolved_line = resolve_reference_links(next_line, &reference_links);
continuation_lines.push(resolved_line);
i += 1;
}
let return_link = format!(
"<a href=\"#reffn_{}\" title=\"Jump back to footnote [{}] in the text.\"> ↩</a>",
number, number
);
if continuation_lines.is_empty() {
result_lines.push(format!(
"<blockquote id=\"fn_{}\"><sup>{}</sup>. {}{}</blockquote>",
number, number, first_line_resolved, return_link
));
} else {
let continuation_content = continuation_lines.join("\n");
let continuation_html =
render_footnote_continuation(&continuation_content, hardbreaks);
result_lines.push(format!(
"<blockquote id=\"fn_{}\"><sup>{}</sup>. {}{}</blockquote>\n{}",
number, number, first_line_resolved, return_link, continuation_html
));
}
} else {
result_lines.push(line.to_string());
i += 1;
}
}
result_lines.join("\n")
}
fn convert_footnote_references_to_placeholder(content: &str) -> String {
let mut result = String::new();
let mut in_fence: Option<(char, usize)> = None;
for line in content.lines() {
let trimmed = line.trim_start();
if let Some((fence_char, fence_len)) = in_fence {
let run = trimmed.chars().take_while(|&c| c == fence_char).count();
if run >= fence_len && trimmed.trim_end().chars().all(|c| c == fence_char) {
in_fence = None;
}
result.push_str(line);
result.push('\n');
continue;
}
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
let fence_char = trimmed.chars().next().unwrap();
let fence_len = trimmed.chars().take_while(|&c| c == fence_char).count();
in_fence = Some((fence_char, fence_len));
result.push_str(line);
result.push('\n');
continue;
}
result.push_str(&convert_footnote_refs_in_line(line));
result.push('\n');
}
if !content.ends_with('\n') && result.ends_with('\n') {
result.pop();
}
result
}
fn convert_footnote_refs_in_line(line: &str) -> String {
let mut result = String::new();
let mut rest = line;
loop {
match find_inline_code_span(rest) {
Some((start, end)) => {
result.push_str(&convert_footnote_refs_in_text(&rest[..start]));
result.push_str(&rest[start..end]); rest = &rest[end..];
}
None => {
result.push_str(&convert_footnote_refs_in_text(rest));
break;
}
}
}
result
}
fn find_inline_code_span(s: &str) -> Option<(usize, usize)> {
let bytes = s.as_bytes();
let mut open = s.find('`')?;
loop {
let open_len = bytes[open..].iter().take_while(|&&b| b == b'`').count();
let mut idx = open + open_len;
while idx < bytes.len() {
if bytes[idx] == b'`' {
let run_start = idx;
while idx < bytes.len() && bytes[idx] == b'`' {
idx += 1;
}
if idx - run_start == open_len {
return Some((open, idx));
}
} else {
idx += 1;
}
}
let next = s[open + open_len..].find('`')?;
open = open + open_len + next;
}
}
fn convert_footnote_refs_in_text(content: &str) -> String {
let mut result = String::new();
let mut chars = content.char_indices().peekable();
while let Some((i, c)) = chars.next() {
if c == '[' && content[i..].starts_with("[^") {
let rest = &content[i + 2..];
if let Some(end) = rest.find(']') {
let number = &rest[..end];
let after = &rest[end + 1..];
if !after.starts_with(':')
&& !number.is_empty()
&& number.chars().all(|c| c.is_alphanumeric())
{
result.push_str(&format!("%%FNREF_{}%%", number));
for _ in 0..(1 + end + 1) {
chars.next();
}
continue;
}
}
}
result.push(c);
}
result
}
fn convert_footnote_placeholders_to_html(html: &str) -> String {
let mut result = html.to_string();
let re_pattern = "%%FNREF_";
while let Some(start) = result.find(re_pattern) {
let after_prefix = &result[start + re_pattern.len()..];
if let Some(end) = after_prefix.find("%%") {
let number = &after_prefix[..end];
let replacement = format!(
"<sup><a href=\"#fn_{}\" id=\"reffn_{}\">{}</a></sup>",
number, number, number
);
let full_placeholder = format!("%%FNREF_{}%%", number);
result = result.replacen(&full_placeholder, &replacement, 1);
} else {
break;
}
}
result
}
fn parse_footnote_def_start(line: &str) -> Option<(&str, &str)> {
let trimmed = line.trim_start();
if !trimmed.starts_with("[^") {
return None;
}
let after_bracket = &trimmed[2..];
let end_bracket = after_bracket.find("]:")?;
let number = &after_bracket[..end_bracket];
let rest = &after_bracket[end_bracket + 2..].trim_start();
Some((number, rest))
}
fn render_footnote_continuation(content: &str, hardbreaks: bool) -> String {
let min_indent = content
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| line.len() - line.trim_start().len())
.min()
.unwrap_or(0);
let dedented: String = content
.lines()
.map(|line| {
if line.len() >= min_indent {
&line[min_indent..]
} else {
line.trim_start()
}
})
.collect::<Vec<_>>()
.join("\n");
let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_STRIKETHROUGH);
let parser = Parser::new_ext(&dedented, options);
let events: Vec<Event> = parser
.map(|event| {
if hardbreaks {
match event {
Event::SoftBreak => Event::HardBreak,
_ => event,
}
} else {
event
}
})
.collect();
let mut html = String::new();
html::push_html(&mut html, events.into_iter());
html.trim().to_string()
}
fn fix_multiline_footnotes(content: &str) -> String {
let lines: Vec<&str> = content.lines().collect();
let mut result = Vec::new();
let mut in_footnote = false;
for line in lines {
if line.starts_with("[^") && line.contains("]:") {
in_footnote = true;
result.push(line.to_string());
} else if in_footnote {
let trimmed = line.trim_start();
if trimmed.is_empty() {
in_footnote = false;
result.push(line.to_string());
} else if trimmed.starts_with("[^") && trimmed.contains("]:") {
in_footnote = true;
result.push(line.to_string());
} else if trimmed.starts_with('#') {
in_footnote = false;
result.push(line.to_string());
} else {
result.push(format!(" {}", line));
}
} else {
result.push(line.to_string());
}
}
result.join("\n")
}
fn fix_table_separator_columns(content: &str) -> String {
let lines: Vec<&str> = content.lines().collect();
let mut result = Vec::new();
let mut i = 0;
while i < lines.len() {
let line = lines[i];
let trimmed = line.trim();
if trimmed.starts_with('|') {
if i + 1 < lines.len() {
let next_line = lines[i + 1];
if is_table_separator_row(next_line) {
let fixed_header = fix_table_row_trailing_pipe(line);
let header_cols = count_table_columns(&fixed_header);
let separator_cols = count_table_columns(next_line);
result.push(fixed_header);
i += 1;
if header_cols > 0 && separator_cols != header_cols {
let fixed_separator = generate_separator_row(header_cols, next_line);
result.push(fixed_separator);
} else {
result.push(next_line.to_string());
}
i += 1;
continue;
}
}
}
result.push(line.to_string());
i += 1;
}
result.join("\n")
}
fn fix_table_row_trailing_pipe(line: &str) -> String {
let trimmed = line.trim();
if trimmed.starts_with('|') && !trimmed.ends_with('|') {
format!("{}|", line)
} else {
line.to_string()
}
}
fn count_table_columns(line: &str) -> usize {
let trimmed = line.trim();
if !trimmed.starts_with('|') {
return 0;
}
let pipe_count = trimmed.chars().filter(|&c| c == '|').count();
pipe_count.saturating_sub(1)
}
fn is_table_separator_row(line: &str) -> bool {
let trimmed = line.trim();
if !trimmed.starts_with('|') || !trimmed.ends_with('|') {
return false;
}
if !trimmed.contains('-') {
return false;
}
trimmed
.chars()
.all(|c| c == '|' || c == '-' || c == ':' || c.is_whitespace())
}
fn generate_separator_row(col_count: usize, original: &str) -> String {
let trimmed = original.trim();
let original_alignments: Vec<&str> = trimmed
.trim_start_matches('|')
.trim_end_matches('|')
.split('|')
.map(|cell| {
let cell = cell.trim();
if cell.starts_with(':') && cell.ends_with(':') {
":--:" } else if cell.starts_with(':') {
":--" } else if cell.ends_with(':') {
"--:" } else {
"--" }
})
.collect();
let cols: Vec<&str> = (0..col_count)
.map(|i| {
if i < original_alignments.len() {
original_alignments[i]
} else {
"--" }
})
.collect();
format!("|{}|", cols.join("|"))
}
fn fix_fullwidth_heading_spaces(content: &str) -> String {
content
.lines()
.map(|line| {
let trimmed = line.trim_start();
if trimmed.starts_with('#') {
let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
if hash_count > 0 && hash_count <= 6 {
let after_hashes = &trimmed[hash_count..];
if after_hashes.starts_with('\u{3000}') {
let leading_whitespace = &line[..line.len() - trimmed.len()];
let rest = &after_hashes['\u{3000}'.len_utf8()..];
return format!(
"{}{} {}",
leading_whitespace,
"#".repeat(hash_count),
rest
);
}
}
}
line.to_string()
})
.collect::<Vec<_>>()
.join("\n")
}
fn fix_image_paths_with_spaces(content: &str) -> String {
let mut result = String::new();
let mut chars = content.chars().peekable();
while let Some(c) = chars.next() {
if c == '!' {
if chars.peek() == Some(&'[') {
let mut img_str = String::from("!");
img_str.push(chars.next().unwrap());
let mut bracket_depth = 1;
while let Some(&ch) = chars.peek() {
img_str.push(chars.next().unwrap());
if ch == '[' {
bracket_depth += 1;
} else if ch == ']' {
bracket_depth -= 1;
if bracket_depth == 0 {
break;
}
}
}
if chars.peek() == Some(&'(') {
img_str.push(chars.next().unwrap());
let mut url = String::new();
let mut paren_depth = 1;
while let Some(&ch) = chars.peek() {
if ch == '(' {
paren_depth += 1;
url.push(chars.next().unwrap());
} else if ch == ')' {
paren_depth -= 1;
if paren_depth == 0 {
chars.next(); break;
}
url.push(chars.next().unwrap());
} else {
url.push(chars.next().unwrap());
}
}
if url.contains(' ') && !url.starts_with('<') {
img_str.push('<');
img_str.push_str(&url);
img_str.push('>');
} else {
img_str.push_str(&url);
}
img_str.push(')');
}
result.push_str(&img_str);
} else {
result.push(c);
}
} else {
result.push(c);
}
}
result
}
fn fix_relative_links(html: &str) -> String {
let mut result = String::new();
let mut chars = html.char_indices().peekable();
let mut in_tag = false;
while let Some((_, c)) = chars.next() {
result.push(c);
if c == '<' {
in_tag = true;
continue;
}
if c == '>' {
in_tag = false;
continue;
}
if in_tag && (c == '"' || c == '\'') {
let quote_char = c;
let before_quote = &result[..result.len() - quote_char.len_utf8()];
let suffix: String = before_quote
.chars()
.rev()
.take(5)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
if suffix.eq_ignore_ascii_case("href=") {
let mut url = String::new();
let mut closed = false;
for (_, ch) in chars.by_ref() {
if ch == quote_char {
result.push_str(&fix_md_extension(&url));
result.push(quote_char);
closed = true;
break;
}
url.push(ch);
}
if !closed {
result.push_str(&url);
}
}
}
}
normalize_path_separators(&result)
}
fn fix_md_extension(url: &str) -> String {
if let Some(stripped) = url.strip_suffix(".md") {
format!("{}.html", stripped)
} else if let Some(pos) = url.find(".md#") {
format!("{}.html{}", &url[..pos], &url[pos + 3..])
} else {
url.to_string()
}
}
fn quote_opens_link_attr(result: &str, quote_char: char) -> bool {
let before = &result[..result.len() - quote_char.len_utf8()];
let tail: String = before
.chars()
.rev()
.take(5)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
let tail = tail.to_ascii_lowercase();
tail.ends_with("href=") || tail.ends_with("src=")
}
fn map_link_attr_values(html: &str, f: impl Fn(String) -> String) -> String {
let mut result = String::new();
let mut chars = html.char_indices().peekable();
while let Some((_, c)) = chars.next() {
result.push(c);
if (c == '"' || c == '\'') && quote_opens_link_attr(&result, c) {
let quote_char = c;
let mut url = String::new();
let mut closed = false;
for (_, ch) in chars.by_ref() {
if ch == quote_char {
result.push_str(&f(url.clone()));
result.push(quote_char);
closed = true;
break;
}
url.push(ch);
}
if !closed {
result.push_str(&url);
}
}
}
result
}
fn remove_leading_slash_from_links(html: &str) -> String {
map_link_attr_values(html, |url| {
if url.starts_with('/') && !url.starts_with("//") {
let lower = url.to_lowercase();
if !lower.starts_with("/http://") && !lower.starts_with("/https://") {
return url.chars().skip(1).collect();
}
}
url
})
}
fn normalize_path_separators(html: &str) -> String {
map_link_attr_values(html, |url| url.replace('\\', "/"))
}
fn add_target_blank_to_external_links(html: &str) -> String {
let mut result = String::new();
let mut chars = html.char_indices().peekable();
while let Some((i, c)) = chars.next() {
if c == '<' && html[i..].starts_with("<a ") {
let mut tag_content = String::from("<a ");
chars.next(); chars.next();
for (_, ch) in chars.by_ref() {
tag_content.push(ch);
if ch == '>' {
break;
}
}
let tag_lower = tag_content.to_lowercase();
let has_target = tag_lower.contains("target=");
let is_external = tag_lower.contains("href=\"http://")
|| tag_lower.contains("href=\"https://")
|| tag_lower.contains("href='http://")
|| tag_lower.contains("href='https://");
if is_external && !has_target {
let without_close = tag_content.trim_end_matches('>');
result.push_str(without_close);
result.push_str(" target=\"_blank\" rel=\"noopener noreferrer\">");
} else {
result.push_str(&tag_content);
}
} else {
result.push(c);
}
}
result
}
fn autolink_urls(html: &str) -> String {
let mut result = String::new();
let mut chars = html.char_indices().peekable();
let mut in_code = false;
while let Some((i, c)) = chars.next() {
if c == '<' {
result.push(c);
let mut tag_content = String::new();
for (_, ch) in chars.by_ref() {
result.push(ch);
if ch == '>' {
break;
}
tag_content.push(ch);
}
let tag_lower = tag_content.to_lowercase();
if tag_lower.starts_with("code") || tag_lower.starts_with("pre") {
in_code = true;
} else if tag_lower.starts_with("/code") || tag_lower.starts_with("/pre") {
in_code = false;
}
continue;
}
if in_code {
result.push(c);
continue;
}
if c == 'h' && html[i..].starts_with("http://") || html[i..].starts_with("https://") {
if result.ends_with("href=\"") || result.ends_with("src=\"") {
result.push(c);
continue;
}
let url_start = i;
let mut url_end = i + 1;
while let Some(&(next_i, next_c)) = chars.peek() {
if next_c.is_whitespace()
|| next_c == '<'
|| next_c == '>'
|| next_c == '"'
|| next_c == '\''
{
break;
}
url_end = next_i + next_c.len_utf8();
chars.next();
}
let mut url = &html[url_start..url_end];
while url.ends_with('.')
|| url.ends_with(',')
|| url.ends_with(';')
|| url.ends_with(':')
|| url.ends_with(')')
|| url.ends_with('!')
|| url.ends_with('?')
{
url = &url[..url.len() - 1];
}
result.push_str(&format!(r#"<a href="{}" target="_blank">{}</a>"#, url, url));
let trimmed_len = url_end - url_start - url.len();
if trimmed_len > 0 {
result.push_str(&html[url_start + url.len()..url_end]);
}
} else {
result.push(c);
}
}
result
}
fn convert_remaining_markdown_images(html: &str) -> String {
let mut result = String::new();
let mut chars = html.char_indices().peekable();
let mut in_code = false;
while let Some((_, c)) = chars.next() {
if c == '<' {
result.push(c);
let mut tag_content = String::new();
for (_, ch) in chars.by_ref() {
result.push(ch);
if ch == '>' {
break;
}
tag_content.push(ch);
}
let tag_lower = tag_content.to_lowercase();
if tag_lower.starts_with("code") || tag_lower.starts_with("pre") {
in_code = true;
} else if tag_lower.starts_with("/code") || tag_lower.starts_with("/pre") {
in_code = false;
}
continue;
}
if in_code {
result.push(c);
continue;
}
if c == '!' && chars.peek().map(|(_, ch)| *ch) == Some('[') {
chars.next();
let mut alt = String::new();
let mut bracket_depth = 1;
for (_, ch) in chars.by_ref() {
if ch == '[' {
bracket_depth += 1;
alt.push(ch);
} else if ch == ']' {
bracket_depth -= 1;
if bracket_depth == 0 {
break;
}
alt.push(ch);
} else {
alt.push(ch);
}
}
if chars.peek().map(|(_, ch)| *ch) == Some('(') {
chars.next();
let mut url = String::new();
let mut paren_depth = 1;
for (_, ch) in chars.by_ref() {
if ch == '(' {
paren_depth += 1;
url.push(ch);
} else if ch == ')' {
paren_depth -= 1;
if paren_depth == 0 {
break;
}
url.push(ch);
} else {
url.push(ch);
}
}
result.push_str(&format!(
r#"<img src="{}" alt="{}">"#,
html_escape(&url),
html_escape(&alt)
));
} else {
result.push('!');
result.push('[');
result.push_str(&alt);
result.push(']');
}
} else {
result.push(c);
}
}
result
}
fn convert_relative_links_to_absolute(html: &str, current_path: &str) -> String {
let result = html.to_string();
let depth = Path::new(current_path)
.parent()
.map(|p| {
let dir = p.to_string_lossy();
if dir.is_empty() {
0
} else {
dir.matches('/').count() + 1
}
})
.unwrap_or(0);
let root_prefix: String = "../".repeat(depth);
let result = adjust_attribute_urls(&result, r#"href=""#, &root_prefix, depth, true);
adjust_attribute_urls(&result, r#"src=""#, &root_prefix, depth, false)
}
fn adjust_attribute_urls(
html: &str,
attr_pattern: &str,
root_prefix: &str,
depth: usize,
convert_bare_dir_paths: bool,
) -> String {
let result = html;
let mut new_result = String::new();
let mut last_end = 0;
let mut search_start = 0;
while let Some(attr_pos) = result[search_start..].find(attr_pattern) {
let abs_attr_pos = search_start + attr_pos;
let url_start = abs_attr_pos + attr_pattern.len();
if let Some(url_end_offset) = result[url_start..].find('"') {
let url_end = url_start + url_end_offset;
let url = &result[url_start..url_end];
let is_root_relative = url.starts_with('/')
&& !url.starts_with("//") && !url.to_lowercase().starts_with("/http://")
&& !url.to_lowercase().starts_with("/https://");
if is_root_relative {
new_result.push_str(&result[last_end..url_start]);
new_result.push_str(root_prefix);
new_result.push_str(&url[1..]); last_end = url_end;
search_start = url_end + 1;
continue;
}
let needs_conversion = convert_bare_dir_paths
&& !url.is_empty()
&& url.contains('/') && !url.starts_with("http://")
&& !url.starts_with("https://")
&& !url.starts_with('#')
&& !url.starts_with("../")
&& !url.starts_with("./")
&& !url.starts_with('/')
&& !url.starts_with("mailto:")
&& !url.starts_with("javascript:")
&& !url.starts_with("data:")
&& depth > 0;
if needs_conversion {
new_result.push_str(&result[last_end..url_start]);
new_result.push_str(root_prefix);
new_result.push_str(url);
last_end = url_end;
}
search_start = url_end + 1;
} else {
search_start = url_start + 1;
}
}
new_result.push_str(&result[last_end..]);
new_result
}
pub fn render_asciidoc(content: &str) -> String {
render_asciidoc_internal(content)
}
pub fn render_asciidoc_with_path(content: &str, current_path: Option<&str>) -> String {
let html = render_asciidoc_internal(content);
if let Some(path) = current_path {
convert_relative_links_to_absolute(&html, path)
} else {
html
}
}
pub fn extract_headings_from_asciidoc(content: &str) -> Vec<TocItem> {
let mut headings = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("==") && !trimmed.starts_with("====") {
let eq_count = trimmed.chars().take_while(|&c| c == '=').count();
if (2..=5).contains(&eq_count) {
let level = eq_count as u8; let text = trimmed[eq_count..].trim().to_string();
if (2..=4).contains(&level) && !text.is_empty() {
let id = slugify(&text);
headings.push(TocItem { level, text, id });
}
}
}
}
headings
}
fn render_asciidoc_internal(content: &str) -> String {
let content = content.replace("\r\n", "\n").replace("\r", "\n");
let content = content.replace('\u{FEFF}', "");
let scanner = asciidocr::scanner::Scanner::new(&content);
let mut parser = asciidocr::parser::Parser::new(std::path::PathBuf::from("."));
match parser.parse(scanner) {
Ok(asg) => {
match asciidocr::backends::htmls::render_htmlbook(&asg) {
Ok(html) => {
let html = extract_body_content(&html);
let html = fix_asciidoc_relative_links(&html);
let html = remove_leading_slash_from_links(&html);
let html = autolink_urls(&html);
add_target_blank_to_external_links(&html)
}
Err(e) => {
eprintln!(" Warning: AsciiDoc conversion error: {:?}", e);
format!("<p>{}</p>", html_escape(&content))
}
}
}
Err(e) => {
eprintln!(" Warning: AsciiDoc parsing error: {:?}", e);
format!("<p>{}</p>", html_escape(&content))
}
}
}
fn extract_body_content(html: &str) -> String {
if let Some(body_start) = html.find("<body>") {
let content_start = body_start + 6; if let Some(body_end) = html.find("</body>") {
return html[content_start..body_end].trim().to_string();
}
}
html.to_string()
}
fn fix_asciidoc_relative_links(html: &str) -> String {
let mut result = html.to_string();
let patterns = [
(r#".adoc""#, r#".html""#),
(r#".adoc#"#, r#".html#"#),
(r#".adoc'"#, r#".html'"#),
(r#".asciidoc""#, r#".html""#),
(r#".asciidoc#"#, r#".html#"#),
(r#".asciidoc'"#, r#".html'"#),
(r#".md""#, r#".html""#),
(r#".md#"#, r#".html#"#),
(r#".md'"#, r#".html'"#),
];
for (from, to) in patterns {
result = result.replace(from, to);
}
result = normalize_path_separators(&result);
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_render_basic_markdown() {
let md = "# Hello\n\nThis is a **test**.";
let html = render_markdown(md);
assert!(
html.contains("<h1 id=\"hello\">Hello</h1>"),
"HTML: {}",
html
);
assert!(html.contains("<strong>test</strong>"));
}
#[test]
fn test_render_table() {
let md = r#"
| Header 1 | Header 2 |
|----------|----------|
| Cell 1 | Cell 2 |
"#;
let html = render_markdown(md);
assert!(html.contains("<table>"));
assert!(html.contains("<th>Header 1</th>"));
}
#[test]
fn test_render_mermaid() {
let md = r#"
```mermaid
sequenceDiagram
A->>B: Hello
```
"#;
let html = render_markdown(md);
assert!(html.contains(r#"<div class="mermaid">"#));
assert!(html.contains("sequenceDiagram"));
}
#[test]
fn test_fix_relative_links() {
let html = r#"<a href="chapter1.md">Link</a>"#;
let fixed = fix_relative_links(html);
assert!(fixed.contains(r#"href="chapter1.html""#));
}
#[test]
fn test_image_in_table() {
let md = r#"
| Col1 | Col2 |
|:--:|:--:|
||text|
"#;
let html = render_markdown(md);
println!("Generated HTML: {}", html);
assert!(
html.contains("<img"),
"Image tag should be generated: {}",
html
);
}
#[test]
fn test_image_in_table_japanese() {
let md = r#"## デザイン
|該当するタイムラインがある場合|該当するタイムラインがない場合|
|:--:|:--:|
|||
## 項目一覧"#;
let html = render_markdown(md);
println!("Generated HTML: {}", html);
assert!(
html.contains("<img"),
"Image tag should be generated: {}",
html
);
}
#[test]
fn test_image_with_space_in_filename() {
let md = r#"||"#;
let html = render_markdown(md);
println!("With space: {}", html);
let md2 = r#"||"#;
let html2 = render_markdown(md2);
println!("No space: {}", html2);
}
#[test]
fn test_autolink_urls() {
let md = "Guide Git:https://github.com/guide-inc-org/kcmsr-member-site-spec";
let html = render_markdown(md);
println!("Autolink result: {}", html);
assert!(html.contains(r#"<a href="https://github.com/guide-inc-org/kcmsr-member-site-spec" target="_blank">"#),
"URL should be auto-linked: {}", html);
}
#[test]
fn test_autolink_does_not_double_link() {
let md = "[Link](https://example.com)";
let html = render_markdown(md);
println!("Already linked result: {}", html);
let count = html.matches("https://example.com").count();
assert_eq!(count, 1, "URL should appear only once: {}", html);
}
#[test]
fn test_multiline_footnotes() {
let md = r#"Text with footnote[^1].
[^1]: First line
- Second line
- Third line
[^2]: Another footnote"#;
let html = render_markdown(md);
println!("Footnote HTML: {}", html);
assert!(
html.contains("<li>"),
"Footnote should contain list items: {}",
html
);
}
#[test]
fn test_fix_multiline_footnotes_preprocessing() {
let input = r#"[^1]: First line
- Second line
- Third line
[^2]: Another"#;
let output = fix_multiline_footnotes(input);
println!("Preprocessed:\n{}", output);
assert!(
output.contains(" - Second line"),
"Second line should be indented: {}",
output
);
assert!(
output.contains(" - Third line"),
"Third line should be indented: {}",
output
);
assert!(
!output.contains(" [^2]"),
"New footnote should not be indented: {}",
output
);
}
#[test]
fn test_slugify_matches_github_slugger() {
assert_eq!(
slugify("/auth/verification-email/resend"),
"authverification-emailresend"
);
assert_eq!(slugify("Hello World"), "hello-world");
assert_eq!(slugify("A.B.C"), "abc"); assert_eq!(slugify("日本語テスト"), "日本語テスト"); assert_eq!(slugify("test_underscore"), "test_underscore"); assert_eq!(slugify("a--b"), "a-b"); }
#[test]
fn test_duplicate_heading_ids_are_deduplicated() {
let md = "## 概要\n\n本文A\n\n## 概要\n\n本文B\n\n## 概要\n\n本文C\n";
let html = render_markdown(md);
assert!(html.contains(r#"<h2 id="概要">"#), "html: {}", html);
assert!(html.contains(r#"<h2 id="概要-1">"#), "html: {}", html);
assert!(html.contains(r#"<h2 id="概要-2">"#), "html: {}", html);
}
#[test]
fn test_toc_ids_match_rendered_heading_ids_for_duplicates() {
let md = "## 概要\n\n本文A\n\n### 詳細\n\n## 概要\n\n本文B\n";
let html = render_markdown(md);
let toc = extract_headings(md);
for item in &toc {
assert!(
html.contains(&format!(r#"id="{}""#, item.id)),
"TOC id {} not found in rendered html: {}",
item.id,
html
);
}
let ids: Vec<&str> = toc.iter().map(|t| t.id.as_str()).collect();
assert_eq!(ids.iter().filter(|i| **i == "概要").count(), 1);
assert!(ids.contains(&"概要-1"));
}
#[test]
fn test_md_link_in_inline_code_not_rewritten() {
let md = "詳細は `chapter1.md#section` を参照。\n\n[link](other.md#anchor)\n";
let html = render_markdown(md);
assert!(
html.contains("<code>chapter1.md#section</code>"),
"inline code must be untouched: {}",
html
);
assert!(
html.contains(r##"href="other.html#anchor""##),
"real links must still be converted: {}",
html
);
}
#[test]
fn test_md_link_in_code_block_not_rewritten() {
let md = "```\nsee chapter1.md#section and \"file.md\"\n```\n";
let html = render_markdown(md);
assert!(
html.contains("chapter1.md#section"),
"code block must be untouched: {}",
html
);
assert!(!html.contains("chapter1.html"), "html: {}", html);
}
#[test]
fn test_normalize_path_separators_fires() {
let html =
r#"<a href="docs\sub\file.html">x</a> <img src="img\pic.png"> and text\with\backslash"#;
let fixed = normalize_path_separators(html);
assert!(fixed.contains(r#"href="docs/sub/file.html""#), "{}", fixed);
assert!(fixed.contains(r#"src="img/pic.png""#), "{}", fixed);
assert!(fixed.contains(r"text\with\backslash"), "{}", fixed);
}
#[test]
fn test_remove_leading_slash_fires() {
let html = r#"<a href="/guide/page.html">x</a><img src="/assets/pic.png"><a href="//cdn.example.com/x">y</a>"#;
let fixed = remove_leading_slash_from_links(html);
assert!(fixed.contains(r#"href="guide/page.html""#), "{}", fixed);
assert!(fixed.contains(r#"src="assets/pic.png""#), "{}", fixed);
assert!(fixed.contains(r#"href="//cdn.example.com/x""#), "{}", fixed);
}
#[test]
fn test_root_relative_img_src_depth_adjusted() {
let md = "\n\n[link](/api-docs/)\n";
let html = render_markdown_with_path(md, Some("Guide/Sub/Page.md"), false);
assert!(
html.contains(r#"src="../../assets/pic.png""#),
"src must be depth-adjusted: {}",
html
);
assert!(
html.contains(r#"href="../../api-docs/""#),
"href behavior unchanged: {}",
html
);
}
#[test]
fn test_page_relative_img_src_untouched() {
let md = "\n";
let html = render_markdown_with_path(md, Some("Guide/Sub/Page.md"), false);
assert!(
html.contains(r#"src="images/pic.png""#),
"page-relative src must stay as written: {}",
html
);
}
#[test]
fn test_remaining_markdown_image_escapes_quotes() {
let html = r#"<div></div>"#;
let fixed = convert_remaining_markdown_images(html);
assert!(
fixed.contains(r#"alt="weather "sunny"""#),
"quotes in alt must be escaped: {}",
fixed
);
}
#[test]
fn test_href_like_text_outside_tag_not_rewritten() {
let html = r#"<p>example: href="a.md" is the syntax. <a href="b.md">real</a></p>"#;
let fixed = fix_relative_links(html);
assert!(fixed.contains(r#"href="a.md" is the syntax"#), "{}", fixed);
assert!(fixed.contains(r#"<a href="b.html">"#), "{}", fixed);
}
#[test]
fn test_footnote_ref_in_inline_code_not_converted() {
let md = "正規表現 `[^abc]` は abc 以外にマッチする。[^1]\n\n[^1]: 実際の脚注\n";
let html = render_markdown(md);
assert!(
html.contains("<code>[^abc]</code>"),
"inline code must be untouched: {}",
html
);
assert!(
!html.contains("reffn_abc"),
"no footnote must be generated from code: {}",
html
);
assert!(html.contains("reffn_1"), "html: {}", html);
}
#[test]
fn test_footnote_ref_in_fenced_code_not_converted() {
let md = "```\nmatch = re.compile(r\"[^abc]\")\n```\n\n本文[^2]です。\n\n[^2]: 脚注\n";
let html = render_markdown(md);
assert!(
!html.contains("reffn_abc"),
"no footnote from fenced code: {}",
html
);
assert!(
html.contains("reffn_2"),
"real footnote still works: {}",
html
);
}
}
#[test]
fn test_footnote_in_table() {
let md = r#"| Col1 | Col2 | Col3 |
|------|------|------|
| [A][^1] | data | end |
[A]: #link
[^1]: Footnote one
"#;
let html = render_markdown(md);
println!("HTML: {}", html);
assert!(
html.contains("<td>data</td>") || html.contains(">data<"),
"data should be in its own cell: {}",
html
);
}
#[test]
fn test_reference_link_basic() {
let md = r#"[改定履歴][AL_RH]
[AL_RH]: #改訂履歴"#;
let html = render_markdown(md);
println!("Test 1 (basic with space): {}", html);
assert!(
html.contains("<a "),
"Reference link should create anchor: {}",
html
);
}
#[test]
fn test_reference_link_no_space() {
let md = r#"[改定履歴][AL_RH]
[AL_RH]:#改訂履歴"#;
let html = render_markdown(md);
println!("Test 2 (no space): {}", html);
assert!(
html.contains("<a "),
"Reference link without space should work: {}",
html
);
}
#[test]
fn test_reference_link_after_html_comment() {
let md = r#"[改定履歴][AL_RH]
<!-- 目次 -->
[AL_RH]: #改訂履歴"#;
let html = render_markdown(md);
println!("Test 3 (after HTML comment): {}", html);
assert!(
html.contains("<a "),
"Reference link after HTML comment should work: {}",
html
);
}
#[test]
fn test_reference_link_after_html_comment_with_blank_line() {
let md = r#"[改定履歴][AL_RH]
<!-- 目次 -->
[AL_RH]: #改訂履歴"#;
let html = render_markdown(md);
println!("Test 4 (after HTML comment with blank line): {}", html);
assert!(
html.contains("<a "),
"Reference link after HTML comment with blank line should work: {}",
html
);
}
#[test]
fn test_reference_link_with_bom() {
let bom = "\u{FEFF}";
let md = format!(
r#"[改定履歴][AL_RH]
{}<!-- 目次 -->
[AL_RH]:#改訂履歴"#,
bom
);
let html = render_markdown(&md);
println!("Test 5 (with BOM): {}", html);
assert!(
html.contains("<a "),
"Reference link with BOM should work: {}",
html
);
}
#[test]
fn test_footnote_with_list() {
let content = "- データソース項目の値\n- 上記以外の場合";
let html = render_footnote_continuation(content, false);
println!("Footnote continuation HTML: {}", html);
assert!(
html.contains("<li>") && html.contains("<ul>"),
"Should contain list: {}",
html
);
}
#[test]
fn test_full_reference_link_in_footnote() {
let md = r#"Text[^1].
[^1]: .paymentAvailableStatus=[未申込][決済方法申込状態]の場合: "銀行引落(登録)"
[決済方法申込状態]:#決済方法申込状態"#;
let html = render_markdown(md);
println!("Full reference link in footnote: {}", html);
assert!(
html.contains("<a href=\"#決済方法申込状態\">未申込</a>"),
"Full reference link [text][ref] should be resolved: {}",
html
);
assert!(
html.contains("の場合:"),
"Text after reference link should be preserved: {}",
html
);
}
#[test]
fn test_resolve_reference_links_full_style() {
let mut refs = std::collections::HashMap::new();
refs.insert(
"決済方法申込状態".to_lowercase(),
"#決済方法申込状態".to_string(),
);
let input = "[未申込][決済方法申込状態]の場合";
let output = resolve_reference_links(input, &refs);
println!("Resolved: {}", output);
assert!(
output.contains("<a href=\"#決済方法申込状態\">未申込</a>"),
"Should resolve [text][ref]: {}",
output
);
assert!(
output.contains("の場合"),
"Text after link should be preserved: {}",
output
);
}
#[test]
fn test_multilang_relative_links() {
let md = "[Link](repositories/docs-path.md)";
let current_path = "getting-started.md";
let html = render_markdown_with_path(md, Some(current_path), false);
println!("Result HTML: {}", html);
assert!(
!html.contains("../repositories/docs-path.html"),
"Should not prepend ../ to relative links when depth is 0: {}",
html
);
assert!(
html.contains("href=\"repositories/docs-path.html\""),
"Should preserve relative link: {}",
html
);
}