about summary refs log tree commit diff
path: root/kittybox-rs/templates
diff options
context:
space:
mode:
Diffstat (limited to 'kittybox-rs/templates')
-rw-r--r--kittybox-rs/templates/Cargo.toml9
-rw-r--r--kittybox-rs/templates/src/lib.rs18
-rw-r--r--kittybox-rs/templates/src/mf2.rs409
-rw-r--r--kittybox-rs/templates/src/templates.rs414
4 files changed, 430 insertions, 420 deletions
diff --git a/kittybox-rs/templates/Cargo.toml b/kittybox-rs/templates/Cargo.toml
index fe8ac19..a32a3a2 100644
--- a/kittybox-rs/templates/Cargo.toml
+++ b/kittybox-rs/templates/Cargo.toml
@@ -8,19 +8,20 @@ edition = "2021"
 [dev-dependencies]
 faker_rand = "^0.1.1"
 rand = "^0.8.5"
-test-logger = "^0.1.0"
 [dev-dependencies.microformats]
 version="^0.2.0"
 
 [dependencies]
 ellipse = "^0.2.0"           # Truncate and ellipsize strings in a human-friendly way
 http = "^0.2.7"              # Hyper's strong HTTP types
-log = "^0.4.14"              # A lightweight logging facade for Rust
 markup = "^0.12.0"           # HTML templating engine
 serde_json = "^1.0.64"       # A JSON serialization file format
-[dependencies.chrono]        # Date and time library for Rust
+[dependencies.chrono]
 version = "^0.4.19"
 features = ["serde"]
 [dependencies.kittybox-util]
 version = "0.1.0"
-path = "../util"
\ No newline at end of file
+path = "../util"
+[dependencies.kittybox-indieauth]
+version = "0.1.0"
+path = "../indieauth"
\ No newline at end of file
diff --git a/kittybox-rs/templates/src/lib.rs b/kittybox-rs/templates/src/lib.rs
index 8e9abba..d58e831 100644
--- a/kittybox-rs/templates/src/lib.rs
+++ b/kittybox-rs/templates/src/lib.rs
@@ -1,9 +1,13 @@
 mod templates;
-pub use templates::{Entry, ErrorPage, Feed, MainPage, Template, VCard, POSTS_PER_PAGE};
+pub use templates::{ErrorPage, MainPage, Template};
 mod onboarding;
 pub use onboarding::OnboardingPage;
+mod indieauth;
+pub use indieauth::AuthorizationRequestPage;
 mod login;
 pub use login::LoginPage;
