///| HTML Renderer
///| Renders markdown AST to HTML
///|
/// Output buffer that remembers whether it is at the start of a line, so that
/// block-level elements can always begin on one — the `cr()` of the reference
/// implementation's HTML renderer.
priv struct HtmlBuf {
inner : StringBuilder
mut at_line_start : Bool
}
///|
fn HtmlBuf::new(size_hint? : Int = 0) -> HtmlBuf {
{ inner: StringBuilder(size_hint~), at_line_start: true }
}
///|
fn HtmlBuf::write_string(self : HtmlBuf, s : String) -> Unit {
if s.is_empty() {
return
}
self.inner.write_string(s)
self.at_line_start = s.unsafe_get(s.length() - 1) == '\n'
}
///|
fn HtmlBuf::write_char(self : HtmlBuf, c : Char) -> Unit {
self.inner.write_char(c)
self.at_line_start = c == '\n'
}
///|
/// Start a new line unless the output already is at one.
fn HtmlBuf::cr(self : HtmlBuf) -> Unit {
if !self.at_line_start {
self.write_char('\n')
}
}
///|
fn HtmlBuf::to_string(self : HtmlBuf) -> String {
self.inner.to_string()
}
///|
/// Everything the block/inline renderers need to know beyond the node itself.
priv struct RenderCtx {
autolink : Bool
tagfilter : Bool
defs : Map[String, LinkDefinition]
footnote_defs : Map[String, FootnoteRenderDefinition]
footnote_order : Array[String]
footnote_numbers : Map[String, Int]
footnote_ref_counts : Map[String, Int]
math_block_renderer : ((String) -> String)?
}
///|
/// Footnote content collected before rendering the document body.
priv struct FootnoteRenderDefinition {
children : Array[Block]
}
///|
/// Render a document to HTML
pub fn render_html(
doc : Document,
autolink? : Bool = true,
tagfilter? : Bool = true,
) -> String {
render_html_with_options(doc, RenderOptions::default(), autolink~, tagfilter~)
}
///|
/// Render with replaceable block renderers such as KaTeX.
pub fn render_html_with_options(
doc : Document,
options : RenderOptions,
autolink? : Bool = true,
tagfilter? : Bool = true,
) -> String {
let defs : Map[String, LinkDefinition] = Map(
[],
capacity=doc.definitions.length(),
)
for def in doc.definitions {
let key = normalize_label(def.label)
if !defs.contains(key) {
defs[key] = def
}
}
let footnote_defs : Map[String, FootnoteRenderDefinition] = Map([])
collect_footnote_definitions(doc.children, footnote_defs)
let ctx = RenderCtx::{
autolink,
tagfilter,
defs,
footnote_defs,
footnote_order: [],
footnote_numbers: Map([]),
footnote_ref_counts: Map([]),
math_block_renderer: options.math_block_renderer,
}
let buf = HtmlBuf::new(size_hint=doc.span.to - doc.span.from)
for block in doc.children {
render_block_html(block, buf, ctx)
}
render_footnote_section(buf, ctx)
buf.to_string()
}
///|
/// Collect definitions recursively; the first definition for a label wins.
fn collect_footnote_definitions(
blocks : Array[Block],
defs : Map[String, FootnoteRenderDefinition],
) -> Unit {
for block in blocks {
match block {
Block::FootnoteDefinition(label~, children~, ..) => {
let key = normalize_label(label)
if !defs.contains(key) {
defs[key] = { children, }
}
}
Block::Blockquote(children~, ..) | Block::Alert(children~, ..) =>
collect_footnote_definitions(children, defs)
Block::Directive(children~, ..) =>
collect_footnote_definitions(children, defs)
Block::Attributed(block~, ..) =>
collect_footnote_definitions([block], defs)
Block::BulletList(items~, ..) | Block::OrderedList(items~, ..) =>
for item in items {
collect_footnote_definitions(item.children, defs)
}
_ => ()
}
}
}
///|
/// Render a block element to HTML
fn render_block_html(block : Block, buf : HtmlBuf, ctx : RenderCtx) -> Unit {
match block {
Block::Attributed(block~, attributes~, ..) =>
render_attributed_block(block, attributes, buf, ctx)
Block::Paragraph(children~, ..) => {
buf.cr()
buf.write_string("")
render_inlines_html(children, buf, ctx)
buf.write_string("
\n")
}
Block::Heading(level~, children~, ..) => {
buf.cr()
buf.write_string("')
render_inlines_html(children, buf, ctx)
buf.write_string(" \n")
}
Block::ThematicBreak(..) => {
buf.cr()
buf.write_string("
\n")
}
Block::FencedCode(info~, code~, ..) => {
buf.cr()
let lang = code_block_language(info)
if lang.is_empty() {
buf.write_string("")
} else {
buf.write_string("")
}
write_code_content(code, buf)
buf.write_string("
\n")
}
Block::MathBlock(value~, ..) => {
buf.cr()
match ctx.math_block_renderer {
Some(renderer) => {
let html = renderer(value)
buf.write_string(html)
if !html.has_suffix("\n") {
buf.write_char('\n')
}
}
None => {
buf.write_string("")
buf.write_string(escape_html(value))
buf.write_string("
\n")
}
}
}
Block::Directive(name~, meta~, children~, ..) => {
buf.cr()
buf.write_string("\n")
for child in children {
render_block_html(child, buf, ctx)
}
buf.write_string("\n")
}
Block::DefinitionList(items~, ..) => {
buf.cr()
buf.write_string("\n")
for item in items {
buf.write_string("- ")
render_inlines_html(item.term, buf, ctx)
buf.write_string("
\n")
for definition in item.definitions {
buf.write_string("- ")
render_inlines_html(definition, buf, ctx)
buf.write_string("
\n")
}
}
buf.write_string("
\n")
}
Block::IndentedCode(code~, ..) => {
buf.cr()
buf.write_string("")
write_code_content(code, buf)
buf.write_string("
\n")
}
Block::Blockquote(children~, ..) => {
buf.cr()
buf.write_string("\n")
for child in children {
render_block_html(child, buf, ctx)
}
buf.write_string("
\n")
}
Block::Alert(kind~, children~, ..) => {
let slug = alert_kind_slug(kind)
buf.cr()
buf.write_string("\n")
buf.write_string(alert_kind_title(kind))
buf.write_string("
\n")
for child in children {
render_block_html(child, buf, ctx)
}
buf.write_string("\n")
}
Block::BulletList(items~, tight~, ..) => {
buf.cr()
buf.write_string("\n")
for item in items {
render_list_item_html(item, buf, tight, ctx)
}
buf.write_string("
\n")
}
Block::OrderedList(items~, start~, tight~, ..) => {
buf.cr()
if start == 1 {
buf.write_string("\n")
} else {
buf.write_string("\n")
}
for item in items {
render_list_item_html(item, buf, tight, ctx)
}
buf.write_string("
\n")
}
Block::HtmlBlock(html~, ..) => {
buf.cr()
buf.write_string(if ctx.tagfilter { gfm_tagfilter(html) } else { html })
if !html.has_suffix("\n") {
buf.write_char('\n')
}
}
Block::Table(header~, alignments~, rows~, ..) => {
buf.cr()
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, ctx)
}
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 {
if i >= alignments.length() {
break
}
let align = if i < alignments.length() {
alignments[i]
} else {
TableAlign::None
}
render_table_cell_html(cell, buf, "td", align, ctx)
}
buf.write_string(" \n")
}
buf.write_string("\n")
}
buf.write_string("
\n")
}
Block::BlankLines(..) => () // Blank lines don't produce HTML output
Block::FootnoteDefinition(..) => ()
}
}
///| Render attributes on common semantic blocks; use a neutral wrapper for
///|
/// block kinds whose opening tag is owned by a specialized renderer.
fn render_attributed_block(
block : Block,
attributes : Array[MarkdownAttribute],
buf : HtmlBuf,
ctx : RenderCtx,
) -> Unit {
match block {
Block::FootnoteDefinition(..) => ()
Block::Heading(level~, children~, ..) => {
buf.cr()
buf.write_string("')
render_inlines_html(children, buf, ctx)
buf.write_string(" \n")
}
Block::Paragraph(children~, ..) => {
buf.cr()
buf.write_string("')
render_inlines_html(children, buf, ctx)
buf.write_string("
\n")
}
_ => {
buf.cr()
buf.write_string("\n")
render_block_html(block, buf, ctx)
buf.write_string("\n")
}
}
}
///|
/// Emit safe normalized attributes, merging repeated class shorthands.
fn write_markdown_attributes(
attributes : Array[MarkdownAttribute],
buf : HtmlBuf,
) -> Unit {
let classes = StringBuilder()
for attribute in attributes {
if attribute.name == "id" {
buf.write_string(" id=\"")
buf.write_string(escape_html(attribute.value))
buf.write_char('"')
} else if attribute.name == "class" {
if !classes.to_string().is_empty() {
classes.write_char(' ')
}
classes.write_string(attribute.value)
}
}
let class_value = classes.to_string()
if !class_value.is_empty() {
buf.write_string(" class=\"")
buf.write_string(escape_html(class_value))
buf.write_char('"')
}
for attribute in attributes {
let lower = attribute.name.to_lower()
if lower == "id" ||
lower == "class" ||
lower == "style" ||
lower.has_prefix("on") {
continue
}
buf.write_char(' ')
buf.write_string(attribute.name)
if !attribute.value.is_empty() {
buf.write_string("=\"")
buf.write_string(escape_html(attribute.value))
buf.write_char('"')
}
}
}
///|
fn alert_kind_slug(kind : AlertKind) -> String {
kind.slug()
}
///|
fn alert_kind_title(kind : AlertKind) -> String {
match kind {
Note => "Note"
Tip => "Tip"
Important => "Important"
Warning => "Warning"
Caution => "Caution"
}
}
///|
/// The language class of a fenced code block: the first word of the info
/// string, with escapes and entity references resolved.
fn code_block_language(info : String) -> String {
let decoded = unescape_string(info)
let len = decoded.length()
let mut start = 0
while start < len && is_html_space(decoded.unsafe_get(start)) {
start = start + 1
}
let mut end = start
while end < len && !is_html_space(decoded.unsafe_get(end)) {
end = end + 1
}
decoded.unsafe_substring(start~, end~)
}
///|
/// Write code-block content, guaranteeing the trailing newline CommonMark
/// requires inside ``.
fn write_code_content(code : String, buf : HtmlBuf) -> Unit {
if code.is_empty() {
return
}
buf.write_string(escape_html(code))
if !code.has_suffix("\n") {
buf.write_char('\n')
}
}
///|
/// Render a list item to HTML
fn render_list_item_html(
item : ListItem,
buf : HtmlBuf,
tight : Bool,
ctx : RenderCtx,
) -> Unit {
buf.cr()
buf.write_string("- ")
// Task list checkbox
match item.checked {
Some(true) =>
buf.write_string(" ")
Some(false) => buf.write_string(" ")
None => ()
}
for child in item.children {
match child {
// A tight list drops the
wrapper around its items' paragraphs.
Paragraph(children~, ..) if tight =>
render_inlines_html(children, buf, ctx)
_ => render_block_html(child, buf, ctx)
}
}
buf.write_string("
\n")
}
///|
/// Render a table cell to HTML
fn render_table_cell_html(
cell : TableCell,
buf : HtmlBuf,
tag : String,
align : TableAlign,
ctx : RenderCtx,
) -> 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, ctx)
buf.write_string("")
buf.write_string(tag)
buf.write_string(">\n")
}
///|
/// Render inline elements to HTML
fn render_inlines_html(
inlines : Array[Inline],
buf : HtmlBuf,
ctx : RenderCtx,
) -> Unit {
for inline in inlines {
render_inline_html(inline, buf, ctx)
}
}
///|
/// 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 : HtmlBuf,
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_autolink(content, pos) {
Some(found) => {
write_escaped_text_range(content, pos, found.start, buf)
let display = content.unsafe_substring(start=found.start, end=found.end)
buf.write_string(" buf.write_string("http://")
Email => buf.write_string("mailto:")
Url => ()
}
write_escaped_href(display, buf)
buf.write_string("\">")
buf.write_string(escape_html(display))
buf.write_string("")
pos = found.end
}
None => {
write_escaped_text_range(content, pos, len, buf)
pos = len
}
}
}
}
///|
fn write_escaped_text_range(
content : String,
start : Int,
end : Int,
buf : HtmlBuf,
) -> Unit {
if end > start {
buf.write_string(escape_html(content.unsafe_substring(start~, end~)))
}
}
///|
/// Resolve a reference link/image label against the document's definitions.
fn RenderCtx::lookup(self : RenderCtx, label : String) -> LinkDefinition? {
self.defs.get(normalize_label(label))
}
///|
/// Render a single inline element to HTML
fn render_inline_html(inline : Inline, buf : HtmlBuf, ctx : RenderCtx) -> Unit {
match inline {
Inline::Text(content~, ..) =>
render_text_html(content, buf, autolink=ctx.autolink)
Inline::Code(content~, ..) => {
buf.write_string("")
buf.write_string(escape_html(normalize_code_span(content)))
buf.write_string("")
}
Inline::Directive(name~, label~, attributes~, ..) => {
buf.write_string("')
buf.write_string(escape_html(label))
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, ctx)
buf.write_string("")
}
Inline::Strong(children~, ..) => {
buf.write_string("")
render_inlines_html(children, buf, ctx)
buf.write_string("")
}
Inline::Strikethrough(children~, ..) => {
buf.write_string("")
render_inlines_html(children, buf, ctx)
buf.write_string("")
}
Inline::Link(children~, url~, title~, ..) =>
write_anchor(children, url, title, buf, ctx)
Inline::RefLink(children~, label~, ..) =>
match ctx.lookup(label) {
Some(def) => write_anchor(children, def.url, def.title, buf, ctx)
None => {
buf.write_char('[')
render_inlines_html(children, buf, ctx)
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~, ..) =>
write_image(alt, url, title, buf, ctx)
Inline::RefImage(alt~, label~, ..) =>
match ctx.lookup(label) {
Some(def) => write_image(alt, def.url, def.title, buf, ctx)
None => {
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(if ctx.tagfilter { gfm_tagfilter(html) } else { html })
Inline::SoftBreak(..) => buf.write_char('\n')
Inline::HardBreak(..) => buf.write_string("
\n")
Inline::FootnoteReference(label~, ..) => {
let key = normalize_label(label)
guard ctx.footnote_defs.contains(key) else {
buf.write_string("[^")
buf.write_string(escape_html(label))
buf.write_char(']')
return
}
let number = match ctx.footnote_numbers.get(key) {
Some(number) => number
None => {
let number = ctx.footnote_order.length() + 1
ctx.footnote_order.push(key)
ctx.footnote_numbers[key] = number
number
}
}
let occurrence = match ctx.footnote_ref_counts.get(key) {
Some(count) => count + 1
None => 1
}
ctx.footnote_ref_counts[key] = occurrence
buf.write_string(" 1 {
buf.write_char('-')
buf.write_string(occurrence.to_string())
}
buf.write_string(
"\" data-footnote-ref=\"\" aria-describedby=\"footnote-label\">",
)
buf.write_string(number.to_string())
buf.write_string("")
}
}
}
///|
/// Render referenced footnotes at the end of the document in reference order.
fn render_footnote_section(buf : HtmlBuf, ctx : RenderCtx) -> Unit {
guard !ctx.footnote_order.is_empty() else { return }
buf.cr()
buf.write_string("\n")
buf.write_string(
"Footnotes
\n\n",
)
for key in ctx.footnote_order {
guard ctx.footnote_defs.get(key) is Some(def) else { continue }
let number = ctx.footnote_numbers[key]
buf.write_string("- \n")
for child in def.children {
render_block_html(child, buf, ctx)
}
let count = match ctx.footnote_ref_counts.get(key) {
Some(count) => count
None => 0
}
if count > 0 {
buf.write_string("
")
for occurrence = 1; occurrence <= count; occurrence = occurrence + 1 {
if occurrence > 1 {
buf.write_char(' ')
}
buf.write_string(" 1 {
buf.write_char('-')
buf.write_string(occurrence.to_string())
}
buf.write_string(
"\" data-footnote-backref=\"\" aria-label=\"Back to reference ",
)
buf.write_string(number.to_string())
if occurrence > 1 {
buf.write_char('-')
buf.write_string(occurrence.to_string())
}
buf.write_string("\" class=\"data-footnote-backref\">↩")
}
buf.write_string("
\n")
}
buf.write_string(" \n")
}
buf.write_string("
\n \n")
}
///|
fn write_anchor(
children : Array[Inline],
url : String,
title : String,
buf : HtmlBuf,
ctx : RenderCtx,
) -> Unit {
buf.write_string("')
render_inlines_html(children, buf, { ..ctx, autolink: false })
buf.write_string("")
}
///|
fn write_image(
alt : String,
url : String,
title : String,
buf : HtmlBuf,
ctx : RenderCtx,
) -> Unit {
buf.write_string("
")
}
///|
/// An image's `alt` keeps the source of its label so it round-trips; the HTML
/// form is that label rendered as plain text.
fn render_alt_text(alt : String, ctx : RenderCtx) -> String {
let buf = StringBuilder(size_hint=alt.length())
write_plain_text(parse_inlines_with_defs(alt, ctx.defs, false), buf)
buf.to_string()
}
///|
fn write_plain_text(inlines : Array[Inline], buf : StringBuilder) -> Unit {
for inline in inlines {
match inline {
Inline::Text(content~, ..) => buf.write_string(content)
Inline::Code(content~, ..) =>
buf.write_string(normalize_code_span(content))
Inline::Directive(label~, ..) => buf.write_string(label)
Inline::HtmlInline(html~, ..) => buf.write_string(html)
Inline::SoftBreak(..) | Inline::HardBreak(..) => buf.write_char('\n')
Inline::Emphasis(children~, ..)
| Inline::Strong(children~, ..)
| Inline::Strikethrough(children~, ..)
| Inline::Link(children~, ..)
| Inline::RefLink(children~, ..) => write_plain_text(children, buf)
Inline::Image(alt~, ..) | Inline::RefImage(alt~, ..) =>
buf.write_string(alt)
Inline::Autolink(url~, ..) => buf.write_string(url)
Inline::WikiLink(target~, label~, ..) =>
buf.write_string(if label.is_empty() { target } else { label })
Inline::FootnoteReference(label~, ..) => buf.write_string(label)
}
}
}
///|
/// 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(size_hint=s.length())
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()
}
///|
/// Characters that may appear literally in an `href`; everything else is
/// percent-encoded (or, for `&` and `'`, written as a character reference).
/// The table matches the reference implementation's.
fn is_href_safe(code : Int) -> Bool {
if code < 0x21 || code > 0x7E {
return false
}
match code {
0x22
| 0x26
| 0x27
| 0x3C
| 0x3E
| 0x5B
| 0x5C
| 0x5D
| 0x5E
| 0x60
| 0x7B
| 0x7C
| 0x7D => false
_ => true
}
}
///|
fn write_hex_byte(byte : Int, buf : HtmlBuf) -> Unit {
let digits = "0123456789ABCDEF"
buf.write_char('%')
buf.write_string(
digits.unsafe_substring(
start=(byte >> 4) & 0xF,
end=((byte >> 4) & 0xF) + 1,
),
)
buf.write_string(
digits.unsafe_substring(start=byte & 0xF, end=(byte & 0xF) + 1),
)
}
///|
/// Percent-encode a URL for use in an `href`/`src` attribute.
fn write_escaped_href(url : String, buf : HtmlBuf) -> Unit {
for c in url {
let code = c.to_int()
if is_href_safe(code) {
buf.write_char(c)
} else if code == 0x26 {
buf.write_string("&")
} else if code == 0x27 {
buf.write_string("'")
} else if code < 0x80 {
write_hex_byte(code, buf)
} else if code < 0x800 {
write_hex_byte(0xC0 | (code >> 6), buf)
write_hex_byte(0x80 | (code & 0x3F), buf)
} else if code < 0x10000 {
write_hex_byte(0xE0 | (code >> 12), buf)
write_hex_byte(0x80 | ((code >> 6) & 0x3F), buf)
write_hex_byte(0x80 | (code & 0x3F), buf)
} else {
write_hex_byte(0xF0 | (code >> 18), buf)
write_hex_byte(0x80 | ((code >> 12) & 0x3F), buf)
write_hex_byte(0x80 | ((code >> 6) & 0x3F), buf)
write_hex_byte(0x80 | (code & 0x3F), buf)
}
}
}
///|
/// Parse markdown and render to HTML
pub fn md_to_html(
source : String,
wikilinks? : Bool = false,
autolink? : Bool = true,
tagfilter? : Bool = true,
) -> String {
let result = parse(source, wikilinks~)
render_html(result.document, autolink~, tagfilter~)
}
///|
/// Parse markdown and render to HTML (strict mode)
///
/// Kept for backwards compatibility: the parser is now always spec-strict.
pub fn md_to_html_strict(
source : String,
wikilinks? : Bool = false,
autolink? : Bool = true,
tagfilter? : Bool = true,
) -> String {
md_to_html(source, wikilinks~, autolink~, tagfilter~)
}