Code highlighting, and a major refactor of the rendering code
1
.gitignore
vendored
|
@ -1,3 +1,4 @@
|
|||
target
|
||||
dist
|
||||
oldicons
|
||||
result
|
||||
|
|
121
Cargo.lock
generated
|
@ -2,6 +2,15 @@
|
|||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android-tzdata"
|
||||
version = "0.1.1"
|
||||
|
@ -131,6 +140,12 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.161"
|
||||
|
@ -160,6 +175,9 @@ dependencies = [
|
|||
"pulldown-latex",
|
||||
"serde",
|
||||
"toml",
|
||||
"tree-sitter",
|
||||
"tree-sitter-highlight",
|
||||
"tree-sitter-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -204,6 +222,35 @@ dependencies = [
|
|||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.214"
|
||||
|
@ -240,16 +287,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.85"
|
||||
name = "streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5023162dfcd14ef8f32034d8bcd4cc5ddc61ef7a247c024a33e24e1f24d21b56"
|
||||
checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.87"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.68"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02dd99dc800bbb97186339685293e1cc5d9df1f8fae2d0aecd9ff1c77efea892"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.68"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7c61ec9a6f64d2793d8a45faba21efbe3ced62a886d44c36a009b2b519b4c7e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.19"
|
||||
|
@ -284,6 +357,48 @@ dependencies = [
|
|||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter"
|
||||
version = "0.24.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9871f16d6cf5c4757dcf30d5d2172a2df6987c510c017bbb7abfb7f9aa24d06"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"regex",
|
||||
"regex-syntax",
|
||||
"streaming-iterator",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-highlight"
|
||||
version = "0.24.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48859aa39513716018d81904220960f415dbb72e071234a721304d20bf245e4c"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"regex",
|
||||
"streaming-iterator",
|
||||
"thiserror",
|
||||
"tree-sitter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-language"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8ddffe35a0e5eeeadf13ff7350af564c6e73993a24db62caee1822b185c2600"
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-rust"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cffbbcb780348fbae8395742ae5b34c1fd794e4085d43aac9f259387f9a84dc8"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.13"
|
||||
|
|
|
@ -14,3 +14,6 @@ toml = "0.8.19"
|
|||
# before switching to the std version.
|
||||
once_cell = "1.20.2"
|
||||
chrono = { version = "0.4.38", features = ["serde"] }
|
||||
tree-sitter = "0.24.3"
|
||||
tree-sitter-rust = "0.23.0"
|
||||
tree-sitter-highlight = "0.24.3"
|
||||
|
|
|
@ -3,6 +3,11 @@
|
|||
created_at = "2024-11-02T05:13:44+01:00"
|
||||
```
|
||||
|
||||
{ role=description }
|
||||
:::
|
||||
This article goes over pretty much everything about the mobile rhythm game "Arcaea". Along the way, you'll learn about everything the game does right, and the areas it could improve on. By the end, I hope you'll come to understand my love for this game.
|
||||
:::
|
||||
|
||||
# Why I love arcaea
|
||||
|
||||
## What is arcaea
|
Before ![]() (image error) Size: 498 KiB After ![]() (image error) Size: 498 KiB ![]() ![]() |
Before ![]() (image error) Size: 32 KiB After ![]() (image error) Size: 32 KiB ![]() ![]() |
Before ![]() (image error) Size: 251 KiB After ![]() (image error) Size: 251 KiB ![]() ![]() |
Before ![]() (image error) Size: 101 KiB After ![]() (image error) Size: 101 KiB ![]() ![]() |
Before ![]() (image error) Size: 1.7 MiB After ![]() (image error) Size: 1.7 MiB ![]() ![]() |
Before (image error) Size: 422 KiB After (image error) Size: 422 KiB |
Before ![]() (image error) Size: 424 KiB After ![]() (image error) Size: 424 KiB ![]() ![]() |
Before ![]() (image error) Size: 86 KiB After ![]() (image error) Size: 86 KiB ![]() ![]() |
Before ![]() (image error) Size: 31 KiB After ![]() (image error) Size: 31 KiB ![]() ![]() |
Before ![]() (image error) Size: 11 KiB After ![]() (image error) Size: 11 KiB ![]() ![]() |
Before ![]() (image error) Size: 202 KiB After ![]() (image error) Size: 202 KiB ![]() ![]() |
Before ![]() (image error) Size: 117 KiB After ![]() (image error) Size: 117 KiB ![]() ![]() |
Before (image error) Size: 121 KiB After (image error) Size: 121 KiB |
Before ![]() (image error) Size: 54 KiB After ![]() (image error) Size: 54 KiB ![]() ![]() |
9
content/echoes/index.dj
Normal file
|
@ -0,0 +1,9 @@
|
|||
# Echoes
|
||||
|
||||
> "Remnants of the One who once dwelled within the Silver of the Sky. They now wander endlessly through the mists of the heavens, at times drawn to lost vessels who seek to hear them. Thence, thou with this knowledge are yet to attain the full knowledge of the Plan, but worry not — the World shall linger much longer still."
|
||||
|
||||
This page contains a list of all my long-form blog posts.
|
||||
|
||||
- [Why I love Arcaea](./arcaea)
|
||||
- [Games I love](./games)
|
||||
- [The rhythm of the moon](./the-realm-s-secrets)
|
|
@ -8,4 +8,8 @@ You can message me in the following places \^-\^
|
|||
|
||||
- via email at <hi@moonythm.dev>
|
||||
- on discord as `@prescientmoon`
|
||||
- on the ~ IRC network as `prescientmoon`
|
||||
- on certain IRC networks as `prescientmoon`
|
||||
|
||||
## Lore
|
||||
|
||||
Some pages will have lore bits sprinkled throughout. English is not my native tongue, so writing in a more archaic-sounding style comes difficult to me, but I'm doing my best. That is, some of the lore snippets might sound utterly meaningless, although I guess that's half the vibe.
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
pkgs.rust-analyzer
|
||||
pkgs.rustfmt
|
||||
pkgs.imagemagick
|
||||
pkgs.http-server
|
||||
];
|
||||
|
||||
buildInputs = with pkgs; [ ];
|
||||
|
|
|
@ -195,3 +195,112 @@ math[display="block"] {
|
|||
/* }}}*/
|
||||
}
|
||||
/* }}}*/
|
||||
|
||||
/* {{{ Light theme */
|
||||
@media (prefers-color-scheme: light) {
|
||||
body {
|
||||
color: #4c4f69;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #eff1f5;
|
||||
}
|
||||
|
||||
pre > code {
|
||||
display: block;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* {{{ Syntax highlighting*/
|
||||
span.variable {
|
||||
color: #4c4f69;
|
||||
}
|
||||
|
||||
span.attribute,
|
||||
span.constant {
|
||||
color: #fe640b;
|
||||
}
|
||||
|
||||
span.comment,
|
||||
span.comment-documentation {
|
||||
color: #9ca0b0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
span.constant-builtin {
|
||||
color: #fe640b;
|
||||
}
|
||||
|
||||
span.constructor {
|
||||
color: #209fb5;
|
||||
}
|
||||
|
||||
span.function,
|
||||
span.function-method {
|
||||
color: #1e66f5;
|
||||
}
|
||||
|
||||
span.function-builtin {
|
||||
color: #fe640b;
|
||||
}
|
||||
|
||||
span.function-macro {
|
||||
color: #179299;
|
||||
}
|
||||
|
||||
span.keyword {
|
||||
color: #8839ef;
|
||||
}
|
||||
|
||||
span.label {
|
||||
color: #209fb5;
|
||||
}
|
||||
|
||||
span.operator {
|
||||
color: #04a5e5;
|
||||
}
|
||||
|
||||
span.property {
|
||||
color: #7287fd;
|
||||
}
|
||||
|
||||
span.punctuation,
|
||||
span.punctuation-delimiter {
|
||||
color: #7c7f93;
|
||||
}
|
||||
|
||||
span.punctuation-bracket {
|
||||
color: #7c7f93;
|
||||
}
|
||||
|
||||
span.string {
|
||||
color: #40a02b;
|
||||
}
|
||||
|
||||
span.string-special {
|
||||
color: #ea76cb;
|
||||
}
|
||||
|
||||
span.tag {
|
||||
color: #8839ef;
|
||||
}
|
||||
|
||||
span.type,
|
||||
span.type-builtin {
|
||||
color: #df8e1d;
|
||||
}
|
||||
|
||||
span.variable {
|
||||
color: #4c4f69;
|
||||
}
|
||||
|
||||
span.variable-builtin {
|
||||
color: #d20f39;
|
||||
}
|
||||
|
||||
span.variable-parameter {
|
||||
color: #e64553;
|
||||
}
|
||||
/* }}}*/
|
||||
}
|
||||
/* }}} */
|
||||
|
|
672
src/html.rs
|
@ -1,16 +1,23 @@
|
|||
use std::collections::HashMap;
|
||||
use std::fmt::Display;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use anyhow::bail;
|
||||
use chrono::DateTime;
|
||||
use chrono::TimeZone;
|
||||
use jotdown::Alignment;
|
||||
use jotdown::AttributeValue;
|
||||
use jotdown::Container;
|
||||
use jotdown::Event;
|
||||
use jotdown::LinkType;
|
||||
use jotdown::ListKind;
|
||||
use jotdown::OrderedListNumbering::*;
|
||||
use jotdown::SpanLinkType;
|
||||
use tree_sitter::Language;
|
||||
use tree_sitter_highlight::Highlight;
|
||||
use tree_sitter_highlight::HighlightConfiguration;
|
||||
use tree_sitter_highlight::HighlightEvent;
|
||||
use tree_sitter_highlight::Highlighter;
|
||||
|
||||
use crate::metadata::PageMetadata;
|
||||
use crate::metadata::PageRoute;
|
||||
|
@ -36,7 +43,7 @@ pub struct Writer<'s> {
|
|||
list_tightness: Vec<bool>,
|
||||
states: Vec<State<'s>>,
|
||||
footnotes: Footnotes<'s>,
|
||||
metadata: Option<&'s PageMetadata>,
|
||||
metadata: Option<&'s PageMetadata<'s>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
@ -45,8 +52,10 @@ enum State<'s> {
|
|||
Ignore,
|
||||
Raw,
|
||||
Math(bool),
|
||||
CodeBlock(String),
|
||||
Aside(TemplateRenderer<'s>),
|
||||
Article(TemplateRenderer<'s>),
|
||||
Footnote(Vec<jotdown::Event<'s>>),
|
||||
}
|
||||
|
||||
impl<'s> Writer<'s> {
|
||||
|
@ -65,80 +74,72 @@ impl<'s> Writer<'s> {
|
|||
e: &Event<'s>,
|
||||
mut out: impl std::fmt::Write,
|
||||
) -> anyhow::Result<()> {
|
||||
// {{{ Handle footnotes
|
||||
if let Event::Start(Container::Footnote { label }, ..) = e {
|
||||
self.footnotes.start(label, Vec::new());
|
||||
return Ok(());
|
||||
} else if let Some(events) = self.footnotes.current() {
|
||||
if matches!(e, Event::End(Container::Footnote { .. })) {
|
||||
self.footnotes.end();
|
||||
} else {
|
||||
events.push(e.clone());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
// }}}
|
||||
// {{{ Handle important state changes
|
||||
match e {
|
||||
Event::Start(Container::LinkDefinition { .. }, ..) => {
|
||||
self.states.push(State::Ignore);
|
||||
return Ok(());
|
||||
}
|
||||
Event::End(Container::LinkDefinition { .. }) => {
|
||||
// Sanity check
|
||||
assert!(matches!(self.states.last(), Some(State::Ignore)));
|
||||
self.states.pop();
|
||||
}
|
||||
|
||||
Event::Start(Container::RawBlock { format } | Container::RawInline { format }, ..) => {
|
||||
if format == &"html" {
|
||||
self.states.push(State::Raw);
|
||||
} else {
|
||||
self.states.push(State::Ignore);
|
||||
// {{{ Handle "footnote" states
|
||||
if matches!(self.states.last(), Some(State::Footnote(_))) {
|
||||
if let Event::End(Container::Footnote { label }) = e {
|
||||
let Some(State::Footnote(events)) = self.states.pop() else {
|
||||
unreachable!()
|
||||
};
|
||||
|
||||
self.footnotes.insert(label, events);
|
||||
} else {
|
||||
let Some(State::Footnote(events)) = self.states.last_mut() else {
|
||||
unreachable!()
|
||||
};
|
||||
|
||||
events.push(e.clone());
|
||||
return Ok(());
|
||||
}
|
||||
Event::End(Container::RawBlock { .. } | Container::RawInline { .. }) => {
|
||||
// Sanity check
|
||||
assert!(matches!(
|
||||
self.states.last(),
|
||||
Some(State::Raw | State::Ignore)
|
||||
));
|
||||
|
||||
self.states.pop();
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
// }}}
|
||||
|
||||
if matches!(self.states.last(), Some(State::Ignore)) {
|
||||
return Ok(());
|
||||
// {{{ Handle "text-only" states
|
||||
if matches!(self.states.last(), Some(State::TextOnly)) {
|
||||
match e {
|
||||
Event::End(Container::Image(..)) => {
|
||||
self.states.pop();
|
||||
}
|
||||
Event::Str(s) => write!(out, "{}", Escaped(s))?,
|
||||
_ => return Ok(()),
|
||||
}
|
||||
}
|
||||
// }}}
|
||||
// {{{ Handle "ignore" states
|
||||
if matches!(self.states.last(), Some(State::Ignore)) {
|
||||
match e {
|
||||
Event::End(
|
||||
Container::RawBlock { .. }
|
||||
| Container::RawInline { .. }
|
||||
| Container::LinkDefinition { .. }
|
||||
| Container::Div { .. },
|
||||
) => {
|
||||
self.states.pop();
|
||||
return Ok(());
|
||||
}
|
||||
_ => return Ok(()),
|
||||
}
|
||||
}
|
||||
// }}}
|
||||
|
||||
match e {
|
||||
// {{{ Container start
|
||||
Event::Start(c, attrs) => {
|
||||
if matches!(self.states.last(), Some(&State::TextOnly)) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match &c {
|
||||
Container::RawBlock { .. } => {}
|
||||
Container::RawInline { .. } => unreachable!(),
|
||||
Container::Footnote { .. } => unreachable!(),
|
||||
// {{{ Section
|
||||
Container::Section { id } => match self.metadata {
|
||||
Some(meta)
|
||||
if &meta.title.id == id && matches!(meta.route, PageRoute::Post(_)) =>
|
||||
if meta.title.id == *id && matches!(meta.route, PageRoute::Post(_)) =>
|
||||
{
|
||||
let renderer = template!("templates/post.html", &mut out)?;
|
||||
// Sanity check
|
||||
let mut renderer = template!("templates/post.html", &mut out)?;
|
||||
|
||||
assert_eq!(renderer.current(), Some("attrs"));
|
||||
write!(out, "{}", Attr("aria-labeledby", id))?;
|
||||
renderer.next(&mut out)?;
|
||||
|
||||
self.states.push(State::Article(renderer));
|
||||
}
|
||||
_ => out.write_str("<section")?,
|
||||
_ => {
|
||||
write!(out, "<section {}>", Attr("aria-labeledby", id))?;
|
||||
}
|
||||
},
|
||||
// }}}
|
||||
// {{{ Aside
|
||||
|
@ -154,27 +155,25 @@ impl<'s> Writer<'s> {
|
|||
};
|
||||
|
||||
while let Some(label) = renderer.current() {
|
||||
if label == "character" {
|
||||
let character = attrs.get_value("character").ok_or_else(|| {
|
||||
anyhow!("Cannot find `character` attribute on `aside` element")
|
||||
})?;
|
||||
match label {
|
||||
"character" => {
|
||||
let character =
|
||||
attrs.get_value("character").ok_or_else(|| {
|
||||
anyhow!("Cannot find `character` attribute on `aside` element")
|
||||
})?;
|
||||
|
||||
character
|
||||
.parts()
|
||||
.try_for_each(|part| write_attr_contents(part, &mut out))?;
|
||||
renderer.next(&mut out)?;
|
||||
} else if label == "title" {
|
||||
let title = attrs.get_value("title").ok_or_else(|| {
|
||||
anyhow!("Cannot find `title` attribute on `aside` element")
|
||||
})?;
|
||||
write_attribute(&mut out, &character)?;
|
||||
}
|
||||
"title" => {
|
||||
let title = attrs.get_value("title").ok_or_else(|| {
|
||||
anyhow!("Cannot find `title` attribute on `aside` element")
|
||||
})?;
|
||||
|
||||
title
|
||||
.parts()
|
||||
.try_for_each(|part| write_attr_contents(part, &mut out))?;
|
||||
renderer.next(&mut out)?;
|
||||
} else {
|
||||
break;
|
||||
write_attribute(&mut out, &title)?;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
renderer.next(&mut out)?;
|
||||
}
|
||||
|
||||
self.states.push(State::Aside(renderer));
|
||||
|
@ -184,7 +183,7 @@ impl<'s> Writer<'s> {
|
|||
Container::List { kind, tight } => {
|
||||
self.list_tightness.push(*tight);
|
||||
match kind {
|
||||
ListKind::Unordered(..) => out.write_str("<ul")?,
|
||||
ListKind::Unordered(..) => out.write_str("<ul>")?,
|
||||
ListKind::Ordered {
|
||||
numbering, start, ..
|
||||
} => {
|
||||
|
@ -202,6 +201,8 @@ impl<'s> Writer<'s> {
|
|||
} {
|
||||
write!(out, r#" type="{}""#, ty)?;
|
||||
}
|
||||
|
||||
write!(out, ">")?;
|
||||
}
|
||||
ListKind::Task(_) => bail!("Task lists are not supported"),
|
||||
}
|
||||
|
@ -210,179 +211,130 @@ impl<'s> Writer<'s> {
|
|||
// {{{ Link
|
||||
Container::Link(dst, ty) => {
|
||||
if matches!(ty, LinkType::Span(SpanLinkType::Unresolved)) {
|
||||
out.write_str("<a")?;
|
||||
out.write_str("<a>")?;
|
||||
} else {
|
||||
out.write_str(r#"<a href=""#)?;
|
||||
if matches!(ty, LinkType::Email) {
|
||||
out.write_str("mailto:")?;
|
||||
}
|
||||
write_attr_contents(dst, &mut out)?;
|
||||
out.write_char('"')?;
|
||||
let prefix = if matches!(ty, LinkType::Email) {
|
||||
"mailto:"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
write!(out, r#"<a href="{prefix}{}"">"#, Escaped(dst))?;
|
||||
}
|
||||
}
|
||||
// }}}
|
||||
// {{{ Table cell
|
||||
Container::TableCell {
|
||||
head, alignment, ..
|
||||
} => {
|
||||
if *head {
|
||||
out.write_str("<td")?;
|
||||
} else {
|
||||
out.write_str("<th")?;
|
||||
}
|
||||
|
||||
if !matches!(alignment, Alignment::Unspecified) {
|
||||
// TODO: move this to css
|
||||
let a = match alignment {
|
||||
Alignment::Unspecified => unreachable!(),
|
||||
Alignment::Left => "left",
|
||||
Alignment::Center => "center",
|
||||
Alignment::Right => "right",
|
||||
};
|
||||
|
||||
write!(out, r#" style="text-align: {};""#, a)?;
|
||||
}
|
||||
|
||||
write!(out, ">")?;
|
||||
}
|
||||
// }}}
|
||||
// {{{ Heading
|
||||
Container::Heading { level, id, .. } => {
|
||||
write!(
|
||||
out,
|
||||
r##"<h{level} id="{}"><a href="#{}">◇</a> "##,
|
||||
Escaped(id),
|
||||
Escaped(id)
|
||||
)?;
|
||||
}
|
||||
// }}}
|
||||
// {{{ Paragraph
|
||||
Container::Paragraph => {
|
||||
if self.list_tightness.last() == Some(&true) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
out.write_str("<p")?;
|
||||
out.write_str("<p>")?;
|
||||
}
|
||||
// }}}
|
||||
Container::Blockquote => out.write_str("<blockquote")?,
|
||||
Container::ListItem { .. } => out.write_str("<li")?,
|
||||
Container::DescriptionList => out.write_str("<dl")?,
|
||||
Container::DescriptionDetails => out.write_str("<dd")?,
|
||||
Container::Table => out.write_str("<table")?,
|
||||
Container::TableRow { .. } => out.write_str("<tr")?,
|
||||
Container::Div { .. } => out.write_str("<div")?,
|
||||
Container::Heading { level, .. } => write!(out, "<h{}", level)?,
|
||||
Container::TableCell { head: false, .. } => out.write_str("<td")?,
|
||||
Container::TableCell { head: true, .. } => out.write_str("<th")?,
|
||||
Container::Caption => out.write_str("<caption")?,
|
||||
Container::Image(..) => out.write_str("<img")?,
|
||||
Container::DescriptionTerm => out.write_str("<dt")?,
|
||||
Container::CodeBlock { .. } => out.write_str("<pre")?,
|
||||
Container::Span | Container::Math { .. } => out.write_str("<span")?,
|
||||
Container::Verbatim => out.write_str("<code")?,
|
||||
Container::Subscript => out.write_str("<sub")?,
|
||||
Container::Superscript => out.write_str("<sup")?,
|
||||
Container::Insert => out.write_str("<ins")?,
|
||||
Container::Delete => out.write_str("<del")?,
|
||||
Container::Strong => out.write_str("<strong")?,
|
||||
Container::Emphasis => out.write_str("<em")?,
|
||||
Container::Mark => out.write_str("<mark")?,
|
||||
// {{{ Div
|
||||
Container::Div { class } => {
|
||||
if attrs
|
||||
.get_value("role")
|
||||
.map_or(false, |role| format!("{role}") == "description")
|
||||
{
|
||||
self.states.push(State::Ignore);
|
||||
} else {
|
||||
write!(out, "<div{}>", Attr("class", class))?;
|
||||
}
|
||||
}
|
||||
// }}}
|
||||
// {{{ Raw block
|
||||
Container::RawBlock { format } | Container::RawInline { format } => {
|
||||
if format == &"html" {
|
||||
self.states.push(State::Raw);
|
||||
} else {
|
||||
self.states.push(State::Ignore);
|
||||
};
|
||||
}
|
||||
// }}}
|
||||
Container::CodeBlock { .. } => {
|
||||
self.states.push(State::CodeBlock(String::new()));
|
||||
out.write_str("<pre><code>")?;
|
||||
}
|
||||
Container::Math { display } => {
|
||||
self.states.push(State::Math(*display));
|
||||
out.write_str("<math>")?;
|
||||
}
|
||||
Container::Image(_, _) => {
|
||||
self.states.push(State::TextOnly);
|
||||
out.write_str(r#"<img alt=""#)?;
|
||||
}
|
||||
Container::Footnote { .. } => self.states.push(State::Footnote(Vec::new())),
|
||||
Container::Blockquote => out.write_str("<blockquote>")?,
|
||||
Container::ListItem { .. } => out.write_str("<li>")?,
|
||||
Container::DescriptionList => out.write_str("<dl>")?,
|
||||
Container::DescriptionDetails => out.write_str("<dd>")?,
|
||||
Container::Table => out.write_str("<table>")?,
|
||||
Container::TableRow { .. } => out.write_str("<tr>")?,
|
||||
Container::Caption => out.write_str("<caption>")?,
|
||||
Container::DescriptionTerm => out.write_str("<dt>")?,
|
||||
Container::Span => out.write_str("<span>")?,
|
||||
Container::Verbatim => out.write_str("<code>")?,
|
||||
Container::Subscript => out.write_str("<sub>")?,
|
||||
Container::Superscript => out.write_str("<sup>")?,
|
||||
Container::Insert => out.write_str("<ins>")?,
|
||||
Container::Delete => out.write_str("<del>")?,
|
||||
Container::Strong => out.write_str("<strong>")?,
|
||||
Container::Emphasis => out.write_str("<em>")?,
|
||||
Container::Mark => out.write_str("<mark>")?,
|
||||
Container::LinkDefinition { .. } => return Ok(()),
|
||||
e => bail!("DJot element {e:?} is not supported"),
|
||||
}
|
||||
|
||||
// {{{ Decide whether this element supports attributes
|
||||
let mut write_attr_contentsibs = true;
|
||||
if matches!(
|
||||
c,
|
||||
Container::Div {
|
||||
class: "aside" | "long-aside" | "char-aside"
|
||||
}
|
||||
) {
|
||||
write_attr_contentsibs = false;
|
||||
}
|
||||
// }}}
|
||||
|
||||
if write_attr_contentsibs {
|
||||
// {{{ Write attributes
|
||||
let mut id_written = false;
|
||||
let mut class_written = false;
|
||||
|
||||
if write_attr_contentsibs {
|
||||
for (a, v) in attrs.unique_pairs() {
|
||||
let is_class = a == "class";
|
||||
let is_id = a == "id";
|
||||
if (!is_id || !id_written) && (!is_class || !class_written) {
|
||||
write!(out, r#" {}=""#, a)?;
|
||||
v.parts()
|
||||
.try_for_each(|part| write_attr_contents(part, &mut out))?;
|
||||
out.write_char('"')?;
|
||||
|
||||
id_written |= is_id;
|
||||
class_written |= is_class;
|
||||
};
|
||||
}
|
||||
}
|
||||
// }}}
|
||||
// {{{ Write default ids/classes
|
||||
match c {
|
||||
Container::Heading { id, .. } if !id_written => {
|
||||
write_attr("id", id, &mut out)?;
|
||||
}
|
||||
Container::Section { id, .. } => {
|
||||
write_attr("aria-labeledby", id, &mut out)?;
|
||||
}
|
||||
Container::Div { class } if !class.is_empty() && !class_written => {
|
||||
write_attr("class", class, &mut out)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// }}}
|
||||
// {{{ Write special attributes
|
||||
match c {
|
||||
// {{{ Write css for aligning table cell text
|
||||
Container::TableCell { alignment, .. }
|
||||
if !matches!(alignment, Alignment::Unspecified) =>
|
||||
{
|
||||
let a = match alignment {
|
||||
Alignment::Unspecified => unreachable!(),
|
||||
Alignment::Left => "left",
|
||||
Alignment::Center => "center",
|
||||
Alignment::Right => "right",
|
||||
};
|
||||
write!(out, r#" style="text-align: {};">"#, a)?;
|
||||
}
|
||||
// }}}
|
||||
// {{{ Write language for codeblock
|
||||
Container::CodeBlock { language } => {
|
||||
if language.is_empty() {
|
||||
out.write_str("><code>")?;
|
||||
} else {
|
||||
out.write_str(r#"><code class="language-"#)?;
|
||||
write_attr_contents(language, &mut out)?;
|
||||
out.write_str(r#"">"#)?;
|
||||
}
|
||||
}
|
||||
// }}}
|
||||
Container::Image(..) => out.write_str(r#" alt=""#)?,
|
||||
Container::Math { display } => {
|
||||
out.write_str(r#">"#)?;
|
||||
self.states.push(State::Math(*display));
|
||||
}
|
||||
_ => match self.states.last_mut() {
|
||||
Some(State::Article(renderer))
|
||||
if renderer.current() == Some("attrs") =>
|
||||
{
|
||||
renderer.next(&mut out)?;
|
||||
}
|
||||
_ => out.write_char('>')?,
|
||||
},
|
||||
}
|
||||
// }}}
|
||||
}
|
||||
|
||||
// {{{ Post-start effects
|
||||
match &c {
|
||||
Container::Heading { id, .. } => {
|
||||
out.write_str(r##"<a href="#"##)?;
|
||||
write_attr_contents(id, &mut out)?;
|
||||
out.write_str(r#"">◇</a> "#)?;
|
||||
}
|
||||
Container::Image(..) => {
|
||||
self.states.push(State::TextOnly);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// }}}
|
||||
}
|
||||
// }}}
|
||||
// {{{ Container end
|
||||
Event::End(c) => {
|
||||
// {{{ Pre-end effects
|
||||
match &c {
|
||||
Container::Image(..) => {
|
||||
match c {
|
||||
Container::Footnote { .. } => unreachable!(),
|
||||
// {{{ Raw block
|
||||
Container::RawBlock { .. } | Container::RawInline { .. } => {
|
||||
// Sanity check
|
||||
assert!(matches!(self.states.last(), Some(State::TextOnly)));
|
||||
assert!(matches!(self.states.last(), Some(State::Raw)));
|
||||
|
||||
self.states.pop();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// }}}
|
||||
|
||||
if matches!(self.states.last(), Some(State::TextOnly)) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match c {
|
||||
Container::RawBlock { .. } => {}
|
||||
Container::RawInline { .. } => unreachable!(),
|
||||
Container::Footnote { .. } => unreachable!(),
|
||||
// }}}
|
||||
// {{{ List
|
||||
Container::List { kind, .. } => {
|
||||
self.list_tightness.pop();
|
||||
|
@ -403,28 +355,18 @@ impl<'s> Writer<'s> {
|
|||
out.write_str("</p>")?;
|
||||
}
|
||||
// }}}
|
||||
// {{{ Image
|
||||
Container::Image(src, ..) => {
|
||||
if !src.is_empty() {
|
||||
out.write_str(r#"" src=""#)?;
|
||||
write_attr_contents(src, &mut out)?;
|
||||
}
|
||||
|
||||
out.write_str(r#"">"#)?;
|
||||
}
|
||||
// }}}
|
||||
// {{{ Math
|
||||
Container::Math { .. } => {
|
||||
// Sanity check
|
||||
assert!(matches!(self.states.last(), Some(State::Math(_))));
|
||||
self.states.pop();
|
||||
out.write_str(r#"</span>"#)?;
|
||||
out.write_str(r#"</math>"#)?;
|
||||
}
|
||||
// }}}
|
||||
// {{{ Section
|
||||
Container::Section { id, .. } => match self.metadata {
|
||||
Some(meta)
|
||||
if &meta.title.id == id
|
||||
if meta.title.id == *id
|
||||
&& matches!(self.states.last(), Some(State::Article(_))) =>
|
||||
{
|
||||
let Some(State::Article(renderer)) = self.states.pop() else {
|
||||
|
@ -445,8 +387,6 @@ impl<'s> Writer<'s> {
|
|||
panic!("Finished `aside` element without being in the `Aside` state.")
|
||||
};
|
||||
|
||||
// Sanity check
|
||||
assert_eq!(renderer.current(), Some("content"));
|
||||
renderer.finish(&mut out)?;
|
||||
}
|
||||
// }}}
|
||||
|
@ -462,42 +402,42 @@ impl<'s> Writer<'s> {
|
|||
while let Some(label) = renderer.next(&mut out)? {
|
||||
if label == "posted_on" {
|
||||
if let Some(d) = meta.config.created_at {
|
||||
write!(&mut out, "Posted on ")?;
|
||||
write_datetime(&d, &mut out)?;
|
||||
write!(out, "Posted on ")?;
|
||||
write_datetime(&mut out, &d)?;
|
||||
} else {
|
||||
write!(&mut out, "Being conjured by ")?;
|
||||
write!(out, "Being conjured by ")?;
|
||||
}
|
||||
} else if label == "updated_on" {
|
||||
write_datetime(&meta.last_modified, &mut out)?;
|
||||
write_datetime(&mut out, &meta.last_modified)?;
|
||||
} else if label == "word_count" {
|
||||
let wc = meta.word_count;
|
||||
if wc < 400 {
|
||||
write!(&mut out, "{}", wc)?;
|
||||
write!(out, "{}", wc)?;
|
||||
} else if wc < 1000 {
|
||||
write!(&mut out, "{}", wc / 10 * 10)?;
|
||||
write!(out, "{}", wc / 10 * 10)?;
|
||||
} else if wc < 2000 {
|
||||
write!(&mut out, "{}", wc / 100 * 100)?;
|
||||
write!(out, "{}", wc / 100 * 100)?;
|
||||
} else {
|
||||
write!(&mut out, "{} thousand", wc / 1000)?;
|
||||
write!(out, "{} thousand", wc / 1000)?;
|
||||
}
|
||||
} else if label == "reading_duration" {
|
||||
let minutes = meta.word_count / 200;
|
||||
if minutes == 0 {
|
||||
let seconds = meta.word_count * 60 / 200;
|
||||
write!(&mut out, "very short {seconds} second")?;
|
||||
write!(out, "very short {seconds} second")?;
|
||||
} else if minutes < 10 {
|
||||
write!(&mut out, "short {minutes} minute")?;
|
||||
write!(out, "short {minutes} minute")?;
|
||||
} else if minutes < 20 {
|
||||
write!(&mut out, "somewhat short {minutes} minute")?;
|
||||
write!(out, "somewhat short {minutes} minute")?;
|
||||
} else if minutes < 30 {
|
||||
write!(&mut out, "somewhat long {minutes}")?;
|
||||
write!(out, "somewhat long {minutes}")?;
|
||||
} else if minutes < 60 {
|
||||
write!(&mut out, "long {minutes}")?;
|
||||
write!(out, "long {minutes}")?;
|
||||
} else {
|
||||
let hours = minutes / 60;
|
||||
let minutes = minutes % 60;
|
||||
write!(
|
||||
&mut out,
|
||||
out,
|
||||
"very long {hours} hour and {minutes} minute"
|
||||
)?;
|
||||
}
|
||||
|
@ -509,6 +449,8 @@ impl<'s> Writer<'s> {
|
|||
}
|
||||
// }}}
|
||||
}
|
||||
Container::Image(src, ..) => write!(out, r#" {}>"#, Attr("src", src))?,
|
||||
Container::LinkDefinition { .. } => self.states.push(State::Ignore),
|
||||
Container::Blockquote => out.write_str("</blockquote>")?,
|
||||
Container::ListItem { .. } => out.write_str("</li>")?,
|
||||
Container::DescriptionList => out.write_str("</dl>")?,
|
||||
|
@ -520,7 +462,87 @@ impl<'s> Writer<'s> {
|
|||
Container::TableCell { head: true, .. } => out.write_str("</th>")?,
|
||||
Container::Caption => out.write_str("</caption>")?,
|
||||
Container::DescriptionTerm => out.write_str("</dt>")?,
|
||||
Container::CodeBlock { .. } => out.write_str("</code></pre>")?,
|
||||
// {{{ Syntax highlighting
|
||||
Container::CodeBlock { language } => {
|
||||
let Some(State::CodeBlock(buffer)) = self.states.pop() else {
|
||||
panic!("Arrived at end of code block without being in the approriate state.");
|
||||
};
|
||||
|
||||
if *language == "rust" {
|
||||
let mut highlighter = Highlighter::new();
|
||||
let language = Language::new(tree_sitter_rust::LANGUAGE);
|
||||
|
||||
let mut config = HighlightConfiguration::new(
|
||||
language,
|
||||
"rust",
|
||||
tree_sitter_rust::HIGHLIGHTS_QUERY,
|
||||
tree_sitter_rust::INJECTIONS_QUERY,
|
||||
"",
|
||||
)?;
|
||||
|
||||
let highlight_names = [
|
||||
"attribute",
|
||||
"comment",
|
||||
"comment.documentation",
|
||||
"constant",
|
||||
"constant.builtin",
|
||||
"constructor",
|
||||
"function",
|
||||
"function.builtin",
|
||||
"function.macro",
|
||||
"function.method",
|
||||
"keyword",
|
||||
"label",
|
||||
"operator",
|
||||
"property",
|
||||
"punctuation",
|
||||
"punctuation.bracket",
|
||||
"punctuation.delimiter",
|
||||
"string",
|
||||
"string.special",
|
||||
"tag",
|
||||
"type",
|
||||
"type.builtin",
|
||||
"variable",
|
||||
"variable.builtin",
|
||||
"variable.parameter",
|
||||
];
|
||||
|
||||
let highlight_classes = highlight_names
|
||||
.iter()
|
||||
.map(|s| s.replace(".", "-"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
config.configure(&highlight_names);
|
||||
|
||||
let highlights =
|
||||
highlighter
|
||||
.highlight(&config, buffer.as_bytes(), None, |_| None)?;
|
||||
|
||||
for event in highlights {
|
||||
match event? {
|
||||
HighlightEvent::Source { start, end } => {
|
||||
write!(out, "{}", Escaped(&buffer[start..end]))?;
|
||||
}
|
||||
HighlightEvent::HighlightStart(Highlight(index)) => {
|
||||
write!(
|
||||
&mut out,
|
||||
r#"<span class="{}">"#,
|
||||
highlight_classes[index]
|
||||
)?;
|
||||
}
|
||||
HighlightEvent::HighlightEnd => {
|
||||
write!(&mut out, r#"</span>"#)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
write!(out, "{}", Escaped(&buffer))?;
|
||||
}
|
||||
|
||||
out.write_str("</code></pre>")?
|
||||
}
|
||||
// }}}
|
||||
Container::Span => out.write_str("</span>")?,
|
||||
Container::Link(..) => out.write_str("</a>")?,
|
||||
Container::Verbatim => out.write_str("</code>")?,
|
||||
|
@ -531,15 +553,14 @@ impl<'s> Writer<'s> {
|
|||
Container::Strong => out.write_str("</strong>")?,
|
||||
Container::Emphasis => out.write_str("</em>")?,
|
||||
Container::Mark => out.write_str("</mark>")?,
|
||||
Container::LinkDefinition { .. } => unreachable!(),
|
||||
e => bail!("DJot element {e:?} is not supported"),
|
||||
}
|
||||
}
|
||||
// }}}
|
||||
// {{{ Raw string
|
||||
Event::Str(s) => match self.states.last() {
|
||||
Some(State::TextOnly) => write_attr_contents(s, &mut out)?,
|
||||
Event::Str(s) => match self.states.last_mut() {
|
||||
Some(State::Raw) => out.write_str(s)?,
|
||||
Some(State::CodeBlock(buffer)) => buffer.push_str(s),
|
||||
// {{{ Math
|
||||
Some(State::Math(display)) => {
|
||||
let config = pulldown_latex::RenderConfig {
|
||||
|
@ -564,7 +585,7 @@ impl<'s> Writer<'s> {
|
|||
out.write_str(&mathml)?;
|
||||
}
|
||||
// }}}
|
||||
_ => write_escape(s, false, &mut out)?,
|
||||
_ => write!(out, "{}", Escaped(s))?,
|
||||
},
|
||||
// }}}
|
||||
// {{{ Footnote reference
|
||||
|
@ -573,8 +594,13 @@ impl<'s> Writer<'s> {
|
|||
if !matches!(self.states.last(), Some(State::TextOnly)) {
|
||||
write!(
|
||||
out,
|
||||
r##"<sup><a id="fnref{}" href="#fn{}" role="doc-noteref">{}</a></sup>"##,
|
||||
number, number, number
|
||||
r##"
|
||||
<sup>
|
||||
<a id="fnref{number}" href="#fn{number}" role="doc-noteref">
|
||||
{number}
|
||||
</a>
|
||||
</sup>
|
||||
"##
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
@ -592,18 +618,7 @@ impl<'s> Writer<'s> {
|
|||
Event::Hardbreak => out.write_str("<br>")?,
|
||||
Event::Softbreak => out.write_char('\n')?,
|
||||
// }}}
|
||||
// {{{ Thematic break
|
||||
Event::ThematicBreak(attrs) => {
|
||||
out.write_str("<hr")?;
|
||||
for (a, v) in attrs.unique_pairs() {
|
||||
write!(out, r#" {}=""#, a)?;
|
||||
v.parts()
|
||||
.try_for_each(|part| write_attr_contents(part, &mut out))?;
|
||||
out.write_char('"')?;
|
||||
}
|
||||
out.write_str(">")?;
|
||||
}
|
||||
// }}}
|
||||
Event::ThematicBreak(_) => out.write_str("<hr>")?,
|
||||
Event::Escape | Event::Blankline | Event::Attributes(..) => {}
|
||||
}
|
||||
|
||||
|
@ -617,7 +632,7 @@ impl<'s> Writer<'s> {
|
|||
out.write_str("<section role=\"doc-endnotes\"><hr><ol>")?;
|
||||
|
||||
while let Some((number, events)) = self.footnotes.next() {
|
||||
write!(out, "<li id=\"fn{}\">", number)?;
|
||||
write!(out, r#"<li id="fn{number}">"#)?;
|
||||
|
||||
for e in events.iter().flatten() {
|
||||
self.render_event(e, &mut out)?;
|
||||
|
@ -625,9 +640,14 @@ impl<'s> Writer<'s> {
|
|||
|
||||
write!(
|
||||
out,
|
||||
"<a href=\"#fnref{}\" role=\"doc-backlink\">Return to content \u{21A9}\u{FE0E}</a></li>",
|
||||
number,
|
||||
r##"
|
||||
<a href="#fnref{number}" role="doc-backlink">
|
||||
Return to content
|
||||
</a></li>
|
||||
"##,
|
||||
)?;
|
||||
|
||||
println!("\u{21A9}\u{FE0E}");
|
||||
}
|
||||
|
||||
out.write_str("</ol></section>")?;
|
||||
|
@ -638,50 +658,52 @@ impl<'s> Writer<'s> {
|
|||
// }}}
|
||||
}
|
||||
|
||||
// {{{ Writing helpers
|
||||
#[inline]
|
||||
fn write_attr_contents(s: &str, out: impl std::fmt::Write) -> std::fmt::Result {
|
||||
write_escape(s, true, out)
|
||||
}
|
||||
// {{{ HTMl escaper
|
||||
pub struct Escaped<'a>(&'a str);
|
||||
|
||||
#[inline]
|
||||
fn write_attr(attr: &str, content: &str, mut out: impl std::fmt::Write) -> std::fmt::Result {
|
||||
write!(&mut out, r#" {attr}=""#)?;
|
||||
write_attr_contents(content, &mut out)?;
|
||||
out.write_char('"')?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_escape(
|
||||
mut s: &str,
|
||||
escape_quotes: bool,
|
||||
mut out: impl std::fmt::Write,
|
||||
) -> std::fmt::Result {
|
||||
let mut ent = "";
|
||||
while let Some(i) = s.find(|c| {
|
||||
match c {
|
||||
'<' => Some("<"),
|
||||
'>' => Some(">"),
|
||||
'&' => Some("&"),
|
||||
'"' if escape_quotes => Some("""),
|
||||
_ => None,
|
||||
impl<'s> Display for Escaped<'s> {
|
||||
fn fmt(&self, out: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let mut s = self.0;
|
||||
let mut ent = "";
|
||||
while let Some(i) = s.find(|c| {
|
||||
match c {
|
||||
'<' => Some("<"),
|
||||
'>' => Some(">"),
|
||||
'&' => Some("&"),
|
||||
'"' => Some("""),
|
||||
_ => None,
|
||||
}
|
||||
.map_or(false, |s| {
|
||||
ent = s;
|
||||
true
|
||||
})
|
||||
}) {
|
||||
out.write_str(&s[..i])?;
|
||||
out.write_str(ent)?;
|
||||
s = &s[i + 1..];
|
||||
}
|
||||
.map_or(false, |s| {
|
||||
ent = s;
|
||||
true
|
||||
})
|
||||
}) {
|
||||
out.write_str(&s[..i])?;
|
||||
out.write_str(ent)?;
|
||||
s = &s[i + 1..];
|
||||
out.write_str(s)
|
||||
}
|
||||
out.write_str(s)
|
||||
}
|
||||
// }}}
|
||||
// {{{ Render attributes
|
||||
pub struct Attr<'a>(&'static str, &'a str);
|
||||
|
||||
impl<'s> Display for Attr<'s> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
if !self.1.is_empty() {
|
||||
write!(f, r#" {}="{}""#, self.0, Escaped(self.1))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
// }}}
|
||||
// {{{ Render datetimes
|
||||
#[inline]
|
||||
fn write_datetime<T: TimeZone>(
|
||||
datetime: &DateTime<T>,
|
||||
mut out: impl std::fmt::Write,
|
||||
datetime: &DateTime<T>,
|
||||
) -> std::fmt::Result {
|
||||
let datetime = datetime.to_utc();
|
||||
write!(
|
||||
|
@ -692,6 +714,13 @@ fn write_datetime<T: TimeZone>(
|
|||
)
|
||||
}
|
||||
// }}}
|
||||
// {{{ Jotdown attribute helpers
|
||||
#[inline]
|
||||
fn write_attribute(mut out: impl std::fmt::Write, attr: &AttributeValue) -> std::fmt::Result {
|
||||
attr.parts()
|
||||
.try_for_each(|part| write!(out, "{}", Escaped(part)))
|
||||
}
|
||||
// }}}
|
||||
// {{{ Footnotes
|
||||
/// Helper to aggregate footnotes for rendering at the end of the document. It will cache footnote
|
||||
/// events until they should be emitted at the end.
|
||||
|
@ -700,8 +729,6 @@ fn write_datetime<T: TimeZone>(
|
|||
/// the order they were first referenced.
|
||||
#[derive(Default)]
|
||||
struct Footnotes<'s> {
|
||||
/// Stack of current open footnotes, with label and staging buffer.
|
||||
open: Vec<(&'s str, Vec<Event<'s>>)>,
|
||||
/// Footnote references in the order they were first encountered.
|
||||
references: Vec<&'s str>,
|
||||
/// Events for each footnote.
|
||||
|
@ -730,20 +757,9 @@ impl<'s> Footnotes<'s> {
|
|||
)
|
||||
}
|
||||
|
||||
/// Start aggregating a footnote.
|
||||
fn start(&mut self, label: &'s str, events: Vec<Event<'s>>) {
|
||||
self.open.push((label, events));
|
||||
}
|
||||
|
||||
/// Obtain the current (most recently started) footnote.
|
||||
fn current(&mut self) -> Option<&mut Vec<Event<'s>>> {
|
||||
self.open.last_mut().map(|(_, e)| e)
|
||||
}
|
||||
|
||||
/// End the current (most recently started) footnote.
|
||||
fn end(&mut self) {
|
||||
let (label, stage) = self.open.pop().unwrap();
|
||||
self.events.insert(label, stage);
|
||||
/// Insert a new footnote to be renderer later
|
||||
fn insert(&mut self, label: &'s str, events: Vec<jotdown::Event<'s>>) {
|
||||
self.events.insert(label, events);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -38,7 +38,7 @@ fn generate_page(path: &Path) -> anyhow::Result<()> {
|
|||
} else if label == "navigation" {
|
||||
out.write_str(r#"<a href="/"><code>~</code></a>"#)?;
|
||||
out.write_str(" / ")?;
|
||||
out.write_str(r#"<a href="/posts"><code>posts</code></a>"#)?;
|
||||
out.write_str(r#"<a href="/echoes"><code>echoes</code></a>"#)?;
|
||||
page_renderer.next(&mut out)?;
|
||||
} else {
|
||||
break;
|
||||
|
|
138
src/metadata.rs
|
@ -4,12 +4,11 @@ use std::path::{Component, Path, PathBuf};
|
|||
use std::process::Command;
|
||||
|
||||
use anyhow::{anyhow, bail, Context};
|
||||
use chrono::{DateTime, FixedOffset};
|
||||
use chrono::{DateTime, FixedOffset, Utc};
|
||||
use jotdown::{Container, Event};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::html;
|
||||
|
||||
// {{{ Config
|
||||
#[derive(Deserialize, Debug, Default)]
|
||||
pub struct PageConfig {
|
||||
pub created_at: Option<DateTime<FixedOffset>>,
|
||||
|
@ -32,7 +31,8 @@ impl PageConfig {
|
|||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// }}}
|
||||
// {{{ Routing
|
||||
#[derive(Debug)]
|
||||
pub enum PageRoute {
|
||||
Home,
|
||||
|
@ -49,16 +49,19 @@ impl PageRoute {
|
|||
|
||||
let result = if first == OsStr::new("index.dj") {
|
||||
Self::Home
|
||||
} else if first == OsStr::new("posts") {
|
||||
if let Some(Component::Normal(second)) = path.components().nth(2) {
|
||||
let mut slice = second.to_str().unwrap();
|
||||
if slice.ends_with(".dj") {
|
||||
slice = slice.strip_suffix(".dj").unwrap();
|
||||
}
|
||||
} else if first == OsStr::new("echoes") {
|
||||
let Some(Component::Normal(second)) = path.components().nth(2) else {
|
||||
bail!("Cannot convert path '{:?}' to page route", path);
|
||||
};
|
||||
let mut slice = second.to_str().unwrap();
|
||||
if slice.ends_with(".dj") {
|
||||
slice = slice.strip_suffix(".dj").unwrap();
|
||||
}
|
||||
|
||||
Self::Post(slice.to_owned())
|
||||
} else {
|
||||
if slice == "index" {
|
||||
Self::Posts
|
||||
} else {
|
||||
Self::Post(slice.to_owned())
|
||||
}
|
||||
} else {
|
||||
bail!("Cannot convert path '{:?}' to page route", path);
|
||||
|
@ -67,26 +70,33 @@ impl PageRoute {
|
|||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PageMetadata {
|
||||
pub title: Heading,
|
||||
pub config: PageConfig,
|
||||
pub word_count: usize,
|
||||
pub last_modified: DateTime<FixedOffset>,
|
||||
pub route: PageRoute,
|
||||
|
||||
// }}}
|
||||
// {{{ Metadata
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Heading<'a> {
|
||||
#[allow(dead_code)]
|
||||
pub toc: Vec<Heading>,
|
||||
#[allow(dead_code)]
|
||||
pub path: PathBuf,
|
||||
pub level: u8,
|
||||
pub id: String, // Heading events own their ID, so we have to clone
|
||||
pub events: Vec<jotdown::Event<'a>>,
|
||||
}
|
||||
|
||||
impl PageMetadata {
|
||||
pub fn new<'s>(
|
||||
mut events: impl Iterator<Item = Event<'s>>,
|
||||
path: PathBuf,
|
||||
) -> anyhow::Result<Self> {
|
||||
#[derive(Debug)]
|
||||
pub struct PageMetadata<'s> {
|
||||
pub config: PageConfig,
|
||||
pub route: PageRoute,
|
||||
|
||||
pub title: Heading<'s>,
|
||||
#[allow(dead_code)]
|
||||
pub description: Vec<jotdown::Event<'s>>,
|
||||
#[allow(dead_code)]
|
||||
pub toc: Vec<Heading<'s>>,
|
||||
|
||||
pub word_count: usize,
|
||||
pub last_modified: DateTime<FixedOffset>,
|
||||
}
|
||||
|
||||
impl<'a> PageMetadata<'a> {
|
||||
pub fn new(mut events: impl Iterator<Item = Event<'a>>, path: PathBuf) -> anyhow::Result<Self> {
|
||||
let mut w = Writer::new();
|
||||
events.try_for_each(|e| w.render_event(&e))?;
|
||||
|
||||
|
@ -104,56 +114,53 @@ impl PageMetadata {
|
|||
.with_context(|| anyhow!("Could not read the last modification date for file"))?
|
||||
.stdout;
|
||||
let last_modified = String::from_utf8(last_modified_output)?;
|
||||
let last_modified = DateTime::parse_from_rfc3339(&last_modified).with_context(|| {
|
||||
anyhow!(
|
||||
"Failed to parse datetime returned by git '{}'",
|
||||
last_modified
|
||||
)
|
||||
})?;
|
||||
|
||||
let last_modified = if last_modified.is_empty() {
|
||||
Utc::now().fixed_offset()
|
||||
} else {
|
||||
DateTime::parse_from_rfc3339(&last_modified).with_context(|| {
|
||||
anyhow!(
|
||||
"Failed to parse datetime returned by git '{}'",
|
||||
last_modified
|
||||
)
|
||||
})?
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
title: title.to_owned(),
|
||||
description: w.description,
|
||||
route: PageRoute::from_path(&path)?,
|
||||
config: w.config,
|
||||
toc: w.toc,
|
||||
word_count: w.word_count,
|
||||
last_modified,
|
||||
path,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Heading {
|
||||
#[allow(dead_code)]
|
||||
pub level: u8,
|
||||
pub id: String,
|
||||
pub text: String,
|
||||
pub html: String,
|
||||
}
|
||||
// }}}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||
enum State {
|
||||
Toplevel,
|
||||
Heading,
|
||||
Config,
|
||||
Description,
|
||||
}
|
||||
|
||||
struct Writer<'s> {
|
||||
/// This renderer is used for generating the html for the titles
|
||||
html_renderer: html::Writer<'s>,
|
||||
config: PageConfig,
|
||||
toc: Vec<Heading>,
|
||||
toc: Vec<Heading<'s>>,
|
||||
toml_text: String,
|
||||
state: State,
|
||||
word_count: usize,
|
||||
description: Vec<jotdown::Event<'s>>,
|
||||
}
|
||||
|
||||
impl<'s> Writer<'s> {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
html_renderer: html::Writer::new(None),
|
||||
config: PageConfig::default(),
|
||||
description: Vec::new(),
|
||||
toc: Vec::new(),
|
||||
toml_text: String::new(),
|
||||
state: State::Toplevel,
|
||||
|
@ -161,7 +168,7 @@ impl<'s> Writer<'s> {
|
|||
}
|
||||
}
|
||||
|
||||
fn render_event(&mut self, e: &jotdown::Event<'s>) -> anyhow::Result<()> {
|
||||
fn render_event<'a>(&mut self, e: &'a jotdown::Event<'s>) -> anyhow::Result<()> {
|
||||
if let Event::Str(content) = e {
|
||||
if self.state != State::Config {
|
||||
self.word_count += content
|
||||
|
@ -172,20 +179,23 @@ impl<'s> Writer<'s> {
|
|||
}
|
||||
|
||||
match e {
|
||||
// {{{ Headings
|
||||
Event::Start(Container::Heading { level, id, .. }, _) => {
|
||||
assert_eq!(self.state, State::Toplevel);
|
||||
self.state = State::Heading;
|
||||
self.toc.push(Heading {
|
||||
level: *level as u8,
|
||||
events: Vec::new(),
|
||||
// These ids are always borrowed, unless modified by the user (i.e. me)
|
||||
id: id.to_string(),
|
||||
text: String::new(),
|
||||
html: String::new(),
|
||||
})
|
||||
}
|
||||
Event::End(Container::Heading { .. }) => {
|
||||
assert_eq!(self.state, State::Heading);
|
||||
self.state = State::Toplevel;
|
||||
}
|
||||
// }}}
|
||||
// {{{ TOML config blocks
|
||||
Event::Start(Container::RawBlock { format: "toml" }, attrs) => {
|
||||
assert_eq!(self.state, State::Toplevel);
|
||||
if let Some(role_attr) = attrs.get_value("role") {
|
||||
|
@ -205,16 +215,28 @@ impl<'s> Writer<'s> {
|
|||
self.toml_text.clear();
|
||||
}
|
||||
}
|
||||
// }}}
|
||||
// {{{ Descriptions
|
||||
Event::Start(Container::Div { .. }, attrs) if self.state == State::Toplevel => {
|
||||
if let Some(role_attr) = attrs.get_value("role") {
|
||||
if format!("{}", role_attr) == "description" {
|
||||
self.state = State::Description
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::End(Container::Div { .. }) if self.state == State::Description => {
|
||||
self.state = State::Toplevel;
|
||||
}
|
||||
// }}}
|
||||
Event::Str(str) if self.state == State::Config => {
|
||||
self.toml_text.write_str(str)?;
|
||||
}
|
||||
other if self.state == State::Heading => {
|
||||
_ if self.state == State::Description => {
|
||||
self.description.push(e.clone());
|
||||
}
|
||||
_ if self.state == State::Heading => {
|
||||
let last_heading = self.toc.last_mut().unwrap();
|
||||
self.html_renderer.render_event(e, &mut last_heading.html)?;
|
||||
|
||||
if let Event::Str(str) = other {
|
||||
last_heading.text.write_str(str)?;
|
||||
}
|
||||
last_heading.events.push(e.clone());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
|
|
@ -127,7 +127,6 @@ impl<'a> TemplateRenderer<'a> {
|
|||
None => (self.template.text.len(), self.template.text.len()),
|
||||
}
|
||||
}
|
||||
|
||||
// }}}
|
||||
}
|
||||
// }}}
|
||||
|
|
13
src/templates/post-summary.html
Normal file
|
@ -0,0 +1,13 @@
|
|||
<article>
|
||||
<h2>
|
||||
<a href="/echoes/{{id}}" rel="bookmark">{{title}}</a>
|
||||
</h2>
|
||||
|
||||
<ul>
|
||||
<li>{{posted_on}} by <a href="about:blank">prescientmoon</a></li>
|
||||
<li>Last updated on {{updated_on}}.</li>
|
||||
<li>About {{word_count}} words; a {{reading_duration}} read</li>
|
||||
</ul>
|
||||
|
||||
{{description}}
|
||||
</article>
|