Learning project: static site generator for ATproto PDS

Adds tangled repo, issue and PR support to mosaic #1

merged opened by hello.j23n.com targeting main from feature/tangled
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:enau5rzvrui4fx4dq5icgtle/sh.tangled.repo.pull/3m3phqhwgot22
+264 -159
Diff #1
+13 -129
src/atproto/mod.rs
··· 1 1 use std::path::PathBuf; 2 2 3 - use atrium_api::{ 4 - app::bsky::embed::defs::AspectRatio, 5 - types::{BlobRef, Unknown}, 6 - }; 7 - 8 3 mod client; 9 4 mod renderable; 10 - pub use atrium_api::app::bsky::feed::post::RecordData as BskyPost; 5 + pub mod types; 6 + 11 7 pub use client::fetch_records; 12 8 pub use renderable::Renderable; 13 9 use tera::{Context, Tera}; 14 - 15 - #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] 16 - pub struct Document { 17 - pub author: String, 18 - pub description: String, 19 - pub title: String, 20 - pub pages: Vec<Page>, 21 - #[serde(rename = "postRef")] 22 - pub post_ref: Option<Unknown>, 23 - pub publication: Unknown, 24 - #[serde(rename = "publishedAt")] 25 - pub published_at: String, 26 - #[serde(skip)] 27 - pub host: String, 28 - #[serde(skip)] 29 - pub did: String, 30 - } 31 - 32 - #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] 33 - pub struct Page { 34 - pub blocks: Vec<BlockWrapper>, 35 - } 36 - 37 - #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] 38 - pub struct BlockWrapper { 39 - pub block: Block, 40 - } 41 - 42 - #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] 43 - #[serde(tag = "$type")] 44 - pub enum Block { 45 - #[serde(rename = "pub.leaflet.blocks.blockquote")] 46 - Blockquote { 47 - plaintext: String, 48 - // TODO facets: [&pub.leaflet.richtext.facet] 49 - }, 50 - 51 - #[serde(rename = "pub.leaflet.blocks.code")] 52 - Code { 53 - plaintext: String, 54 - language: Option<String>, 55 - #[serde(rename = "syntaxHighlightingTheme")] 56 - syntax_highlighting_theme: Option<String>, 57 - }, 58 - 59 - #[serde(rename = "pub.leaflet.blocks.bskyPost")] 60 - BskyPost { 61 - #[serde(rename = "postRef")] 62 - post_ref: String, 63 - }, 64 - 65 - #[serde(rename = "pub.leaflet.blocks.header")] 66 - Header { 67 - level: Option<u32>, 68 - plaintext: String, 69 - // TODO: facets: [&pub.leaflet.richtext.facet 70 - }, 71 - 72 - #[serde(rename = "pub.leaflet.blocks.horizontalRule")] 73 - HorizontalRule {}, 74 - 75 - #[serde(rename = "pub.leaflet.blocks.iframe")] 76 - IFrame { url: String, height: Option<u32> }, 77 - 78 - #[serde(rename = "pub.leaflet.blocks.image")] 79 - Image { 80 - image: BlobRef, 81 - alt: Option<String>, 82 - #[serde(rename = "aspectRatio")] 83 - aspect_ratio: AspectRatio, 84 - }, 85 - 86 - #[serde(rename = "pub.leaflet.blocks.math")] 87 - Math { tex: String }, 88 - 89 - #[serde(rename = "pub.leaflet.blocks.text")] 90 - Text { 91 - plaintext: String, 92 - // TODO: facets: [&pub.leaflet.richtext.facet] 93 - }, 94 - 95 - #[serde(rename = "pub.leaflet.blocks.unorderedList")] 96 - UnorderedList { children: Vec<ListItem> }, 97 - 98 - #[serde(other)] 99 - Unknown, 100 - } 101 - 102 - #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] 103 - pub struct ListItem { 104 - content: ListItemContent, 105 - } 106 - 107 - #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] 108 - #[serde(tag = "$type")] 109 - pub enum ListItemContent { 110 - #[serde(rename = "pub.leaflet.blocks.text")] 111 - Text { plaintext: String }, 112 - #[serde(rename = "pub.leaflet.blocks.header")] 113 - Header { 114 - level: Option<u32>, 115 - plaintext: String, 116 - }, 117 - #[serde(rename = "pub.leaflet.blocks.image")] 118 - Image { 119 - image: BlobRef, 120 - alt: Option<String>, 121 - #[serde(rename = "aspectRatio")] 122 - aspect_ratio: AspectRatio, 123 - }, 124 - } 125 - 126 - #[derive(Debug, serde::Deserialize, serde::Serialize)] 127 - pub struct Website { 128 - src: String, 129 - description: Option<String>, 130 - title: Option<String>, 131 - #[serde(rename = "previewImage")] 132 - preview_image: BlobRef, 133 - } 10 + use types::bsky::Post; 11 + use types::leaflet::Document; 12 + use types::tangled::Repo; 134 13 135 14 #[derive(Clone, serde::Serialize)] 136 15 pub enum ContentType { 137 16 Document(Document), 138 - BskyPost(BskyPost), 17 + Post(Post), 18 + Repo(Repo), 139 19 } 140 20 141 21 #[derive(Clone, serde::Serialize)] ··· 179 59 fn build_name(content: &ContentType) -> String { 180 60 match content { 181 61 ContentType::Document(doc) => doc.title.clone(), 182 - ContentType::BskyPost(post) => String::from(post.created_at.as_str()), 62 + ContentType::Post(post) => String::from(post.created_at.as_str()), 63 + ContentType::Repo(repo) => repo.name.clone(), 183 64 } 184 65 } 185 66 } ··· 238 119 context.insert("host", &doc.host); 239 120 context.insert("did", &doc.did); 240 121 } 241 - ContentType::BskyPost(post) => { 122 + ContentType::Post(post) => { 242 123 context.insert("post", post); 124 + } 125 + ContentType::Repo(repo) => { 126 + context.insert("repo", repo); 243 127 } 244 128 } 245 129 tera.render(&self.template, &context)
+1
src/atproto/types/bsky.rs
··· 1 + pub use atrium_api::app::bsky::feed::post::RecordData as Post;
+124
src/atproto/types/leaflet.rs
··· 1 + use atrium_api::{ 2 + app::bsky::embed::defs::AspectRatio, 3 + types::{BlobRef, Unknown}, 4 + }; 5 + 6 + #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] 7 + pub struct Document { 8 + pub author: String, 9 + pub description: String, 10 + pub title: String, 11 + pub pages: Vec<Page>, 12 + #[serde(rename = "postRef")] 13 + pub post_ref: Option<Unknown>, 14 + pub publication: Unknown, 15 + #[serde(rename = "publishedAt")] 16 + pub published_at: String, 17 + #[serde(skip)] 18 + pub host: String, 19 + #[serde(skip)] 20 + pub did: String, 21 + } 22 + 23 + #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] 24 + pub struct Page { 25 + pub blocks: Vec<BlockWrapper>, 26 + } 27 + 28 + #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] 29 + pub struct BlockWrapper { 30 + pub block: Block, 31 + } 32 + 33 + #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] 34 + #[serde(tag = "$type")] 35 + pub enum Block { 36 + #[serde(rename = "pub.leaflet.blocks.blockquote")] 37 + Blockquote { 38 + plaintext: String, 39 + // TODO facets: [&pub.leaflet.richtext.facet] 40 + }, 41 + 42 + #[serde(rename = "pub.leaflet.blocks.code")] 43 + Code { 44 + plaintext: String, 45 + language: Option<String>, 46 + #[serde(rename = "syntaxHighlightingTheme")] 47 + syntax_highlighting_theme: Option<String>, 48 + }, 49 + 50 + #[serde(rename = "pub.leaflet.blocks.bskyPost")] 51 + BskyPost { 52 + #[serde(rename = "postRef")] 53 + post_ref: String, 54 + }, 55 + 56 + #[serde(rename = "pub.leaflet.blocks.header")] 57 + Header { 58 + level: Option<u32>, 59 + plaintext: String, 60 + // TODO: facets: [&pub.leaflet.richtext.facet 61 + }, 62 + 63 + #[serde(rename = "pub.leaflet.blocks.horizontalRule")] 64 + HorizontalRule {}, 65 + 66 + #[serde(rename = "pub.leaflet.blocks.iframe")] 67 + IFrame { url: String, height: Option<u32> }, 68 + 69 + #[serde(rename = "pub.leaflet.blocks.image")] 70 + Image { 71 + image: BlobRef, 72 + alt: Option<String>, 73 + #[serde(rename = "aspectRatio")] 74 + aspect_ratio: AspectRatio, 75 + }, 76 + 77 + #[serde(rename = "pub.leaflet.blocks.math")] 78 + Math { tex: String }, 79 + 80 + #[serde(rename = "pub.leaflet.blocks.text")] 81 + Text { 82 + plaintext: String, 83 + // TODO: facets: [&pub.leaflet.richtext.facet] 84 + }, 85 + 86 + #[serde(rename = "pub.leaflet.blocks.unorderedList")] 87 + UnorderedList { children: Vec<ListItem> }, 88 + 89 + #[serde(other)] 90 + Unknown, 91 + } 92 + 93 + #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] 94 + pub struct ListItem { 95 + content: ListItemContent, 96 + } 97 + 98 + #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] 99 + #[serde(tag = "$type")] 100 + pub enum ListItemContent { 101 + #[serde(rename = "pub.leaflet.blocks.text")] 102 + Text { plaintext: String }, 103 + #[serde(rename = "pub.leaflet.blocks.header")] 104 + Header { 105 + level: Option<u32>, 106 + plaintext: String, 107 + }, 108 + #[serde(rename = "pub.leaflet.blocks.image")] 109 + Image { 110 + image: BlobRef, 111 + alt: Option<String>, 112 + #[serde(rename = "aspectRatio")] 113 + aspect_ratio: AspectRatio, 114 + }, 115 + } 116 + 117 + #[derive(Debug, serde::Deserialize, serde::Serialize)] 118 + pub struct Website { 119 + src: String, 120 + description: Option<String>, 121 + title: Option<String>, 122 + #[serde(rename = "previewImage")] 123 + preview_image: BlobRef, 124 + }
+3
src/atproto/types/mod.rs
··· 1 + pub mod bsky; 2 + pub mod leaflet; 3 + pub mod tangled;
+45
src/atproto/types/tangled.rs
··· 1 + #[derive(serde::Deserialize, serde::Serialize, Clone)] 2 + #[serde(tag = "$type")] 3 + pub struct Repo { 4 + pub knot: String, 5 + pub name: String, 6 + pub labels: Vec<String>, 7 + #[serde(rename = "createdAt")] 8 + pub created_at: String, 9 + pub description: String, 10 + } 11 + 12 + #[derive(serde::Serialize, serde::Deserialize, Clone)] 13 + #[serde(tag = "$type")] 14 + pub struct Issue { 15 + pub body: String, 16 + pub title: String, 17 + pub repo: String, 18 + #[serde(rename = "createdAt")] 19 + pub created_at: String, 20 + } 21 + 22 + #[derive(serde::Serialize, serde::Deserialize, Clone)] 23 + #[serde(tag = "$type")] 24 + pub struct Pull { 25 + pub patch: String, 26 + pub title: String, 27 + pub source: PullSource, 28 + pub target: PullTarget, 29 + #[serde(rename = "createdAt")] 30 + pub created_at: String, 31 + } 32 + 33 + #[derive(serde::Serialize, serde::Deserialize, Clone)] 34 + #[serde(tag = "$type")] 35 + pub struct PullSource { 36 + pub sha: String, 37 + pub branch: String, 38 + } 39 + 40 + #[derive(serde::Serialize, serde::Deserialize, Clone)] 41 + #[serde(tag = "$type")] 42 + pub struct PullTarget { 43 + pub repo: String, 44 + pub branch: String, 45 + }
+2 -3
src/config/mod.rs
··· 1 - use serde::Deserialize; 2 1 use std::path::PathBuf; 3 2 4 - #[derive(Deserialize)] 3 + #[derive(serde::Deserialize, serde::Serialize, Clone)] 5 4 pub struct Config { 6 5 pub pds: PDS, 7 6 } 8 7 9 - #[derive(Deserialize)] 8 + #[derive(serde::Deserialize, serde::Serialize, Clone)] 10 9 pub struct PDS { 11 10 pub username: String, 12 11 pub password: String,
+56 -26
src/generator.rs
··· 7 7 use crate::config::Config; 8 8 use crate::templates::filters; 9 9 10 - use crate::atproto::{ 11 - BskyPost, ContentPage, ContentType, Document, IndexPage, Renderable, fetch_records, 10 + use crate::atproto::types::{ 11 + bsky::Post, 12 + leaflet::Document, 13 + tangled::{Issue, Pull, Repo}, 12 14 }; 15 + use crate::atproto::{ContentPage, ContentType, IndexPage, Renderable, fetch_records}; 13 16 14 17 use std::path::{Path, PathBuf}; 18 + 19 + #[derive(serde::Serialize)] 20 + pub struct Site { 21 + config: Config, 22 + did: AtIdentifier, 23 + host: String, 24 + } 15 25 16 26 pub async fn generate(config: &Config) -> Result<usize, Box<dyn std::error::Error>> { 17 27 let mut tera = match Tera::new("templates/**/*.html") { ··· 38 48 39 49 println!("Resolved did: {:?}", did); 40 50 41 - let doc_records = fetch_records(&agent, did.clone(), "pub.leaflet.document").await?; 42 - let documents: Vec<Document> = doc_records 43 - .into_iter() 44 - .filter_map(|r| match Document::try_from_unknown(r.value.clone()) { 45 - Ok(mut doc) => { 46 - doc.host = host.clone(); 47 - doc.did = did.as_ref().to_string(); 48 - Some(doc) 49 - } 50 - Err(e) => { 51 - eprintln!("Failed to deserialize: {:?}", e); 52 - None 53 - } 54 - }) 55 - .collect(); 51 + let site = Site { 52 + config: config.clone(), 53 + did: did.clone(), 54 + host: host.clone(), 55 + }; 56 56 57 - let post_records = fetch_records(&agent, did.clone(), "app.bsky.feed.post").await?; 58 - let posts: Vec<BskyPost> = post_records 59 - .into_iter() 60 - .filter_map(|r| BskyPost::try_from_unknown(r.value.clone()).ok()) 61 - .collect(); 57 + // get and parse records from PDS 58 + let documents = fetch_and_deserialize(&agent, &did, "pub.leaflet.document").await?; 59 + let posts = fetch_and_deserialize(&agent, &did, "app.bsky.feed.post").await?; 60 + let repos = fetch_and_deserialize(&agent, &did, "sh.tangled.repo").await?; 61 + let issues = fetch_and_deserialize(&agent, &did, "sh.tangled.repo.issue").await?; 62 + let pulls = fetch_and_deserialize(&agent, &did, "sh.tangled.repo.pull").await?; 62 63 63 - let num_generated = _generate(posts, documents, &tera)?; 64 + // generate static pages 65 + let num_generated = _generate(site, posts, documents, repos, issues, pulls, &tera)?; 64 66 65 67 println!("Done! Generated {:?} files.", num_generated); 66 68 67 69 Ok(num_generated) 68 70 } 69 71 72 + async fn fetch_and_deserialize<T: TryFromUnknown>( 73 + agent: &AtpAgent<MemorySessionStore, ReqwestClient>, 74 + did: &AtIdentifier, 75 + record_id: &str, 76 + ) -> Result<Vec<T>, Box<dyn std::error::Error>> { 77 + let records = fetch_records(&agent, did.clone(), record_id).await?; 78 + let parsed: Vec<T> = records 79 + .into_iter() 80 + .filter_map(|r| T::try_from_unknown(r.value.clone()).ok()) 81 + .collect(); 82 + Ok(parsed) 83 + } 84 + 70 85 fn _generate( 71 - posts: Vec<BskyPost>, 86 + site: Site, 87 + posts: Vec<Post>, 72 88 documents: Vec<Document>, 89 + repos: Vec<Repo>, 90 + issues: Vec<Issue>, 91 + pulls: Vec<Pull>, 73 92 tera: &Tera, 74 93 ) -> Result<usize, Box<dyn std::error::Error>> { 75 94 let mut counter = 0; ··· 81 100 // Create ContentPages for content 82 101 let post_pages: Vec<ContentPage> = posts 83 102 .into_iter() 84 - .map(|i| ContentPage::new(ContentType::BskyPost(i), "post.html", "posts")) 103 + .map(|i| ContentPage::new(ContentType::Post(i), "post.html", "posts")) 85 104 .collect(); 86 105 87 106 let document_pages: Vec<ContentPage> = documents ··· 89 108 .map(|i| ContentPage::new(ContentType::Document(i), "document.html", "documents")) 90 109 .collect(); 91 110 111 + let repo_pages: Vec<ContentPage> = repos 112 + .into_iter() 113 + .map(|r| ContentPage::new(ContentType::Repo(r), "repo.html", "repos")) 114 + .collect(); 115 + 92 116 // Render individual pages 93 - for pages in [&document_pages, &post_pages] { 117 + for pages in [&document_pages, &post_pages, &repo_pages] { 94 118 for page in pages.iter() { 95 119 let html = page.render(tera)?; 96 120 let mut path = PathBuf::from(output_root); ··· 107 131 let document_index = generate_index(&document_pages, output_root, "documents", tera)?; 108 132 counter += 1; 109 133 134 + let repo_index = generate_index(&repo_pages, output_root, "repos", tera)?; 135 + counter += 1; 136 + 110 137 let post_index = generate_index(&post_pages, output_root, "posts", tera)?; 111 138 counter += 1; 112 139 ··· 114 141 generate_home( 115 142 &(document_index, document_pages), 116 143 &(post_index, post_pages), 144 + &(repo_index, repo_pages), 117 145 output_root, 118 146 tera, 119 147 )?; ··· 124 152 fn generate_home( 125 153 documents: &(IndexPage, Vec<ContentPage>), 126 154 posts: &(IndexPage, Vec<ContentPage>), 155 + repos: &(IndexPage, Vec<ContentPage>), 127 156 output_root: &Path, 128 157 tera: &Tera, 129 158 ) -> Result<(), tera::Error> { ··· 133 162 let mut context = Context::new(); 134 163 context.insert("documents", documents); 135 164 context.insert("posts", posts); 165 + context.insert("repos", repos); 136 166 let html = tera.render("home.html", &context)?; 137 167 138 168 std::fs::write(output_path, html)?;
+12 -1
templates/home.html
··· 3 3 {% block title %}j23n{% endblock %} 4 4 5 5 {% block content %} 6 - {% include "home-blurb.html" %} 6 + {% include "home-blurb.html" ignore missing %} 7 7 8 8 <section id="documents"> 9 9 {% set index = documents.0 %} ··· 12 12 <ul> 13 13 {% for document in pages %} 14 14 <li><a href="{{ document.url | safe }}">{{ document.name }}</a></li> 15 + {% endfor %} 16 + </ul> 17 + </section> 18 + 19 + <section id="repos"> 20 + {% set index = repos.0 %} 21 + {% set pages = repos.1 %} 22 + <h2><a href="{{ index.url | safe }}">{{ index.name | title }}</a></h2> 23 + <ul> 24 + {% for repo in pages %} 25 + <li><a href="{{ repo.url | safe }}">{{ repo.name }}</a></li> 15 26 {% endfor %} 16 27 </ul> 17 28 </section>
+8
templates/repo.html
··· 1 + {% extends "base.html" %} 2 + 3 + {% block content %} 4 + <article> 5 + <h2>{{ repo.name }}</h2> 6 + <p>{{ repo.description }}<p> 7 + </article> 8 + {% endblock %}

History

2 rounds 0 comments
sign up or login to add to the discussion
2 commits
expand
WIP: add tangled support
add rudimentary tangled support
expand 0 comments
pull request successfully merged
2 commits
expand
add init subcommand
WIP: add tangled support
expand 0 comments