+mod mf2;
+pub use mf2::{Entry, VCard, Feed, Food, POSTS_PER_PAGE};
 
 #[cfg(test)]
 mod tests {
@@ -196,11 +200,9 @@ mod tests {
     #[test]
     #[ignore = "see https://gitlab.com/maxburon/microformats-parser/-/issues/7"]
     fn test_note() {
-        test_logger::ensure_env_logger_initialized();
-
         let mf2 = gen_random_post(&rand::random::<Domain>().to_string(), PostType::Note);
 
-        let html = crate::templates::Entry { post: &mf2 }.to_string();
+        let html = crate::mf2::Entry { post: &mf2 }.to_string();
 
         let url: Url = mf2
             .pointer("/properties/uid/0")
@@ -231,10 +233,8 @@ mod tests {
 
     #[test]
     fn test_article() {
-        test_logger::ensure_env_logger_initialized();
-
         let mf2 = gen_random_post(&rand::random::<Domain>().to_string(), PostType::Article);
-        let html = crate::templates::Entry { post: &mf2 }.to_string();
+        let html = crate::mf2::Entry { post: &mf2 }.to_string();
         let url: Url = mf2
             .pointer("/properties/uid/0")
             .and_then(|i| i.as_str())
@@ -273,8 +273,6 @@ mod tests {
 
     #[test]
     fn test_like_of() {
-        test_logger::ensure_env_logger_initialized();
-
         for likeof in [
             PostType::LikeOf(gen_random_post(
                 &rand::random::<Domain>().to_string(),
@@ -294,7 +292,7 @@ mod tests {
                 .and_then(|i| i.as_str())
                 .and_then(|u| u.parse().ok())
                 .unwrap();
-            let html = crate::templates::Entry { post: &mf2 }.to_string();
+            let html = crate::mf2::Entry { post: &mf2 }.to_string();
             let parsed: Document = microformats::from_html(&html, url.clone()).unwrap();
 
             if let Some(item) = parsed.items.get(0) {
diff --git a/kittybox-rs/templates/src/mf2.rs b/kittybox-rs/templates/src/mf2.rs
new file mode 100644
index 0000000..d9fc5e7
--- /dev/null
+++ b/kittybox-rs/templates/src/mf2.rs
@@ -0,0 +1,409 @@
+use ellipse::Ellipse;
+
+pub static POSTS_PER_PAGE: usize = 20;
+
+/// Return a pretty location specifier from a geo: URI.
+fn decode_geo_uri(uri: &str) -> String {
+    if let Some(part) = uri.split(':').collect::<Vec<_>>().get(1) {
+        if let Some(part) = part.split(';').next() {
+            let mut parts = part.split(',');
+            let lat = parts.next().unwrap();
+            let lon = parts.next().unwrap();
+            // TODO - format them as proper latitude and longitude
+            return format!("{}, {}", lat, lon);
+        } else {
+            uri.to_string()
+        }
+    } else {
+        uri.to_string()
+    }
+}
+
+markup::define! {
+    Entry<'a>(post: &'a serde_json::Value) {
+        @if post.pointer("/properties/like-of").is_none() && post.pointer("/properties/bookmark-of").is_none() {
+            @FullEntry { post }
+        } else {
+            // Show a mini-post.
+            @MiniEntry { post }
+        }
+    }
+    MiniEntry<'a>(post: &'a serde_json::Value) {
+        article."h-entry mini-entry" {
+            @if let Some(author) = post["properties"]["author"][0].as_object() {
+                span."mini-h-card"."u-author" {
+                    a."u-author"[href=author["properties"]["uid"][0].as_str().unwrap()] {
+                        @if let Some(photo) = author["properties"]["photo"][0].as_str() {
+                            img[src=photo, loading="lazy"];
+                        }
+                        @author["properties"]["name"][0].as_str().unwrap()
+                    }
+                }
+                @if let Some(likeof) = post["properties"]["like-of"][0].as_str() {
+                    " ❤️ "
+                    a."u-like-of"[href=likeof] { @likeof }
+                } else if let Some(likeof) = post["properties"]["like-of"][0].as_object() {
+                    a."u-like-of"[href=likeof["properties"]["url"][0].as_str().unwrap()] {
+                        @likeof["properties"]["name"][0]
+                            .as_str()
+                            .unwrap_or_else(|| likeof["properties"]["url"][0].as_str().unwrap())
+                    }
+                }
+                @if let Some(bookmarkof) = post["properties"]["bookmark-of"][0].as_str() {
+                    " 🔖 "
+                    a."u-bookmark-of"[href=bookmarkof] { @bookmarkof }
+                } else if let Some(bookmarkof) = post["properties"]["bookmark-of"][0].as_object() {
+                    a."u-bookmark-of"[href=bookmarkof["properties"]["url"][0].as_str().unwrap()] {
+                        @bookmarkof["properties"]["name"][0]
+                            .as_str()
+                            .unwrap_or_else(|| bookmarkof["properties"]["url"][0].as_str().unwrap())
+                    }
+                }
+                @if let Some(published) = post["properties"]["published"][0].as_str() {
+                    time."dt-published"[datetime=published] {
+                        @chrono::DateTime::parse_from_rfc3339(published)
+                            .map(|dt| dt.format("on %a %b %e %T %Y").to_string())
+                            .unwrap_or("sometime in the past".to_string())
+                    }
+                }
+            }
+        }
+    }
+    FullEntry<'a>(post: &'a serde_json::Value) {
+        article."h-entry" {
+            header.metadata {
+                @if let Some(name) = post["properties"]["name"][0].as_str() {
+                    h1."p-name" { @name }
+                }
+                @if let Some(author) = post["properties"]["author"][0].as_object() {
+                    section."mini-h-card" {
+                        a.larger."u-author"[href=author["properties"]["uid"][0].as_str().unwrap()] {
+                            @if let Some(photo) = author["properties"]["photo"][0].as_str() {
+                                img[src=photo, loading="lazy"];
+                            }
+                            @author["properties"]["name"][0].as_str().unwrap()
+                        }
+                    }
+                }
+                div {
+                    span {
+                        a."u-url"."u-uid"[href=post["properties"]["uid"][0].as_str().unwrap()] {
+                            @if let Some(published) = post["properties"]["published"][0].as_str() {
+                                time."dt-published"[datetime=published] {
+                                    @chrono::DateTime::parse_from_rfc3339(published)
+                                        .map(|dt| dt.format("%a %b %e %T %Y").to_string())
+                                        .unwrap_or("sometime in the past".to_string())
+                                }
+                            }
+                        }
+                    }
+                    @if post["properties"]["visibility"][0].as_str().unwrap_or("public") != "public" {
+                        span."p-visibility"[value=post["properties"]["visibility"][0].as_str().unwrap()] {
+                            @post["properties"]["visibility"][0].as_str().unwrap()
+                        }
+                    }
+                    @if post["properties"]["category"].is_array() {
+                        span {
+                            ul.categories {
+                                "Tagged: "
+                                @for cat in post["properties"]["category"].as_array().unwrap() {
+                                    li."p-category" { @cat.as_str().unwrap() }
+                                }
+                            }
+                        }
+                    }
+                    @if post["properties"]["in-reply-to"].is_array() {
+                        // TODO: Rich reply contexts - blocked on MF2 parser
+                        span {
+                            "In reply to: "
+                            ul.replyctx {
+                                @for ctx in post["properties"]["in-reply-to"].as_array().unwrap() {
+                                    li { a."u-in-reply-to"[href=ctx.as_str().unwrap()] {
+                                        @ctx.as_str().unwrap().truncate_ellipse(24).as_ref()
+                                    } }
+                                }
+                            }
+                        }
+                    }
+                }
+                @if post["properties"]["url"].as_array().unwrap().len() > 1 {
+                    hr;
+                    ul {
+                        "Pretty permalinks for this post:"
+                        @for url in post["properties"]["url"].as_array().unwrap().iter().filter(|i| **i != post["properties"]["uid"][0]).map(|i| i.as_str().unwrap()) {
+                            li {
+                                a."u-url"[href=url] { @url }
+                            }
+                        }
+                    }
+                }
+                @if post["properties"]["location"].is_array() || post["properties"]["checkin"].is_array() {
+                    div {
+                        @if post["properties"]["checkin"].is_array() {
+                            span {
+                                "Check-in to: "
+                                @if post["properties"]["checkin"][0].is_string() {
+                                    // It's a URL
+                                    a."u-checkin"[href=post["properties"]["checkin"][0].as_str().unwrap()] {
+                                        @post["properties"]["checkin"][0].as_str().unwrap().truncate_ellipse(24).as_ref()
+                                    }
+                                } else {
+                                    a."u-checkin"[href=post["properties"]["checkin"][0]["properties"]["uid"][0].as_str().unwrap()] {
+                                        @post["properties"]["checkin"][0]["properties"]["name"][0].as_str().unwrap()
+                                    }
+                                }
+                            }
+                        }
+                        @if post["properties"]["location"].is_array() {
+                            span {
+                                "Location: "
+                                @if post["properties"]["location"][0].is_string() {
+                                    // It's a geo: URL
+                                    // We need to decode it
+                                    a."u-location"[href=post["properties"]["location"][0].as_str().unwrap()] {
+                                        @decode_geo_uri(post["properties"]["location"][0].as_str().unwrap())
+                                    }
+                                } else {
+                                    // It's an inner h-geo object
+                                    a."u-location"[href=post["properties"]["location"][0]["value"].as_str().map(|x| x.to_string()).unwrap_or(format!("geo:{},{}", post["properties"]["location"][0]["properties"]["latitude"][0].as_str().unwrap(), post["properties"]["location"][0]["properties"]["longitude"][0].as_str().unwrap()))] {
+                                        // I'm a lazy bitch
+                                        @decode_geo_uri(&post["properties"]["location"][0]["value"].as_str().map(|x| x.to_string()).unwrap_or(format!("geo:{},{}", post["properties"]["location"][0]["properties"]["latitude"][0].as_str().unwrap(), post["properties"]["location"][0]["properties"]["longitude"][0].as_str().unwrap())))
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+                @if post["properties"]["ate"].is_array() || post["properties"]["drank"].is_array() {
+                    div {
+                        @if post["properties"]["ate"].is_array() {
+                            span { ul {
+                                "Ate:"
+                                @for food in post["properties"]["ate"].as_array().unwrap() {
+                                    li {
+                                        @if food.is_string() {
+                                            // If this is a string, it's a URL.
+                                            a."u-ate"[href=food.as_str().unwrap()] {
+                                                @food.as_str().unwrap().truncate_ellipse(24).as_ref()
+                                            }
+                                        } else {
+                                            // This is a rich food object (mm, sounds tasty! I wanna eat something tasty)
+                                            a."u-ate"[href=food["properties"]["uid"][0].as_str().unwrap_or("#")] {
+                                                @food["properties"]["name"][0].as_str()
+                                                    .unwrap_or(food["properties"]["uid"][0].as_str().unwrap_or("#").truncate_ellipse(24).as_ref())
+                                            }
+                                        }
+                                    }
+                                }
+                            } }
+                        }
+                        @if post["properties"]["drank"].is_array() {
+                            span { ul {
+                                "Drank:"
+                                @for food in post["properties"]["drank"].as_array().unwrap() {
+                                    li {
+                                        @if food.is_string() {
+                                            // If this is a string, it's a URL.
+                                            a."u-drank"[href=food.as_str().unwrap()] {
+                                                @food.as_str().unwrap().truncate_ellipse(24).as_ref()
+                                            }
+                                        } else {
+                                            // This is a rich food object (mm, sounds tasty! I wanna eat something tasty)
+                                            a."u-drank"[href=food["properties"]["uid"][0].as_str().unwrap_or("#")] {
+                                                @food["properties"]["name"][0].as_str()
+                                                    .unwrap_or(food["properties"]["uid"][0].as_str().unwrap_or("#").truncate_ellipse(24).as_ref())
+                                            }
+                                        }
+                                    }
+                                }
+                            } }
+                        }
+                    }
+                }
+            }
+            @PhotoGallery { photos: post["properties"]["photo"].as_array() }
+            @if post["properties"]["content"][0]["html"].is_string() {
+                main."e-content" {
+                    @markup::raw(post["properties"]["content"][0]["html"].as_str().unwrap().trim())
+                }
+            }
+            @WebInteractions { post }
+        }
+    }
+    VCard<'a>(card: &'a serde_json::Value) {
+        article."h-card" {
+            @if card["properties"]["photo"][0].is_string() {
+                img."u-photo"[src=card["properties"]["photo"][0].as_str().unwrap()];
+            }
+            h1 {
+                a."u-url"."u-uid"."p-name"[href=card["properties"]["uid"][0].as_str().unwrap()] {
+                    @card["properties"]["name"][0].as_str().unwrap()
+                }
+            }
+            @if card["properties"]["pronoun"].is_array() {
+                span {
+                    "("
+                    @for (i, pronoun) in card["properties"]["pronoun"].as_array().unwrap().iter().filter_map(|v| v.as_str()).enumerate() {
+                        span."p-pronoun" {
+                            @pronoun
+                        }
+                        // Insert commas between multiple sets of pronouns
+                        @if i < (card["properties"]["pronoun"].as_array().unwrap().len() - 1) {", "}
+                    }
+                    ")"
+                }
+            }
+            @if card["properties"]["note"].is_array() {
+                p."p-note" {
+                    @card["properties"]["note"][0]["value"].as_str().unwrap_or_else(|| card["properties"]["note"][0].as_str().unwrap())
+                }
+            }
+            @if card["properties"]["url"].is_array() {
+                ul {
+                    "Can be found elsewhere at:"
+                    @for url in card["properties"]["url"].as_array().unwrap().iter().filter_map(|v| v.as_str()).filter(|v| v != &card["properties"]["uid"][0].as_str().unwrap()).filter(|v| !v.starts_with(&card["properties"]["author"][0].as_str().unwrap())) {
+                        li { a."u-url"[href=url, rel="me"] { @url } }
+                    }
+                }
+            }
+        }
+    }
+    Food<'a>(food: &'a serde_json::Value) {
+        article."h-food" {
+            header.metadata {
+                h1 {
+                    a."p-name"."u-url"[href=food["properties"]["url"][0].as_str().unwrap()] {
+                        @food["properties"]["name"][0].as_str().unwrap()
+                    }
+                }
+            }
+            @PhotoGallery { photos: food["properties"]["photo"].as_array() }
+        }
+    }
+    Feed<'a>(feed: &'a serde_json::Value) {
+        div."h-feed" {
+            div.metadata {
+                @if feed["properties"]["name"][0].is_string() {
+                    h1."p-name".titanic {
+                        a[href=feed["properties"]["uid"][0].as_str().unwrap(), rel="feed"] {
+                            @feed["properties"]["name"][0].as_str().unwrap()
+                        }
+                    }
+                }
+            }
+            @if feed["children"].is_array() {
+                @for child in feed["children"].as_array().unwrap() {
+                    @match child["type"][0].as_str().unwrap() {
+                        "h-entry" => { @Entry { post: child } }
+                        "h-feed" => { @Feed { feed: child } }
+                        "h-food" => { @Food { food: child } }
+                        //"h-event" => { }
+                        "h-card" => { @VCard { card: child } }
+                        something_else => {
+                            p {
+                                "There's supposed to be an "
+                                    @something_else
+                                    " object here. But Kittybox can't render it right now."
+                                    small { "Sorry! TToTT" }
+                            }
+                        }
+                    }
+                }
+            }
+            @if feed["children"].as_array().map(|a| a.len()).unwrap_or(0) == 0 {
+                p {
+                    "Looks like you reached the end. Wanna jump back to the "
+                    a[href=feed["properties"]["uid"][0].as_str().unwrap()] {
+                        "beginning"
+                    } "?"
+                }
+            }
+            @if feed["children"].as_array().map(|a| a.len()).unwrap_or(0) == super::POSTS_PER_PAGE {
+                a[rel="prev", href=feed["properties"]["uid"][0].as_str().unwrap().to_string()
+                    + "?after=" + feed["children"][super::POSTS_PER_PAGE - 1]["properties"]["uid"][0].as_str().unwrap()] {
+                    "Older posts"
+                }
+            }
+        }
+    }
+
+    //=======================================
+    // Components library
+    //=======================================
+    PhotoGallery<'a>(photos: Option<&'a Vec<serde_json::Value>>) {
+        @if let Some(photos) = photos {
+            @for photo in photos.iter() {
+                @if let Some(photo) = photo.as_str() {
+                    img."u-photo"[src=photo, loading="lazy"];
+                } else if photo.is_object() {
+                    @if let Some(thumbnail) = photo["thumbnail"].as_str() {
+                        a."u-photo"[href=photo["value"].as_str().unwrap()] {
+                            img[src=thumbnail,
+                                loading="lazy",
+                                alt=photo["alt"].as_str().unwrap_or("")
+                            ];
+                        }
+                    } else {
+                        img."u-photo"[src=photo["value"].as_str().unwrap(),
+                                      loading="lazy",
+                                      alt=photo["alt"].as_str().unwrap_or("")
+                        ];
+                    }
+                }
+            }
+        }
+    }
+    WebInteractions<'a>(post: &'a serde_json::Value) {
+        footer.webinteractions {
+            ul.counters {
+                li {
+                    span.icon { "❤️" }
+                    span.counter { @post["properties"]["like"].as_array().map(|a| a.len()).unwrap_or(0) }
+                }
+                li {
+                    span.icon { "💬" }
+                    span.counter { @post["properties"]["comment"].as_array().map(|a| a.len()).unwrap_or(0) }
+                }
+                li {
+                    span.icon { "🔄" }
+                    span.counter { @post["properties"]["repost"].as_array().map(|a| a.len()).unwrap_or(0) }
+                }
+                li {
+                    span.icon { "🔖" }
+                    span.counter { @post["properties"]["bookmark"].as_array().map(|a| a.len()).unwrap_or(0) }
+                }
+            }
+            /*@if (
+                post["properties"]["like"].as_array().map(|a| a.len()).unwrap_or(0)
+                    + post["properties"]["bookmark"].as_array().map(|a| a.len()).unwrap_or(0)
+                    + post["properties"]["repost"].as_array().map(|a| a.len()).unwrap_or(0)
+                    + post["properties"]["comment"].as_array().map(|a| a.len()).unwrap_or(0)
+            ) > 0 {
+                details {
+                    summary { "Show comments and reactions" }
+                    // TODO actually render facepiles and comments
+                    @if let Some(likes) = post["properties"]["like"].as_array() {
+                        @if !likes.is_empty() {
+                            // Show a facepile of likes for a post
+                        }
+                    }
+                    @if let Some(bookmarks) = post["properties"]["bookmark"].as_array() {
+                        @if !bookmarks.is_empty() {
+                            // Show a facepile of bookmarks for a post
+                        }
+                    }
+                    @if let Some(reposts) = post["properties"]["repost"].as_array() {
+                        @if !reposts.is_empty() {
+                            // Show a facepile of reposts for a post
+                        }
+                    }
+                    @if let Some(comments) = post["properties"]["comment"].as_array() {
+                        @for comment in comments.iter() {
+                            // Show all the comments recursively (so we could do Salmention with them)
+                        }
+                    }
+                }
+            }*/
+        }
+    }
+}
diff --git a/kittybox-rs/templates/src/templates.rs b/kittybox-rs/templates/src/templates.rs
index 0b1db28..60da6af 100644
--- a/kittybox-rs/templates/src/templates.rs
+++ b/kittybox-rs/templates/src/templates.rs
@@ -1,29 +1,10 @@
-use ellipse::Ellipse;
 use http::StatusCode;
-use kittybox_util::{IndiewebEndpoints, MicropubChannel};
-use log::error;
+use kittybox_util::MicropubChannel;
 
-pub static POSTS_PER_PAGE: usize = 20;
-
-/// Return a pretty location specifier from a geo: URI.
-fn decode_geo_uri(uri: &str) -> String {
-    if let Some(part) = uri.split(':').collect::<Vec<_>>().get(1) {
-        if let Some(part) = part.split(';').next() {
-            let mut parts = part.split(',');
-            let lat = parts.next().unwrap();
-            let lon = parts.next().unwrap();
-            // TODO - format them as proper latitude and longitude
-            return format!("{}, {}", lat, lon);
-        } else {
-            uri.to_string()
-        }
-    } else {
-        uri.to_string()
-    }
-}
+use crate::{Feed, VCard};
 
 markup::define! {
-    Template<'a>(title: &'a str, blog_name: &'a str, endpoints: Option<IndiewebEndpoints>, feeds: Vec<MicropubChannel>, user: Option<String>, content: String) {
+    Template<'a>(title: &'a str, blog_name: &'a str, feeds: Vec<MicropubChannel>, user: Option<String>, content: String) {
         @markup::doctype()
         html {
             head {
@@ -31,20 +12,19 @@ markup::define! {
                 link[rel="preconnect", href="https://fonts.gstatic.com"];
                 link[rel="stylesheet", href="/.kittybox/static/style.css"];
                 meta[name="viewport", content="initial-scale=1, width=device-width"];
-                // Static, because it's built into the server itself
+
                 link[rel="micropub", href="/.kittybox/micropub"];
                 link[rel="micropub_media", href="/.kittybox/media"];
-                // TODO change this once neccesary endpoints are implemented
-                @if let Some(endpoints) = endpoints {
-                    link[rel="authorization_endpoint", href=&endpoints.authorization_endpoint];
-                    link[rel="token_endpoint", href=&endpoints.token_endpoint];
+                link[rel="indieauth_metadata", href="/.kittybox/indieauth/metadata"];
+
+                /*@if let Some(endpoints) = endpoints {
                     @if let Some(webmention) = &endpoints.webmention {
                         link[rel="webmention", href=&webmention];
                     }
                     @if let Some(microsub) = &endpoints.microsub {
                         link[rel="microsub", href=&microsub];
                     }
-                }
+                }*/
             }
             body {
                 // TODO Somehow compress headerbar into a menu when the screen space is tight
@@ -78,384 +58,6 @@ markup::define! {
             }
         }
     }
-    Entry<'a>(post: &'a serde_json::Value) {
-        @if post.pointer("/properties/like-of").is_none() && post.pointer("/properties/bookmark-of").is_none() {
-            @FullEntry { post }
-        } else {
-            // Show a mini-post.
-            @MiniEntry { post }
-        }
-    }
-    MiniEntry<'a>(post: &'a serde_json::Value) {
-        article."h-entry mini-entry" {
-            @if let Some(author) = post["properties"]["author"][0].as_object() {
-                span."mini-h-card"."u-author" {
-                    a."u-author"[href=author["properties"]["uid"][0].as_str().unwrap()] {
-                        @if let Some(photo) = author["properties"]["photo"][0].as_str() {
-                            img[src=photo, loading="lazy"];
-                        }
-                        @author["properties"]["name"][0].as_str().unwrap()
-                    }
-                }
-                @if let Some(likeof) = post["properties"]["like-of"][0].as_str() {
-                    " ❤️ "
-                    a."u-like-of"[href=likeof] { @likeof }
-                } else if let Some(likeof) = post["properties"]["like-of"][0].as_object() {
-                    a."u-like-of"[href=likeof["properties"]["url"][0].as_str().unwrap()] {
-                        @likeof["properties"]["name"][0]
-                            .as_str()
-                            .unwrap_or_else(|| likeof["properties"]["url"][0].as_str().unwrap())
-                    }
-                }
-                @if let Some(bookmarkof) = post["properties"]["bookmark-of"][0].as_str() {
-                    " 🔖 "
-                    a."u-bookmark-of"[href=bookmarkof] { @bookmarkof }
-                } else if let Some(bookmarkof) = post["properties"]["bookmark-of"][0].as_object() {
-                    a."u-bookmark-of"[href=bookmarkof["properties"]["url"][0].as_str().unwrap()] {
-                        @bookmarkof["properties"]["name"][0]
-                            .as_str()
-                            .unwrap_or_else(|| bookmarkof["properties"]["url"][0].as_str().unwrap())
-                    }
-                }
-                @if let Some(published) = post["properties"]["published"][0].as_str() {
-                    time."dt-published"[datetime=published] {
-                        @chrono::DateTime::parse_from_rfc3339(published)
-                            .map(|dt| dt.format("on %a %b %e %T %Y").to_string())
-                            .unwrap_or("sometime in the past".to_string())
-                    }
-                }
-            }
-        }
-    }
-    FullEntry<'a>(post: &'a serde_json::Value) {
-        article."h-entry" {
-            header.metadata {
-                @if let Some(name) = post["properties"]["name"][0].as_str() {
-                    h1."p-name" { @name }
-                }
-                @if let Some(author) = post["properties"]["author"][0].as_object() {
-                    section."mini-h-card" {
-                        a.larger."u-author"[href=author["properties"]["uid"][0].as_str().unwrap()] {
-                            @if let Some(photo) = author["properties"]["photo"][0].as_str() {
-                                img[src=photo, loading="lazy"];
-                            }
-                            @author["properties"]["name"][0].as_str().unwrap()
-                        }
-                    }
-                }
-                div {
-                    span {
-                        a."u-url"."u-uid"[href=post["properties"]["uid"][0].as_str().unwrap()] {
-                            @if let Some(published) = post["properties"]["published"][0].as_str() {
-                                time."dt-published"[datetime=published] {
-                                    @chrono::DateTime::parse_from_rfc3339(published)
-                                        .map(|dt| dt.format("%a %b %e %T %Y").to_string())
-                                        .unwrap_or("sometime in the past".to_string())
-                                }
-                            }
-                        }
-                    }
-                    @if post["properties"]["visibility"][0].as_str().unwrap_or("public") != "public" {
-                        span."p-visibility"[value=post["properties"]["visibility"][0].as_str().unwrap()] {
-                            @post["properties"]["visibility"][0].as_str().unwrap()
-                        }
-                    }
-                    @if post["properties"]["category"].is_array() {
-                        span {
-                            ul.categories {
-                                "Tagged: "
-                                @for cat in post["properties"]["category"].as_array().unwrap() {
-                                    li."p-category" { @cat.as_str().unwrap() }
-                                }
-                            }
-                        }
-                    }
-                    @if post["properties"]["in-reply-to"].is_array() {
-                        // TODO: Rich reply contexts - blocked on MF2 parser
-                        span {
-                            "In reply to: "
-                            ul.replyctx {
-                                @for ctx in post["properties"]["in-reply-to"].as_array().unwrap() {
-                                    li { a."u-in-reply-to"[href=ctx.as_str().unwrap()] {
-                                        @ctx.as_str().unwrap().truncate_ellipse(24).as_ref()
-                                    } }
-                                }
-                            }
-                        }
-                    }
-                }
-                @if post["properties"]["url"].as_array().unwrap().len() > 1 {
-                    hr;
-                    ul {
-                        "Pretty permalinks for this post:"
-                        @for url in post["properties"]["url"].as_array().unwrap().iter().filter(|i| **i != post["properties"]["uid"][0]).map(|i| i.as_str().unwrap()) {
-                            li {
-                                a."u-url"[href=url] { @url }
-                            }
-                        }
-                    }
-                }
-                @if post["properties"]["location"].is_array() || post["properties"]["checkin"].is_array() {
-                    div {
-                        @if post["properties"]["checkin"].is_array() {
-                            span {
-                                "Check-in to: "
-                                @if post["properties"]["checkin"][0].is_string() {
-                                    // It's a URL
-                                    a."u-checkin"[href=post["properties"]["checkin"][0].as_str().unwrap()] {
-                                        @post["properties"]["checkin"][0].as_str().unwrap().truncate_ellipse(24).as_ref()
-                                    }
-                                } else {
-                                    a."u-checkin"[href=post["properties"]["checkin"][0]["properties"]["uid"][0].as_str().unwrap()] {
-                                        @post["properties"]["checkin"][0]["properties"]["name"][0].as_str().unwrap()
-                                    }
-                                }
-                            }
-                        }
-                        @if post["properties"]["location"].is_array() {
-                            span {
-                                "Location: "
-                                @if post["properties"]["location"][0].is_string() {
-                                    // It's a geo: URL
-                                    // We need to decode it
-                                    a."u-location"[href=post["properties"]["location"][0].as_str().unwrap()] {
-                                        @decode_geo_uri(post["properties"]["location"][0].as_str().unwrap())
-                                    }
-                                } else {
-                                    // It's an inner h-geo object
-                                    a."u-location"[href=post["properties"]["location"][0]["value"].as_str().map(|x| x.to_string()).unwrap_or(format!("geo:{},{}", post["properties"]["location"][0]["properties"]["latitude"][0].as_str().unwrap(), post["properties"]["location"][0]["properties"]["longitude"][0].as_str().unwrap()))] {
-                                        // I'm a lazy bitch
-                                        @decode_geo_uri(&post["properties"]["location"][0]["value"].as_str().map(|x| x.to_string()).unwrap_or(format!("geo:{},{}", post["properties"]["location"][0]["properties"]["latitude"][0].as_str().unwrap(), post["properties"]["location"][0]["properties"]["longitude"][0].as_str().unwrap())))
-                                    }
-                                }
-                            }
-                        }
-                    }
-                }
-                @if post["properties"]["ate"].is_array() || post["properties"]["drank"].is_array() {
-                    div {
-                        @if post["properties"]["ate"].is_array() {
-                            span { ul {
-                                "Ate:"
-                                @for food in post["properties"]["ate"].as_array().unwrap() {
-                                    li {
-                                        @if food.is_string() {
-                                            // If this is a string, it's a URL.
-                                            a."u-ate"[href=food.as_str().unwrap()] {
-                                                @food.as_str().unwrap().truncate_ellipse(24).as_ref()
-                                            }
-                                        } else {
-                                            // This is a rich food object (mm, sounds tasty! I wanna eat something tasty)
-                                            a."u-ate"[href=food["properties"]["uid"][0].as_str().unwrap_or("#")] {
-                                                @food["properties"]["name"][0].as_str()
-                                                    .unwrap_or(food["properties"]["uid"][0].as_str().unwrap_or("#").truncate_ellipse(24).as_ref())
-                                            }
-                                        }
-                                    }
-                                }
-                            } }
-                        }
-                        @if post["properties"]["drank"].is_array() {
-                            span { ul {
-                                "Drank:"
-                                @for food in post["properties"]["drank"].as_array().unwrap() {
-                                    li {
-                                        @if food.is_string() {
-                                            // If this is a string, it's a URL.
-                                            a."u-drank"[href=food.as_str().unwrap()] {
-                                                @food.as_str().unwrap().truncate_ellipse(24).as_ref()
-                                            }
-                                        } else {
-                                            // This is a rich food object (mm, sounds tasty! I wanna eat something tasty)
-                                            a."u-drank"[href=food["properties"]["uid"][0].as_str().unwrap_or("#")] {
-                                                @food["properties"]["name"][0].as_str()
-                                                    .unwrap_or(food["properties"]["uid"][0].as_str().unwrap_or("#").truncate_ellipse(24).as_ref())
-                                            }
-                                        }
-                                    }
-                                }
-                            } }
-                        }
-                    }
-                }
-            }
-            @PhotoGallery { photos: post["properties"]["photo"].as_array() }
-            @if post["properties"]["content"][0]["html"].is_string() {
-                main."e-content" {
-                    @markup::raw(post["properties"]["content"][0]["html"].as_str().unwrap().trim())
-                }
-            }
-            @WebInteractions { post }
-        }
-    }
-    PhotoGallery<'a>(photos: Option<&'a Vec<serde_json::Value>>) {
-        @if let Some(photos) = photos {
-            @for photo in photos.iter() {
-                @if let Some(photo) = photo.as_str() {
-                    img."u-photo"[src=photo, loading="lazy"];
-                } else if photo.is_object() {
-                    @if let Some(thumbnail) = photo["thumbnail"].as_str() {
-                        a."u-photo"[href=photo["value"].as_str().unwrap()] {
-                            img[src=thumbnail,
-                                loading="lazy",
-                                alt=photo["alt"].as_str().unwrap_or("")
-                            ];
-                        }
-                    } else {
-                        img."u-photo"[src=photo["value"].as_str().unwrap(),
-                                      loading="lazy",
-                                      alt=photo["alt"].as_str().unwrap_or("")
-                        ];
-                    }
-                }
-            }
-        }
-    }
-    WebInteractions<'a>(post: &'a serde_json::Value) {
-        footer.webinteractions {
-            ul.counters {
-                li {
-                    span.icon { "❤️" }
-                    span.counter { @post["properties"]["like"].as_array().map(|a| a.len()).unwrap_or(0) }
-                }
-                li {
-                    span.icon { "💬" }
-                    span.counter { @post["properties"]["comment"].as_array().map(|a| a.len()).unwrap_or(0) }
-                }
-                li {
-                    span.icon { "🔄" }
-                    span.counter { @post["properties"]["repost"].as_array().map(|a| a.len()).unwrap_or(0) }
-                }
-                li {
-                    span.icon { "🔖" }
-                    span.counter { @post["properties"]["bookmark"].as_array().map(|a| a.len()).unwrap_or(0) }
-                }
-            }
-            /*@if (
-                post["properties"]["like"].as_array().map(|a| a.len()).unwrap_or(0)
-                    + post["properties"]["bookmark"].as_array().map(|a| a.len()).unwrap_or(0)
-                    + post["properties"]["repost"].as_array().map(|a| a.len()).unwrap_or(0)
-                    + post["properties"]["comment"].as_array().map(|a| a.len()).unwrap_or(0)
-            ) > 0 {
-                details {
-                    summary { "Show comments and reactions" }
-                    // TODO actually render facepiles and comments
-                    @if let Some(likes) = post["properties"]["like"].as_array() {
-                        @if !likes.is_empty() {
-                            // Show a facepile of likes for a post
-                        }
-                    }
-                    @if let Some(bookmarks) = post["properties"]["bookmark"].as_array() {
-                        @if !bookmarks.is_empty() {
-                            // Show a facepile of bookmarks for a post
-                        }
-                    }
-                    @if let Some(reposts) = post["properties"]["repost"].as_array() {
-                        @if !reposts.is_empty() {
-                            // Show a facepile of reposts for a post
-                        }
-                    }
-                    @if let Some(comments) = post["properties"]["comment"].as_array() {
-                        @for comment in comments.iter() {
-                            // Show all the comments recursively (so we could do Salmention with them)
-                        }
-                    }
-                }
-            }*/
-        }
-    }
-    VCard<'a>(card: &'a serde_json::Value) {
-        article."h-card" {
-            @if card["properties"]["photo"][0].is_string() {
-                img."u-photo"[src=card["properties"]["photo"][0].as_str().unwrap()];
-            }
-            h1 {
-                a."u-url"."u-uid"."p-name"[href=card["properties"]["uid"][0].as_str().unwrap()] {
-                    @card["properties"]["name"][0].as_str().unwrap()
-                }
-            }
-            @if card["properties"]["pronoun"].is_array() {
-                span {
-                    "("
-                    @for (i, pronoun) in card["properties"]["pronoun"].as_array().unwrap().iter().filter_map(|v| v.as_str()).enumerate() {
-                        span."p-pronoun" {
-                            @pronoun
-                        }
-                        // Insert commas between multiple sets of pronouns
-                        @if i < (card["properties"]["pronoun"].as_array().unwrap().len() - 1) {", "}
-                    }
-                    ")"
-                }
-            }
-            @if card["properties"]["note"].is_array() {
-                p."p-note" {
-                    @card["properties"]["note"][0]["value"].as_str().unwrap_or_else(|| card["properties"]["note"][0].as_str().unwrap())
-                }
-            }
-            @if card["properties"]["url"].is_array() {
-                ul {
-                    "Can be found elsewhere at:"
-                    @for url in card["properties"]["url"].as_array().unwrap().iter().filter_map(|v| v.as_str()).filter(|v| v != &card["properties"]["uid"][0].as_str().unwrap()).filter(|v| !v.starts_with(&card["properties"]["author"][0].as_str().unwrap())) {
-                        li { a."u-url"[href=url, rel="me"] { @url } }
-                    }
-                }
-            }
-        }
-    }
-    Food<'a>(food: &'a serde_json::Value) {
-        article."h-food" {
-            header.metadata {
-                h1 {
-                    a."p-name"."u-url"[href=food["properties"]["url"][0].as_str().unwrap()] {
-                        @food["properties"]["name"][0].as_str().unwrap()
-                    }
-                }
-            }
-            @PhotoGallery { photos: food["properties"]["photo"].as_array() }
-        }
-    }
-    Feed<'a>(feed: &'a serde_json::Value) {
-        div."h-feed" {
-            div.metadata {
-                @if feed["properties"]["name"][0].is_string() {
-                    h1."p-name".titanic {
-                        a[href=feed["properties"]["uid"][0].as_str().unwrap(), rel="feed"] {
-                            @feed["properties"]["name"][0].as_str().unwrap()
-                        }
-                    }
-                }
-            }
-            @if feed["children"].is_array() {
-                @for child in feed["children"].as_array().unwrap() {
-                    @match child["type"][0].as_str().unwrap() {
-                        "h-entry" => { @Entry { post: child } }
-                        "h-feed" => { @Feed { feed: child } }
-                        "h-event" => {
-                            @{error!("Templating error: h-events aren't implemented yet");}
-                        }
-                        "h-card" => { @VCard { card: child }}
-                        something_else => {
-                            @{error!("Templating error: found a {} object that couldn't be parsed", something_else);}
-                        }
-                    }
-                }
-            }
-            @if feed["children"].as_array().map(|a| a.len()).unwrap_or(0) == 0 {
-                p {
-                    "Looks like you reached the end. Wanna jump back to the "
-                    a[href=feed["properties"]["uid"][0].as_str().unwrap()] {
-                        "beginning"
-                    } "?"
-                }
-            }
-            @if feed["children"].as_array().map(|a| a.len()).unwrap_or(0) == super::POSTS_PER_PAGE {
-                a[rel="prev", href=feed["properties"]["uid"][0].as_str().unwrap().to_string()
-                    + "?after=" + feed["children"][super::POSTS_PER_PAGE - 1]["properties"]["uid"][0].as_str().unwrap()] {
-                    "Older posts"
-                }
-            }
-        }
-    }
     MainPage<'a>(feed: &'a serde_json::Value, card: &'a serde_json::Value) {
         .sidebyside {
             @VCard { card }