-
-
Notifications
You must be signed in to change notification settings - Fork 7
/
djot.gleam
251 lines (225 loc) · 7.46 KB
/
djot.gleam
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
// IMPORTS ---------------------------------------------------------------------
import gleam/bool
import gleam/dict.{type Dict}
import gleam/list
import gleam/option.{type Option}
import gleam/regex.{Match}
import gleam/result
import gleam/string
import jot.{Document}
import lustre/attribute.{attribute}
import lustre/element.{type Element}
import lustre/element/html
import tom.{type Toml}
// TYPES -----------------------------------------------------------------------
/// A renderer for a djot document knows how to turn each block or inline element
/// into some custom view. That view could be anything, but it's typically a
/// Lustre element.
///
/// Some ideas for other renderers include:
///
/// - A renderer that turns a djot document into a JSON object
/// - A renderer that generates a table of contents
/// - A renderer that generates Nakai elements instead of Lustre ones
///
/// Sometimes a custom renderer might need access to the TOML metadata of a
/// document. For that, take a look at the [`render_with_metadata`](#render_with_metadata)
/// function.
///
/// This renderer is compatible with **v0.2.1** of the [jot](https://hexdocs.pm/jot/jot.html)
/// package.
///
pub type Renderer(view) {
Renderer(
codeblock: fn(Dict(String, String), Option(String), String) -> view,
heading: fn(Dict(String, String), Int, List(view)) -> view,
link: fn(jot.Destination, Dict(String, String), List(view)) -> view,
paragraph: fn(Dict(String, String), List(view)) -> view,
text: fn(String) -> view,
)
}
// CONSTRUCTORS ----------------------------------------------------------------
/// The default renderer generates some sensible Lustre elements from a djot
/// document. You can use this if you need a quick drop-in renderer for some
/// markup in a Lustre project.
///
pub fn default_renderer() -> Renderer(Element(msg)) {
let to_attributes = fn(attrs) {
use attrs, key, val <- dict.fold(attrs, [])
[attribute(key, val), ..attrs]
}
Renderer(
codeblock: fn(attrs, lang, code) {
let lang = option.unwrap(lang, "text")
html.pre(to_attributes(attrs), [
html.code([attribute("data-lang", lang)], [element.text(code)]),
])
},
heading: fn(attrs, level, content) {
case level {
1 -> html.h1(to_attributes(attrs), content)
2 -> html.h2(to_attributes(attrs), content)
3 -> html.h3(to_attributes(attrs), content)
4 -> html.h4(to_attributes(attrs), content)
5 -> html.h5(to_attributes(attrs), content)
6 -> html.h6(to_attributes(attrs), content)
_ -> html.p(to_attributes(attrs), content)
}
},
link: fn(destination, references, content) {
case destination {
jot.Reference(ref) ->
case dict.get(references, ref) {
Ok(url) -> html.a([attribute.href(url)], content)
Error(_) ->
html.a(
[
attribute.href("#" <> linkify(ref)),
attribute.id(linkify("back-to-" <> ref)),
],
content,
)
}
jot.Url(url) -> html.a([attribute("href", url)], content)
}
},
paragraph: fn(attrs, content) { html.p(to_attributes(attrs), content) },
text: fn(text) { element.text(text) },
)
}
// QUERIES ---------------------------------------------------------------------
/// Extract the frontmatter string from a djot document. Frontmatter is anything
/// between two lines of three dashes, like this:
///
/// ```djot
/// ---
/// title = "My Document"
/// ---
///
/// # My Document
///
/// ...
/// ```
///
/// The document **must** start with exactly three dashes and a newline for there
/// to be any frontmatter. If there is no frontmatter, this function returns
/// `Error(Nil)`,
///
pub fn frontmatter(document: String) -> Result(String, Nil) {
use <- bool.guard(!string.starts_with(document, "---"), Error(Nil))
let options = regex.Options(case_insensitive: False, multi_line: True)
let assert Ok(re) = regex.compile("^---\\n[\\s\\S]*?\\n---", options)
case regex.scan(re, document) {
[Match(content: frontmatter, ..), ..] ->
Ok(
frontmatter
|> string.drop_left(4)
|> string.drop_right(4),
)
_ -> Error(Nil)
}
}
/// Extract the TOML metadata from a djot document. This takes the [`frontmatter`](#frontmatter)
/// and parses it as TOML. If there is *no* frontmatter, this function returns
/// an empty dictionary.
///
/// If the frontmatter is invalid TOML, this function returns a TOML parse error.
///
pub fn metadata(document: String) -> Result(Dict(String, Toml), tom.ParseError) {
case frontmatter(document) {
Ok(frontmatter) -> tom.parse(frontmatter)
Error(_) -> Ok(dict.new())
}
}
/// Extract the djot content from a document with optional frontmatter. If the
/// document does not have frontmatter, this acts as an identity function.
///
pub fn content(document: String) -> String {
let toml = frontmatter(document)
case toml {
Ok(toml) -> string.replace(document, "---\n" <> toml <> "\n---", "")
Error(_) -> document
}
}
// CONVERSIONS -----------------------------------------------------------------
/// Render a djot document using the given renderer. If the document contains
/// [`frontmatter`](#frontmatter) it is stripped out before rendering.
///
pub fn render(document: String, renderer: Renderer(view)) -> List(view) {
let content = content(document)
let Document(content, references) = jot.parse(content)
content
|> list.map(render_block(_, references, renderer))
}
/// Render a djot document using the given renderer. TOML metadata is extracted
/// from the document's frontmatter and passed to the renderer. If the frontmatter
/// is invalid TOML this function will return the TOML parse error, but if there
/// is no frontmatter to parse this function will succeed and just pass an empty
/// dictionary to the renderer.
///
pub fn render_with_metadata(
document: String,
renderer: fn(Dict(String, Toml)) -> Renderer(view),
) -> Result(List(view), tom.ParseError) {
let toml = frontmatter(document)
use metadata <- result.try(
toml
|> result.unwrap("")
|> tom.parse,
)
let content = content(document)
let renderer = renderer(metadata)
let Document(content, references) = jot.parse(content)
content
|> list.map(render_block(_, references, renderer))
|> Ok
}
fn render_block(
block: jot.Container,
references: Dict(String, String),
renderer: Renderer(view),
) -> view {
case block {
jot.Paragraph(attrs, inline) -> {
renderer.paragraph(
attrs,
list.map(inline, render_inline(_, references, renderer)),
)
}
jot.Heading(attrs, level, inline) -> {
renderer.heading(
attrs,
level,
list.map(inline, render_inline(_, references, renderer)),
)
}
jot.Codeblock(attrs, language, code) -> {
renderer.codeblock(attrs, language, code)
}
}
}
fn render_inline(
inline: jot.Inline,
references: Dict(String, String),
renderer: Renderer(view),
) -> view {
case inline {
jot.Text(text) -> {
renderer.text(text)
}
jot.Link(content, destination) -> {
renderer.link(
destination,
references,
list.map(content, render_inline(_, references, renderer)),
)
}
}
}
// UTILS -----------------------------------------------------------------------
fn linkify(text: String) -> String {
let assert Ok(re) = regex.from_string(" +")
text
|> regex.split(re, _)
|> string.join("-")
}