1
Fork 0

Code highlighting, and a major refactor of the rendering code

This commit is contained in:
prescientmoon 2024-11-06 04:26:30 +01:00
parent e6895391f6
commit 4ca0d221d5
Signed by: prescientmoon
SSH key fingerprint: SHA256:WFp/cO76nbarETAoQcQXuV+0h7XJsEsOCI0UsyPIy6U
30 changed files with 689 additions and 392 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
target
dist
oldicons
result

121
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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

View file

Before

(image error) Size: 498 KiB

After

(image error) Size: 498 KiB

View file

Before

(image error) Size: 32 KiB

After

(image error) Size: 32 KiB

View file

Before

(image error) Size: 251 KiB

After

(image error) Size: 251 KiB

View file

Before

(image error) Size: 101 KiB

After

(image error) Size: 101 KiB

View file

Before

(image error) Size: 1.7 MiB

After

(image error) Size: 1.7 MiB

View file

Before

(image error) Size: 422 KiB

After

(image error) Size: 422 KiB

View file

Before

(image error) Size: 424 KiB

After

(image error) Size: 424 KiB

View file

Before

(image error) Size: 86 KiB

After

(image error) Size: 86 KiB

View file

Before

(image error) Size: 31 KiB

After

(image error) Size: 31 KiB

View file

Before

(image error) Size: 11 KiB

After

(image error) Size: 11 KiB

View file

Before

(image error) Size: 202 KiB

After

(image error) Size: 202 KiB

View file

Before

(image error) Size: 117 KiB

After

(image error) Size: 117 KiB

View file

Before

(image error) Size: 121 KiB

After

(image error) Size: 121 KiB

View file

Before

(image error) Size: 54 KiB

After

(image error) Size: 54 KiB

9
content/echoes/index.dj Normal file
View 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)

View file

@ -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.

View file

@ -17,6 +17,7 @@
pkgs.rust-analyzer
pkgs.rustfmt
pkgs.imagemagick
pkgs.http-server
];
buildInputs = with pkgs; [ ];

View file

@ -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;
}
/* }}}*/
}
/* }}} */

View file

@ -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("&lt;"),
'>' => Some("&gt;"),
'&' => Some("&amp;"),
'"' if escape_quotes => Some("&quot;"),
_ => 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("&lt;"),
'>' => Some("&gt;"),
'&' => Some("&amp;"),
'"' => Some("&quot;"),
_ => 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);
}
}

View file

@ -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;

View file

@ -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());
}
_ => {}
}

View file

@ -127,7 +127,6 @@ impl<'a> TemplateRenderer<'a> {
None => (self.template.text.len(), self.template.text.len()),
}
}
// }}}
}
// }}}

View 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>