Current section
Files
Jump to
Current section
Files
src/jot.gleam
// TODO: collapse adjacent text nodes
import gleam/dict.{type Dict}
import gleam/int
import gleam/list
import gleam/string
import gleam/option.{type Option, None, Some}
pub type Document {
Document(content: List(Container), references: Dict(String, String))
}
pub type Container {
Paragraph(List(Inline))
Heading(level: Int, attributes: Attrs, content: List(Inline))
Codeblock(language: Option(String), content: String)
}
pub type Inline {
Text(String)
Link(content: List(Inline), destination: Destination)
}
pub type Destination {
Reference(String)
Url(String)
}
type Attrs =
List(#(String, String))
type Chars =
List(String)
type Refs =
Dict(String, String)
// TODO: document
pub fn to_html(djot: String) -> String {
djot
|> parse
|> document_to_html
}
// TODO: document
pub fn parse(djot: String) -> Document {
djot
|> string.replace("\r\n", "\n")
|> string.to_graphemes
|> parse_document(dict.new(), [])
}
fn drop_lines(in: Chars) -> Chars {
case in {
[] -> []
["\n", ..rest] -> drop_lines(rest)
[c, ..rest] -> [c, ..rest]
}
}
fn drop_spaces(in: Chars) -> Chars {
case in {
[] -> []
[" ", ..rest] -> drop_spaces(rest)
[c, ..rest] -> [c, ..rest]
}
}
fn parse_document(in: Chars, refs: Refs, ast: List(Container)) -> Document {
let in = drop_lines(in)
let in = drop_spaces(in)
case in {
[] -> Document(list.reverse(ast), refs)
["#", ..in] -> {
let #(heading, refs, in) = parse_heading(in, refs)
parse_document(in, refs, [heading, ..ast])
}
["~" as delim, ..in2] | ["`" as delim, ..in2] -> {
case parse_codeblock(in2, delim) {
None -> {
let #(paragraph, in) = parse_paragraph(in)
parse_document(in, refs, [paragraph, ..ast])
}
Some(#(codeblock, in)) -> parse_document(in, refs, [codeblock, ..ast])
}
}
["[", ..in2] -> {
case parse_ref_def(in2, "") {
None -> {
let #(paragraph, in) = parse_paragraph(in)
parse_document(in, refs, [paragraph, ..ast])
}
Some(#(id, url, in)) -> {
let refs = dict.insert(refs, id, url)
parse_document(in, refs, ast)
}
}
}
_ -> {
let #(paragraph, in) = parse_paragraph(in)
parse_document(in, refs, [paragraph, ..ast])
}
}
}
fn parse_codeblock(in: Chars, delim: String) -> Option(#(Container, Chars)) {
use #(language, count, in) <- option.then(parse_codeblock_start(in, delim, 1))
let #(content, in) = parse_codeblock_content(in, delim, count, "")
Some(#(Codeblock(language, content), in))
}
fn parse_codeblock_start(
in: Chars,
delim: String,
count: Int,
) -> Option(#(Option(String), Int, Chars)) {
case in {
[c, ..in] if c == delim -> parse_codeblock_start(in, delim, count + 1)
["\n", ..in] if count >= 3 -> Some(#(None, count, in))
[_, ..] if count >= 3 -> {
let in = drop_spaces(in)
let #(language, in) = parse_codeblock_language(in, "")
Some(#(language, count, in))
}
_ -> None
}
}
fn parse_codeblock_content(
in: Chars,
delim: String,
count: Int,
acc: String,
) -> #(String, Chars) {
case parse_codeblock_end(in, delim, count) {
None -> {
let #(acc, in) = slurp_verbatim_line(in, acc)
parse_codeblock_content(in, delim, count, acc)
}
Some(#(in)) -> #(acc, in)
}
}
fn slurp_verbatim_line(in: Chars, acc: String) -> #(String, Chars) {
case in {
[] -> #(acc, [])
["\n", ..in] -> #(acc <> "\n", in)
[c, ..in] -> slurp_verbatim_line(in, acc <> c)
}
}
fn parse_codeblock_end(in: Chars, delim: String, count: Int) -> Option(#(Chars)) {
case in {
["\n", ..in] if count == 0 -> Some(#(in))
_ if count == 0 -> Some(#(in))
[c, ..in] if c == delim -> parse_codeblock_end(in, delim, count - 1)
[] -> Some(#(in))
_ -> None
}
}
fn parse_codeblock_language(
in: Chars,
language: String,
) -> #(Option(String), Chars) {
case in {
[] -> #(None, in)
["\n", ..in] if language == "" -> #(None, in)
["\n", ..in] -> #(Some(language), in)
[c, ..in] -> parse_codeblock_language(in, language <> c)
}
}
fn parse_ref_def(in: Chars, id: String) -> Option(#(String, String, Chars)) {
case in {
["]", ":", ..in] -> parse_ref_value(in, id, "")
[] | ["]", ..] -> None
[c, ..in] -> parse_ref_def(in, id <> c)
}
}
fn parse_ref_value(
in: Chars,
id: String,
url: String,
) -> Option(#(String, String, Chars)) {
case in {
[] -> Some(#(id, string.trim(url), []))
["\n", ..in] -> Some(#(id, string.trim(url), in))
[c, ..in] -> parse_ref_value(in, id, url <> c)
}
}
fn parse_heading(in: Chars, refs: Refs) -> #(Container, Refs, Chars) {
case heading_level(in, 1) {
Some(#(level, in)) -> {
let in = drop_spaces(in)
let #(inline_in, in) = take_heading_chars(in, level, [])
let inline = parse_inline(inline_in, "", [])
let text = take_inline_text(inline, "")
let #(refs, attrs) = case id_sanitise(text) {
"" -> #(refs, [])
id -> #(dict.insert(refs, id, "#" <> id), [#("id", id)])
}
let heading = Heading(level, attrs, inline)
#(heading, refs, in)
}
None -> {
let #(p, in) = parse_paragraph(["#", ..in])
#(p, refs, in)
}
}
}
fn id_sanitise(content: String) -> String {
content
|> string.to_graphemes
|> list.filter(id_char)
|> id_escape("")
}
fn id_char(char: String) -> Bool {
case char {
"#" | "?" | "!" -> False
_ -> True
}
}
fn id_escape(content: Chars, acc: String) -> String {
case content {
[] -> acc
[" ", ..rest] | ["\n", ..rest] if rest == [] -> acc
[" ", ..rest] | ["\n", ..rest] if acc == "" -> id_escape(rest, acc)
[" ", ..rest] | ["\n", ..rest] -> id_escape(rest, acc <> "-")
[c, ..rest] -> id_escape(rest, acc <> c)
}
}
fn take_heading_chars(in: Chars, level: Int, acc: Chars) -> #(Chars, Chars) {
case in {
[] | ["\n"] -> #(list.reverse(acc), [])
["\n", "\n", ..in] -> #(list.reverse(acc), in)
["\n", "#", ..rest] -> {
case take_heading_chars_newline_hash(rest, level - 1, ["\n", ..acc]) {
Some(#(acc, in)) -> take_heading_chars(in, level, acc)
None -> #(list.reverse(acc), in)
}
}
[c, ..in] -> take_heading_chars(in, level, [c, ..acc])
}
}
fn take_heading_chars_newline_hash(
in: Chars,
level: Int,
acc: Chars,
) -> Option(#(Chars, Chars)) {
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: Chars, text: String, acc: List(Inline)) -> List(Inline) {
case in {
[] if text == "" -> list.reverse(acc)
[] -> parse_inline([], "", [Text(text), ..acc])
["[", ..rest] -> {
case parse_link(rest) {
None -> parse_inline(rest, text <> "[", acc)
Some(#(link, in)) -> parse_inline(in, "", [link, ..acc])
}
}
[c, ..rest] -> parse_inline(rest, text <> c, acc)
}
}
fn parse_link(in: Chars) -> Option(#(Inline, Chars)) {
case take_link_chars(in, []) {
// This wasn't a link, it was just a `[` in the text
None -> None
Some(#(inline_in, ref, in)) -> {
let inline = parse_inline(inline_in, "", [])
let link = Link(inline, ref)
Some(#(link, in))
}
}
}
fn take_link_chars(
in: Chars,
inline_in: Chars,
) -> Option(#(Chars, Destination, Chars)) {
case in {
// This wasn't a link, it was just a `[..]` in the text
[] -> None
["]", "[", ..in] -> {
let inline_in = list.reverse(inline_in)
take_link_chars_destination(in, False, inline_in, "")
}
["]", "(", ..in] -> {
let inline_in = list.reverse(inline_in)
take_link_chars_destination(in, True, inline_in, "")
}
[c, ..rest] -> take_link_chars(rest, [c, ..inline_in])
}
}
fn take_link_chars_destination(
in: Chars,
is_url: Bool,
inline_in: Chars,
acc: String,
) -> Option(#(Chars, Destination, Chars)) {
case in {
[] -> None
[")", ..in] if is_url -> Some(#(inline_in, Url(acc), in))
["]", ..in] if !is_url -> Some(#(inline_in, Reference(acc), in))
["\n", ..rest] -> take_link_chars_destination(rest, is_url, inline_in, acc)
[c, ..rest] ->
take_link_chars_destination(rest, is_url, inline_in, acc <> c)
}
}
fn heading_level(in: Chars, level: Int) -> Option(#(Int, Chars)) {
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 {
Text(text) -> take_inline_text(rest, acc <> text)
Link(nested, _) -> {
let acc = take_inline_text(nested, acc)
take_inline_text(rest, acc)
}
}
}
}
fn parse_paragraph(in: Chars) -> #(Container, Chars) {
let #(inline_in, in) = take_paragraph_chars(in, [])
let inline = parse_inline(inline_in, "", [])
#(Paragraph(inline), in)
}
fn take_paragraph_chars(in: Chars, acc: Chars) -> #(Chars, Chars) {
case in {
[] | ["\n"] -> #(list.reverse(acc), [])
["\n", "\n", ..rest] -> #(list.reverse(acc), rest)
[c, ..rest] -> take_paragraph_chars(rest, [c, ..acc])
}
}
// TODO: document
pub fn document_to_html(document: Document) -> String {
containers_to_html(document.content, document.references, "")
}
fn containers_to_html(
containers: List(Container),
refs: Refs,
html: String,
) -> String {
case containers {
[] -> html
[container, ..rest] -> {
let html = container_to_html(html, container, refs)
containers_to_html(rest, refs, html)
}
}
}
fn container_to_html(html: String, container: Container, refs: Refs) -> String {
case container {
Paragraph(inlines) -> {
html
|> open_tag("p", [])
|> inlines_to_html(inlines, refs)
|> close_tag("p")
}
Codeblock(language, content) -> {
let code_attrs = case language {
Some(lang) -> [#("class", "language-" <> lang)]
None -> []
}
html
|> open_tag("pre", [])
|> open_tag("code", code_attrs)
|> string.append(content)
|> close_tag("code")
|> close_tag("pre")
}
Heading(level, attributes, inlines) -> {
let tag = "h" <> int.to_string(level)
html
|> open_tag(tag, attributes)
|> inlines_to_html(inlines, refs)
|> close_tag(tag)
}
} <> "\n"
}
fn open_tag(html: String, tag: String, attributes: Attrs) -> String {
let html = html <> "<" <> tag
attributes_to_html(html, attributes) <> ">"
}
fn close_tag(html: String, tag: String) -> String {
html <> "</" <> tag <> ">"
}
fn inlines_to_html(html: String, inlines: List(Inline), refs: Refs) -> String {
case inlines {
[] -> html
[inline, ..rest] -> {
html
|> inline_to_html(inline, refs)
|> inlines_to_html(rest, refs)
|> string.trim_right
}
}
}
fn inline_to_html(html: String, inline: Inline, refs: Refs) -> String {
case inline {
Text(text) -> html <> text
Link(text, destination) -> {
html
|> open_tag("a", destination_attribute(destination, refs))
|> inlines_to_html(text, refs)
|> close_tag("a")
}
}
}
fn destination_attribute(
destination: Destination,
refs: Refs,
) -> List(#(String, String)) {
case destination {
Reference(id) ->
case dict.get(refs, id) {
Ok(url) -> [#("href", url)]
Error(Nil) -> []
}
Url(url) -> [#("href", url)]
}
}
fn attributes_to_html(html: String, attributes: Attrs) -> String {
case attributes {
[] -> html
[#(key, value), ..rest] -> {
let html = html <> " " <> key <> "=\"" <> value <> "\""
attributes_to_html(html, rest)
}
}
}