///|
// pub(all) struct MarkdownAst {
// blocks : Markdown
// } derive(Eq, Debug)
pub type Markdown = Array[Block]
///|
/// An ordered or unordered list block.
pub(all) struct ListBlock {
/// Whether this is an ordered (numbered) list.
ordered : Bool
/// The starting number for an ordered list, if specified.
start : Int?
/// The list's items, each itself a sequence of blocks (allowing items
/// to contain multiple paragraphs, nested lists, etc.).
items : Array[Array[Block]]
} derive(Eq, Debug, FromJson, ToJson)
// @block block
///|
/// A single block-level Markdown element.
pub(all) enum Block {
/// A heading, with its level (1–6) and inline content.
Heading(Int, Array[Inline])
/// A paragraph of inline content.
Paragraph(Array[Inline])
/// A thematic break (horizontal rule).
ThematicBreak
/// A fenced or indented code block, with an optional language/info
/// string and the raw code text.
CodeBlock(String?, String)
/// A block quote, containing its own nested sequence of blocks.
BlockQuote(Array[Block])
/// An ordered or unordered list.
List(ListBlock)
/// A raw block of HTML.
HtmlBlock(String)
/// A table.
Table(TableBlock)
} derive(Eq, ToJson, Debug, FromJson)
// @end
///|
/// A single inline-level Markdown element.
pub(all) enum Inline {
/// Plain text content.
Text(String)
/// Emphasized (typically italic) inline content.
Emphasis(Array[Inline])
/// Strongly emphasized (typically bold) inline content.
Strong(Array[Inline])
/// Inline code.
Code(String)
/// A hyperlink, with its inline label content and target URL.
Link(Array[Inline], String)
/// An image, with its alt text and source URL.
Image(String, String)
/// Raw inline HTML.
Html(String)
/// A soft line break (rendered as whitespace).
SoftBreak
/// A hard line break (rendered as an explicit line break, e.g. `
`).
HardBreak
} derive(Eq, Debug, FromJson, ToJson)
///|
/// A table block, with its header row and body rows, each cell holding
/// inline content.
pub(all) struct TableBlock {
headers : Array[Array[Inline]]
rows : Array[Array[Array[Inline]]]
} derive(Eq, Debug, FromJson, ToJson)
///|
/// Types that can be rendered to a plain-text representation with all
/// markup stripped.
pub trait Plainable {
fn to_plain(self : Self) -> String
}
///|
/// Renders each element to plain text and concatenates the results.
pub impl[T : Plainable] Plainable for Array[T] with fn to_plain(self : Array[T]) -> String {
self.map(item => item.to_plain()).join(" ")
}
///|
/// Renders a `Block` to its plain-text (non-HTML) representation, e.g.
/// for use in summaries, search indexing, or previews where markup should
/// be stripped.
pub impl Plainable for Block with fn to_plain(self : Block) -> String {
match self {
Heading(_, content) => Plainable::to_plain(content)
Paragraph(content) => Plainable::to_plain(content)
ThematicBreak => ""
CodeBlock(_, content) => content
BlockQuote(blocks) => Plainable::to_plain(blocks)
List(list) => list.to_plain()
HtmlBlock(content) => content
Table(table) => Plainable::to_plain(table)
}
}
///|
/// Renders an `Inline` to its plain-text (non-HTML) representation.
pub impl Plainable for Inline with fn to_plain(self : Inline) -> String {
match self {
Text(text) => text
Emphasis(content) => Plainable::to_plain(content)
Strong(content) => Plainable::to_plain(content)
Code(code) => code
Link(content, _) => Plainable::to_plain(content)
Image(_, alt) => alt
Html(html) => html
SoftBreak => " "
HardBreak => "\n"
}
}
///|
/// Renders a `ListBlock` to its plain-text (non-HTML) representation.
pub impl Plainable for ListBlock with fn to_plain(self : ListBlock) -> String {
(if self.start is Some(start) { start.to_string() + " " } else { "" }) +
Plainable::to_plain(self.items)
}
///|
/// Renders a `TableBlock` to its plain-text (non-HTML) representation.
pub impl Plainable for TableBlock with fn to_plain(self : TableBlock) -> String {
Plainable::to_plain(self.headers) +
" " +
self.rows.map(row => Plainable::to_plain(row)).join(" ")
}
///|
/// Parses `input` (any `Show`-able value, typically a `String` of raw
/// Markdown source) into a Markdown AST.
pub fn[T : Show] parse_markdown(input : T) -> Markdown {
parse_string(input.to_string().replace_all(old="\r\n", new="\n"))
}
///|
fn parse_string(input : String) -> Markdown {
let lines = normalize_lines(input)
parse_blocks(lines)
}
///|
fn parse_blocks(lines : Array[String]) -> Markdown {
let blocks : Markdown = []
let mut index = 0
while index < lines.length() {
let line = lines[index]
let trimmed = line.trim().to_owned()
if trimmed == "" {
index = index + 1
} else if is_fence_start(trimmed) {
let (block, next) = parse_fenced_code(lines, index)
blocks.push(block)
index = next
} else if is_atx_heading(trimmed) {
blocks.push(parse_heading(trimmed))
index = index + 1
} else if is_thematic_break(trimmed) {
blocks.push(ThematicBreak)
index = index + 1
} else if is_block_quote(trimmed) {
let (block, next) = parse_block_quote(lines, index)
blocks.push(block)
index = next
} else if is_list_item(trimmed) {
let (block, next) = parse_list(lines, index)
blocks.push(block)
index = next
} else if is_table(lines, index) {
let (block, next) = parse_table(lines, index)
blocks.push(block)
index = next
} else if is_html_block_start(trimmed) {
let (block, next) = parse_html_block(lines, index)
blocks.push(block)
index = next
} else {
let (block, next) = parse_paragraph(lines, index)
blocks.push(block)
index = next
}
}
blocks
}
///|
fn parse_heading(line : String) -> Block {
let mut level = 0
while level < line.length() && char_at(line, level) == '#' && level < 6 {
level = level + 1
}
let raw = substring(line, level, line.length()).trim().to_owned()
let content = strip_closing_heading_marker(raw)
Heading(level, parse_inlines(content))
}
///|
fn parse_fenced_code(lines : Array[String], start : Int) -> (Block, Int) {
let opening = lines[start].trim().to_owned()
let marker = char_at(opening, 0)
let fence_len = count_prefix(opening, marker)
let info = opening_suffix(opening, fence_len).trim().to_owned()
let content = StringBuilder::new()
let mut index = start + 1
while index < lines.length() {
let line = lines[index]
let trimmed = line.trim().to_owned()
if is_matching_fence(trimmed, marker, fence_len) {
return (
CodeBlock(
if info == "" {
None
} else {
Some(info)
},
content.to_string(),
),
index + 1,
)
}
content.write_string(line)
if index + 1 < lines.length() {
content.write_char('\n')
}
index = index + 1
}
(
CodeBlock(if info == "" { None } else { Some(info) }, content.to_string()),
index,
)
}
///|
fn parse_block_quote(lines : Array[String], start : Int) -> (Block, Int) {
let quote_lines : Array[String] = []
let mut index = start
while index < lines.length() {
let trimmed = lines[index].trim().to_owned()
if trimmed == "" {
quote_lines.push("")
index = index + 1
} else if is_block_quote(trimmed) {
quote_lines.push(strip_block_quote_marker(trimmed))
index = index + 1
} else {
break
}
}
(BlockQuote(parse_blocks(quote_lines)), index)
}
///|
fn parse_list(lines : Array[String], start : Int) -> (Block, Int) {
let first = parse_list_marker(lines[start].trim().to_owned()).unwrap()
let items : Array[Markdown] = []
let mut index = start
while index < lines.length() {
let trimmed = lines[index].trim().to_owned()
match parse_list_marker(trimmed) {
Some(marker) if marker.ordered == first.ordered => {
let item_lines : Array[String] = [marker.rest]
index = index + 1
while index < lines.length() {
let next = lines[index]
let next_trimmed = next.trim().to_owned()
if next_trimmed == "" {
item_lines.push("")
index = index + 1
} else if count_leading_spaces(next) >= 2 {
item_lines.push(strip_leading_spaces(next, 2))
index = index + 1
} else if is_list_item(next_trimmed) {
break
} else {
break
}
}
items.push(parse_blocks(item_lines))
}
_ => break
}
}
(List({ ordered: first.ordered, start: first.start, items }), index)
}
///|
fn parse_html_block(lines : Array[String], start : Int) -> (Block, Int) {
let content = StringBuilder::new()
let mut index = start
while index < lines.length() {
let line = lines[index]
if line.trim().to_owned() == "" {
break
}
if index > start {
content.write_char('\n')
}
content.write_string(line)
index = index + 1
}
(HtmlBlock(content.to_string()), index)
}
///|
fn parse_paragraph(lines : Array[String], start : Int) -> (Block, Int) {
let content = StringBuilder::new()
let mut index = start
while index < lines.length() {
let line = lines[index]
let trimmed = line.trim().to_owned()
if trimmed == "" || is_block_start(trimmed) {
break
}
if index > start {
content.write_char('\n')
}
content.write_string(trimmed)
index = index + 1
}
(Paragraph(parse_inlines(content.to_string())), index)
}
///|
fn parse_inlines(input : String) -> Array[Inline] {
let nodes : Array[Inline] = []
let mut text = StringBuilder::new()
let mut index = 0
while index < input.length() {
if starts_at(input, index, " \n") {
text = flush_text(text, nodes)
nodes.push(HardBreak)
index = index + 3
} else if char_at(input, index) == '\n' {
text = flush_text(text, nodes)
nodes.push(SoftBreak)
index = index + 1
} else if char_at(input, index) == '`' {
match find_char(input, '`', index + 1) {
Some(end) => {
text = flush_text(text, nodes)
nodes.push(Code(substring(input, index + 1, end)))
index = end + 1
}
None => {
text.write_char(char_at(input, index))
index = index + 1
}
}
} else if starts_at(input, index, "![") {
match parse_image(input, index) {
Some((node, next)) => {
text = flush_text(text, nodes)
nodes.push(node)
index = next
}
None => {
text.write_char(char_at(input, index))
index = index + 1
}
}
} else if char_at(input, index) == '[' {
match parse_link(input, index) {
Some((node, next)) => {
text = flush_text(text, nodes)
nodes.push(node)
index = next
}
None => {
text.write_char(char_at(input, index))
index = index + 1
}
}
} else if starts_at(input, index, "**") {
match parse_delimited(input, index, "**", true) {
Some((node, next)) => {
text = flush_text(text, nodes)
nodes.push(node)
index = next
}
None => {
text.write_char(char_at(input, index))
index = index + 1
}
}
} else if char_at(input, index) == '*' {
match parse_delimited(input, index, "*", false) {
Some((node, next)) => {
text = flush_text(text, nodes)
nodes.push(node)
index = next
}
None => {
text.write_char(char_at(input, index))
index = index + 1
}
}
} else if char_at(input, index) == '<' {
match find_char(input, '>', index + 1) {
Some(end) => {
text = flush_text(text, nodes)
nodes.push(Html(substring(input, index, end + 1)))
index = end + 1
}
None => {
text.write_char(char_at(input, index))
index = index + 1
}
}
} else {
text.write_char(char_at(input, index))
index = index + 1
}
}
let _ = flush_text(text, nodes)
nodes
}
///|
fn parse_link(input : String, start : Int) -> (Inline, Int)? {
match find_char(input, ']', start + 1) {
Some(label_end) if label_end + 1 < input.length() &&
char_at(input, label_end + 1) == '(' =>
match find_char(input, ')', label_end + 2) {
Some(dest_end) => {
let label = substring(input, start + 1, label_end)
let destination = substring(input, label_end + 2, dest_end)
.trim()
.to_owned()
Some((Link(parse_inlines(label), destination), dest_end + 1))
}
None => None
}
_ => None
}
}
///|
fn parse_image(input : String, start : Int) -> (Inline, Int)? {
match find_char(input, ']', start + 2) {
Some(label_end) if label_end + 1 < input.length() &&
char_at(input, label_end + 1) == '(' =>
match find_char(input, ')', label_end + 2) {
Some(dest_end) => {
let alt = substring(input, start + 2, label_end)
let destination = substring(input, label_end + 2, dest_end)
.trim()
.to_owned()
Some((Image(alt, destination), dest_end + 1))
}
None => None
}
_ => None
}
}
///|
fn parse_delimited(
input : String,
start : Int,
delimiter : String,
strong : Bool,
) -> (Inline, Int)? {
match find_string(input, delimiter, start + delimiter.length()) {
Some(end) => {
let content = substring(input, start + delimiter.length(), end)
let parsed = parse_inlines(content)
if strong {
Some((Strong(parsed), end + delimiter.length()))
} else {
Some((Emphasis(parsed), end + delimiter.length()))
}
}
None => None
}
}
///|
fn flush_text(buffer : StringBuilder, output : Array[Inline]) -> StringBuilder {
let text = buffer.to_string()
if text != "" {
output.push(Text(text))
}
StringBuilder::new()
}
///|
fn is_block_start(trimmed : String) -> Bool {
is_fence_start(trimmed) ||
is_atx_heading(trimmed) ||
is_thematic_break(trimmed) ||
is_block_quote(trimmed) ||
is_list_item(trimmed) ||
is_html_block_start(trimmed)
}
///|
fn is_atx_heading(line : String) -> Bool {
let mut level = 0
while level < line.length() && char_at(line, level) == '#' && level < 7 {
level = level + 1
}
level > 0 &&
level <= 6 &&
(
level == line.length() ||
char_at(line, level) == ' ' ||
char_at(line, level) == '\t'
)
}
///|
fn is_fence_start(line : String) -> Bool {
(line.has_prefix("```") && count_prefix(line, '`') >= 3) ||
(line.has_prefix("~~~") && count_prefix(line, '~') >= 3)
}
///|
fn is_matching_fence(line : String, marker : Char, minimum : Int) -> Bool {
line.length() >= minimum &&
char_at(line, 0) == marker &&
count_prefix(line, marker) >= minimum &&
substring(line, count_prefix(line, marker), line.length()).trim() == ""
}
///|
fn is_thematic_break(line : String) -> Bool {
let compact = remove_spaces(line)
if compact.length() < 3 {
false
} else {
let marker = char_at(compact, 0)
(marker == '-' || marker == '*' || marker == '_') &&
all_chars_are(compact, marker)
}
}
///|
fn is_block_quote(line : String) -> Bool {
line.has_prefix(">")
}
///|
fn is_list_item(line : String) -> Bool {
parse_list_marker(line) is Some(_)
}
///|
fn is_html_block_start(line : String) -> Bool {
line.has_prefix("<") && line.contains(">")
}
///|
fn strip_closing_heading_marker(input : String) -> String {
let trimmed = input.trim().to_owned()
let mut end = trimmed.length()
while end > 0 && char_at(trimmed, end - 1) == '#' {
end = end - 1
}
if end < trimmed.length() && (end == 0 || char_at(trimmed, end - 1) == ' ') {
substring(trimmed, 0, end).trim().to_owned()
} else {
trimmed
}
}
///|
fn strip_block_quote_marker(line : String) -> String {
if line.length() == 1 {
""
} else if char_at(line, 1) == ' ' {
substring(line, 2, line.length())
} else {
substring(line, 1, line.length())
}
}
///|
priv struct ListMarker {
ordered : Bool
start : Int?
rest : String
}
///|
fn parse_list_marker(line : String) -> ListMarker? {
if line.length() >= 2 &&
(
char_at(line, 0) == '-' ||
char_at(line, 0) == '+' ||
char_at(line, 0) == '*'
) &&
(char_at(line, 1) == ' ' || char_at(line, 1) == '\t') {
Some({
ordered: false,
start: None,
rest: substring(line, 2, line.length()),
})
} else {
let mut index = 0
while index < line.length() && is_digit(char_at(line, index)) {
index = index + 1
}
if index > 0 &&
index + 1 < line.length() &&
(char_at(line, index) == '.' || char_at(line, index) == ')') &&
(char_at(line, index + 1) == ' ' || char_at(line, index + 1) == '\t') {
Some({
ordered: true,
start: Some(parse_positive_int(substring(line, 0, index))),
rest: substring(line, index + 2, line.length()),
})
} else {
None
}
}
}
///|
fn is_table(lines : Array[String], index : Int) -> Bool {
if index + 1 >= lines.length() {
return false
}
let first = lines[index].trim().to_owned()
let second = lines[index + 1].trim().to_owned()
is_table_row(first) && is_separator_row(second)
}
///|
fn is_table_row(line : String) -> Bool {
line.has_prefix("|") && line.has_suffix("|")
}
///|
fn is_separator_row(line : String) -> Bool {
if !is_table_row(line) {
return false
}
let inner = substring(line, 1, line.length() - 1)
for cell in inner.split("|") {
let t = cell.trim().to_owned()
if t == "" {
return false
}
let stripped = if t.has_prefix(":") {
substring(t, 1, t.length())
} else {
t
}
let stripped = if stripped.has_suffix(":") {
substring(stripped, 0, stripped.length() - 1)
} else {
stripped
}
for ch in stripped {
if ch != '-' {
return false
}
}
}
true
}
///|
fn parse_table_row(line : String) -> Array[Array[Inline]] {
let inner = substring(line, 1, line.length() - 1)
inner
.split("|")
.to_array()
.map(fn(cell) { parse_inlines(cell.trim().to_owned()) })
}
///|
fn parse_table(lines : Array[String], start : Int) -> (Block, Int) {
let headers = parse_table_row(lines[start].trim().to_owned())
let mut index = start + 2
let rows : Array[Array[Array[Inline]]] = []
while index < lines.length() {
let trimmed = lines[index].trim().to_owned()
if is_table_row(trimmed) {
rows.push(parse_table_row(trimmed))
index = index + 1
} else {
break
}
}
(Table({ headers, rows }), index)
}
///|
fn normalize_lines(input : String) -> Array[String] {
let normalized = replace_crlf(input)
let views = normalized.split("\n").to_array()
let lines : Array[String] = []
for view in views {
lines.push(view.to_owned())
}
lines
}
///|
fn replace_crlf(input : String) -> String {
let output = StringBuilder::new()
let mut index = 0
while index < input.length() {
if char_at(input, index) == '\r' {
if index + 1 < input.length() && char_at(input, index + 1) == '\n' {
output.write_char('\n')
index = index + 2
} else {
output.write_char('\n')
index = index + 1
}
} else {
output.write_char(char_at(input, index))
index = index + 1
}
}
output.to_string()
}
///|
fn remove_spaces(input : String) -> String {
let output = StringBuilder::new()
for ch in input {
if ch != ' ' && ch != '\t' {
output.write_char(ch)
}
}
output.to_string()
}
///|
fn all_chars_are(input : String, expected : Char) -> Bool {
for ch in input {
if ch != expected {
return false
}
}
true
}
///|
fn count_prefix(input : String, expected : Char) -> Int {
let mut count = 0
while count < input.length() && char_at(input, count) == expected {
count = count + 1
}
count
}
///|
fn count_leading_spaces(input : String) -> Int {
let mut count = 0
while count < input.length() && char_at(input, count) == ' ' {
count = count + 1
}
count
}
///|
fn strip_leading_spaces(input : String, max : Int) -> String {
let mut count = 0
while count < input.length() && count < max && char_at(input, count) == ' ' {
count = count + 1
}
substring(input, count, input.length())
}
///|
fn opening_suffix(input : String, start : Int) -> String {
substring(input, start, input.length())
}
///|
fn starts_at(input : String, start : Int, needle : String) -> Bool {
if start + needle.length() > input.length() {
false
} else {
let mut index = 0
while index < needle.length() {
if char_at(input, start + index) != char_at(needle, index) {
return false
}
index = index + 1
}
true
}
}
///|
fn find_char(input : String, needle : Char, start : Int) -> Int? {
let mut index = start
while index < input.length() {
if char_at(input, index) == needle {
return Some(index)
}
index = index + 1
}
None
}
///|
fn find_string(input : String, needle : String, start : Int) -> Int? {
let mut index = start
while index + needle.length() <= input.length() {
if starts_at(input, index, needle) {
return Some(index)
}
index = index + 1
}
None
}
///|
fn substring(input : String, start : Int, end : Int) -> String {
let output = StringBuilder::new()
let mut index = start
while index < end && index < input.length() {
output.write_char(char_at(input, index))
index = index + 1
}
output.to_string()
}
///|
fn char_at(input : String, index : Int) -> Char {
input.code_unit_at(index).unsafe_to_char()
}
///|
fn is_digit(ch : Char) -> Bool {
ch >= '0' && ch <= '9'
}
///|
fn parse_positive_int(input : String) -> Int {
let mut value = 0
for ch in input {
value = value * 10 + (ch.to_int() - '0'.to_int())
}
value
}