Learning project: static site generator for ATproto PDS

Adds support for leaflet facets #4

merged opened by hello.j23n.com targeting main from feature/leaflet-facets
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:enau5rzvrui4fx4dq5icgtle/sh.tangled.repo.pull/3m3q4rrjyoa22
+118 -8
Diff #0
+1
Cargo.lock
··· 1281 1281 "clap", 1282 1282 "dotenvy", 1283 1283 "serde", 1284 + "serde_json", 1284 1285 "tera", 1285 1286 "tiny_http", 1286 1287 "tokio",
+1
Cargo.toml
··· 11 11 clap = { version = "4.5.49", features = ["derive"] } 12 12 dotenvy = "0.15.7" 13 13 serde = { version = "1.0.228", features = ["derive"] } 14 + serde_json = "1.0.145" 14 15 tera = "1.20.0" 15 16 tiny_http = "0.12.0" 16 17 tokio = { version = "1.47.1", features = ["full"] }
+43 -4
src/atproto/types/leaflet.rs
··· 26 26 pub block: Block, 27 27 } 28 28 29 + #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] 30 + pub struct Facet { 31 + pub index: Index, 32 + pub features: Vec<Feature>, 33 + } 34 + 35 + #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] 36 + pub struct Index { 37 + #[serde(rename = "byteEnd")] 38 + pub byte_end: u32, 39 + #[serde(rename = "byteStart")] 40 + pub byte_start: u32, 41 + } 42 + 43 + #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] 44 + #[serde(tag = "$type")] 45 + pub enum Feature { 46 + #[serde(rename = "pub.leaflet.richtext.facet#code")] 47 + Code, 48 + #[serde(rename = "pub.leaflet.richtext.facet#highlight")] 49 + Highlight, 50 + #[serde(rename = "pub.leaflet.richtext.facet#link")] 51 + Link { uri: String }, 52 + #[serde(rename = "pub.leaflet.richtext.facet#underline")] 53 + Underline, 54 + #[serde(rename = "pub.leaflet.richtext.facet#strikethrough")] 55 + StrikeThrough, 56 + #[serde(rename = "pub.leaflet.richtext.facet#id")] 57 + Id { id: String }, 58 + #[serde(rename = "pub.leaflet.richtext.facet#bold")] 59 + Bold, 60 + #[serde(rename = "pub.leaflet.richtext.facet#italic")] 61 + Italic, 62 + } 63 + 29 64 #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] 30 65 #[serde(tag = "$type")] 31 66 pub enum Block { 32 67 #[serde(rename = "pub.leaflet.blocks.blockquote")] 33 68 Blockquote { 34 69 plaintext: String, 35 - // TODO facets: [&pub.leaflet.richtext.facet] 70 + facets: Vec<Facet>, 36 71 }, 37 72 38 73 #[serde(rename = "pub.leaflet.blocks.code")] ··· 53 88 Header { 54 89 level: Option<u32>, 55 90 plaintext: String, 56 - // TODO: facets: [&pub.leaflet.richtext.facet 91 + facets: Vec<Facet>, 57 92 }, 58 93 59 94 #[serde(rename = "pub.leaflet.blocks.horizontalRule")] ··· 76 111 #[serde(rename = "pub.leaflet.blocks.text")] 77 112 Text { 78 113 plaintext: String, 79 - // TODO: facets: [&pub.leaflet.richtext.facet] 114 + facets: Vec<Facet>, 80 115 }, 81 116 82 117 #[serde(rename = "pub.leaflet.blocks.unorderedList")] ··· 95 130 #[serde(tag = "$type")] 96 131 pub enum ListItemContent { 97 132 #[serde(rename = "pub.leaflet.blocks.text")] 98 - Text { plaintext: String }, 133 + Text { 134 + plaintext: String, 135 + facets: Vec<Facet>, 136 + }, 99 137 #[serde(rename = "pub.leaflet.blocks.header")] 100 138 Header { 101 139 level: Option<u32>, 102 140 plaintext: String, 141 + facets: Vec<Facet>, 103 142 }, 104 143 #[serde(rename = "pub.leaflet.blocks.image")] 105 144 Image {
+1
src/generator.rs
··· 48 48 Some("home-blurb.html"), 49 49 )])?; 50 50 tera.register_filter("blob_to_url", filters::blob_to_url_filter); 51 + tera.register_filter("apply_facets", filters::apply_facets); 51 52 52 53 let username = config.pds.username.clone(); 53 54 let password = config.pds.password.clone();
+69 -1
src/templates/filters.rs
··· 1 + use crate::atproto::types::leaflet::{Facet, Feature}; 1 2 use std::collections::HashMap; 2 - use tera::{Value, to_value}; 3 + use tera::{Result as TeraResult, Value, to_value}; 3 4 4 5 pub fn blob_to_url_filter(value: &Value, args: &HashMap<String, Value>) -> tera::Result<Value> { 5 6 let host = args ··· 39 40 40 41 Err(tera::Error::msg("Could not extract CID from BlobRef")) 41 42 } 43 + 44 + pub fn apply_facets(value: &Value, args: &HashMap<String, Value>) -> TeraResult<Value> { 45 + let plaintext = value.as_str().unwrap(); 46 + 47 + let facets_value = args 48 + .get("facets") 49 + .ok_or_else(|| tera::Error::msg("apply_facets: missing 'facets' argument"))?; 50 + 51 + let facets: Vec<Facet> = serde_json::from_value(facets_value.clone()) 52 + .map_err(|e| tera::Error::msg(format!("Failed to parse facets: {}", e)))?; 53 + 54 + let html = render_text_with_facets(plaintext, facets); 55 + 56 + Ok(tera::Value::String(html)) 57 + } 58 + 59 + fn render_text_with_facets(plaintext: &str, facets: Vec<Facet>) -> String { 60 + let mut tokens: Vec<(u32, String)> = facets 61 + .iter() 62 + .flat_map(|f| { 63 + f.features.iter().flat_map(move |feat| { 64 + let token_close = match feat { 65 + Feature::Code => String::from("</code>"), 66 + Feature::Highlight => String::from("</mark>"), 67 + Feature::Link { uri } => String::from("</a>"), 68 + Feature::Underline => String::from("</u>"), 69 + Feature::StrikeThrough => String::from("</s>"), 70 + Feature::Id { id } => String::from("</a>"), 71 + Feature::Bold => String::from("</b>"), 72 + Feature::Italic => String::from("</i>"), 73 + }; 74 + let token_open = match feat { 75 + Feature::Code => String::from("<code>"), 76 + Feature::Highlight => String::from("<mark>"), 77 + Feature::Link { uri } => format!("<a href='{}'>", uri), 78 + Feature::Underline => String::from("<u>"), 79 + Feature::StrikeThrough => String::from("<s>"), 80 + Feature::Id { id } => format!("<a href='{}'>", id), 81 + Feature::Bold => String::from("<b>"), 82 + Feature::Italic => String::from("<i>"), 83 + }; 84 + [ 85 + (f.index.byte_end, token_close), 86 + (f.index.byte_start, token_open), 87 + ] 88 + }) 89 + }) 90 + .collect(); 91 + 92 + let mut rendered_html = String::from(plaintext); 93 + 94 + // it's possible for one facet to have a startByte equal to the endByte 95 + // of another facet. To avoid things like <b>asdf<i></b>, we need to 96 + // sort again. 97 + tokens.sort_by(|a, b| match b.0.cmp(&a.0) { 98 + std::cmp::Ordering::Equal => { 99 + let a_is_closing = a.1.starts_with("</"); 100 + let b_is_closing = b.1.starts_with("</"); 101 + b_is_closing.cmp(&a_is_closing).reverse() 102 + } 103 + other => other, 104 + }); 105 + tokens 106 + .iter() 107 + .for_each(|pair| rendered_html.insert_str(pair.0 as usize, &pair.1)); 108 + rendered_html 109 + }
+3 -3
templates/macros.html
··· 4 4 {%- set block = block %} 5 5 6 6 {%- if type == "pub.leaflet.blocks.text" %} 7 - <p>{{ block.plaintext }}</p> 7 + <p>{{ block.plaintext | apply_facets(facets=block.facets) | safe }}</p> 8 8 {%- elif type == "pub.leaflet.blocks.code" %} 9 9 <pre><code class="{{ block.language }}">{{ block.plaintext }}</code></pre> 10 10 {%- elif type == "pub.leaflet.blocks.image" %} ··· 12 12 {% if block.alt %}<small>{{ block.alt }}</small>{% endif %} 13 13 {%- elif type == "pub.leaflet.blocks.header" %} 14 14 {%- set level = block.level | default(value="3") %} 15 - <h{{ level }}>{{ block.plaintext }}</h{{ level }}> 15 + <h{{ level }}>{{ block.plaintext | apply_facets(facets=block.facets) | safe }}</h{{ level }}> 16 16 {%- elif type == "pub.leaflet.blocks.unorderedList" %} 17 17 <ul> 18 18 {%- for child in block.children %} ··· 21 21 </ul> 22 22 {%- elif type == "pub.leaflet.blocks.blockquote" %} 23 23 <blockquote> 24 - <p>{{ block.plaintext }}</p> 24 + <p>{{ block.plaintext | apply_facets(facets=block.facets) | safe }}</p> 25 25 </blockquote> 26 26 {%- elif type == "pub.leaflet.blocks.horizontalRule" %} 27 27 <hr />

History

1 round 0 comments
sign up or login to add to the discussion
hello.j23n.com submitted #0
1 commit
expand
add leaflet facets support
expand 0 comments
pull request successfully merged