tangled
alpha
login
or
join now
ericwood.org
/
photos-site
1
fork
atom
A little app to serve my photography from my personal website
1
fork
atom
overview
issues
pulls
pipelines
extract table of contents and render it
ericwood.org
3 months ago
c7cba5fb
3d749f71
0/0
Waiting for spindle ...
+129
-43
3 changed files
expand all
collapse all
unified
split
src
blog.rs
views
blog
show
mod.rs
template.jinja
+101
-41
src/blog.rs
reviewed
···
1
1
use std::{
2
2
-
collections::HashMap,
2
2
+
borrow::Borrow,
3
3
+
collections::{HashMap, VecDeque},
3
4
fmt::{self, Write},
4
5
fs::{self, File, read_to_string},
5
6
io::{BufRead, BufReader, Write as IoWrite},
···
8
9
9
10
use arborium::Highlighter;
10
11
use comrak::{
12
12
+
Arena,
11
13
adapters::{HeadingAdapter, HeadingMeta, SyntaxHighlighterAdapter},
12
12
-
markdown_to_html_with_plugins,
13
13
-
nodes::Sourcepos,
14
14
-
options,
14
14
+
arena_tree::NodeEdge,
15
15
+
html::format_document_with_plugins,
16
16
+
nodes::{AstNode, NodeValue, Sourcepos},
17
17
+
options, parse_document,
15
18
};
16
16
-
use minijinja::context;
19
19
+
use serde::Serialize;
17
20
18
18
-
use crate::{AppState, config::Config, date_time::DateTime, templates::render};
21
21
+
use crate::{
22
22
+
AppState,
23
23
+
config::Config,
24
24
+
date_time::DateTime,
25
25
+
views::{View, blog::BlogShow},
26
26
+
};
19
27
20
28
#[derive(serde::Deserialize, serde::Serialize, Clone)]
21
29
pub struct BlogPost {
···
177
185
}
178
186
}
179
187
180
180
-
pub fn render_post(path: &PathBuf) -> anyhow::Result<String> {
188
188
+
fn text_from_node<'a>(node: &'a AstNode<'a>) -> String {
189
189
+
let mut text = String::new();
190
190
+
for child in node.children() {
191
191
+
if let NodeValue::Text(s) = &child.data.borrow().value {
192
192
+
text.push_str(s.borrow());
193
193
+
} else {
194
194
+
text.push_str(&text_from_node(child));
195
195
+
}
196
196
+
}
197
197
+
198
198
+
text
199
199
+
}
200
200
+
201
201
+
#[derive(Serialize, Clone, Debug)]
202
202
+
pub struct Section {
203
203
+
pub name: String,
204
204
+
pub slug: String,
205
205
+
pub level: u8,
206
206
+
pub subsections: Vec<Section>,
207
207
+
}
208
208
+
209
209
+
fn create_toc<'a>(root: &'a AstNode<'a>) -> Vec<Section> {
210
210
+
let mut sections: Vec<Section> = Vec::new();
211
211
+
212
212
+
for edge in root.traverse() {
213
213
+
if let NodeEdge::Start(node) = edge
214
214
+
&& let NodeValue::Heading(ref heading) = node.data().value
215
215
+
{
216
216
+
let name = text_from_node(node);
217
217
+
let slug = slug::slugify(&name);
218
218
+
let level = heading.level;
219
219
+
220
220
+
let section = Section {
221
221
+
name,
222
222
+
slug,
223
223
+
level,
224
224
+
subsections: Vec::new(),
225
225
+
};
226
226
+
sections.push(section);
227
227
+
}
228
228
+
}
229
229
+
230
230
+
let mut toc: VecDeque<Section> = VecDeque::new();
231
231
+
let mut queue: VecDeque<Section> = VecDeque::from(sections);
232
232
+
while let Some(mut current) = queue.pop_back() {
233
233
+
while let Some(section) = toc.front()
234
234
+
&& section.level > current.level
235
235
+
{
236
236
+
let section = toc.pop_front().unwrap();
237
237
+
current.subsections.push(section);
238
238
+
}
239
239
+
240
240
+
toc.push_front(current);
241
241
+
}
242
242
+
243
243
+
toc.into()
244
244
+
}
245
245
+
246
246
+
pub fn render_post(path: &PathBuf) -> anyhow::Result<(String, Vec<Section>)> {
181
247
let md = read_to_string(path)?;
182
248
let syntax_adapter = SyntaxAdapter::new();
183
249
let heading_adapter = LinkedHeadingAdapter;
184
250
let mut plugins = options::Plugins::default();
185
251
plugins.render.codefence_syntax_highlighter = Some(&syntax_adapter);
186
252
plugins.render.heading_adapter = Some(&heading_adapter);
187
187
-
let body = markdown_to_html_with_plugins(
188
188
-
&md,
189
189
-
&comrak::Options {
190
190
-
extension: options::Extension::builder()
191
191
-
.math_dollars(true)
192
192
-
.multiline_block_quotes(true)
193
193
-
.strikethrough(true)
194
194
-
.superscript(true)
195
195
-
.footnotes(true)
196
196
-
.underline(true)
197
197
-
.greentext(true)
198
198
-
.autolink(true)
199
199
-
.alerts(true)
200
200
-
.table(true)
201
201
-
.math_code(true)
202
202
-
.maybe_front_matter_delimiter(Some("---".to_string()))
203
203
-
.build(),
204
204
-
parse: options::Parse::builder().build(),
205
205
-
render: options::Render::builder().build(),
206
206
-
},
207
207
-
&plugins,
208
208
-
);
253
253
+
let options = comrak::Options {
254
254
+
extension: options::Extension::builder()
255
255
+
.math_dollars(true)
256
256
+
.multiline_block_quotes(true)
257
257
+
.strikethrough(true)
258
258
+
.superscript(true)
259
259
+
.footnotes(true)
260
260
+
.underline(true)
261
261
+
.greentext(true)
262
262
+
.autolink(true)
263
263
+
.alerts(true)
264
264
+
.table(true)
265
265
+
.math_code(true)
266
266
+
.maybe_front_matter_delimiter(Some("---".to_string()))
267
267
+
.build(),
268
268
+
parse: options::Parse::builder().build(),
269
269
+
render: options::Render::builder().build(),
270
270
+
};
271
271
+
let arena = Arena::new();
272
272
+
let root = parse_document(&arena, &md, &options);
273
273
+
let toc = create_toc(root);
274
274
+
275
275
+
let mut body = String::new();
276
276
+
format_document_with_plugins(root, &options, &mut body, &plugins)?;
209
277
210
210
-
Ok(body)
278
278
+
Ok((body, toc))
211
279
}
212
280
213
281
// Render all of our blog posts as fully static HTML files so we can serve them up without having
···
219
287
}
220
288
221
289
for post in state.blog_slugs.values() {
222
222
-
let body = render_post(&post.file_path)?;
223
223
-
let post = post.clone();
224
224
-
let rendered = render(
225
225
-
&state.reloader,
226
226
-
"blog/show",
227
227
-
context! {
228
228
-
post,
229
229
-
body,
230
230
-
},
231
231
-
)?;
290
290
+
let view = BlogShow::new(post);
291
291
+
let rendered = view.render(&state.reloader)?;
232
292
233
233
-
let mut file = File::create(post.cache_path)?;
293
293
+
let mut file = File::create(post.cache_path.clone())?;
234
294
file.write_all(rendered.as_bytes())?;
235
295
}
236
296
+27
-2
src/views/blog/show/mod.rs
reviewed
···
2
2
use minijinja_autoreload::AutoReloader;
3
3
4
4
use crate::{
5
5
-
blog::{BlogPost, render_post},
5
5
+
blog::{BlogPost, Section, render_post},
6
6
templates::render,
7
7
views::View,
8
8
};
···
19
19
20
20
impl<'a> View for BlogShow<'a> {
21
21
fn render(&self, reloader: &AutoReloader) -> anyhow::Result<String> {
22
22
-
let body = render_post(&self.post.file_path)?;
22
22
+
let (body, toc) = render_post(&self.post.file_path)?;
23
23
let has_code = body.contains("<pre class=\"highlighted\">");
24
24
+
let toc_html = render_toc(toc);
24
25
let html = render(
25
26
reloader,
26
27
"views/blog/show",
27
28
context! {
28
29
post => self.post,
29
30
body,
31
31
+
toc_html,
30
32
has_code,
31
33
},
32
34
)?;
···
34
36
Ok(html)
35
37
}
36
38
}
39
39
+
40
40
+
fn render_toc(toc: Vec<Section>) -> String {
41
41
+
if toc.is_empty() {
42
42
+
return "".to_string();
43
43
+
}
44
44
+
45
45
+
let mut markup = vec!["<ul>".to_string()];
46
46
+
for section in toc {
47
47
+
let Section {
48
48
+
name,
49
49
+
slug,
50
50
+
subsections,
51
51
+
..
52
52
+
} = section;
53
53
+
let subsections = render_toc(subsections);
54
54
+
markup.push(format!(
55
55
+
"<li><a href=\"#{slug}\">{name}</a>\n{subsections}</li>"
56
56
+
));
57
57
+
}
58
58
+
59
59
+
markup.push("</ul>".to_string());
60
60
+
markup.join("\n")
61
61
+
}
+1
src/views/blog/show/template.jinja
reviewed
···
13
13
<p>
14
14
<time datetime="{{ post.published_at }}">{{ post.published_at }}</time>
15
15
</p>
16
16
+
{{ toc_html }}
16
17
<div class="blog__body">
17
18
{{ body }}
18
19
</div>