use std::collections::BTreeMap;
use ox_content_ast::{
BlockQuote, Break, CodeBlock, Definition, Delete, Document, Emphasis, FootnoteDefinition,
FootnoteReference, Heading, Html, Image, InlineCode, Link, List, ListItem, Node, Paragraph,
Strong, Table, TableCell, TableRow, Text, ThematicBreak, Visit,
};
use crate::render::{RenderResult, Renderer};
#[derive(Debug, Clone)]
pub struct HtmlRendererOptions {
pub xhtml: bool,
pub soft_break: String,
pub hard_break: String,
pub highlight: bool,
pub sanitize: bool,
pub convert_md_links: bool,
pub base_url: String,
pub source_path: String,
pub code_annotations: bool,
pub code_annotation_meta_key: String,
pub code_annotation_syntax: CodeAnnotationSyntax,
pub code_annotation_default_line_numbers: bool,
}
impl HtmlRendererOptions {
#[must_use]
pub fn new() -> Self {
Self {
xhtml: false,
soft_break: "\n".to_string(),
hard_break: "<br>\n".to_string(),
highlight: false,
sanitize: false,
convert_md_links: false,
base_url: "/".to_string(),
source_path: String::new(),
code_annotations: false,
code_annotation_meta_key: "annotate".to_string(),
code_annotation_syntax: CodeAnnotationSyntax::Attribute,
code_annotation_default_line_numbers: false,
}
}
}
impl Default for HtmlRendererOptions {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CodeAnnotationSyntax {
Attribute,
VitePress,
Both,
}
impl CodeAnnotationSyntax {
fn includes_attribute(self) -> bool {
matches!(self, Self::Attribute | Self::Both)
}
fn includes_vitepress(self) -> bool {
matches!(self, Self::VitePress | Self::Both)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CodeAnnotationKind {
Highlight,
Warning,
Error,
Add,
Remove,
Focus,
}
impl CodeAnnotationKind {
fn from_str(value: &str) -> Option<Self> {
match value {
"highlight" => Some(Self::Highlight),
"warning" => Some(Self::Warning),
"error" => Some(Self::Error),
"add" => Some(Self::Add),
"remove" => Some(Self::Remove),
"focus" => Some(Self::Focus),
_ => None,
}
}
fn class_name(self) -> &'static str {
match self {
Self::Highlight => "ox-code-line--highlight",
Self::Warning => "ox-code-line--warning",
Self::Error => "ox-code-line--error",
Self::Add => "ox-code-line--add",
Self::Remove => "ox-code-line--remove",
Self::Focus => "ox-code-line--focus",
}
}
fn extra_class_names(self) -> &'static [&'static str] {
match self {
Self::Highlight => &["highlighted"],
Self::Warning => &["highlighted", "warning"],
Self::Error => &["highlighted", "error"],
Self::Add => &["diff", "add"],
Self::Remove => &["diff", "remove"],
Self::Focus => &["focused"],
}
}
fn block_class_name(self) -> Option<&'static str> {
match self {
Self::Highlight | Self::Warning | Self::Error => Some("has-highlighted"),
Self::Add | Self::Remove => Some("has-diff"),
Self::Focus => Some("has-focused"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CalloutKind {
Note,
Tip,
Important,
Warning,
Caution,
}
impl CalloutKind {
fn parse_marker(value: &str) -> Option<(Self, &str)> {
let marker = value.strip_prefix("[!")?;
let end = marker.find(']')?;
let kind = match marker[..end].trim().to_ascii_uppercase().as_str() {
"NOTE" => Self::Note,
"TIP" => Self::Tip,
"IMPORTANT" => Self::Important,
"WARNING" => Self::Warning,
"CAUTION" => Self::Caution,
_ => return None,
};
Some((kind, marker[end + 1..].trim_start_matches(char::is_whitespace)))
}
fn class_name(self) -> &'static str {
match self {
Self::Note => "note",
Self::Tip => "tip",
Self::Important => "important",
Self::Warning => "warning",
Self::Caution => "caution",
}
}
fn label(self) -> &'static str {
match self {
Self::Note => "Note",
Self::Tip => "Tip",
Self::Important => "Important",
Self::Warning => "Warning",
Self::Caution => "Caution",
}
}
}
#[derive(Debug, Clone)]
struct CodeLineRenderState {
value: String,
annotations: Vec<CodeAnnotationKind>,
}
#[derive(Debug, Clone)]
struct CodeBlockRenderState {
language: Option<String>,
title: Option<String>,
line_numbers_start: Option<usize>,
lines: Vec<CodeLineRenderState>,
}
impl CodeBlockRenderState {
fn has_annotations(&self) -> bool {
self.lines.iter().any(|line| !line.annotations.is_empty())
}
fn has_focus(&self) -> bool {
self.lines.iter().any(|line| line.annotations.contains(&CodeAnnotationKind::Focus))
}
fn block_classes(&self) -> Vec<&'static str> {
let mut classes = Vec::new();
if self.has_annotations() || self.line_numbers_start.is_some() || self.title.is_some() {
classes.push("ox-code-block");
}
if self.has_annotations() {
classes.push("ox-code-block--annotated");
}
if self.line_numbers_start.is_some() {
classes.push("ox-code-block--line-numbers");
classes.push("line-numbers-mode");
}
if self.title.is_some() {
classes.push("ox-code-block--with-title");
}
for line in &self.lines {
for annotation in &line.annotations {
if let Some(class_name) = annotation.block_class_name() {
if !classes.contains(&class_name) {
classes.push(class_name);
}
}
}
}
classes
}
fn needs_line_wrappers(&self) -> bool {
self.has_annotations() || self.line_numbers_start.is_some()
}
}
#[derive(Debug, Clone)]
struct NormalizedCodeBlockInfo {
language: Option<String>,
meta: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MetaTokenKind {
Raw,
Braces,
Brackets,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct MetaToken<'a> {
kind: MetaTokenKind,
value: &'a str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PendingCodeAnnotation {
kind: CodeAnnotationKind,
remaining: usize,
}
#[derive(Debug, Clone)]
struct ParsedInlineDirective {
kind: CodeAnnotationKind,
count: usize,
stripped_line: String,
standalone: bool,
}
fn parse_code_annotations(meta: &str, key: &str) -> BTreeMap<usize, Vec<CodeAnnotationKind>> {
let Some(value) = extract_meta_attribute(meta, key) else {
return BTreeMap::new();
};
let mut annotations = BTreeMap::new();
for entry in value.split(';') {
let Some((raw_kind, raw_lines)) = entry.split_once(':') else {
continue;
};
let Some(kind) = CodeAnnotationKind::from_str(raw_kind.trim()) else {
continue;
};
for line_number in parse_line_numbers(raw_lines.trim()) {
push_code_annotation(&mut annotations, line_number, kind);
}
}
annotations
}
fn extract_meta_attribute<'a>(meta: &'a str, target: &str) -> Option<&'a str> {
let bytes = meta.as_bytes();
let mut index = 0;
while index < bytes.len() {
while index < bytes.len() && bytes[index].is_ascii_whitespace() {
index += 1;
}
if index >= bytes.len() {
break;
}
let key_start = index;
while index < bytes.len() && !bytes[index].is_ascii_whitespace() && bytes[index] != b'=' {
index += 1;
}
if key_start == index {
index += 1;
continue;
}
let key = &meta[key_start..index];
while index < bytes.len() && bytes[index].is_ascii_whitespace() {
index += 1;
}
if index >= bytes.len() || bytes[index] != b'=' {
continue;
}
index += 1;
while index < bytes.len() && bytes[index].is_ascii_whitespace() {
index += 1;
}
if index >= bytes.len() {
break;
}
let value = if bytes[index] == b'"' || bytes[index] == b'\'' {
let quote = bytes[index];
index += 1;
let value_start = index;
while index < bytes.len() && bytes[index] != quote {
index += 1;
}
let value_end = index;
if index < bytes.len() {
index += 1;
}
&meta[value_start..value_end]
} else {
let value_start = index;
while index < bytes.len() && !bytes[index].is_ascii_whitespace() {
index += 1;
}
&meta[value_start..index]
};
if key == target {
return Some(value);
}
}
None
}
fn parse_line_numbers(value: &str) -> Vec<usize> {
let mut line_numbers = Vec::new();
for part in value.split(',').map(str::trim).filter(|part| !part.is_empty()) {
if let Some((raw_start, raw_end)) = part.split_once('-') {
let Ok(start) = raw_start.trim().parse::<usize>() else {
continue;
};
let Ok(end) = raw_end.trim().parse::<usize>() else {
continue;
};
if start == 0 || end < start {
continue;
}
for line_number in start..=end {
if !line_numbers.contains(&line_number) {
line_numbers.push(line_number);
}
}
continue;
}
let Ok(line_number) = part.parse::<usize>() else {
continue;
};
if line_number > 0 && !line_numbers.contains(&line_number) {
line_numbers.push(line_number);
}
}
line_numbers.sort_unstable();
line_numbers
}
fn push_code_annotation(
annotations: &mut BTreeMap<usize, Vec<CodeAnnotationKind>>,
line_number: usize,
kind: CodeAnnotationKind,
) {
let kinds = annotations.entry(line_number).or_default();
if !kinds.contains(&kind) {
kinds.push(kind);
}
}
fn split_code_block_meta(meta: &str) -> Vec<MetaToken<'_>> {
let bytes = meta.as_bytes();
let mut index = 0;
let mut tokens = Vec::new();
while index < bytes.len() {
while index < bytes.len() && bytes[index].is_ascii_whitespace() {
index += 1;
}
if index >= bytes.len() {
break;
}
match bytes[index] {
b'{' => {
let start = index + 1;
index += 1;
while index < bytes.len() && bytes[index] != b'}' {
index += 1;
}
tokens.push(MetaToken {
kind: MetaTokenKind::Braces,
value: &meta[start..index.min(bytes.len())],
});
if index < bytes.len() {
index += 1;
}
}
b'[' => {
let start = index + 1;
index += 1;
while index < bytes.len() && bytes[index] != b']' {
index += 1;
}
tokens.push(MetaToken {
kind: MetaTokenKind::Brackets,
value: &meta[start..index.min(bytes.len())],
});
if index < bytes.len() {
index += 1;
}
}
_ => {
let start = index;
let mut quote: Option<u8> = None;
while index < bytes.len() {
let byte = bytes[index];
if let Some(current_quote) = quote {
if byte == current_quote {
quote = None;
}
index += 1;
continue;
}
if byte == b'"' || byte == b'\'' {
quote = Some(byte);
index += 1;
continue;
}
if byte.is_ascii_whitespace() || byte == b'{' || byte == b'[' {
break;
}
index += 1;
}
tokens.push(MetaToken { kind: MetaTokenKind::Raw, value: &meta[start..index] });
}
}
}
tokens
}
fn split_code_block_language_token(raw: &str) -> (&str, &str) {
for (index, ch) in raw.char_indices() {
match ch {
'{' | '[' => return (&raw[..index], &raw[index..]),
':' if raw[index..].starts_with(":line-numbers")
|| raw[index..].starts_with(":no-line-numbers") =>
{
return (&raw[..index], &raw[index..]);
}
_ => {}
}
}
(raw, "")
}
fn normalize_code_block_info(lang: Option<&str>, meta: Option<&str>) -> NormalizedCodeBlockInfo {
let mut meta_parts: Vec<&str> = Vec::new();
let mut language = None;
if let Some(raw_lang) = lang.map(str::trim).filter(|value| !value.is_empty()) {
let (normalized_lang, inline_meta) = split_code_block_language_token(raw_lang);
if !normalized_lang.is_empty() {
language = Some(normalized_lang.to_string());
}
if !inline_meta.trim().is_empty() {
meta_parts.push(inline_meta.trim());
}
}
if let Some(raw_meta) = meta.map(str::trim).filter(|value| !value.is_empty()) {
meta_parts.push(raw_meta);
}
NormalizedCodeBlockInfo { language, meta: meta_parts.join(" ") }
}
fn apply_annotation_numbers(
lines: &mut [CodeLineRenderState],
line_numbers: &[usize],
kind: CodeAnnotationKind,
) {
for line_number in line_numbers {
let Some(line) = lines.get_mut(line_number.saturating_sub(1)) else {
continue;
};
if !line.annotations.contains(&kind) {
line.annotations.push(kind);
}
}
}
fn apply_btree_annotations(
lines: &mut [CodeLineRenderState],
annotations: &BTreeMap<usize, Vec<CodeAnnotationKind>>,
) {
for (line_number, kinds) in annotations {
let Some(line) = lines.get_mut(line_number.saturating_sub(1)) else {
continue;
};
for kind in kinds {
if !line.annotations.contains(kind) {
line.annotations.push(*kind);
}
}
}
}
fn apply_pending_annotations(
line: &mut CodeLineRenderState,
pending_annotations: &mut Vec<PendingCodeAnnotation>,
) {
let mut remaining = Vec::new();
for mut pending in pending_annotations.drain(..) {
if !line.annotations.contains(&pending.kind) {
line.annotations.push(pending.kind);
}
if pending.remaining > 1 {
pending.remaining -= 1;
remaining.push(pending);
}
}
*pending_annotations = remaining;
}
fn parse_annotation_count(value: &str) -> usize {
value.trim().parse::<usize>().ok().filter(|count| *count > 0).unwrap_or(1)
}
fn parse_vitepress_directive_kind(value: &str) -> Option<(CodeAnnotationKind, usize)> {
let trimmed = value.trim();
if trimmed == "++" {
return Some((CodeAnnotationKind::Add, 1));
}
if trimmed == "--" {
return Some((CodeAnnotationKind::Remove, 1));
}
if let Some((kind, count)) = trimmed.split_once(':') {
let parsed_kind = match kind.trim() {
"highlight" => CodeAnnotationKind::Highlight,
"focus" => CodeAnnotationKind::Focus,
"warning" => CodeAnnotationKind::Warning,
"error" => CodeAnnotationKind::Error,
_ => return None,
};
return Some((parsed_kind, parse_annotation_count(count)));
}
match trimmed {
"highlight" => Some((CodeAnnotationKind::Highlight, 1)),
"warning" => Some((CodeAnnotationKind::Warning, 1)),
"error" => Some((CodeAnnotationKind::Error, 1)),
"focus" => Some((CodeAnnotationKind::Focus, 1)),
_ => None,
}
}
fn parse_vitepress_inline_directive(line: &str) -> Option<ParsedInlineDirective> {
let marker_start = line.find("[!code ")?;
let directive_start = marker_start + "[!code ".len();
let marker_end = line[directive_start..].find(']')? + directive_start;
let directive = &line[directive_start..marker_end];
let before_marker = &line[..marker_start];
let after_marker = &line[marker_end + 1..];
let trimmed_before = before_marker.trim_end();
let (comment_start, requires_closer) = if trimmed_before.ends_with("//") {
(trimmed_before.len() - 2, false)
} else if trimmed_before.ends_with('#') {
(trimmed_before.len() - 1, false)
} else if trimmed_before.ends_with("<!--") {
(trimmed_before.len() - 4, true)
} else if trimmed_before.ends_with("/*") {
(trimmed_before.len() - 2, true)
} else {
return None;
};
let trailing = after_marker.trim();
if requires_closer && trailing != "-->" && trailing != "*/" {
return None;
}
if !requires_closer && !trailing.is_empty() {
return None;
}
let stripped_line = before_marker[..comment_start].trim_end().to_string();
let standalone = stripped_line.trim().is_empty();
let (kind, count) = parse_vitepress_directive_kind(directive)?;
Some(ParsedInlineDirective { kind, count, stripped_line, standalone })
}
fn parse_vitepress_inline_annotations(value: &str) -> Vec<CodeLineRenderState> {
let mut lines = Vec::new();
let mut pending_annotations: Vec<PendingCodeAnnotation> = Vec::new();
for raw_line in value.split('\n') {
if let Some(directive) = parse_vitepress_inline_directive(raw_line) {
if directive.standalone {
pending_annotations.push(PendingCodeAnnotation {
kind: directive.kind,
remaining: directive.count,
});
continue;
}
let mut line =
CodeLineRenderState { value: directive.stripped_line, annotations: Vec::new() };
apply_pending_annotations(&mut line, &mut pending_annotations);
if !line.annotations.contains(&directive.kind) {
line.annotations.push(directive.kind);
}
if directive.count > 1 {
pending_annotations.push(PendingCodeAnnotation {
kind: directive.kind,
remaining: directive.count - 1,
});
}
lines.push(line);
continue;
}
let mut line = CodeLineRenderState { value: raw_line.to_string(), annotations: Vec::new() };
apply_pending_annotations(&mut line, &mut pending_annotations);
lines.push(line);
}
lines
}
pub struct HtmlRenderer {
options: HtmlRendererOptions,
output: String,
}
impl HtmlRenderer {
#[must_use]
pub fn new() -> Self {
Self { options: HtmlRendererOptions::new(), output: String::new() }
}
#[must_use]
pub fn with_options(options: HtmlRendererOptions) -> Self {
Self { options, output: String::new() }
}
#[must_use]
pub fn render(&mut self, document: &Document<'_>) -> String {
self.output.clear();
let estimated_len = (document.span.len() as usize).saturating_mul(3) / 2;
if self.output.capacity() < estimated_len {
self.output.reserve(estimated_len - self.output.capacity());
}
self.visit_document(document);
std::mem::take(&mut self.output)
}
fn write(&mut self, s: &str) {
self.output.push_str(s);
}
fn write_escaped(&mut self, s: &str) {
let bytes = s.as_bytes();
let mut start = 0;
for (idx, byte) in bytes.iter().copied().enumerate() {
let escaped = match byte {
b'&' => Some("&"),
b'<' => Some("<"),
b'>' => Some(">"),
b'"' => Some("""),
b'\'' => Some("'"),
_ => None,
};
if let Some(escaped) = escaped {
if start < idx {
self.output.push_str(&s[start..idx]);
}
self.output.push_str(escaped);
start = idx + 1;
}
}
if start < s.len() {
self.output.push_str(&s[start..]);
}
}
fn write_url_escaped(&mut self, s: &str) {
let bytes = s.as_bytes();
let mut start = 0;
for (idx, byte) in bytes.iter().copied().enumerate() {
let escaped = match byte {
b'&' => Some("&"),
b'<' => Some("%3C"),
b'>' => Some("%3E"),
b'"' => Some("%22"),
b' ' => Some("%20"),
_ => None,
};
if let Some(escaped) = escaped {
if start < idx {
self.output.push_str(&s[start..idx]);
}
self.output.push_str(escaped);
start = idx + 1;
}
}
if start < s.len() {
self.output.push_str(&s[start..]);
}
}
fn render_paragraph_with_skipped_text_prefix<'a>(
&self,
paragraph: &Paragraph<'a>,
mut skip_chars: usize,
) -> String {
let mut renderer = HtmlRenderer::with_options(self.options.clone());
for child in ¶graph.children {
match child {
Node::Text(text) if skip_chars > 0 => {
if skip_chars >= text.value.len() {
skip_chars -= text.value.len();
continue;
}
renderer.write_escaped(&text.value[skip_chars..]);
skip_chars = 0;
}
_ => renderer.visit_node(child),
}
}
renderer.output
}
fn detect_callout<'a>(paragraph: &Paragraph<'a>) -> Option<(CalloutKind, usize)> {
let mut prefix = String::new();
for child in ¶graph.children {
let Node::Text(text) = child else {
break;
};
prefix.push_str(text.value);
if let Some((kind, remainder)) = CalloutKind::parse_marker(&prefix) {
let consumed = prefix.len().saturating_sub(remainder.len());
return Some((kind, consumed));
}
}
None
}
fn render_callout_block_quote<'a>(&mut self, block_quote: &BlockQuote<'a>) -> bool {
let Some(Node::Paragraph(first_paragraph)) = block_quote.children.first() else {
return false;
};
let Some((kind, consumed_chars)) = Self::detect_callout(first_paragraph) else {
return false;
};
self.write("<blockquote class=\"ox-callout ox-callout--");
self.write(kind.class_name());
self.write("\">\n");
self.write("<p class=\"ox-callout-title\">");
self.write(kind.label());
self.write("</p>\n");
let paragraph_body =
self.render_paragraph_with_skipped_text_prefix(first_paragraph, consumed_chars);
if !paragraph_body.trim().is_empty() {
self.write("<p>");
self.write(¶graph_body);
self.write("</p>\n");
}
for child in block_quote.children.iter().skip(1) {
self.visit_node(child);
}
self.write("</blockquote>\n");
true
}
fn build_code_block_state(&self, code_block: &CodeBlock<'_>) -> CodeBlockRenderState {
let info = normalize_code_block_info(code_block.lang, code_block.meta);
let syntax = self.options.code_annotation_syntax;
let mut lines = if self.options.code_annotations && syntax.includes_vitepress() {
parse_vitepress_inline_annotations(code_block.value)
} else {
code_block
.value
.split('\n')
.map(|line| CodeLineRenderState {
value: line.to_string(),
annotations: Vec::new(),
})
.collect()
};
let mut title = None;
let mut line_numbers_start = if self.options.code_annotations
&& syntax.includes_vitepress()
&& self.options.code_annotation_default_line_numbers
{
Some(1)
} else {
None
};
if self.options.code_annotations && !info.meta.is_empty() {
if syntax.includes_attribute() {
let annotations =
parse_code_annotations(&info.meta, &self.options.code_annotation_meta_key);
apply_btree_annotations(&mut lines, &annotations);
}
if syntax.includes_vitepress() {
for token in split_code_block_meta(&info.meta) {
match token.kind {
MetaTokenKind::Braces => {
let line_numbers = parse_line_numbers(token.value);
apply_annotation_numbers(
&mut lines,
&line_numbers,
CodeAnnotationKind::Highlight,
);
}
MetaTokenKind::Brackets => {
if title.is_none() && !token.value.trim().is_empty() {
title = Some(token.value.trim().to_string());
}
}
MetaTokenKind::Raw => {
if token.value == ":line-numbers" {
line_numbers_start = Some(1);
} else if let Some(start) =
token.value.strip_prefix(":line-numbers=").and_then(|value| {
value
.trim()
.parse::<usize>()
.ok()
.filter(|line_number| *line_number > 0)
})
{
line_numbers_start = Some(start);
} else if token.value == ":no-line-numbers" {
line_numbers_start = None;
}
}
}
}
}
}
CodeBlockRenderState { language: info.language, title, line_numbers_start, lines }
}
fn write_code_lines(&mut self, state: &CodeBlockRenderState) {
let has_focus = state.has_focus();
for (index, line) in state.lines.iter().enumerate() {
let line_number = index + 1;
let mut class_names: Vec<&str> = vec!["line", "ox-code-line"];
for annotation in &line.annotations {
let class_name = annotation.class_name();
if !class_names.contains(&class_name) {
class_names.push(class_name);
}
for extra_class_name in annotation.extra_class_names() {
if !class_names.contains(extra_class_name) {
class_names.push(extra_class_name);
}
}
}
if has_focus && !line.annotations.contains(&CodeAnnotationKind::Focus) {
class_names.push("ox-code-line--dimmed");
}
self.write("<span class=\"");
self.write(&class_names.join(" "));
self.write("\" data-line=\"");
self.write(&line_number.to_string());
self.write("\"");
if let Some(start) = state.line_numbers_start {
self.write(" data-line-number=\"");
self.write(&(start + index).to_string());
self.write("\"");
}
self.write(">");
self.write_escaped(&line.value);
self.write("</span>");
if index + 1 < state.lines.len() {
self.write("\n");
}
}
}
fn convert_md_url(&self, url: &str) -> String {
let (path, fragment) = match url.split_once('#') {
Some((p, f)) => (p, Some(f)),
None => (url, None),
};
let is_md = std::path::Path::new(path)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"));
if !self.options.convert_md_links || !is_md {
return url.to_string();
}
let path_without_ext = &path[..path.len() - 3];
let source_is_index = self.is_source_index();
let converted = if path.starts_with('/') {
let path_without_slash = &path_without_ext[1..];
let base = &self.options.base_url;
if path_without_slash.is_empty() || path_without_slash == "index" {
format!("{base}index.html")
} else {
format!("{base}{path_without_slash}/index.html")
}
} else if path.starts_with("./") {
let name = &path_without_ext[2..]; if name == "index" {
"./index.html".to_string()
} else if source_is_index {
format!("./{name}/index.html")
} else {
format!("../{name}/index.html")
}
} else if path.starts_with("../") {
let rest = &path_without_ext[3..]; if source_is_index {
if rest == "index" || rest.ends_with("/index") {
let dir = rest.trim_end_matches("/index").trim_end_matches("index");
if dir.is_empty() {
"../index.html".to_string()
} else {
format!("../{dir}/index.html")
}
} else {
format!("../{rest}/index.html")
}
} else {
if rest == "index" || rest.ends_with("/index") {
let dir = rest.trim_end_matches("/index").trim_end_matches("index");
if dir.is_empty() {
"../../index.html".to_string()
} else {
format!("../../{dir}/index.html")
}
} else {
format!("../../{rest}/index.html")
}
}
} else {
if path_without_ext == "index" || path_without_ext.ends_with("/index") {
let dir = path_without_ext.trim_end_matches("/index").trim_end_matches("index");
if dir.is_empty() {
"./index.html".to_string()
} else if source_is_index {
format!("./{dir}/index.html")
} else {
format!("../{dir}/index.html")
}
} else if source_is_index {
format!("./{path_without_ext}/index.html")
} else {
format!("../{path_without_ext}/index.html")
}
};
match fragment {
Some(f) => format!("{converted}#{f}"),
None => converted,
}
}
fn is_source_index(&self) -> bool {
if self.options.source_path.is_empty() {
return false;
}
let source = std::path::Path::new(&self.options.source_path);
source.file_stem().is_some_and(|stem| stem.eq_ignore_ascii_case("index"))
}
}
impl Default for HtmlRenderer {
fn default() -> Self {
Self::new()
}
}
impl Renderer for HtmlRenderer {
type Output = String;
fn render(&mut self, document: &Document<'_>) -> RenderResult<Self::Output> {
Ok(self.render(document))
}
}
impl<'a> Visit<'a> for HtmlRenderer {
fn visit_paragraph(&mut self, paragraph: &Paragraph<'a>) {
self.write("<p>");
for child in ¶graph.children {
self.visit_node(child);
}
self.write("</p>\n");
}
fn visit_heading(&mut self, heading: &Heading<'a>) {
let tag = match heading.depth {
1 => "h1",
2 => "h2",
3 => "h3",
4 => "h4",
5 => "h5",
_ => "h6",
};
self.write("<");
self.write(tag);
self.write(">");
for child in &heading.children {
self.visit_node(child);
}
self.write("</");
self.write(tag);
self.write(">\n");
}
fn visit_thematic_break(&mut self, _thematic_break: &ThematicBreak) {
if self.options.xhtml {
self.write("<hr />\n");
} else {
self.write("<hr>\n");
}
}
fn visit_block_quote(&mut self, block_quote: &BlockQuote<'a>) {
if self.render_callout_block_quote(block_quote) {
return;
}
self.write("<blockquote>\n");
for child in &block_quote.children {
self.visit_node(child);
}
self.write("</blockquote>\n");
}
fn visit_list(&mut self, list: &List<'a>) {
if list.ordered {
if let Some(start) = list.start {
if start != 1 {
self.write("<ol start=\"");
self.write(&start.to_string());
self.write("\">\n");
} else {
self.write("<ol>\n");
}
} else {
self.write("<ol>\n");
}
} else {
self.write("<ul>\n");
}
for child in &list.children {
self.visit_list_item(child);
}
if list.ordered {
self.write("</ol>\n");
} else {
self.write("</ul>\n");
}
}
fn visit_list_item(&mut self, list_item: &ListItem<'a>) {
self.write("<li>");
if let Some(checked) = list_item.checked {
if checked {
self.write("<input type=\"checkbox\" checked disabled> ");
} else {
self.write("<input type=\"checkbox\" disabled> ");
}
}
for child in &list_item.children {
self.visit_node(child);
}
self.write("</li>\n");
}
fn visit_code_block(&mut self, code_block: &CodeBlock<'a>) {
let state = self.build_code_block_state(code_block);
let block_classes = state.block_classes();
self.write("<pre");
if !block_classes.is_empty() {
self.write(" class=\"");
self.write(&block_classes.join(" "));
self.write("\"");
}
if let Some(title) = state.title.as_deref() {
self.write(" data-code-title=\"");
self.write_escaped(title);
self.write("\"");
}
if let Some(start) = state.line_numbers_start {
self.write(" data-line-numbers=\"true\" data-line-number-start=\"");
self.write(&start.to_string());
self.write("\"");
}
self.write("><code");
if let Some(lang) = state.language.as_deref() {
self.write(" class=\"language-");
self.write_escaped(lang);
self.write("\"");
}
self.write(">");
if state.needs_line_wrappers() {
self.write_code_lines(&state);
} else {
self.write_escaped(code_block.value);
}
self.write("</code></pre>\n");
}
fn visit_html(&mut self, html: &Html<'a>) {
if self.options.sanitize {
self.write_escaped(html.value);
} else {
self.write(html.value);
}
self.write("\n");
}
fn visit_table(&mut self, table: &Table<'a>) {
self.write("<table>\n");
for (i, row) in table.children.iter().enumerate() {
if i == 0 {
self.write("<thead>\n");
} else if i == 1 {
self.write("<tbody>\n");
}
self.visit_table_row_with_header(row, i == 0, &table.align);
if i == 0 {
self.write("</thead>\n");
}
}
if !table.children.is_empty() {
self.write("</tbody>\n");
}
self.write("</table>\n");
}
fn visit_text(&mut self, text: &Text<'a>) {
self.write_escaped(text.value);
}
fn visit_emphasis(&mut self, emphasis: &Emphasis<'a>) {
self.write("<em>");
for child in &emphasis.children {
self.visit_node(child);
}
self.write("</em>");
}
fn visit_strong(&mut self, strong: &Strong<'a>) {
self.write("<strong>");
for child in &strong.children {
self.visit_node(child);
}
self.write("</strong>");
}
fn visit_inline_code(&mut self, inline_code: &InlineCode<'a>) {
self.write("<code>");
self.write_escaped(inline_code.value);
self.write("</code>");
}
fn visit_break(&mut self, _break_node: &Break) {
self.output.push_str(self.options.hard_break.as_str());
}
fn visit_link(&mut self, link: &Link<'a>) {
self.write("<a href=\"");
let url = self.convert_md_url(link.url);
self.write_url_escaped(&url);
self.write("\"");
if link.url.starts_with("http://") || link.url.starts_with("https://") {
self.write(" target=\"_blank\" rel=\"noopener noreferrer\"");
}
if let Some(title) = link.title {
self.write(" title=\"");
self.write_escaped(title);
self.write("\"");
}
self.write(">");
for child in &link.children {
self.visit_node(child);
}
self.write("</a>");
}
fn visit_image(&mut self, image: &Image<'a>) {
self.write("<img src=\"");
self.write_url_escaped(image.url);
self.write("\" alt=\"");
self.write_escaped(image.alt);
self.write("\"");
if let Some(title) = image.title {
self.write(" title=\"");
self.write_escaped(title);
self.write("\"");
}
if self.options.xhtml {
self.write(" />");
} else {
self.write(">");
}
}
fn visit_delete(&mut self, delete: &Delete<'a>) {
self.write("<del>");
for child in &delete.children {
self.visit_node(child);
}
self.write("</del>");
}
fn visit_footnote_reference(&mut self, footnote_ref: &FootnoteReference<'a>) {
self.write("<sup><a href=\"#fn-");
self.write_escaped(footnote_ref.identifier);
self.write("\" id=\"fnref-");
self.write_escaped(footnote_ref.identifier);
self.write("\">");
self.write_escaped(footnote_ref.identifier);
self.write("</a></sup>");
}
fn visit_definition(&mut self, _definition: &Definition<'a>) {
}
fn visit_footnote_definition(&mut self, footnote_def: &FootnoteDefinition<'a>) {
self.write("<div id=\"fn-");
self.write_escaped(footnote_def.identifier);
self.write("\" class=\"footnote\">\n");
for child in &footnote_def.children {
self.visit_node(child);
}
self.write("<a href=\"#fnref-");
self.write_escaped(footnote_def.identifier);
self.write("\">↩</a>\n</div>\n");
}
}
impl HtmlRenderer {
fn visit_table_row_with_header(
&mut self,
row: &TableRow<'_>,
is_header: bool,
align: &ox_content_allocator::Vec<'_, ox_content_ast::AlignKind>,
) {
self.write("<tr>\n");
let tag = if is_header { "th" } else { "td" };
for (idx, cell) in row.children.iter().enumerate() {
self.write("<");
self.write(tag);
match align.get(idx).copied().unwrap_or(ox_content_ast::AlignKind::None) {
ox_content_ast::AlignKind::Left => self.write(" align=\"left\""),
ox_content_ast::AlignKind::Center => self.write(" align=\"center\""),
ox_content_ast::AlignKind::Right => self.write(" align=\"right\""),
ox_content_ast::AlignKind::None => {}
}
self.write(">");
self.visit_table_cell(cell);
self.write("</");
self.write(tag);
self.write(">\n");
}
self.write("</tr>\n");
}
fn visit_table_cell(&mut self, cell: &TableCell<'_>) {
for child in &cell.children {
self.visit_node(child);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ox_content_allocator::Allocator;
use ox_content_parser::Parser;
#[test]
fn test_render_paragraph() {
let allocator = Allocator::new();
let doc = Parser::new(&allocator, "Hello world").parse().unwrap();
let mut renderer = HtmlRenderer::new();
let html = renderer.render(&doc);
assert_eq!(html, "<p>Hello world</p>\n");
}
#[test]
fn test_render_heading() {
let allocator = Allocator::new();
let doc = Parser::new(&allocator, "# Hello").parse().unwrap();
let mut renderer = HtmlRenderer::new();
let html = renderer.render(&doc);
assert_eq!(html, "<h1>Hello</h1>\n");
}
#[test]
fn test_render_block_quote() {
let allocator = Allocator::new();
let doc = Parser::new(&allocator, "> Hello world").parse().unwrap();
let mut renderer = HtmlRenderer::new();
let html = renderer.render(&doc);
assert_eq!(html, "<blockquote>\n<p>Hello world</p>\n</blockquote>\n");
}
#[test]
fn test_render_block_quote_with_inline() {
let allocator = Allocator::new();
let doc = Parser::new(&allocator, "> **Note:** This is important").parse().unwrap();
let mut renderer = HtmlRenderer::new();
let html = renderer.render(&doc);
assert!(html.contains("<blockquote>"));
assert!(html.contains("<strong>Note:</strong>"));
assert!(html.contains("</blockquote>"));
}
#[test]
fn test_render_github_style_important_callout() {
let allocator = Allocator::new();
let doc = Parser::new(&allocator, "> [!IMPORTANT]\n> This is important.").parse().unwrap();
let mut renderer = HtmlRenderer::new();
let html = renderer.render(&doc);
assert!(html.contains("<blockquote class=\"ox-callout ox-callout--important\">"));
assert!(html.contains("<p class=\"ox-callout-title\">Important</p>"));
assert!(html.contains("<p>This is important.</p>"));
assert!(!html.contains("[!IMPORTANT]"));
}
#[test]
fn test_render_github_style_callout_with_inline_content_after_marker() {
let allocator = Allocator::new();
let doc = Parser::new(&allocator, "> [!NOTE] Supports **inline** content").parse().unwrap();
let mut renderer = HtmlRenderer::new();
let html = renderer.render(&doc);
assert!(html.contains("<blockquote class=\"ox-callout ox-callout--note\">"));
assert!(html.contains("<p class=\"ox-callout-title\">Note</p>"));
assert!(html.contains("<p>Supports <strong>inline</strong> content</p>"));
assert!(!html.contains("[!NOTE]"));
}
#[test]
fn test_render_code_block() {
let allocator = Allocator::new();
let doc = Parser::new(&allocator, "```rust\nfn main() {}\n```").parse().unwrap();
let mut renderer = HtmlRenderer::new();
let html = renderer.render(&doc);
assert!(html.contains("<pre><code class=\"language-rust\">"));
}
#[test]
fn test_render_code_block_with_annotations() {
let allocator = Allocator::new();
let doc = Parser::new(
&allocator,
"```ts file=main.ts annotate=\"highlight:1;warning:2;error:3\"\nconst ok = true;\nconst maybe = false;\nthrow new Error('boom');\n```",
)
.parse()
.unwrap();
let mut renderer = HtmlRenderer::with_options(HtmlRendererOptions {
code_annotations: true,
..Default::default()
});
let html = renderer.render(&doc);
assert!(html.contains("class=\"ox-code-block ox-code-block--annotated has-highlighted\""));
assert!(html.contains(
"class=\"line ox-code-line ox-code-line--highlight highlighted\" data-line=\"1\""
));
assert!(html.contains(
"class=\"line ox-code-line ox-code-line--warning highlighted warning\" data-line=\"2\""
));
assert!(html.contains(
"class=\"line ox-code-line ox-code-line--error highlighted error\" data-line=\"3\""
));
assert!(!html.contains("file=main.ts"));
}
#[test]
fn test_render_code_block_with_custom_annotation_meta_key() {
let allocator = Allocator::new();
let doc = Parser::new(
&allocator,
"```ts markers=\"warning:2\"\nconst ok = true;\nconst maybe = false;\n```",
)
.parse()
.unwrap();
let mut renderer = HtmlRenderer::with_options(HtmlRendererOptions {
code_annotations: true,
code_annotation_meta_key: "markers".to_string(),
..Default::default()
});
let html = renderer.render(&doc);
assert!(html.contains("ox-code-block--annotated"));
assert!(html.contains("ox-code-line--warning"));
}
#[test]
fn test_render_code_block_with_vitepress_meta() {
let allocator = Allocator::new();
let doc = Parser::new(
&allocator,
"```ts:line-numbers=2 {1,3} [config.ts]\nconst first = true;\nconst second = false;\nconst third = true;\n```",
)
.parse()
.unwrap();
let mut renderer = HtmlRenderer::with_options(HtmlRendererOptions {
code_annotations: true,
code_annotation_syntax: CodeAnnotationSyntax::VitePress,
..Default::default()
});
let html = renderer.render(&doc);
assert!(html.contains("ox-code-block--annotated"));
assert!(html.contains("ox-code-block--line-numbers"));
assert!(html.contains("ox-code-block--with-title"));
assert!(html.contains("line-numbers-mode"));
assert!(html.contains("has-highlighted"));
assert!(html.contains("data-code-title=\"config.ts\""));
assert!(html.contains("data-line-number-start=\"2\""));
assert!(html.contains("class=\"language-ts\""));
assert!(html.contains("data-line-number=\"2\""));
assert!(html.contains("data-line-number=\"4\""));
assert!(html.contains("ox-code-line--highlight"));
}
#[test]
fn test_render_code_block_with_vitepress_inline_directives() {
let allocator = Allocator::new();
let doc = Parser::new(
&allocator,
"```ts\n// [!code focus:2]\nconst first = true;\nconst second = false;\nconsole.log('old value') // [!code --]\nconsole.log('new value') // [!code ++]\nconsole.warn('careful') // [!code warning]\nthrow new Error('boom') // [!code error]\n```",
)
.parse()
.unwrap();
let mut renderer = HtmlRenderer::with_options(HtmlRendererOptions {
code_annotations: true,
code_annotation_syntax: CodeAnnotationSyntax::VitePress,
..Default::default()
});
let html = renderer.render(&doc);
assert!(!html.contains("[!code"));
assert!(html.contains("has-focused"));
assert!(html.contains("has-diff"));
assert!(html.contains("ox-code-line--focus"));
assert!(html.contains("ox-code-line--dimmed"));
assert!(html.contains("ox-code-line--remove"));
assert!(html.contains("ox-code-line--add"));
assert!(html.contains("ox-code-line--warning"));
assert!(html.contains("ox-code-line--error"));
assert!(html.contains("console.log('old value')"));
assert!(html.contains("console.log('new value')"));
}
#[test]
fn test_render_nested_list() {
let allocator = Allocator::new();
let doc = Parser::new(&allocator, "- item 1\n - sub 1\n- item 2").parse().unwrap();
let mut renderer = HtmlRenderer::new();
let html = renderer.render(&doc);
let normalized = html.replace('\n', "");
assert!(normalized.contains("<li><p>item 1</p><ul><li><p>sub 1</p></li></ul></li>"));
assert!(normalized.contains("<li><p>item 2</p></li>"));
}
#[test]
fn test_render_table() {
let allocator = Allocator::new();
let parser_options = ox_content_parser::ParserOptions::gfm();
let doc = Parser::with_options(&allocator, "| head |\n| --- |\n| body |", parser_options)
.parse()
.unwrap();
let mut renderer = HtmlRenderer::new();
let html = renderer.render(&doc);
assert!(html.contains("<table>"));
assert!(html.contains("<thead>"));
assert!(html.contains("<th>head</th>"));
assert!(html.contains("<tbody>"));
assert!(html.contains("<td>body</td>"));
}
#[test]
fn test_render_table_no_gfm() {
let allocator = Allocator::new();
let doc = Parser::new(&allocator, "| head |\n| --- |\n| body |").parse().unwrap();
let mut renderer = HtmlRenderer::new();
let html = renderer.render(&doc);
assert!(!html.contains("<table>"));
assert!(html.contains("| head |"));
}
#[test]
fn test_render_heading_with_link() {
let allocator = Allocator::new();
let doc = Parser::new(&allocator, "### [index](./index-module.md)").parse().unwrap();
let mut renderer = HtmlRenderer::new();
let html = renderer.render(&doc);
assert_eq!(html, "<h3><a href=\"./index-module.md\">index</a></h3>\n");
}
#[test]
fn test_render_list_with_bold() {
let allocator = Allocator::new();
let doc = Parser::new(&allocator, "- **bold** text").parse().unwrap();
let mut renderer = HtmlRenderer::new();
let html = renderer.render(&doc);
assert!(html.contains("<strong>bold</strong>"));
}
#[test]
fn test_render_task_list() {
let allocator = Allocator::new();
let parser_options = ox_content_parser::ParserOptions::gfm();
let doc = Parser::with_options(&allocator, "- [x] task 1\n- [ ] task 2", parser_options)
.parse()
.unwrap();
let mut renderer = HtmlRenderer::new();
let html = renderer.render(&doc);
assert!(html.contains("<input type=\"checkbox\" checked disabled> <p>task 1</p>"));
assert!(html.contains("<input type=\"checkbox\" disabled> <p>task 2</p>"));
}
#[test]
fn test_render_strikethrough() {
let allocator = Allocator::new();
let doc =
Parser::with_options(&allocator, "~~done~~", ox_content_parser::ParserOptions::gfm())
.parse()
.unwrap();
let mut renderer = HtmlRenderer::new();
let html = renderer.render(&doc);
assert_eq!(html, "<p><del>done</del></p>\n");
}
#[test]
fn test_render_hard_break() {
let allocator = Allocator::new();
let doc = Parser::new(&allocator, "line 1\\\nline 2").parse().unwrap();
let mut renderer = HtmlRenderer::new();
let html = renderer.render(&doc);
assert_eq!(html, "<p>line 1<br>\nline 2</p>\n");
}
#[test]
fn test_render_image() {
let allocator = Allocator::new();
let doc = Parser::new(&allocator, "").parse().unwrap();
let mut renderer = HtmlRenderer::new();
let html = renderer.render(&doc);
assert!(html.contains("<img src=\"/path/to/image.png\" alt=\"Alt text\">"));
}
#[test]
fn test_render_image_xhtml() {
let allocator = Allocator::new();
let doc = Parser::new(&allocator, "").parse().unwrap();
let mut renderer =
HtmlRenderer::with_options(HtmlRendererOptions { xhtml: true, ..Default::default() });
let html = renderer.render(&doc);
assert!(html.contains("<img src=\"/logo.svg\" alt=\"Logo\" />"));
}
#[test]
fn test_convert_md_link_from_index_file() {
let allocator = Allocator::new();
let doc = Parser::new(&allocator, "[Docs](./docs.md)").parse().unwrap();
let mut renderer = HtmlRenderer::with_options(HtmlRendererOptions {
convert_md_links: true,
base_url: "/".to_string(),
source_path: "api/index.md".to_string(),
..Default::default()
});
let html = renderer.render(&doc);
assert!(
html.contains("href=\"./docs/index.html\""),
"Expected ./docs/index.html but got: {html}"
);
}
#[test]
fn test_convert_md_link_from_non_index_file() {
let allocator = Allocator::new();
let doc = Parser::new(&allocator, "[Docs](./docs.md)").parse().unwrap();
let mut renderer = HtmlRenderer::with_options(HtmlRendererOptions {
convert_md_links: true,
base_url: "/".to_string(),
source_path: "api/types.md".to_string(),
..Default::default()
});
let html = renderer.render(&doc);
assert!(
html.contains("href=\"../docs/index.html\""),
"Expected ../docs/index.html but got: {html}"
);
}
#[test]
fn test_convert_md_link_plain_relative_from_index() {
let allocator = Allocator::new();
let doc = Parser::new(&allocator, "[Types](types.md)").parse().unwrap();
let mut renderer = HtmlRenderer::with_options(HtmlRendererOptions {
convert_md_links: true,
base_url: "/".to_string(),
source_path: "api/index.md".to_string(),
..Default::default()
});
let html = renderer.render(&doc);
assert!(
html.contains("href=\"./types/index.html\""),
"Expected ./types/index.html but got: {html}"
);
}
#[test]
fn test_convert_md_link_parent_relative_from_index() {
let allocator = Allocator::new();
let doc = Parser::new(&allocator, "[Guide](../guide.md)").parse().unwrap();
let mut renderer = HtmlRenderer::with_options(HtmlRendererOptions {
convert_md_links: true,
base_url: "/".to_string(),
source_path: "api/index.md".to_string(),
..Default::default()
});
let html = renderer.render(&doc);
assert!(
html.contains("href=\"../guide/index.html\""),
"Expected ../guide/index.html but got: {html}"
);
}
#[test]
fn test_convert_md_link_parent_relative_from_non_index() {
let allocator = Allocator::new();
let doc = Parser::new(&allocator, "[Guide](../guide.md)").parse().unwrap();
let mut renderer = HtmlRenderer::with_options(HtmlRendererOptions {
convert_md_links: true,
base_url: "/".to_string(),
source_path: "api/types.md".to_string(),
..Default::default()
});
let html = renderer.render(&doc);
assert!(
html.contains("href=\"../../guide/index.html\""),
"Expected ../../guide/index.html but got: {html}"
);
}
}