Current section
Files
Jump to
Current section
Files
src/jot.gleam
// TODO: collapse adjacent text nodes
import gleam/bool
import gleam/dict.{type Dict}
import gleam/int
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/result
import gleam/string
import houdini
import splitter.{type Splitter}
pub type Document {
Document(
content: List(Container),
references: Dict(String, String),
reference_attributes: Dict(String, Dict(String, String)),
footnotes: Dict(String, List(Container)),
)
}
fn add_attribute(
attributes: Dict(String, String),
key: String,
value: String,
) -> Dict(String, String) {
case key {
"class" ->
dict.upsert(attributes, key, fn(previous) {
case previous {
None -> value
Some(previous) -> previous <> " " <> value
}
})
_ -> dict.insert(attributes, key, value)
}
}
pub type Container {
ThematicBreak
Paragraph(attributes: Dict(String, String), content: List(Inline))
Heading(attributes: Dict(String, String), level: Int, content: List(Inline))
Codeblock(
attributes: Dict(String, String),
language: Option(String),
content: String,
)
RawBlock(content: String)
BulletList(
layout: ListLayout,
style: BulletStyle,
items: List(List(Container)),
)
OrderedList(
layout: ListLayout,
punctuation: OrdinalPunctuation,
ordinal: OrdinalStyle,
start: Int,
items: List(List(Container)),
)
BlockQuote(attributes: Dict(String, String), items: List(Container))
Div(
/// The div syntax allows a single class to be given after the `:::`, like so:
///
/// ```djot
/// ::: my-class-here
/// Hello, world!
/// :::
/// ```
///
/// If a class was given then it is stored in this field so it can be
/// pattern matched upon. The class is also present in the `attributes`
/// dictionary, merged with any other attributes and classes given using
/// the block attribute syntax (`{key="value"}`).
///
class: Option(String),
attributes: Dict(String, String),
items: List(Container),
)
}
pub type BulletStyle {
BulletDash
BulletStar
BulletPlus
}
pub type OrdinalPunctuation {
FullStop
SingleParen
DoubleParen
}
pub type OrdinalStyle {
NumericOrdinal
LowerAlphaOrdinal
UpperAlphaOrdinal
}
type ListStyle {
Bullet(BulletStyle)
Ordered(start: Int, punctuation: OrdinalPunctuation, style: OrdinalStyle)
}
pub type Inline {
Linebreak
NonBreakingSpace
Text(String)
Link(
attributes: Dict(String, String),
content: List(Inline),
destination: Destination,
)
Image(
attributes: Dict(String, String),
content: List(Inline),
destination: Destination,
)
Span(attributes: Dict(String, String), content: List(Inline))
Emphasis(content: List(Inline))
Strong(content: List(Inline))
Delete(content: List(Inline))
Insert(content: List(Inline))
Mark(content: List(Inline))
Superscript(content: List(Inline))
Subscript(content: List(Inline))
Footnote(reference: String)
Code(content: String)
MathInline(content: String)
MathDisplay(content: String)
Symbol(content: String)
}
pub type ListLayout {
Tight
Loose
}
pub type Destination {
Reference(String)
Url(String)
}
type Refs {
Refs(
urls: Dict(String, String),
url_attributes: Dict(String, Dict(String, String)),
headings: Dict(String, Int),
footnotes: Dict(String, List(Container)),
)
}
/// Convert a string of Djot into a string of HTML.
///
/// If you want to have more control over the HTML generated you can use the
/// `parse` function to convert Djot to a tree of records instead. You can then
/// traverse this tree and turn it into HTML yourself.
///
/// # Security
///
/// This does not escape the content of raw blocks! If you use this with
/// user-input you likely need to escape raw blocks to prevent
/// cross-site-scripting (XSS) attacks.
///
pub fn to_html(djot: String) -> String {
djot
|> parse
|> document_to_html
}
type Splitters {
Splitters(
verbatim_line_end: Splitter,
codeblock_language: Splitter,
inline: Splitter,
link_destination: Splitter,
math_end: Splitter,
)
}
/// Convert a string of Djot into a tree of records.
///
/// This may be useful when you want more control over the HTML to be converted
/// to, or you wish to convert Djot to some other format.
///
pub fn parse(djot: String) -> Document {
let splitters =
Splitters(
verbatim_line_end: splitter.new([" ", "\n"]),
codeblock_language: splitter.new(["`", "\n"]),
inline: splitter.new([
"\\",
"_",
"*",
"[^",
"[",
"![",
"$$`",
"$`",
"`",
"\n",
"--",
"...",
"<",
"{-",
"{+",
"{=",
"~",
"{~",
"^",
"{^",
"{",
":",
]),
link_destination: splitter.new([")", "]", "\n"]),
math_end: splitter.new(["`"]),
)
let refs = Refs(dict.new(), dict.new(), dict.new(), dict.new())
let #(ast, Refs(urls:, url_attributes:, footnotes:, headings:), _) =
djot
|> string.replace("\r\n", "\n")
|> parse_document_content(refs, splitters, [], dict.new())
let urls =
dict.fold(headings, urls, fn(urls, id, count) {
int_fold_down_zero_inclusive(count, urls, fn(urls, i) {
let key = case i {
0 -> id
_ -> id <> "-" <> int.to_string(i)
}
case dict.has_key(urls, key) {
True -> urls
False -> dict.insert(urls, key, "#" <> key)
}
})
})
Document(ast, urls, url_attributes, footnotes)
}
fn int_fold_down_zero_inclusive(
int: Int,
acc: acc,
reduce: fn(acc, Int) -> acc,
) -> acc {
case int < 0 {
True -> acc
False -> int_fold_down_zero_inclusive(int - 1, reduce(acc, int), reduce)
}
}
fn drop_lines(in: String) -> String {
case in {
"\n" <> rest -> drop_lines(rest)
other -> other
}
}
fn drop_spaces(in: String) -> String {
case in {
" " <> rest -> drop_spaces(rest)
other -> other
}
}
fn drop_spaces_tabs(in: String) -> String {
case in {
" " <> rest | "\t" <> rest -> drop_spaces_tabs(rest)
other -> other
}
}
fn count_drop_spaces(in: String, count: Int) -> #(String, Int) {
case in {
" " <> rest -> count_drop_spaces(rest, count + 1)
other -> #(other, count)
}
}
fn count_drop_hyphens(in: String, count: Int) -> #(Int, String) {
case in {
"-" <> rest -> count_drop_hyphens(rest, count + 1)
_ -> #(count, in)
}
}
/// Given the length of a sequence of `-` this turns it in a series of em/en
/// dashes.
fn dash_sequence(hyphens: Int) -> String {
case hyphens % 3, hyphens % 2 {
0, _ -> string.repeat("—", hyphens / 3)
_, 0 -> string.repeat("–", hyphens / 2)
_, _ -> {
// Thank you Yoshie for figuring this out!!
let ems = int.max(0, { hyphens - 2 } / 3)
let hyphens = hyphens - ems * 3
string.repeat("—", ems)
<> string.repeat("–", hyphens / 2)
<> string.repeat("-", hyphens % 2)
}
}
}
fn parse_document_content(
in: String,
refs: Refs,
splitters: Splitters,
ast: List(Container),
attrs: Dict(String, String),
) -> #(List(Container), Refs, String) {
let in = drop_lines(in)
let #(in, spaces_count) = count_drop_spaces(in, 0)
let #(in, refs, container, attrs) =
parse_container(in, refs, splitters, attrs, spaces_count, None)
let ast = case container {
None -> ast
Some(container) -> [container, ..ast]
}
case in {
"" -> #(list.reverse(ast), refs, in)
_ -> parse_document_content(in, refs, splitters, ast, attrs)
}
}
/// Parse a block of Djot that ends once the content is no longer indented
/// to a certain level.
/// For example:
///
/// ```djot
/// Here's the reference.[^ref]
///
/// [^ref]: This footnote is a block with two paragraphs.
///
/// This is part of the block because it is indented past the start of `[^ref]`
///
/// But this would not be parsed as part of the block because it has no indentation
/// ```
fn parse_block(
in: String,
refs: Refs,
splitters: Splitters,
ast: List(Container),
attrs: Dict(String, String),
required_spaces: Int,
) -> #(List(Container), Refs, String) {
let in = drop_lines(in)
let #(in, indentation) = count_drop_spaces(in, 0)
case indentation < required_spaces {
True -> #(list.reverse(ast), refs, in)
False -> {
let #(in, refs, container, attrs) =
parse_container(in, refs, splitters, attrs, indentation, None)
let ast = case container {
None -> ast
Some(container) -> [container, ..ast]
}
case in {
"" -> #(list.reverse(ast), refs, in)
_ -> parse_block(in, refs, splitters, ast, attrs, required_spaces)
}
}
}
}
/// This function allows us to parse the contents of a block after we know
/// that the *first* container meets indentation requirements, but we want to
/// ensure that once this container is parsed, future containers meet the
/// indentation requirements
fn parse_block_after_indent_checked(
in: String,
refs: Refs,
splitters: Splitters,
ast: List(Container),
attrs: Dict(String, String),
required_spaces required_spaces: Int,
indentation indentation: Int,
) -> #(List(Container), Refs, String) {
let #(in, refs, container, attrs) =
parse_container(in, refs, splitters, attrs, indentation, None)
let ast = case container {
None -> ast
Some(container) -> [container, ..ast]
}
case in {
"" -> #(list.reverse(ast), refs, in)
_ -> parse_block(in, refs, splitters, ast, attrs, required_spaces)
}
}
fn parse_container(
in: String,
refs: Refs,
splitters: Splitters,
attrs: Dict(String, String),
indentation: Int,
div_close_size: Option(Int),
) -> #(String, Refs, Option(Container), Dict(String, String)) {
case in {
"" -> #(in, refs, None, dict.new())
"{" <> in2 -> {
case parse_attributes(in2, attrs) {
None -> {
let #(paragraph, in) =
parse_paragraph(in, attrs, splitters, div_close_size)
#(in, refs, Some(paragraph), dict.new())
}
Some(#(attrs, in)) -> #(in, refs, None, attrs)
}
}
"#" <> in -> {
let #(heading, refs, in) =
parse_heading(in, refs, splitters, attrs, div_close_size)
#(in, refs, Some(heading), dict.new())
}
"~" as delim <> in2 | "`" as delim <> in2 -> {
case parse_codeblock(in2, attrs, delim, indentation, splitters) {
None -> {
let #(paragraph, in) =
parse_paragraph(in, attrs, splitters, div_close_size)
#(in, refs, Some(paragraph), dict.new())
}
Some(#(codeblock, in)) -> #(in, refs, Some(codeblock), dict.new())
}
}
"> " <> _ | ">\n" <> _ -> {
let #(block_quote, in) =
parse_block_quote(in, refs, attrs, splitters, div_close_size)
#(in, refs, Some(block_quote), dict.new())
}
"-" as style <> in2 | "*" as style <> in2 | "+" as style <> in2 -> {
case parse_thematic_break(1, in2), in2 {
None, " " <> in2 | None, "\n" <> in2 -> {
let bullet_style = case style {
"-" -> BulletDash
"*" -> BulletStar
_ -> BulletPlus
}
let style = Bullet(bullet_style)
let #(list, in) =
parse_list(in2, refs, attrs, style, Tight, [], splitters)
#(in, refs, Some(list), dict.new())
}
None, _ -> {
let #(paragraph, in) =
parse_paragraph(in, attrs, splitters, div_close_size)
#(in, refs, Some(paragraph), dict.new())
}
Some(#(thematic_break, in)), _ -> {
#(in, refs, Some(thematic_break), dict.new())
}
}
}
"[^" <> in2 -> {
case parse_footnote_def(in2, refs, splitters, "^") {
None -> {
let #(paragraph, in) =
parse_paragraph(in, attrs, splitters, div_close_size)
#(in, refs, Some(paragraph), dict.new())
}
Some(#(id, footnote, refs, in)) -> {
let refs =
Refs(..refs, footnotes: dict.insert(refs.footnotes, id, footnote))
#(in, refs, None, dict.new())
}
}
}
"[" <> in2 -> {
case parse_ref_def(in2, "") {
None -> {
let #(paragraph, in) =
parse_paragraph(in, attrs, splitters, div_close_size)
#(in, refs, Some(paragraph), dict.new())
}
Some(#(id, url, in)) -> {
let url_attributes = case dict.is_empty(attrs) {
True -> refs.url_attributes
False -> dict.insert(refs.url_attributes, id, attrs)
}
let urls = dict.insert(refs.urls, id, url)
let refs = Refs(..refs, urls:, url_attributes:)
#(in, refs, None, dict.new())
}
}
}
":::" <> in2 -> {
case parse_div(in2, refs, attrs, splitters) {
None -> {
let #(paragraph, in) =
parse_paragraph(in, attrs, splitters, div_close_size)
#(in, refs, Some(paragraph), dict.new())
}
Some(#(in, class, attrs, content)) -> {
let div = Some(Div(class, attrs, content))
#(in, refs, div, dict.new())
}
}
}
"(" <> rest -> {
case parse_maybe_list(rest, refs, attrs, splitters, True) {
Some(#(in, refs, list)) -> #(in, refs, Some(list), dict.new())
None -> {
let #(paragraph, in) =
parse_paragraph(in, attrs, splitters, div_close_size)
#(in, refs, Some(paragraph), dict.new())
}
}
}
_ -> {
case parse_maybe_list(in, refs, attrs, splitters, False) {
Some(#(in, refs, list)) -> #(in, refs, Some(list), dict.new())
None -> {
let #(paragraph, in) =
parse_paragraph(in, attrs, splitters, div_close_size)
#(in, refs, Some(paragraph), dict.new())
}
}
}
}
}
fn parse_maybe_list(
in: String,
refs: Refs,
attrs: Dict(String, String),
splitters: Splitters,
paren: Bool,
) -> Option(#(String, Refs, Container)) {
case in {
"0" <> _
| "1" <> _
| "2" <> _
| "3" <> _
| "4" <> _
| "5" <> _
| "6" <> _
| "7" <> _
| "8" <> _
| "9" <> _ -> {
case parse_number_list(in, 0, paren) {
Some(#(punctuation, style, start, in)) -> {
let style = Ordered(start:, punctuation:, style:)
let #(list, in) =
parse_list(in, refs, attrs, style, Tight, [], splitters)
Some(#(in, refs, list))
}
None -> None
}
}
"a" <> _
| "b" <> _
| "c" <> _
| "d" <> _
| "e" <> _
| "f" <> _
| "g" <> _
| "h" <> _
| "i" <> _
| "j" <> _
| "k" <> _
| "l" <> _
| "m" <> _
| "n" <> _
| "o" <> _
| "p" <> _
| "q" <> _
| "r" <> _
| "s" <> _
| "t" <> _
| "u" <> _
| "v" <> _
| "w" <> _
| "x" <> _
| "y" <> _
| "z" <> _ ->
case parse_lower_list(in, 0, paren) {
Some(#(punctuation, style, start, in)) -> {
let style = Ordered(start:, punctuation:, style:)
let #(list, in) =
parse_list(in, refs, attrs, style, Tight, [], splitters)
Some(#(in, refs, list))
}
None -> None
}
"A" <> _
| "B" <> _
| "C" <> _
| "D" <> _
| "E" <> _
| "F" <> _
| "G" <> _
| "H" <> _
| "I" <> _
| "J" <> _
| "K" <> _
| "L" <> _
| "M" <> _
| "N" <> _
| "O" <> _
| "P" <> _
| "Q" <> _
| "R" <> _
| "S" <> _
| "T" <> _
| "U" <> _
| "V" <> _
| "W" <> _
| "X" <> _
| "Y" <> _
| "Z" <> _ ->
case parse_upper_list(in, 0, paren) {
Some(#(punctuation, style, start, in)) -> {
let style = Ordered(start:, punctuation:, style:)
let #(list, in) =
parse_list(in, refs, attrs, style, Tight, [], splitters)
Some(#(in, refs, list))
}
None -> None
}
_ -> None
}
}
/// Parse a div.
fn parse_div(
in: String,
refs: Refs,
attrs: Dict(String, String),
splitters: Splitters,
) -> _ {
let #(size, in2) = count_div_fence_size(in, 3)
let class = parse_div_class(in2)
use #(class, rest) <- option.then(class)
let attrs = case class {
"" -> attrs
class -> add_attribute(attrs, "class", class)
}
let class = case class {
"" -> option.None
_ -> option.Some(class)
}
let #(rest, content) =
parse_div_content(rest, refs, dict.new(), size, splitters, [])
Some(#(rest, class, attrs, content))
}
fn parse_div_content(
in: String,
refs: Refs,
attrs: Dict(String, String),
fence_size: Int,
splitters: Splitters,
children: List(Container),
) -> #(String, List(Container)) {
case check_first_line_suitable_div_end(in, fence_size) {
Some(in2) -> {
#(in2, list.reverse(children))
}
None -> {
let #(in, refs, container, attrs) =
parse_container(in, refs, splitters, attrs, 0, Some(fence_size))
let children = case container {
None -> children
Some(container) -> [container, ..children]
}
case in {
"" -> #(in, list.reverse(children))
_ -> parse_div_content(in, refs, attrs, fence_size, splitters, children)
}
}
}
}
/// Checks if current line is a suitable terminator for
/// a div fence of a particular size.
///
/// Returns the rest of the input if it is.
fn check_first_line_suitable_div_end(
in: String,
fence_size: Int,
) -> Option(String) {
let #(line, rest) = slurp_to_line_end(in)
case check_line_suitable_div_end(line, fence_size) {
False -> None
True -> Some(rest)
}
}
fn check_line_suitable_div_end(line: String, fence_size: Int) -> Bool {
let candidate_fence_size =
line
|> string.trim
|> count_div_terminator_fence_size(0)
case candidate_fence_size {
Some(candidate_fence_size) -> candidate_fence_size >= fence_size
None -> False
}
}
/// Counts the size of a div fence. Used to count pretrimmed lines which may
/// contain a valid terminating fence. Valid pretrimmed fences contain only
/// colons `:`.
///
/// Returns Some(`size`) for a valid fence and None for an invalid fence.
fn count_div_terminator_fence_size(line: String, count: Int) -> Option(Int) {
case line {
"" -> Some(count)
":" <> rest -> count_div_terminator_fence_size(rest, count + 1)
_ -> None
}
}
/// Counts the size of a div fence. Used in initial parsing of a div once a
/// minimum fence structure has been seen: `:::`.
///
/// Returns the size of the fence seen with the remainder of the unused input
/// stream.
fn count_div_fence_size(in: String, count: Int) -> #(Int, String) {
case in {
":" <> rest -> count_div_fence_size(rest, count + 1)
_ -> #(count, in)
}
}
/// Parse the class name for a div. Returns Some if a classname is present.
/// Returns Some if no classname is present. Returns None if the text is an
/// invalid classname.
fn parse_div_class(in: String) -> Option(#(String, String)) {
let #(line, rest) = slurp_to_line_end(in)
let line = string.trim(line)
let has_prohibited = list.any([" ", "\t"], string.contains(line, _))
case has_prohibited {
False -> Some(#(line, rest))
True -> None
}
}
fn parse_thematic_break(
count: Int,
in: String,
) -> Option(#(Container, String)) {
case in {
"" | "\n" <> _ if count >= 3 -> Some(#(ThematicBreak, in))
" " <> rest | "\t" <> rest -> parse_thematic_break(count, rest)
"-" <> rest | "*" <> rest -> parse_thematic_break(count + 1, rest)
_ -> None
}
}
fn parse_codeblock(
in: String,
attrs: Dict(String, String),
delim: String,
indentation: Int,
splitters: Splitters,
) -> Option(#(Container, String)) {
let out = parse_codeblock_start(in, splitters, delim, 1)
use #(language, count, in) <- option.then(out)
let #(content, in) =
parse_codeblock_content(in, delim, count, indentation, "", splitters)
case language {
Some("=html") -> Some(#(RawBlock(string.trim_end(content)), in))
_ -> Some(#(Codeblock(attrs, language, content), in))
}
}
fn parse_codeblock_start(
in: String,
splitters: Splitters,
delim: String,
count: Int,
) -> Option(#(Option(String), Int, String)) {
case in {
"`" as c <> in | "~" as c <> in if c == delim ->
parse_codeblock_start(in, splitters, delim, count + 1)
"\n" <> in if count >= 3 -> Some(#(None, count, in))
"" -> None
_non_empty if count >= 3 -> {
let in = drop_spaces(in)
use #(language, in) <- option.map(parse_codeblock_language(
in,
splitters,
"",
))
#(language, count, in)
}
_ -> None
}
}
fn parse_codeblock_content(
in: String,
delim: String,
count: Int,
indentation: Int,
acc: String,
splitters: Splitters,
) -> #(String, String) {
case parse_codeblock_end(in, delim, count) {
None -> {
let #(acc, in) = slurp_verbatim_line(in, indentation, acc, splitters)
parse_codeblock_content(in, delim, count, indentation, acc, splitters)
}
Some(in) -> #(acc, in)
}
}
fn slurp_verbatim_line(
in: String,
indentation: Int,
acc: String,
splitters: Splitters,
) -> #(String, String) {
case splitter.split(splitters.verbatim_line_end, in) {
#(before, "\n", in) -> #(acc <> before <> "\n", in)
#("", " ", in) if indentation > 0 ->
slurp_verbatim_line(in, indentation - 1, acc, splitters)
#(before, split, in) ->
slurp_verbatim_line(in, indentation, acc <> before <> split, splitters)
}
}
fn parse_codeblock_end(
in: String,
delim: String,
count: Int,
) -> Option(String) {
case in {
"\n" <> in if count == 0 -> Some(in)
_ if count == 0 -> Some(in)
// if the codeblock is indented (ex: in a footnote block), we need to accept an indented end marker
" " <> in -> parse_codeblock_end(in, delim, count)
_ ->
case string.pop_grapheme(in) {
Ok(#(c, in)) if c == delim -> parse_codeblock_end(in, delim, count - 1)
Ok(_) -> None
Error(_) -> Some(in)
}
}
}
fn parse_codeblock_language(
in: String,
splitters: Splitters,
language: String,
) -> Option(#(Option(String), String)) {
case splitter.split(splitters.codeblock_language, in) {
// A language specifier cannot contain a backtick
#(_, "`", _) -> None
#(a, "\n", _) if a == "" && language == "" -> Some(#(None, in))
#(a, "\n", in) -> Some(#(Some(language <> a), in))
_ -> Some(#(None, in))
}
}
fn parse_ref_def(in: String, id: String) -> Option(#(String, String, String)) {
case in {
"]:" <> in -> parse_ref_value(in, id, "")
"" | "]" <> _ | "\n" <> _ -> None
_ ->
case string.pop_grapheme(in) {
Ok(#(c, in)) -> parse_ref_def(in, id <> c)
Error(_) -> None
}
}
}
fn parse_ref_value(
in: String,
id: String,
url: String,
) -> Option(#(String, String, String)) {
case in {
"\n " <> in -> parse_ref_value(drop_spaces(in), id, url)
"\n" <> in -> Some(#(id, string.trim(url), in))
_ ->
case string.pop_grapheme(in) {
Ok(#(c, in)) -> parse_ref_value(in, id, url <> c)
Error(_) -> Some(#(id, string.trim(url), ""))
}
}
}
fn parse_footnote_def(
in: String,
refs: Refs,
splitters: Splitters,
id: String,
) -> Option(#(String, List(Container), Refs, String)) {
case in {
"]:" <> in -> {
let #(in, spaces_count) = count_drop_spaces(in, 0)
// Because this is the beginning of the block, we don't have to make sure
// it is properly indented, so we might be able to skip that process.
let block_parser = case in {
// However, if there is a new line directly following the beginning of the block,
// we need to check the indentation to be sure that it is not an empty block
"\n" <> _ -> parse_block
_ -> fn(in, refs, splitters, ast, attrs, required_spaces) {
parse_block_after_indent_checked(
in,
refs,
splitters,
ast,
attrs,
required_spaces,
indentation: 4 + string.length(id) + spaces_count,
)
}
}
let #(block, refs, rest) =
block_parser(in, refs, splitters, [], dict.new(), 1)
Some(#(id, block, refs, rest))
}
"" | "]" <> _ | "\n" <> _ -> None
_ ->
case string.pop_grapheme(in) {
Ok(#(c, in)) -> parse_footnote_def(in, refs, splitters, id <> c)
Error(_) -> None
}
}
}
fn parse_attributes(
in: String,
attrs: Dict(String, String),
) -> Option(#(Dict(String, String), String)) {
let in = drop_spaces(in)
case in {
"" -> None
"}" <> in -> Some(#(attrs, in))
"#" <> in -> {
case parse_attributes_id_or_class(in, "") {
Some(#(id, in)) -> parse_attributes(in, add_attribute(attrs, "id", id))
None -> None
}
}
"." <> in -> {
case parse_attributes_id_or_class(in, "") {
Some(#(c, in)) -> parse_attributes(in, add_attribute(attrs, "class", c))
None -> None
}
}
_ -> {
case parse_attribute(in, "") {
Some(#(k, v, in)) -> parse_attributes(in, add_attribute(attrs, k, v))
None -> None
}
}
}
}
fn parse_attribute(
in: String,
key: String,
) -> Option(#(String, String, String)) {
case in {
"" | " " <> _ -> None
"=\"" <> in -> parse_attribute_quoted_value(in, key, "")
"=" <> in -> parse_attribute_value(in, key, "")
_ ->
case string.pop_grapheme(in) {
Ok(#(c, in)) -> parse_attribute(in, key <> c)
Error(_) -> None
}
}
}
fn parse_attribute_value(
in: String,
key: String,
value: String,
) -> Option(#(String, String, String)) {
case in {
"" -> None
" " <> in -> Some(#(key, value, in))
"}" <> _ -> Some(#(key, value, in))
_ ->
case string.pop_grapheme(in) {
Ok(#(c, in)) -> parse_attribute_value(in, key, value <> c)
Error(_) -> None
}
}
}
fn parse_attribute_quoted_value(
in: String,
key: String,
value: String,
) -> Option(#(String, String, String)) {
case in {
"" -> None
"\"" <> in -> Some(#(key, value, in))
_ ->
case string.pop_grapheme(in) {
Ok(#(c, in)) -> parse_attribute_quoted_value(in, key, value <> c)
Error(_) -> None
}
}
}
fn parse_attributes_id_or_class(
in: String,
id: String,
) -> Option(#(String, String)) {
case in {
"" | "}" <> _ | " " <> _ -> Some(#(id, in))
"#" <> _ | "." <> _ | "=" <> _ -> None
// TODO: in future this will be permitted as attributes can be over multiple lines
"\n" <> _ -> None
_ ->
case string.pop_grapheme(in) {
Ok(#(c, in)) -> parse_attributes_id_or_class(in, id <> c)
Error(_) -> Some(#(id, in))
}
}
}
fn parse_block_quote(
in: String,
refs: Refs,
attrs: Dict(String, String),
splitters: Splitters,
div_close_size: Option(Int),
) -> #(Container, String) {
let #(reversed_lines, in) = take_block_quote_chars(in, [], div_close_size)
let items = case list.reverse(reversed_lines) {
[] -> []
lines -> {
let content = string.join(lines, "\n")
parse_block_quote_items(content, refs, dict.new(), splitters, [])
}
}
#(BlockQuote(attrs, items), in)
}
fn take_block_quote_chars(
in: String,
lines: List(String),
div_close_size: Option(Int),
) -> #(List(String), String) {
case in {
// An empty line marks the end of the block quote.
"\n" <> in -> #(lines, in)
">" -> #(["", ..lines], "")
">\n" <> in ->
case lines {
// Empty lines at the beginning of the block quote are ignored.
[] -> take_block_quote_chars(in, [], div_close_size)
_ -> take_block_quote_chars(in, ["", ..lines], div_close_size)
}
"> " <> in -> {
case string.split_once(in, "\n") {
Ok(#(line, in)) ->
take_block_quote_chars(in, [line, ..lines], div_close_size)
Error(_) -> #([in, ..lines], "")
}
}
in -> take_block_quote_stop_on_div_close(in, lines, div_close_size)
}
}
fn take_block_quote_stop_on_div_close(
in: String,
lines: List(String),
div_close_size: Option(Int),
) {
let #(line, rest) = slurp_to_line_end(in)
case div_close_size {
None ->
case rest {
"" -> #([line, ..lines], "")
_ -> take_block_quote_chars(rest, [line, ..lines], div_close_size)
}
Some(size) ->
case check_line_suitable_div_end(line, size) {
True -> #(lines, in)
False ->
case rest {
"" -> #([line, ..lines], "")
_ -> take_block_quote_chars(rest, [line, ..lines], div_close_size)
}
}
}
}
fn parse_block_quote_items(
in: String,
refs: Refs,
attrs: Dict(String, String),
splitters: Splitters,
children: List(Container),
) -> List(Container) {
let #(in, refs, container, attrs) =
parse_container(in, refs, splitters, attrs, 0, None)
let children = case container {
None -> children
Some(container) -> [container, ..children]
}
case in {
"" -> list.reverse(children)
_ -> parse_block_quote_items(in, refs, attrs, splitters, children)
}
}
fn parse_heading(
in: String,
refs: Refs,
splitters: Splitters,
attrs: Dict(String, String),
div_close_size: Option(Int),
) -> #(Container, Refs, String) {
case heading_level(in, 1) {
Some(#(level, in)) -> {
let in = drop_spaces(in)
let #(inline_in, in) = take_heading_chars(in, level, "")
let #(inline, inline_in_remaining) =
parse_inline(inline_in, splitters, "", [])
let text = take_inline_text(inline, "")
let #(refs, attrs) = case id_sanitise(text) {
"" -> #(refs, attrs)
id -> {
case dict.get(refs.headings, id) {
// This is not the first heading seen with this content. We must
// add a suffix to the id to make it unique.
Ok(i) -> {
let i = i + 1
let refs =
Refs(..refs, headings: dict.insert(refs.headings, id, i))
let id = id <> "-" <> int.to_string(i)
let attrs = add_attribute(attrs, "id", id)
#(refs, attrs)
}
// This is the first heading seen with this content
Error(_) -> {
let refs =
Refs(..refs, headings: dict.insert(refs.headings, id, 0))
let attrs = add_attribute(attrs, "id", id)
#(refs, attrs)
}
}
}
}
let heading = Heading(attrs, level, inline)
#(heading, refs, inline_in_remaining <> in)
}
None -> {
let #(p, in) =
parse_paragraph("#" <> in, attrs, splitters, div_close_size)
#(p, refs, in)
}
}
}
fn id_sanitise(content: String) -> String {
content
|> string.replace("#", "")
|> string.replace("?", "")
|> string.replace("!", "")
|> string.replace(",", "")
|> string.trim
|> string.replace(" ", "-")
|> string.replace("\n", "-")
}
fn take_heading_chars(
in: String,
level: Int,
acc: String,
) -> #(String, String) {
case in {
"" | "\n" -> #(acc, "")
"\n\n" <> in -> #(acc, in)
"\n#" <> rest -> {
case take_heading_chars_newline_hash(rest, level - 1, acc <> "\n") {
Some(#(acc, in)) -> take_heading_chars(in, level, acc)
None -> #(acc, in)
}
}
_ ->
case string.pop_grapheme(in) {
Ok(#(c, in)) -> take_heading_chars(in, level, acc <> c)
Error(_) -> #(acc, "")
}
}
}
fn take_heading_chars_newline_hash(
in: String,
level: Int,
acc: String,
) -> Option(#(String, String)) {
case in {
_ if level < 0 -> None
"" if level > 0 -> None
"" if level == 0 -> Some(#(acc, ""))
" " <> in if level == 0 -> Some(#(acc, in))
"#" <> rest -> take_heading_chars_newline_hash(rest, level - 1, acc)
_ -> None
}
}
fn parse_inline(
in: String,
splitters: Splitters,
text: String,
acc: List(Inline),
) -> #(List(Inline), String) {
case splitter.split(splitters.inline, in) {
// End of the input
#(text2, "", "") ->
case text <> text2 {
"" -> #(list.reverse(acc), "")
text -> #(list.reverse([Text(text), ..acc]), "")
}
#(before, "...", in) -> {
let text = text <> before <> "…"
parse_inline(in, splitters, text, acc)
}
#(before, "--", in) -> {
let #(count, in) = count_drop_hyphens(in, 2)
let text = text <> before <> dash_sequence(count)
parse_inline(in, splitters, text, acc)
}
// // Escapes
#(before, "\\", in) -> {
let text = text <> before
case in {
"!" as e <> in
| "\"" as e <> in
| "#" as e <> in
| "$" as e <> in
| "%" as e <> in
| "&" as e <> in
| "'" as e <> in
| "(" as e <> in
| ")" as e <> in
| "*" as e <> in
| "+" as e <> in
| "," as e <> in
| "-" as e <> in
| "." as e <> in
| "/" as e <> in
| ":" as e <> in
| ";" as e <> in
| "<" as e <> in
| "=" as e <> in
| ">" as e <> in
| "?" as e <> in
| "@" as e <> in
| "[" as e <> in
| "\\" as e <> in
| "]" as e <> in
| "^" as e <> in
| "_" as e <> in
| "`" as e <> in
| "{" as e <> in
| "|" as e <> in
| "}" as e <> in
| "~" as e <> in -> parse_inline(in, splitters, text <> e, acc)
"\n" <> in ->
parse_inline(in, splitters, "", [Linebreak, Text(text), ..acc])
" " <> in ->
case drop_spaces_tabs(in) {
"\n" <> in ->
parse_inline(in, splitters, "", [Linebreak, Text(text), ..acc])
_ ->
parse_inline(in, splitters, "", [
NonBreakingSpace,
Text(text),
..acc
])
}
"\t" <> in2 ->
case drop_spaces_tabs(in2) {
"\n" <> in ->
parse_inline(in, splitters, "", [Linebreak, Text(text), ..acc])
_ -> parse_inline(in, splitters, text <> "\\", acc)
}
_other -> parse_inline(in, splitters, text <> "\\", acc)
}
}
#(a, "_" as start, in) | #(a, "*" as start, in) -> {
let text = text <> a
case in {
" " as b <> in | "\t" as b <> in | "\n" as b <> in ->
parse_inline(in, splitters, text <> start <> b, acc)
_ ->
case parse_emphasis(in, splitters, start) {
None -> parse_inline(in, splitters, text <> start, acc)
Some(#(inner, in)) -> {
let item = case start {
"*" -> Strong(inner)
_ -> Emphasis(inner)
}
parse_inline(in, splitters, "", [item, Text(text), ..acc])
}
}
}
}
#(a, "[^", rest) -> {
let text = text <> a
case parse_footnote(rest, "^") {
None -> parse_inline(rest, splitters, text <> "[^", acc)
// if this is actually a definition instead of a reference, return early
// This applies in situations such as the following:
// ```
// [^footnote]: very long footnote[^another-footnote]
// [^another-footnote]: bla bla[^another-footnote]
// ```
Some(#(_footnote, ":" <> _)) if text != "" -> #(
list.reverse([Text(text), ..acc]),
in,
)
Some(#(_footnote, ":" <> _)) -> #(list.reverse(acc), in)
Some(#(footnote, in)) ->
parse_inline(in, splitters, "", [footnote, Text(text), ..acc])
}
}
// Link and image
#(a, "[", in) -> {
let text = text <> a
case parse_link_or_recover(in, splitters, Link, "[") {
Error(#(failed_text, remaining)) ->
parse_inline(remaining, splitters, text <> failed_text, acc)
Ok(#(link, remaining)) ->
parse_inline(remaining, splitters, "", [link, Text(text), ..acc])
}
}
#(a, "![", in) -> {
let text = text <> a
case parse_link_or_recover(in, splitters, Image, "![") {
Error(#(failed_text, remaining)) ->
parse_inline(remaining, splitters, text <> failed_text, acc)
Ok(#(image, remaining)) ->
parse_inline(remaining, splitters, "", [image, Text(text), ..acc])
}
}
// Code
#(a, "`", in) -> {
let text = text <> a
let #(code, in) = parse_code(in, 1)
parse_inline(in, splitters, "", [code, Text(text), ..acc])
}
#(a, "\n", in) -> {
let text = text <> a
drop_spaces(in)
|> parse_inline(splitters, text <> "\n", acc)
}
// Math (inline)
#(a, "$`", in) -> {
let text = text <> a
case parse_math(in, splitters, False) {
None -> parse_inline(in, splitters, text <> "$`", acc)
Some(#(math, in)) ->
parse_inline(in, splitters, "", [math, Text(text), ..acc])
}
}
// Math (display)
#(a, "$$`", in) -> {
let text = text <> a
case parse_math(in, splitters, True) {
None -> parse_inline(in, splitters, text <> "$$`", acc)
Some(#(math, in)) ->
parse_inline(in, splitters, "", [math, Text(text), ..acc])
}
}
// Autolinks
#(a, "<", in) -> {
let text = text <> a
case parse_autolink(in) {
None -> parse_inline(in, splitters, text <> "<", acc)
Some(#(link, in)) ->
parse_inline(in, splitters, "", [link, Text(text), ..acc])
}
}
// Delete
#(a, "{-", in) -> {
let text = text <> a
case parse_insert_delete_mark_sup_sub(in, splitters, "-}") {
None -> parse_inline(in, splitters, text <> "{-", acc)
Some(#(inner, in)) ->
parse_inline(in, splitters, "", [Delete(inner), Text(text), ..acc])
}
}
// Insert
#(a, "{+", in) -> {
let text = text <> a
case parse_insert_delete_mark_sup_sub(in, splitters, "+}") {
None -> parse_inline(in, splitters, text <> "{+", acc)
Some(#(inner, in)) ->
parse_inline(in, splitters, "", [Insert(inner), Text(text), ..acc])
}
}
// Mark
#(a, "{=", in) -> {
let text = text <> a
case parse_insert_delete_mark_sup_sub(in, splitters, "=}") {
None -> parse_inline(in, splitters, text <> "{=", acc)
Some(#(inner, in)) ->
parse_inline(in, splitters, "", [Mark(inner), Text(text), ..acc])
}
}
// Superscript
#(a, "^", in) -> {
let text = text <> a
case parse_insert_delete_mark_sup_sub(in, splitters, "^") {
None -> parse_inline(in, splitters, text <> "^", acc)
Some(#(inner, in)) ->
parse_inline(in, splitters, "", [
Superscript(inner),
Text(text),
..acc
])
}
}
#(a, "{^", in) -> {
let text = text <> a
case parse_insert_delete_mark_sup_sub(in, splitters, "^}") {
None -> parse_inline(in, splitters, text <> "{^", acc)
Some(#(inner, in)) ->
parse_inline(in, splitters, "", [
Superscript(inner),
Text(text),
..acc
])
}
}
// Subscript
#(a, "~", in) -> {
let text = text <> a
case parse_insert_delete_mark_sup_sub(in, splitters, "~") {
None -> parse_inline(in, splitters, text <> "~", acc)
Some(#(inner, in)) ->
parse_inline(in, splitters, "", [Subscript(inner), Text(text), ..acc])
}
}
#(a, "{~", in) -> {
let text = text <> a
case parse_insert_delete_mark_sup_sub(in, splitters, "~}") {
None -> parse_inline(in, splitters, text <> "{~", acc)
Some(#(inner, in)) ->
parse_inline(in, splitters, "", [Subscript(inner), Text(text), ..acc])
}
}
// Standalone attributes (they are discarded)
#(a, "{", in) -> {
let text = text <> a
case parse_attributes(in, dict.new()) {
None -> parse_inline(in, splitters, text <> "{", acc)
Some(#(_attrs, in)) ->
parse_inline(in, splitters, "", [Text(text), ..acc])
}
}
// Symbols
#(a, ":", in) -> {
let text = text <> a
case parse_symbol(in) {
None -> parse_inline(in, splitters, text <> ":", acc)
Some(#(symbol, in)) ->
parse_inline(in, splitters, "", [symbol, Text(text), ..acc])
}
}
#(text2, text3, in) ->
case text <> text2 <> text3 {
"" -> #(list.reverse(acc), in)
text -> #(list.reverse([Text(text), ..acc]), in)
}
}
}
fn parse_autolink(in: String) -> Option(#(Inline, String)) {
case string.split_once(in, ">") {
Error(_) -> None
Ok(#(url, rest)) -> {
// Check if it looks like an email or URL
case string.contains(url, "@") {
True -> {
// Email autolink
let href = "mailto:" <> url
Some(#(Link(dict.new(), [Text(url)], Url(href)), rest))
}
False -> {
// URL autolink - check if it has a scheme
case string.contains(url, "://") || string.starts_with(url, "//") {
True -> Some(#(Link(dict.new(), [Text(url)], Url(url)), rest))
False -> None
}
}
}
}
}
}
fn parse_symbol(in: String) -> Option(#(Inline, String)) {
case take_symbol_chars(in, "") {
Some(#(text, rest)) -> Some(#(Symbol(text), rest))
_ -> None
}
}
// The Djot syntax reference says "Surrounding a word with : signs creates
// a “symbol,” which by default is just rendered literally. "a word" is
// not defined in that document. Per djoths, a symbol's name matches
// the regexp [-_+a-zA-Z09]+, see:
// https://github.com/jgm/djoths/blob/83dbadd6aa325ff23f0e4144221b0df2c64becc7/src/Djot/Inlines.hs#L213-L221
fn take_symbol_chars(in: String, acc: String) -> Option(#(String, String)) {
case in {
"" -> None
":" <> _ if acc == "" -> None
":" <> rest -> Some(#(acc, rest))
"a" as c <> rest
| "b" as c <> rest
| "c" as c <> rest
| "d" as c <> rest
| "e" as c <> rest
| "f" as c <> rest
| "g" as c <> rest
| "h" as c <> rest
| "i" as c <> rest
| "j" as c <> rest
| "k" as c <> rest
| "l" as c <> rest
| "m" as c <> rest
| "n" as c <> rest
| "o" as c <> rest
| "p" as c <> rest
| "q" as c <> rest
| "r" as c <> rest
| "s" as c <> rest
| "t" as c <> rest
| "u" as c <> rest
| "v" as c <> rest
| "w" as c <> rest
| "x" as c <> rest
| "y" as c <> rest
| "z" as c <> rest
| "A" as c <> rest
| "B" as c <> rest
| "C" as c <> rest
| "D" as c <> rest
| "E" as c <> rest
| "F" as c <> rest
| "G" as c <> rest
| "H" as c <> rest
| "I" as c <> rest
| "J" as c <> rest
| "K" as c <> rest
| "L" as c <> rest
| "M" as c <> rest
| "N" as c <> rest
| "O" as c <> rest
| "P" as c <> rest
| "Q" as c <> rest
| "R" as c <> rest
| "S" as c <> rest
| "T" as c <> rest
| "U" as c <> rest
| "V" as c <> rest
| "W" as c <> rest
| "X" as c <> rest
| "Y" as c <> rest
| "Z" as c <> rest
| "0" as c <> rest
| "1" as c <> rest
| "2" as c <> rest
| "3" as c <> rest
| "4" as c <> rest
| "5" as c <> rest
| "6" as c <> rest
| "7" as c <> rest
| "8" as c <> rest
| "9" as c <> rest
| "_" as c <> rest
| "-" as c <> rest
| "+" as c <> rest -> take_symbol_chars(rest, acc <> c)
_ -> None
}
}
fn parse_math(
in: String,
splitters: Splitters,
display: Bool,
) -> Option(#(Inline, String)) {
case splitter.split(splitters.math_end, in) {
#(_, "", "") -> None
#(latex, _, rest) -> {
let math = case display {
True -> MathDisplay(latex)
False -> MathInline(latex)
}
Some(#(math, rest))
}
}
}
fn parse_code(in: String, count: Int) -> #(Inline, String) {
case in {
"`" <> in -> parse_code(in, count + 1)
_ -> {
let #(content, in) = parse_code_content(in, count, "")
// If the string has a single space at the end then a backtick we are
// supposed to not include that space. This is so inline code can start
// with a backtick.
let content = case string.starts_with(content, " `") {
True -> string.trim_start(content)
False -> content
}
let content = case string.ends_with(content, "` ") {
True -> string.trim_end(content)
False -> content
}
#(Code(content), in)
}
}
}
fn parse_code_content(
in: String,
count: Int,
content: String,
) -> #(String, String) {
case in {
"" -> #(content, in)
"`" <> in -> {
let #(done, content, in) = parse_code_end(in, count, 1, content)
case done {
True -> #(content, in)
False -> parse_code_content(in, count, content)
}
}
_ ->
case string.pop_grapheme(in) {
Ok(#(c, in)) -> parse_code_content(in, count, content <> c)
Error(_) -> #(content, in)
}
}
}
fn parse_code_end(
in: String,
limit: Int,
count: Int,
content: String,
) -> #(Bool, String, String) {
case in {
"" -> #(True, content, in)
"`" <> in -> parse_code_end(in, limit, count + 1, content)
_ if limit == count -> #(True, content, in)
_ -> #(False, content <> string.repeat("`", count), in)
}
}
fn parse_emphasis(
in: String,
splitters: Splitters,
close: String,
) -> Option(#(List(Inline), String)) {
case take_emphasis_chars(in, close, "") {
None -> None
Some(#(inline_in, in)) -> {
let #(inline, inline_in_remaining) =
parse_inline(inline_in, splitters, "", [])
Some(#(inline, inline_in_remaining <> in))
}
}
}
fn take_emphasis_chars(
in: String,
close: String,
acc: String,
) -> Option(#(String, String)) {
case in {
"" -> None
// Inline code overrides emphasis
"`" <> _ -> None
// The close is not a close if it is preceeded by whitespace
"\t" as ws <> in | "\n" as ws <> in | " " as ws <> in ->
case string.pop_grapheme(in) {
Ok(#(c, in)) if c == close ->
take_emphasis_chars(in, close, acc <> ws <> c)
_ -> take_emphasis_chars(in, close, acc <> ws)
}
_ ->
case string.pop_grapheme(in) {
Ok(#(c, __)) if c == close && acc == "" -> None
Ok(#(c, in)) if c == close -> Some(#(acc, in))
Ok(#(c, in)) -> take_emphasis_chars(in, close, acc <> c)
Error(_) -> None
}
}
}
fn parse_insert_delete_mark_sup_sub(
in: String,
splitters: Splitters,
close: String,
) -> Option(#(List(Inline), String)) {
case string.split_once(in, close) {
Error(_) -> None
Ok(#(inline_in, rest)) -> {
let #(inline, inline_in_remaining) =
parse_inline(inline_in, splitters, "", [])
Some(#(inline, inline_in_remaining <> rest))
}
}
}
fn parse_link_or_recover(
in: String,
splitters: Splitters,
to_inline: fn(Dict(String, String), List(Inline), Destination) -> Inline,
opening: String,
) -> Result(#(Inline, String), #(String, String)) {
case parse_link(in, splitters, to_inline) {
Some(#(inline, remaining)) -> Ok(#(inline, remaining))
None -> {
// Link parsing failed - consume until we find a clear boundary
// to avoid partial parsing of emphasis markers inside
let #(consumed, remaining) = consume_until_space_or_newline(in, "")
Error(#(opening <> consumed, remaining))
}
}
}
fn consume_until_space_or_newline(
in: String,
acc: String,
) -> #(String, String) {
case in {
"" -> #(acc, "")
" " <> _ -> #(acc, in)
"\n" <> _ -> #(acc, in)
_ ->
case string.pop_grapheme(in) {
Ok(#(c, rest)) -> consume_until_space_or_newline(rest, acc <> c)
Error(_) -> #(acc, "")
}
}
}
fn parse_link(
in: String,
splitters: Splitters,
to_inline: fn(Dict(String, String), List(Inline), Destination) -> Inline,
) -> Option(#(Inline, String)) {
case take_link_chars_or_span(in, "", splitters) {
// This wasn't a link/span, it was just a `[` in the text
None -> None
// Span with attributes [text]{attrs}
Some(#(inline_in, None, in)) -> {
let #(inline, inline_in_remaining) =
parse_inline(inline_in, splitters, "", [])
// Check for attributes after ]
case in {
"{" <> rest ->
case parse_attributes(rest, dict.new()) {
Some(#(attrs, in)) ->
Some(#(Span(attrs, inline), inline_in_remaining <> in))
None -> None
}
_ -> None
}
}
// Link/Image with destination
Some(#(inline_in, Some(ref), in)) -> {
let #(inline, inline_in_remaining) =
parse_inline(inline_in, splitters, "", [])
let ref = case ref {
Reference("") -> Reference(take_inline_text(inline, ""))
ref -> ref
}
// Check for attributes after the link destination
let #(attrs, in) = case in {
"{" <> rest ->
case parse_attributes(rest, dict.new()) {
Some(#(attrs, in)) -> #(attrs, in)
None -> #(dict.new(), in)
}
_ -> #(dict.new(), in)
}
Some(#(to_inline(attrs, inline, ref), inline_in_remaining <> in))
}
}
}
fn take_link_chars_or_span(
in: String,
inline_in: String,
splitters: Splitters,
) -> Option(#(String, Option(Destination), String)) {
take_link_chars_or_span_depth(in, inline_in, splitters, 0)
}
fn take_link_chars_or_span_depth(
in: String,
inline_in: String,
splitters: Splitters,
depth: Int,
) -> Option(#(String, Option(Destination), String)) {
case in {
"" -> None
"![" <> rest ->
// Nested image - increase depth
take_link_chars_or_span_depth(
rest,
inline_in <> "![",
splitters,
depth + 1,
)
"[" <> rest ->
// Nested opening bracket - increase depth
take_link_chars_or_span_depth(
rest,
inline_in <> "[",
splitters,
depth + 1,
)
"]" <> rest if depth > 0 ->
// This ] closes a nested bracket
take_link_chars_or_span_depth(
rest,
inline_in <> "]",
splitters,
depth - 1,
)
"][" <> rest if depth == 0 -> {
// This is ][ - reference link
case take_link_chars_destination(rest, False, inline_in, splitters, "") {
Some(#(inline_in, dest, in)) -> Some(#(inline_in, Some(dest), in))
None -> None
}
}
"](" <> rest if depth == 0 -> {
// This is ]( - inline link
case take_link_chars_destination(rest, True, inline_in, splitters, "") {
Some(#(inline_in, dest, in)) -> Some(#(inline_in, Some(dest), in))
None -> None
}
}
"]{" <> rest if depth == 0 ->
// This is ]{ - span with attributes
Some(#(inline_in, None, "{" <> rest))
"]" <> _ if depth == 0 ->
// Just ] without continuation - not a link
None
_ -> {
// Consume one character and continue
case string.pop_grapheme(in) {
Ok(#(c, rest)) ->
take_link_chars_or_span_depth(rest, inline_in <> c, splitters, depth)
Error(_) -> None
}
}
}
}
fn take_link_chars_destination(
in: String,
is_url: Bool,
inline_in: String,
splitters: Splitters,
acc: String,
) -> Option(#(String, Destination, String)) {
case splitter.split(splitters.link_destination, in) {
#(a, ")", in) if is_url -> Some(#(inline_in, Url(acc <> a), in))
#(a, "]", in) if !is_url -> Some(#(inline_in, Reference(acc <> a), in))
#(a, "\n", rest) if is_url ->
take_link_chars_destination(rest, is_url, inline_in, splitters, acc <> a)
#(a, "\n", rest) if !is_url ->
take_link_chars_destination(
rest,
is_url,
inline_in,
splitters,
acc <> a <> " ",
)
_ -> None
}
}
fn parse_footnote(in: String, acc: String) -> Option(#(Inline, String)) {
case in {
// This wasn't a footnote, it was just a `[^` in the text
"" -> None
"]" <> rest -> {
Some(#(Footnote(acc), rest))
}
_ ->
case string.pop_grapheme(in) {
Ok(#(c, rest)) -> parse_footnote(rest, acc <> c)
// This wasn't a footnote, it was just a `[^` in the text
Error(_) -> None
}
}
}
fn heading_level(in: String, level: Int) -> Option(#(Int, String)) {
case in {
"#" <> rest -> heading_level(rest, level + 1)
"" if level > 0 -> Some(#(level, ""))
" " <> rest | "\n" <> rest if level != 0 -> Some(#(level, rest))
_ -> None
}
}
fn take_inline_text(inlines: List(Inline), acc: String) -> String {
case inlines {
[] -> acc
[first, ..rest] ->
case first {
NonBreakingSpace -> take_inline_text(rest, acc <> " ")
Text(text)
| Code(text)
| MathInline(text)
| MathDisplay(text)
| Symbol(text) -> take_inline_text(rest, acc <> text)
Strong(inlines)
| Emphasis(inlines)
| Delete(inlines)
| Insert(inlines)
| Mark(inlines)
| Superscript(inlines)
| Subscript(inlines) ->
take_inline_text(list.append(inlines, rest), acc)
Link(_, nested, _) | Image(_, nested, _) | Span(_, nested) -> {
let acc = take_inline_text(nested, acc)
take_inline_text(rest, acc)
}
Linebreak | Footnote(_) -> {
take_inline_text(rest, acc)
}
}
}
}
fn parse_paragraph(
in: String,
attrs: Dict(String, String),
splitters: Splitters,
div_close_size: Option(Int),
) -> #(Container, String) {
let #(inline_in, in) = take_paragraph_chars(in, div_close_size)
let #(inline, inline_in_remaining) =
parse_inline(inline_in, splitters, "", [])
#(Paragraph(attrs, inline), inline_in_remaining <> in)
}
fn parse_list(
in: String,
refs: Refs,
attrs: Dict(String, String),
style: ListStyle,
layout: ListLayout,
items: List(List(Container)),
splitters: Splitters,
) -> #(Container, String) {
let #(inline_in, in, layout) = take_list_item_chars(in, "", style, layout)
let item = parse_list_item(inline_in, refs, attrs, splitters, [])
let items = [item, ..items]
case continue_list(in, style) {
Some(in) -> parse_list(in, refs, attrs, style, layout, items, splitters)
None -> {
let items = list.reverse(items)
let container = case style {
Bullet(style) -> BulletList(layout:, style:, items:)
Ordered(start:, punctuation:, style: ordinal) ->
OrderedList(layout:, punctuation:, ordinal:, start:, items:)
}
#(container, in)
}
}
}
fn parse_list_item(
in: String,
refs: Refs,
attrs: Dict(String, String),
splitters: Splitters,
children: List(Container),
) -> List(Container) {
let #(in, refs, container, attrs) =
parse_container(in, refs, splitters, attrs, 0, None)
let children = case container {
None -> children
Some(container) -> [container, ..children]
}
case in {
"" -> list.reverse(children)
_ -> parse_list_item(in, refs, attrs, splitters, children)
}
}
fn take_list_item_chars(
in: String,
acc: String,
style: ListStyle,
layout: ListLayout,
) -> #(String, String, ListLayout) {
let #(line, in) = case string.split_once(in, "\n") {
Ok(split) -> split
Error(_) -> #(in, "")
}
let acc = acc <> line
case in {
"" -> #(acc, "", layout)
" " <> _ -> take_list_item_chars(in, acc <> "\n", style, layout)
// A blank line followed by indented content, meaning this is
// content for the current list item.
"\n " <> rest -> {
let #(rest, indent) = count_drop_spaces(rest, 1)
let layout = case parse_list_marker(rest) {
Some(_) -> layout
None -> Loose
}
let acc = acc <> "\n\n"
take_list_item_chars_indented(rest, acc, style, layout, indent)
}
// A blank line followed by un-indented content, so the end of this
// current list item.
"\n" <> in -> {
let layout = case continue_list(in, style) {
Some(_) -> Loose
None -> layout
}
#(acc, in, layout)
}
_ -> {
case parse_list_marker(in) {
Some(_) -> #(acc, in, layout)
None -> take_list_item_chars(in, acc <> "\n", style, layout)
}
}
}
}
fn parse_list_marker(in: String) -> Option(#(ListStyle, String)) {
case in {
"- " <> in | "-\n" <> in -> Some(#(Bullet(BulletDash), in))
"* " <> in | "*\n" <> in -> Some(#(Bullet(BulletStar), in))
"+ " <> in | "+\n" <> in -> Some(#(Bullet(BulletPlus), in))
"(" <> in -> parse_list_marker_maybe_paren(in, True)
_ -> parse_list_marker_maybe_paren(in, False)
}
}
fn parse_list_marker_maybe_paren(
in: String,
paren: Bool,
) -> Option(#(ListStyle, String)) {
case in {
"0" <> _
| "1" <> _
| "2" <> _
| "3" <> _
| "4" <> _
| "5" <> _
| "6" <> _
| "7" <> _
| "8" <> _
| "9" <> _ ->
case parse_number_list(in, 0, paren) {
Some(#(punctuation, style, start, in)) ->
Some(#(Ordered(start:, style:, punctuation:), in))
None -> None
}
"a" <> _
| "b" <> _
| "c" <> _
| "d" <> _
| "e" <> _
| "f" <> _
| "g" <> _
| "h" <> _
| "i" <> _
| "j" <> _
| "k" <> _
| "l" <> _
| "m" <> _
| "n" <> _
| "o" <> _
| "p" <> _
| "q" <> _
| "r" <> _
| "s" <> _
| "t" <> _
| "u" <> _
| "v" <> _
| "w" <> _
| "x" <> _
| "y" <> _
| "z" <> _ ->
case parse_lower_list(in, 0, paren) {
Some(#(punctuation, style, start, in)) ->
Some(#(Ordered(start:, style:, punctuation:), in))
None -> None
}
"A" <> _
| "B" <> _
| "C" <> _
| "D" <> _
| "E" <> _
| "F" <> _
| "G" <> _
| "H" <> _
| "I" <> _
| "J" <> _
| "K" <> _
| "L" <> _
| "M" <> _
| "N" <> _
| "O" <> _
| "P" <> _
| "Q" <> _
| "R" <> _
| "S" <> _
| "T" <> _
| "U" <> _
| "V" <> _
| "W" <> _
| "X" <> _
| "Y" <> _
| "Z" <> _ ->
case parse_upper_list(in, 0, paren) {
Some(#(punctuation, style, start, in)) ->
Some(#(Ordered(start:, style:, punctuation:), in))
None -> None
}
_ -> None
}
}
fn take_list_item_chars_indented(
in: String,
acc: String,
style: ListStyle,
layout: ListLayout,
indent: Int,
) -> #(String, String, ListLayout) {
let in = drop_n_spaces(in, indent)
let #(line, in) = case string.split_once(in, "\n") {
Ok(split) -> split
Error(_) -> #(in, "")
}
let acc = acc <> line
case in {
"" -> #(acc, "", layout)
" " <> _ ->
take_list_item_chars_indented(in, acc <> "\n", style, layout, indent)
"\n " <> rest -> {
let layout = case parse_list_marker(drop_spaces(rest)) {
Some(_) -> layout
None -> Loose
}
let acc = acc <> "\n\n"
let in = string.drop_start(in, 1)
take_list_item_chars_indented(in, acc, style, layout, indent)
}
// A blank line followed by un-indented content, so this is the end of this
// current list item.
"\n" <> rest2 -> #(acc, rest2, layout)
_ -> {
case continue_list(in, style) {
Some(_) -> #(acc, in, layout)
None ->
take_list_item_chars_indented(in, acc <> "\n", style, layout, indent)
}
}
}
}
fn continue_list(in: String, style: ListStyle) -> Option(String) {
case parse_list_marker(in) {
Some(#(next, in)) ->
case style, next {
Ordered(punctuation: p1, style: s1, start: _),
Ordered(punctuation: p2, style: s2, start: _)
if p1 == p2 && s1 == s2
-> Some(in)
_, _ if style == next -> Some(in)
_, _ -> None
}
None -> None
}
}
fn drop_n_spaces(in: String, count: Int) -> String {
case in {
_ if count == 0 -> in
" " <> rest -> drop_n_spaces(rest, count - 1)
_ -> in
}
}
fn parse_number_list(
in: String,
num: Int,
// Whether the ordinal started with a paren
paren: Bool,
) -> Option(#(OrdinalPunctuation, OrdinalStyle, Int, String)) {
case in {
"0" <> rest -> parse_number_list(rest, num * 10 + 0, paren)
"1" <> rest -> parse_number_list(rest, num * 10 + 1, paren)
"2" <> rest -> parse_number_list(rest, num * 10 + 2, paren)
"3" <> rest -> parse_number_list(rest, num * 10 + 3, paren)
"4" <> rest -> parse_number_list(rest, num * 10 + 4, paren)
"5" <> rest -> parse_number_list(rest, num * 10 + 5, paren)
"6" <> rest -> parse_number_list(rest, num * 10 + 6, paren)
"7" <> rest -> parse_number_list(rest, num * 10 + 7, paren)
"8" <> rest -> parse_number_list(rest, num * 10 + 8, paren)
"9" <> rest -> parse_number_list(rest, num * 10 + 9, paren)
". " <> rest | ".\n" <> rest -> Some(#(FullStop, NumericOrdinal, num, rest))
") " <> rest | ")\n" <> rest -> {
let punctuation = case paren {
True -> DoubleParen
False -> SingleParen
}
Some(#(punctuation, NumericOrdinal, num, rest))
}
_ -> None
}
}
fn parse_lower_list(
in: String,
num: Int,
// Whether the ordinal started with a paren
paren: Bool,
) -> Option(#(OrdinalPunctuation, OrdinalStyle, Int, String)) {
case in {
"a" <> in -> parse_lower_list(in, num * 26 + 1, paren)
"b" <> in -> parse_lower_list(in, num * 26 + 2, paren)
"c" <> in -> parse_lower_list(in, num * 26 + 3, paren)
"d" <> in -> parse_lower_list(in, num * 26 + 4, paren)
"e" <> in -> parse_lower_list(in, num * 26 + 5, paren)
"f" <> in -> parse_lower_list(in, num * 26 + 6, paren)
"g" <> in -> parse_lower_list(in, num * 26 + 7, paren)
"h" <> in -> parse_lower_list(in, num * 26 + 8, paren)
"i" <> in -> parse_lower_list(in, num * 26 + 9, paren)
"j" <> in -> parse_lower_list(in, num * 26 + 10, paren)
"k" <> in -> parse_lower_list(in, num * 26 + 11, paren)
"l" <> in -> parse_lower_list(in, num * 26 + 12, paren)
"m" <> in -> parse_lower_list(in, num * 26 + 13, paren)
"n" <> in -> parse_lower_list(in, num * 26 + 14, paren)
"o" <> in -> parse_lower_list(in, num * 26 + 15, paren)
"p" <> in -> parse_lower_list(in, num * 26 + 16, paren)
"q" <> in -> parse_lower_list(in, num * 26 + 17, paren)
"r" <> in -> parse_lower_list(in, num * 26 + 18, paren)
"s" <> in -> parse_lower_list(in, num * 26 + 19, paren)
"t" <> in -> parse_lower_list(in, num * 26 + 20, paren)
"u" <> in -> parse_lower_list(in, num * 26 + 21, paren)
"v" <> in -> parse_lower_list(in, num * 26 + 22, paren)
"w" <> in -> parse_lower_list(in, num * 26 + 23, paren)
"x" <> in -> parse_lower_list(in, num * 26 + 24, paren)
"y" <> in -> parse_lower_list(in, num * 26 + 25, paren)
"z" <> in -> parse_lower_list(in, num * 26 + 26, paren)
". " <> rest | ".\n" <> rest if !paren ->
Some(#(FullStop, LowerAlphaOrdinal, num, rest))
") " <> rest | ")\n" <> rest -> {
let punctuation = case paren {
True -> DoubleParen
False -> SingleParen
}
Some(#(punctuation, LowerAlphaOrdinal, num, rest))
}
_ -> None
}
}
fn parse_upper_list(
in: String,
num: Int,
// Whether the ordinal started with a paren
paren: Bool,
) -> Option(#(OrdinalPunctuation, OrdinalStyle, Int, String)) {
case in {
"A" <> in -> parse_upper_list(in, num * 26 + 1, paren)
"B" <> in -> parse_upper_list(in, num * 26 + 2, paren)
"C" <> in -> parse_upper_list(in, num * 26 + 3, paren)
"D" <> in -> parse_upper_list(in, num * 26 + 4, paren)
"E" <> in -> parse_upper_list(in, num * 26 + 5, paren)
"F" <> in -> parse_upper_list(in, num * 26 + 6, paren)
"G" <> in -> parse_upper_list(in, num * 26 + 7, paren)
"H" <> in -> parse_upper_list(in, num * 26 + 8, paren)
"I" <> in -> parse_upper_list(in, num * 26 + 9, paren)
"J" <> in -> parse_upper_list(in, num * 26 + 10, paren)
"K" <> in -> parse_upper_list(in, num * 26 + 11, paren)
"L" <> in -> parse_upper_list(in, num * 26 + 12, paren)
"M" <> in -> parse_upper_list(in, num * 26 + 13, paren)
"N" <> in -> parse_upper_list(in, num * 26 + 14, paren)
"O" <> in -> parse_upper_list(in, num * 26 + 15, paren)
"P" <> in -> parse_upper_list(in, num * 26 + 16, paren)
"Q" <> in -> parse_upper_list(in, num * 26 + 17, paren)
"R" <> in -> parse_upper_list(in, num * 26 + 18, paren)
"S" <> in -> parse_upper_list(in, num * 26 + 19, paren)
"T" <> in -> parse_upper_list(in, num * 26 + 20, paren)
"U" <> in -> parse_upper_list(in, num * 26 + 21, paren)
"V" <> in -> parse_upper_list(in, num * 26 + 22, paren)
"W" <> in -> parse_upper_list(in, num * 26 + 23, paren)
"X" <> in -> parse_upper_list(in, num * 26 + 24, paren)
"Y" <> in -> parse_upper_list(in, num * 26 + 25, paren)
"Z" <> in -> parse_upper_list(in, num * 26 + 26, paren)
". " <> rest | ".\n" <> rest if !paren ->
Some(#(FullStop, UpperAlphaOrdinal, num, rest))
") " <> rest | ")\n" <> rest -> {
let punctuation = case paren {
True -> DoubleParen
False -> SingleParen
}
Some(#(punctuation, UpperAlphaOrdinal, num, rest))
}
_ -> None
}
}
fn take_paragraph_chars(
in: String,
div_close_size: Option(Int),
) -> #(String, String) {
let #(paragraph, in) = case string.split_once(in, "\n\n") {
Ok(#(content, in)) -> #(content, in)
Error(_) ->
case string.ends_with(in, "\n") {
True -> #(string.drop_end(in, 1), "")
False -> #(in, "")
}
}
case div_close_size {
Some(size) -> {
let #(split_paragraph, paragraph_in) =
search_paragraph_for_div_end(paragraph, [], size)
case split_paragraph, paragraph_in {
"", "" -> #(paragraph, in)
_, "" -> #(split_paragraph, in)
_, _ -> #(split_paragraph, paragraph_in <> "\n\n" <> in)
}
}
None -> #(paragraph, in)
}
}
/// Search a stretch of paragraph characters for valid div terminator. A valid
/// div terminator is a line containing leading and trailing whitespace with an
/// uninterrupted fence of colons `:`. The fence must be at least `size` long.
fn search_paragraph_for_div_end(
in: String,
acc: List(String),
size: Int,
) -> #(String, String) {
let #(line, rest) = slurp_to_line_end(in)
case check_line_suitable_div_end(line, size) {
True -> #(acc |> list.reverse |> string.join("\n"), in)
False -> {
case rest {
"" -> #([line, ..acc] |> list.reverse |> string.join("\n"), "")
rest -> search_paragraph_for_div_end(rest, [line, ..acc], size)
}
}
}
}
/// Split at \n. If a newline is not present, then the remaining characters
/// will be returned as if there where a newline as the final character.
fn slurp_to_line_end(in: String) -> #(String, String) {
case string.split_once(in, "\n") {
Ok(split) -> split
Error(Nil) -> #(in, "")
}
}
type RenderRefs {
RenderRefs(
urls: Dict(String, String),
reference_attributes: Dict(String, Dict(String, String)),
footnotes: Dict(String, List(Container)),
)
}
/// Convert a document tree into a string of HTML.
///
/// See `to_html` for further documentation.
///
pub fn document_to_html(document: Document) -> String {
let generated_html =
containers_to_html(
document.content,
RenderRefs(
urls: document.references,
reference_attributes: document.reference_attributes,
footnotes: document.footnotes,
),
GeneratedHtml("", []),
)
// only create the footnotes section if it is needed
use <- bool.guard(
list.is_empty(generated_html.used_footnotes),
generated_html.html,
)
let footnotes_section_html =
generated_html
|> open_tag("section", dict.from_list([#("role", "doc-endnotes")]))
|> append_to_html("\n")
|> open_tag("hr", dict.new())
|> append_to_html("\n")
|> open_tag("ol", dict.new())
|> append_to_html("\n")
let html_with_footnotes =
create_footnotes(
document,
list.reverse(footnotes_section_html.used_footnotes),
footnotes_section_html,
)
{
html_with_footnotes
|> close_tag("ol")
|> append_to_html("\n")
|> close_tag("section")
|> append_to_html("\n")
}.html
}
type Footnotes =
List(#(Int, String))
type GeneratedHtml {
GeneratedHtml(html: String, used_footnotes: Footnotes)
}
fn containers_to_html_with_last_paragraph(
containers: List(Container),
refs: RenderRefs,
html: GeneratedHtml,
apply: fn(GeneratedHtml) -> GeneratedHtml,
) -> GeneratedHtml {
case containers {
[] -> html
[container] -> {
case container {
Paragraph(attrs, inlines) ->
html
|> open_tag("p", attrs)
|> inlines_to_html(inlines, refs, TrimLast)
|> apply()
|> close_tag("p")
_ ->
container_to_html(html, container, refs)
|> open_tag("p", dict.new())
|> apply()
|> close_tag("p")
}
}
[container, ..rest] -> {
let html = container_to_html(html, container, refs)
containers_to_html_with_last_paragraph(rest, refs, html, apply)
}
}
}
fn containers_to_html(
containers: List(Container),
refs: RenderRefs,
html: GeneratedHtml,
) -> GeneratedHtml {
case containers {
[] -> html
[container, ..rest] -> {
let html = container_to_html(html, container, refs)
containers_to_html(rest, refs, html)
}
}
}
fn container_to_html(
html: GeneratedHtml,
container: Container,
refs: RenderRefs,
) -> GeneratedHtml {
let new_html = case container {
ThematicBreak -> html |> open_tag("hr", dict.new())
Paragraph(attrs, inlines) -> {
html
|> open_tag("p", attrs)
|> inlines_to_html(inlines, refs, TrimLast)
|> close_tag("p")
}
Codeblock(attrs, language, content) -> {
let code_attrs = case language {
Some(lang) -> add_attribute(attrs, "class", "language-" <> lang)
None -> attrs
}
html
|> open_tag("pre", dict.new())
|> open_tag("code", code_attrs)
|> append_to_html(houdini.escape(content))
|> close_tag("code")
|> close_tag("pre")
}
Heading(attrs, level, inlines) -> {
let tag = "h" <> int.to_string(level)
html
|> open_tag(tag, attrs)
|> inlines_to_html(inlines, refs, TrimLast)
|> close_tag(tag)
}
RawBlock(content) -> GeneratedHtml(..html, html: html.html <> content)
BulletList(layout:, style: _, items:) -> {
html
|> open_tag("ul", dict.new())
|> append_to_html("\n")
|> list_items_to_html(layout, items, refs)
|> close_tag("ul")
}
OrderedList(layout:, punctuation: _, ordinal:, start:, items:) -> {
let attrs = case start {
1 -> dict.new()
_ -> dict.from_list([#("start", int.to_string(start))])
}
let attrs = case ordinal {
NumericOrdinal -> attrs
LowerAlphaOrdinal -> dict.insert(attrs, "type", "a")
UpperAlphaOrdinal -> dict.insert(attrs, "type", "A")
}
html
|> open_tag("ol", attrs)
|> append_to_html("\n")
|> list_items_to_html(layout, items, refs)
|> close_tag("ol")
}
BlockQuote(attrs, items) ->
html
|> open_tag("blockquote", attrs)
|> append_to_html("\n")
|> containers_to_html(items, refs, _)
|> close_tag("blockquote")
Div(class: _, attributes:, items:) ->
html
|> open_tag("div", attributes)
|> append_to_html("\n")
|> containers_to_html(items, refs, _)
|> close_tag("div")
}
append_to_html(new_html, "\n")
}
fn create_footnotes(
document: Document,
used_footnotes: List(#(Int, String)),
html_acc: GeneratedHtml,
) {
let footnote_to_html = fn(
html: GeneratedHtml,
footnote: String,
footnote_number: String,
) {
dict.get(document.footnotes, footnote)
|> result.try(fn(footnote) {
// Even if the footnote is empty, we need to still make sure a backlink is generated
case list.is_empty(footnote) {
True -> Error(Nil)
False -> Ok(footnote)
}
})
|> result.map(fn(footnote) {
containers_to_html_with_last_paragraph(
footnote,
RenderRefs(
document.references,
document.reference_attributes,
document.footnotes,
),
html,
add_footnote_link(_, footnote_number),
)
})
|> result.lazy_unwrap(fn() {
html
|> open_tag_ordered_attributes("p", [])
|> add_footnote_link(footnote_number)
|> close_tag("p")
})
}
case used_footnotes {
[] -> html_acc
[#(footnote_number, footnote), ..other_footnotes] -> {
let footnote_number = int.to_string(footnote_number)
let html =
html_acc
|> open_tag("li", dict.from_list([#("id", "fn" <> footnote_number)]))
|> append_to_html("\n")
|> footnote_to_html(footnote, footnote_number)
|> append_to_html("\n")
|> close_tag("li")
|> append_to_html("\n")
let new_used_footnotes =
list.append(get_new_footnotes(html_acc, html, []), other_footnotes)
create_footnotes(document, new_used_footnotes, html)
}
}
}
fn add_footnote_link(html: GeneratedHtml, footnote_number: String) {
html
|> open_tag_ordered_attributes("a", [
#("href", "#fnref" <> footnote_number),
#("role", "doc-backlink"),
])
|> append_to_html("↩︎")
|> close_tag("a")
}
fn get_new_footnotes(
original_html: GeneratedHtml,
new_html: GeneratedHtml,
acc: List(#(Int, String)),
) {
case original_html.used_footnotes, new_html.used_footnotes {
[original, ..], [new, ..] if original == new -> acc
_, [new, ..rest] ->
get_new_footnotes(
original_html,
GeneratedHtml(..new_html, used_footnotes: rest),
[new, ..acc],
)
_, _ -> acc
}
}
fn append_to_html(original_html: GeneratedHtml, str: String) -> GeneratedHtml {
GeneratedHtml(..original_html, html: original_html.html <> str)
}
fn open_tag(
initial_html: GeneratedHtml,
tag: String,
attributes: Dict(String, String),
) -> GeneratedHtml {
let html = initial_html.html <> "<" <> tag
GeneratedHtml(
..initial_html,
html: attributes_to_html(html, attributes) <> ">",
)
}
// Some of the tests require a specific order of attributes for them to pass (unlike most which are alphabetical)
// This function allows you to provide a specific order, which open_tag cannot guarantee as dict.Dict has no set order.
fn open_tag_ordered_attributes(
initial_html: GeneratedHtml,
tag: String,
attributes: List(#(String, String)),
) -> GeneratedHtml {
let html = initial_html.html <> "<" <> tag
GeneratedHtml(
..initial_html,
html: ordered_attributes_to_html(attributes, html) <> ">",
)
}
fn close_tag(initial_html: GeneratedHtml, tag: String) -> GeneratedHtml {
GeneratedHtml(..initial_html, html: initial_html.html <> "</" <> tag <> ">")
}
type Trim {
NoTrim
TrimLast
}
fn list_items_to_html(
html: GeneratedHtml,
layout: ListLayout,
items: List(List(Container)),
refs: RenderRefs,
) -> GeneratedHtml {
case items {
[] -> html
[[Paragraph(_, inlines)], ..rest] if layout == Tight -> {
html
|> open_tag("li", dict.new())
|> append_to_html("\n")
|> inlines_to_html(inlines, refs, TrimLast)
|> append_to_html("\n")
|> close_tag("li")
|> append_to_html("\n")
|> list_items_to_html(layout, rest, refs)
}
[[Paragraph(_, inlines), nested_list, ..item_rest], ..rest]
if layout == Tight
-> {
html
|> open_tag("li", dict.new())
|> append_to_html("\n")
|> inlines_to_html(inlines, refs, TrimLast)
|> append_to_html("\n")
|> containers_to_html([nested_list, ..item_rest], refs, _)
|> close_tag("li")
|> append_to_html("\n")
|> list_items_to_html(layout, rest, refs)
}
[item, ..rest] -> {
html
|> open_tag("li", dict.new())
|> append_to_html("\n")
|> containers_to_html(item, refs, _)
|> close_tag("li")
|> append_to_html("\n")
|> list_items_to_html(layout, rest, refs)
}
}
}
fn inlines_to_html(
html: GeneratedHtml,
inlines: List(Inline),
refs: RenderRefs,
trim: Trim,
) -> GeneratedHtml {
case inlines {
[] -> html
[inline] if trim == TrimLast -> {
html
|> inline_to_html(inline, refs, trim)
}
[inline, ..rest] -> {
html
|> inline_to_html(inline, refs, NoTrim)
|> inlines_to_html(rest, refs, trim)
}
}
}
fn inline_to_html(
html: GeneratedHtml,
inline: Inline,
refs: RenderRefs,
trim: Trim,
) -> GeneratedHtml {
case inline {
MathInline(latex) -> {
let math_class = dict.from_list([#("class", "math inline")])
let latex = "\\(" <> houdini.escape(latex) <> "\\)"
html
|> open_tag("span", math_class)
|> append_to_html(latex)
|> close_tag("span")
}
MathDisplay(latex) -> {
let math_class = dict.from_list([#("class", "math display")])
let latex = "\\[" <> houdini.escape(latex) <> "\\]"
html
|> open_tag("span", math_class)
|> append_to_html(latex)
|> close_tag("span")
}
NonBreakingSpace -> {
html |> append_to_html(" ")
}
Linebreak -> {
html
|> open_tag("br", dict.new())
|> append_to_html("\n")
}
Text(text) -> {
let text = houdini.escape(text)
case trim {
NoTrim -> append_to_html(html, text)
TrimLast -> append_to_html(html, string.trim_end(text))
}
}
Strong(inlines) -> {
html
|> open_tag("strong", dict.new())
|> inlines_to_html(inlines, refs, trim)
|> close_tag("strong")
}
Emphasis(inlines) -> {
html
|> open_tag("em", dict.new())
|> inlines_to_html(inlines, refs, trim)
|> close_tag("em")
}
Delete(inlines) -> {
html
|> open_tag("del", dict.new())
|> inlines_to_html(inlines, refs, NoTrim)
|> close_tag("del")
}
Insert(inlines) -> {
html
|> open_tag("ins", dict.new())
|> inlines_to_html(inlines, refs, NoTrim)
|> close_tag("ins")
}
Mark(inlines) -> {
html
|> open_tag("mark", dict.new())
|> inlines_to_html(inlines, refs, NoTrim)
|> close_tag("mark")
}
Superscript(inlines) -> {
html
|> open_tag("sup", dict.new())
|> inlines_to_html(inlines, refs, NoTrim)
|> close_tag("sup")
}
Subscript(inlines) -> {
html
|> open_tag("sub", dict.new())
|> inlines_to_html(inlines, refs, NoTrim)
|> close_tag("sub")
}
Link(attributes, text, destination) -> {
// Merge: reference attrs <- href <- inline attrs
let ref_attrs = get_reference_attributes(destination, refs)
let attrs =
ref_attrs
|> dict.merge(destination_attribute("href", destination, refs))
|> dict.merge(attributes)
html
|> open_tag("a", attrs)
|> inlines_to_html(text, refs, trim)
|> close_tag("a")
}
Image(attributes, text, destination) -> {
// Merge: reference attrs <- src/alt <- inline attrs
let ref_attrs = get_reference_attributes(destination, refs)
let attrs =
ref_attrs
|> dict.merge(destination_attribute("src", destination, refs))
|> dict.insert("alt", houdini.escape(take_inline_text(text, "")))
|> dict.merge(attributes)
html
|> open_tag("img", attrs)
}
Symbol(content) -> {
let attrs =
dict.new()
|> add_attribute("class", "symbol")
html
|> open_tag("span", attrs)
|> append_to_html(content)
|> close_tag("span")
}
Span(attributes, inlines) -> {
html
|> open_tag("span", attributes)
|> inlines_to_html(inlines, refs, trim)
|> close_tag("span")
}
Code(content) -> {
let content = houdini.escape(content)
html
|> open_tag("code", dict.new())
|> append_to_html(content)
|> close_tag("code")
}
Footnote(reference) -> {
let #(footnote_number, new_used_footnotes) =
find_footnote_number(
html.used_footnotes,
reference,
html.used_footnotes,
)
let footnote_attrs = [
#("id", "fnref" <> footnote_number),
#("href", "#fn" <> footnote_number),
#("role", "doc-noteref"),
]
let updated_html =
html
|> open_tag_ordered_attributes("a", footnote_attrs)
|> append_to_html("<sup>" <> footnote_number <> "</sup>")
|> close_tag("a")
GeneratedHtml(..updated_html, used_footnotes: new_used_footnotes)
}
}
}
fn find_footnote_number(
footnotes_to_check: Footnotes,
reference: String,
used_footnotes: Footnotes,
) -> #(String, Footnotes) {
case footnotes_to_check {
[] -> {
let next_number =
{
used_footnotes
|> list.first()
|> result.map(fn(f) { f.0 })
|> result.unwrap(0)
}
+ 1
#(int.to_string(next_number), [
#(next_number, reference),
..used_footnotes
])
}
[#(index, ref), ..] if reference == ref -> {
#(int.to_string(index), used_footnotes)
}
[_, ..rest] -> find_footnote_number(rest, reference, used_footnotes)
}
}
fn get_reference_attributes(
destination: Destination,
refs: RenderRefs,
) -> Dict(String, String) {
case destination {
Url(_) -> dict.new()
Reference(id) ->
dict.get(refs.reference_attributes, id)
|> result.unwrap(dict.new())
}
}
fn destination_attribute(
key: String,
destination: Destination,
refs: RenderRefs,
) -> Dict(String, String) {
let dict = dict.new()
case destination {
Url(url) -> dict.insert(dict, key, houdini.escape(url))
Reference(id) ->
case dict.get(refs.urls, id) {
Ok(url) -> dict.insert(dict, key, houdini.escape(url))
_ -> dict
}
}
}
fn attributes_to_html(
html: String,
attributes: Dict(String, String),
) -> String {
attributes
|> dict.to_list
|> list.sort(fn(a, b) { string.compare(a.0, b.0) })
|> ordered_attributes_to_html(html)
}
fn ordered_attributes_to_html(
attributes: List(#(String, String)),
html: String,
) -> String {
list.fold(attributes, html, fn(html, pair) {
html <> " " <> pair.0 <> "=\"" <> pair.1 <> "\""
})
}
/// Get the text from within a container.
///
/// Raw blocks, footnotes, and the ordinals and bullets from lists are not
/// included.
///
pub fn inner_text(container: Container) -> String {
case container {
RawBlock(..) | ThematicBreak -> ""
Codeblock(content:, ..) -> content
Paragraph(content:, ..) | Heading(content:, ..) ->
list.fold(content, "", inline_text)
BlockQuote(items:, ..) | Div(items:, ..) ->
list.map(items, inner_text) |> string.join("\n\n")
BulletList(items:, ..) | OrderedList(items:, ..) ->
items |> list.flat_map(list.map(_, inner_text)) |> string.join("\n\n")
}
}
fn inline_text(accumulator: String, item: Inline) -> String {
case item {
Footnote(..) | Image(..) -> accumulator
Linebreak -> accumulator <> "\n\n"
NonBreakingSpace -> accumulator <> " "
Code(content:)
| MathInline(content:)
| MathDisplay(content:)
| Symbol(content:)
| Text(content) -> accumulator <> content
Link(content:, ..)
| Span(content:, ..)
| Emphasis(content:)
| Strong(content:)
| Delete(content:)
| Insert(content:)
| Mark(content:)
| Superscript(content:)
| Subscript(content:) -> list.fold(content, accumulator, inline_text)
}
}