///| HTML Renderer
///| Renders markdown AST to HTML
///|
/// Render a document to HTML
pub fn render_html(doc : Document, autolink? : Bool = true) -> String {
let buf = StringBuilder::new()
for block in doc.children {
render_block_html(block, buf, autolink~)
}
buf.to_string()
}
///|
/// Render a block element to HTML
fn render_block_html(
block : Block,
buf : StringBuilder,
autolink? : Bool = true,
) -> Unit {
match block {
Block::Paragraph(children~, ..) => {
buf.write_string("")
render_inlines_html(children, buf, autolink~)
buf.write_string("
\n")
}
Block::Heading(level~, children~, ..) => {
buf.write_string("')
render_inlines_html(children, buf, autolink~)
buf.write_string(" \n")
}
Block::ThematicBreak(..) => buf.write_string("
\n")
Block::FencedCode(info~, code~, ..) => {
if info.is_empty() {
buf.write_string("")
} else {
// Extract language from info string (first word)
let parts = info.split(" ").collect()
let lang = if parts.length() > 0 { parts[0].to_owned() } else { info }
buf.write_string("")
}
buf.write_string(escape_html(code))
buf.write_string("
\n")
}
Block::IndentedCode(code~, ..) => {
buf.write_string("")
buf.write_string(escape_html(code))
buf.write_string("
\n")
}
Block::Blockquote(children~, ..) => {
buf.write_string("\n")
for child in children {
render_block_html(child, buf, autolink~)
}
buf.write_string("
\n")
}
Block::BulletList(items~, tight~, ..) => {
// Check if this list contains task items
let has_task = items.iter().any(fn(item) { !(item.checked is None) })
if has_task {
buf.write_string("\n")
} else {
buf.write_string("\n")
}
for item in items {
render_list_item_html(item, buf, tight, has_task, autolink~)
}
buf.write_string("
\n")
}
Block::OrderedList(items~, start~, tight~, ..) => {
let has_task = items.iter().any(fn(item) { !(item.checked is None) })
if start == 1 {
if has_task {
buf.write_string("\n")
} else {
buf.write_string("\n")
}
} else {
if has_task {
buf.write_string("\n")
}
for item in items {
render_list_item_html(item, buf, tight, has_task, autolink~)
}
buf.write_string("
\n")
}
Block::HtmlBlock(html~, ..) => {
buf.write_string(html)
if !html.has_suffix("\n") {
buf.write_char('\n')
}
}
Block::Table(header~, alignments~, rows~, ..) => {
buf.write_string("\n\n\n")
for i, cell in header {
let align = if i < alignments.length() {
alignments[i]
} else {
TableAlign::None
}
render_table_cell_html(cell, buf, "th", align, autolink~)
}
buf.write_string(" \n\n")
if rows.length() > 0 {
buf.write_string("\n")
for row in rows {
buf.write_string("\n")
for i, cell in row {
let align = if i < alignments.length() {
alignments[i]
} else {
TableAlign::None
}
render_table_cell_html(cell, buf, "td", align, autolink~)
}
buf.write_string(" \n")
}
buf.write_string("\n")
}
buf.write_string("
\n")
}
Block::BlankLines(..) => () // Blank lines don't produce HTML output
Block::FootnoteDefinition(label~, children~, ..) => {
buf.write_string("\n")
for child in children {
render_block_html(child, buf, autolink~)
}
buf.write_string("\n")
}
}
}
///|
/// Render a list item to HTML
fn render_list_item_html(
item : ListItem,
buf : StringBuilder,
tight : Bool,
is_task_list : Bool,
autolink? : Bool = true,
) -> Unit {
// Task list items get a special class
if is_task_list && !(item.checked is None) {
buf.write_string("- ")
} else {
buf.write_string("
- ")
}
// Task list checkbox
match item.checked {
Some(true) =>
buf.write_string(" ")
Some(false) => buf.write_string(" ")
None => ()
}
if tight {
// Tight list: render inline content without
wrapper
for child in item.children {
match child {
Paragraph(children~, ..) =>
render_inlines_html(children, buf, autolink~)
_ => render_block_html(child, buf, autolink~)
}
}
} else {
// Loose list: render blocks normally
buf.write_char('\n')
for child in item.children {
render_block_html(child, buf, autolink~)
}
}
buf.write_string("
\n")
}
///|
/// Render a table cell to HTML
fn render_table_cell_html(
cell : TableCell,
buf : StringBuilder,
tag : String,
align : TableAlign,
autolink? : Bool = true,
) -> Unit {
buf.write_char('<')
buf.write_string(tag)
match align {
TableAlign::Left => buf.write_string(" align=\"left\"")
TableAlign::Center => buf.write_string(" align=\"center\"")
TableAlign::Right => buf.write_string(" align=\"right\"")
TableAlign::None => ()
}
buf.write_char('>')
render_inlines_html(cell.children, buf, autolink~)
buf.write_string("")
buf.write_string(tag)
buf.write_string(">\n")
}
///|
/// Render inline elements to HTML
fn render_inlines_html(
inlines : Array[Inline],
buf : StringBuilder,
autolink? : Bool = true,
) -> Unit {
for inline in inlines {
render_inline_html(inline, buf, autolink~)
}
}
///|
/// Build the rendered destination for a wiki link.
fn render_wikilink_destination(target : String, fragment : String) -> String {
if fragment.is_empty() {
target
} else {
target + "#" + fragment
}
}
///|
/// Render text content, optionally turning bare http(s) URLs into anchors.
fn render_text_html(
content : String,
buf : StringBuilder,
autolink? : Bool = true,
) -> Unit {
if !autolink {
buf.write_string(escape_html(content))
return
}
let len = content.length()
let mut pos = 0
while pos < len {
match find_next_url_start(content, pos) {
Some(start) => {
write_escaped_text_range(content, pos, start, buf)
let raw_end = find_url_raw_end(content, start)
let url_end = trim_url_end(content, start, raw_end)
if url_end > start {
let url = content.unsafe_substring(start~, end=url_end)
buf.write_string("")
buf.write_string(escape_html(url))
buf.write_string("")
pos = url_end
} else {
write_escaped_text_range(content, start, raw_end, buf)
pos = raw_end
}
}
None => {
write_escaped_text_range(content, pos, len, buf)
pos = len
}
}
}
}
///|
fn write_escaped_text_range(
content : String,
start : Int,
end : Int,
buf : StringBuilder,
) -> Unit {
if end > start {
buf.write_string(escape_html(content.unsafe_substring(start~, end~)))
}
}
///|
/// Render a single inline element to HTML
fn render_inline_html(
inline : Inline,
buf : StringBuilder,
autolink? : Bool = true,
) -> Unit {
match inline {
Inline::Text(content~, ..) => render_text_html(content, buf, autolink~)
Inline::Code(content~, ..) => {
buf.write_string("")
buf.write_string(escape_html(content))
buf.write_string("")
}
Inline::WikiLink(target~, label~, fragment~, ..) => {
let href = render_wikilink_destination(target, fragment)
let text = if label.is_empty() { href } else { label }
buf.write_string("")
buf.write_string(escape_html(text))
buf.write_string("")
}
Inline::Emphasis(children~, ..) => {
buf.write_string("")
render_inlines_html(children, buf, autolink~)
buf.write_string("")
}
Inline::Strong(children~, ..) => {
buf.write_string("")
render_inlines_html(children, buf, autolink~)
buf.write_string("")
}
Inline::Strikethrough(children~, ..) => {
buf.write_string("")
render_inlines_html(children, buf, autolink~)
buf.write_string("")
}
Inline::Link(children~, url~, title~, ..) => {
buf.write_string("')
render_inlines_html(children, buf, autolink=false)
buf.write_string("")
}
Inline::RefLink(children~, label~, ..) => {
// Reference links should be resolved before rendering
// For now, render as plain text with the label
buf.write_char('[')
render_inlines_html(children, buf, autolink=false)
buf.write_string("][")
buf.write_string(escape_html(label))
buf.write_char(']')
}
Inline::Autolink(url~, is_email~, ..) => {
buf.write_string("")
buf.write_string(escape_html(url))
buf.write_string("")
}
Inline::Image(alt~, url~, title~, ..) => {
buf.write_string("
")
}
Inline::RefImage(alt~, label~, ..) => {
// Reference images should be resolved before rendering
buf.write_string("![")
buf.write_string(escape_html(alt))
buf.write_string("][")
buf.write_string(escape_html(label))
buf.write_char(']')
}
Inline::HtmlInline(html~, ..) => buf.write_string(html)
Inline::SoftBreak(..) => buf.write_char('\n')
Inline::HardBreak(..) => buf.write_string("
\n")
Inline::FootnoteReference(label~, ..) => {
buf.write_string("")
buf.write_string(escape_html(label))
buf.write_string("")
}
}
}
///|
/// Escape HTML special characters in text content.
/// Pass `attr=true` to also escape `'` (for attribute values).
///
/// Most input strings don't contain any HTML-special characters, so a
/// quick scan-pass lets us return the input unchanged in that common
/// case and avoid building a new buffer.
fn escape_html(s : String, attr? : Bool = false) -> String {
let mut needs_escape = false
for c in s {
match c {
'&' | '<' | '>' | '"' => {
needs_escape = true
break
}
'\'' if attr => {
needs_escape = true
break
}
_ => ()
}
}
if !needs_escape {
return s
}
let buf = StringBuilder::new()
for c in s {
match c {
'&' => buf.write_string("&")
'<' => buf.write_string("<")
'>' => buf.write_string(">")
'"' => buf.write_string(""")
'\'' if attr => buf.write_string("'")
_ => buf.write_char(c)
}
}
buf.to_string()
}
///|
/// Escape HTML special characters in attribute values
fn escape_html_attr(s : String) -> String {
escape_html(s, attr=true)
}
///|
/// Parse markdown and render to HTML
pub fn md_to_html(
source : String,
wikilinks? : Bool = false,
autolink? : Bool = true,
) -> String {
let result = parse(source, wikilinks~)
render_html(result.document, autolink~)
}
///|
/// Parse markdown and render to HTML (strict mode)
pub fn md_to_html_strict(
source : String,
wikilinks? : Bool = false,
autolink? : Bool = true,
) -> String {
let result = parse(source, strict=true, wikilinks~)
render_html(result.document, autolink~)
}