at main 576 lines 18 kB view raw
1use std::fmt::Write; 2 3use jacquard::client::AgentSessionExt; 4use jacquard::types::cid::Cid; 5use jacquard::types::string::{AtUri, Did}; 6use markdown_weaver_escape::escape_html; 7use weaver_api::pub_leaflet::blocks::{ 8 blockquote::Blockquote, 9 bsky_post::BskyPost, 10 button::Button, 11 code::Code, 12 header::Header, 13 iframe::Iframe, 14 image::Image, 15 math::Math, 16 page::Page, 17 poll::Poll, 18 text::Text, 19 unordered_list::{ListItem, ListItemContent, UnorderedList}, 20 website::Website, 21}; 22use weaver_api::pub_leaflet::pages::linear_document::{Block, BlockBlock, LinearDocument}; 23 24use crate::facet::{NormalizedFacet, render_faceted_html}; 25 26pub struct LeafletRenderContext { 27 pub author_did: Did<'static>, 28} 29 30impl LeafletRenderContext { 31 pub fn new(author_did: Did<'static>) -> Self { 32 Self { author_did } 33 } 34 35 fn blob_url(&self, cid: &Cid<'_>) -> String { 36 format!( 37 "https://leaflet.pub/api/atproto_images?did={}&cid={}", 38 self.author_did.as_ref(), 39 cid.as_ref() 40 ) 41 } 42} 43 44pub async fn render_linear_document<A: AgentSessionExt>( 45 doc: &LinearDocument<'_>, 46 ctx: &LeafletRenderContext, 47 agent: &A, 48) -> String { 49 let mut html = String::new(); 50 html.push_str("<div class=\"leaflet-document\">"); 51 52 for block in &doc.blocks { 53 html.push_str(&render_block(block, ctx, agent).await); 54 } 55 56 html.push_str("</div>"); 57 html 58} 59 60pub async fn render_block<A: AgentSessionExt>( 61 block: &Block<'_>, 62 ctx: &LeafletRenderContext, 63 agent: &A, 64) -> String { 65 let mut html = String::new(); 66 67 let alignment_class = block 68 .alignment 69 .as_ref() 70 .map(|a| match a.as_ref() { 71 "pub.leaflet.pages.linearDocument#textAlignCenter" => " align-center", 72 "pub.leaflet.pages.linearDocument#textAlignRight" => " align-right", 73 "pub.leaflet.pages.linearDocument#textAlignJustify" => " align-justify", 74 _ => "", 75 }) 76 .unwrap_or(""); 77 78 match &block.block { 79 BlockBlock::Text(text) => { 80 render_text_block(&mut html, text, alignment_class); 81 } 82 BlockBlock::Header(header) => { 83 render_header_block(&mut html, header, alignment_class); 84 } 85 BlockBlock::Blockquote(quote) => { 86 render_blockquote_block(&mut html, quote); 87 } 88 BlockBlock::Code(code) => { 89 render_code_block(&mut html, code); 90 } 91 BlockBlock::UnorderedList(list) => { 92 render_unordered_list(&mut html, list, ctx, agent).await; 93 } 94 BlockBlock::Image(image) => { 95 render_image_block(&mut html, image, ctx); 96 } 97 BlockBlock::Website(website) => { 98 render_website_block(&mut html, website, ctx); 99 } 100 BlockBlock::Iframe(iframe) => { 101 render_iframe_block(&mut html, iframe); 102 } 103 BlockBlock::BskyPost(post) => { 104 render_bsky_post_block(&mut html, post, agent).await; 105 } 106 BlockBlock::Button(button) => { 107 render_button_block(&mut html, button); 108 } 109 BlockBlock::Poll(poll) => { 110 render_poll_block(&mut html, poll); 111 } 112 BlockBlock::HorizontalRule(_) => { 113 html.push_str("<hr />\n"); 114 } 115 BlockBlock::Page(page) => { 116 render_page_block(&mut html, page); 117 } 118 BlockBlock::Math(math) => { 119 render_math_block(&mut html, math); 120 } 121 BlockBlock::Unknown(data) => { 122 let _ = write!( 123 html, 124 "<div class=\"embed-unknown\">[Unknown block: {:?}]</div>\n", 125 data.type_discriminator() 126 ); 127 } 128 } 129 130 html 131} 132 133fn render_text_block(html: &mut String, text: &Text<'_>, alignment_class: &str) { 134 let _ = write!(html, "<p class=\"leaflet-text{}\">", alignment_class); 135 html.push_str(&render_faceted_text( 136 &text.plaintext, 137 text.facets.as_deref(), 138 )); 139 html.push_str("</p>\n"); 140} 141 142fn render_header_block(html: &mut String, header: &Header<'_>, alignment_class: &str) { 143 let level = header.level.unwrap_or(1).clamp(1, 6); 144 let _ = write!(html, "<h{}{}>", level, alignment_class); 145 html.push_str(&render_faceted_text( 146 &header.plaintext, 147 header.facets.as_deref(), 148 )); 149 let _ = write!(html, "</h{}>\n", level); 150} 151 152fn render_blockquote_block(html: &mut String, quote: &Blockquote<'_>) { 153 html.push_str("<blockquote>"); 154 html.push_str(&render_faceted_text( 155 &quote.plaintext, 156 quote.facets.as_deref(), 157 )); 158 html.push_str("</blockquote>\n"); 159} 160 161fn render_code_block(html: &mut String, code: &Code<'_>) { 162 html.push_str("<pre><code"); 163 if let Some(lang) = &code.language { 164 html.push_str(" class=\"language-"); 165 let _ = escape_html(&mut *html, lang.as_ref()); 166 html.push('"'); 167 } 168 html.push('>'); 169 let _ = escape_html(&mut *html, &code.plaintext); 170 html.push_str("</code></pre>\n"); 171} 172 173async fn render_unordered_list<A: AgentSessionExt>( 174 html: &mut String, 175 list: &UnorderedList<'_>, 176 ctx: &LeafletRenderContext, 177 agent: &A, 178) { 179 html.push_str("<ul>\n"); 180 for item in &list.children { 181 render_list_item(html, item, ctx, agent).await; 182 } 183 html.push_str("</ul>\n"); 184} 185 186async fn render_list_item<A: AgentSessionExt>( 187 html: &mut String, 188 item: &ListItem<'_>, 189 ctx: &LeafletRenderContext, 190 agent: &A, 191) { 192 html.push_str("<li>"); 193 194 match &item.content { 195 ListItemContent::Text(text) => { 196 html.push_str(&render_faceted_text( 197 &text.plaintext, 198 text.facets.as_deref(), 199 )); 200 } 201 ListItemContent::Header(header) => { 202 let level = header.level.unwrap_or(1).clamp(1, 6); 203 let _ = write!(html, "<h{}>", level); 204 html.push_str(&render_faceted_text( 205 &header.plaintext, 206 header.facets.as_deref(), 207 )); 208 let _ = write!(html, "</h{}>", level); 209 } 210 ListItemContent::Image(image) => { 211 render_image_inline(html, image, ctx); 212 } 213 ListItemContent::Unknown(data) => { 214 let _ = write!(html, "[Unknown: {:?}]", data.type_discriminator()); 215 } 216 } 217 218 if let Some(children) = &item.children { 219 html.push_str("\n<ul>\n"); 220 for child in children { 221 Box::pin(render_list_item(html, child, ctx, agent)).await; 222 } 223 html.push_str("</ul>\n"); 224 } 225 226 html.push_str("</li>\n"); 227} 228 229fn render_image_block(html: &mut String, image: &Image<'_>, ctx: &LeafletRenderContext) { 230 html.push_str("<figure>"); 231 render_image_inline(html, image, ctx); 232 if let Some(alt) = &image.alt { 233 html.push_str("<figcaption>"); 234 let _ = escape_html(&mut *html, alt.as_ref()); 235 html.push_str("</figcaption>"); 236 } 237 html.push_str("</figure>\n"); 238} 239 240fn render_image_inline(html: &mut String, image: &Image<'_>, ctx: &LeafletRenderContext) { 241 let src = ctx.blob_url(image.image.blob().cid()); 242 html.push_str("<img src=\""); 243 let _ = escape_html(&mut *html, &src); 244 html.push('"'); 245 if let Some(alt) = &image.alt { 246 html.push_str(" alt=\""); 247 let _ = escape_html(&mut *html, alt.as_ref()); 248 html.push('"'); 249 } 250 let _ = write!( 251 html, 252 " style=\"aspect-ratio: {} / {};\"", 253 image.aspect_ratio.width, image.aspect_ratio.height 254 ); 255 html.push_str(" />"); 256} 257 258fn render_website_block(html: &mut String, website: &Website<'_>, ctx: &LeafletRenderContext) { 259 html.push_str("<a class=\"embed-external\" href=\""); 260 let _ = escape_html(&mut *html, website.src.as_ref()); 261 html.push_str("\" target=\"_blank\" rel=\"noopener\">"); 262 263 if let Some(preview) = &website.preview_image { 264 let thumb_url = ctx.blob_url(preview.blob().cid()); 265 html.push_str("<img class=\"embed-external-thumb\" src=\""); 266 let _ = escape_html(&mut *html, &thumb_url); 267 html.push_str("\" />"); 268 } 269 270 html.push_str("<span class=\"embed-external-info\">"); 271 272 if let Some(title) = &website.title { 273 html.push_str("<span class=\"embed-external-title\">"); 274 let _ = escape_html(&mut *html, title.as_ref()); 275 html.push_str("</span>"); 276 } 277 278 if let Some(desc) = &website.description { 279 html.push_str("<span class=\"embed-external-description\">"); 280 let _ = escape_html(&mut *html, desc.as_ref()); 281 html.push_str("</span>"); 282 } 283 284 html.push_str("<span class=\"embed-external-url\">"); 285 html.push_str(extract_domain(website.src.as_ref())); 286 html.push_str("</span>"); 287 288 html.push_str("</span></a>\n"); 289} 290 291fn render_iframe_block(html: &mut String, iframe: &Iframe<'_>) { 292 let height = iframe.height.unwrap_or(400); 293 html.push_str("<iframe class=\"html-embed-block\" src=\""); 294 let _ = escape_html(&mut *html, iframe.url.as_ref()); 295 let _ = write!( 296 html, 297 "\" height=\"{}\" frameborder=\"0\" allowfullscreen></iframe>\n", 298 height 299 ); 300} 301 302async fn render_bsky_post_block<A: AgentSessionExt>( 303 html: &mut String, 304 post: &BskyPost<'_>, 305 agent: &A, 306) { 307 let uri_str = post.post_ref.uri.as_ref(); 308 309 // Try to fetch and render the actual post (using fetch_and_render_post directly 310 // to avoid potential infinite recursion through fetch_and_render dispatch) 311 if let Ok(uri) = AtUri::new(uri_str) { 312 match crate::atproto::fetch_and_render_post(&uri, agent).await { 313 Ok(rendered) => { 314 html.push_str(&rendered); 315 return; 316 } 317 Err(e) => { 318 tracing::warn!("Failed to fetch embedded post {}: {:?}", uri_str, e); 319 } 320 } 321 } 322 323 // Fallback: render as placeholder 324 html.push_str("<div class=\"embed-video-placeholder\" data-aturi=\""); 325 let _ = escape_html(&mut *html, uri_str); 326 html.push_str("\">[Bluesky Post: "); 327 let _ = escape_html(&mut *html, uri_str); 328 html.push_str("]</div>\n"); 329} 330 331fn render_button_block(html: &mut String, button: &Button<'_>) { 332 html.push_str("<a class=\"leaflet-button\" href=\""); 333 let _ = escape_html(&mut *html, button.url.as_ref()); 334 html.push_str("\">"); 335 let _ = escape_html(&mut *html, button.text.as_ref()); 336 html.push_str("</a>\n"); 337} 338 339fn render_poll_block(html: &mut String, poll: &Poll<'_>) { 340 html.push_str("<div class=\"embed-video-placeholder\">[Poll: "); 341 let _ = escape_html(&mut *html, poll.poll_ref.uri.as_ref()); 342 html.push_str("]</div>\n"); 343} 344 345fn render_page_block(html: &mut String, page: &Page<'_>) { 346 html.push_str("<div class=\"embed-video-placeholder\">[Page Reference: "); 347 let _ = escape_html(&mut *html, page.id.as_ref()); 348 html.push_str("]</div>\n"); 349} 350 351fn render_math_block(html: &mut String, math: &Math<'_>) { 352 match crate::math::render_math(&math.tex, true) { 353 crate::math::MathResult::Success(mathml) => { 354 html.push_str("<div class=\"math-display\">"); 355 html.push_str(&mathml); 356 html.push_str("</div>\n"); 357 } 358 crate::math::MathResult::Error { html: err_html, .. } => { 359 html.push_str(&err_html); 360 html.push('\n'); 361 } 362 } 363} 364 365fn render_faceted_text( 366 text: &str, 367 facets: Option<&[weaver_api::pub_leaflet::richtext::facet::Facet<'_>]>, 368) -> String { 369 if let Some(facets) = facets { 370 let normalized: Vec<NormalizedFacet<'_>> = 371 facets.iter().map(NormalizedFacet::from).collect(); 372 render_faceted_html(text, &normalized).unwrap_or_else(|_| { 373 let mut escaped = String::new(); 374 let _ = escape_html(&mut escaped, text); 375 escaped 376 }) 377 } else { 378 let mut escaped = String::new(); 379 let _ = escape_html(&mut escaped, text); 380 escaped 381 } 382} 383 384fn extract_domain(url: &str) -> &str { 385 url.strip_prefix("https://") 386 .or_else(|| url.strip_prefix("http://")) 387 .and_then(|s| s.split('/').next()) 388 .unwrap_or(url) 389} 390 391/// Sync version of render_linear_document that uses pre-resolved embeds. 392pub fn render_linear_document_sync( 393 doc: &LinearDocument<'_>, 394 ctx: &LeafletRenderContext, 395 resolved_content: Option<&weaver_common::ResolvedContent>, 396) -> String { 397 let mut html = String::new(); 398 html.push_str("<div class=\"leaflet-document\">"); 399 400 for block in &doc.blocks { 401 html.push_str(&render_block_sync(block, ctx, resolved_content)); 402 } 403 404 html.push_str("</div>"); 405 html 406} 407 408/// Sync version of render_block that uses pre-resolved embeds for BskyPost blocks. 409pub fn render_block_sync( 410 block: &Block<'_>, 411 ctx: &LeafletRenderContext, 412 resolved_content: Option<&weaver_common::ResolvedContent>, 413) -> String { 414 let mut html = String::new(); 415 416 let alignment_class = block 417 .alignment 418 .as_ref() 419 .map(|a| match a.as_ref() { 420 "pub.leaflet.pages.linearDocument#textAlignCenter" => " align-center", 421 "pub.leaflet.pages.linearDocument#textAlignRight" => " align-right", 422 "pub.leaflet.pages.linearDocument#textAlignJustify" => " align-justify", 423 _ => "", 424 }) 425 .unwrap_or(""); 426 427 match &block.block { 428 BlockBlock::Text(text) => { 429 render_text_block(&mut html, text, alignment_class); 430 } 431 BlockBlock::Header(header) => { 432 render_header_block(&mut html, header, alignment_class); 433 } 434 BlockBlock::Blockquote(quote) => { 435 render_blockquote_block(&mut html, quote); 436 } 437 BlockBlock::Code(code) => { 438 render_code_block(&mut html, code); 439 } 440 BlockBlock::UnorderedList(list) => { 441 render_unordered_list_sync(&mut html, list, ctx, resolved_content); 442 } 443 BlockBlock::Image(image) => { 444 render_image_block(&mut html, image, ctx); 445 } 446 BlockBlock::Website(website) => { 447 render_website_block(&mut html, website, ctx); 448 } 449 BlockBlock::Iframe(iframe) => { 450 render_iframe_block(&mut html, iframe); 451 } 452 BlockBlock::BskyPost(post) => { 453 render_bsky_post_block_sync(&mut html, post, resolved_content); 454 } 455 BlockBlock::Button(button) => { 456 render_button_block(&mut html, button); 457 } 458 BlockBlock::Poll(poll) => { 459 render_poll_block(&mut html, poll); 460 } 461 BlockBlock::HorizontalRule(_) => { 462 html.push_str("<hr />\n"); 463 } 464 BlockBlock::Page(page) => { 465 render_page_block(&mut html, page); 466 } 467 BlockBlock::Math(math) => { 468 render_math_block(&mut html, math); 469 } 470 BlockBlock::Unknown(data) => { 471 let _ = write!( 472 html, 473 "<div class=\"embed-unknown\">[Unknown block: {:?}]</div>\n", 474 data.type_discriminator() 475 ); 476 } 477 } 478 479 html 480} 481 482fn render_unordered_list_sync( 483 html: &mut String, 484 list: &UnorderedList<'_>, 485 ctx: &LeafletRenderContext, 486 resolved_content: Option<&weaver_common::ResolvedContent>, 487) { 488 html.push_str("<ul>\n"); 489 for item in &list.children { 490 render_list_item_sync(html, item, ctx, resolved_content); 491 } 492 html.push_str("</ul>\n"); 493} 494 495fn render_list_item_sync( 496 html: &mut String, 497 item: &ListItem<'_>, 498 ctx: &LeafletRenderContext, 499 resolved_content: Option<&weaver_common::ResolvedContent>, 500) { 501 html.push_str("<li>"); 502 503 match &item.content { 504 ListItemContent::Text(text) => { 505 html.push_str(&render_faceted_text( 506 &text.plaintext, 507 text.facets.as_deref(), 508 )); 509 } 510 ListItemContent::Header(header) => { 511 let level = header.level.unwrap_or(1).clamp(1, 6); 512 let _ = write!(html, "<h{}>", level); 513 html.push_str(&render_faceted_text( 514 &header.plaintext, 515 header.facets.as_deref(), 516 )); 517 let _ = write!(html, "</h{}>", level); 518 } 519 ListItemContent::Image(image) => { 520 render_image_inline(html, image, ctx); 521 } 522 ListItemContent::Unknown(data) => { 523 let _ = write!(html, "[Unknown: {:?}]", data.type_discriminator()); 524 } 525 } 526 527 if let Some(children) = &item.children { 528 html.push_str("\n<ul>\n"); 529 for child in children { 530 render_list_item_sync(html, child, ctx, resolved_content); 531 } 532 html.push_str("</ul>\n"); 533 } 534 535 html.push_str("</li>\n"); 536} 537 538fn render_bsky_post_block_sync( 539 html: &mut String, 540 post: &BskyPost<'_>, 541 resolved_content: Option<&weaver_common::ResolvedContent>, 542) { 543 let uri_str = post.post_ref.uri.as_ref(); 544 545 // Look up pre-rendered content. 546 if let Some(resolved) = resolved_content { 547 if let Ok(at_uri) = AtUri::new(uri_str) { 548 if let Some(rendered) = resolved.get_embed_content(&at_uri) { 549 html.push_str(rendered); 550 return; 551 } 552 } 553 } 554 555 // Fallback: use bsky embed iframe. 556 // Format: at://did/app.bsky.feed.post/rkey -> https://bsky.app/profile/did/post/rkey 557 if let Some(rest) = uri_str.strip_prefix("at://") { 558 if let Some((did, path)) = rest.split_once('/') { 559 if let Some(rkey) = path.strip_prefix("app.bsky.feed.post/") { 560 html.push_str("<iframe class=\"bsky-embed-iframe\" src=\"https://embed.bsky.app/embed/"); 561 let _ = escape_html(&mut *html, did); 562 html.push_str("/post/"); 563 let _ = escape_html(&mut *html, rkey); 564 html.push_str("\" frameborder=\"0\" scrolling=\"no\" loading=\"lazy\" style=\"border: none; width: 100%; height: 240px;\"></iframe>\n"); 565 return; 566 } 567 } 568 } 569 570 // Last resort: placeholder. 571 html.push_str("<div class=\"embed-video-placeholder\" data-aturi=\""); 572 let _ = escape_html(&mut *html, uri_str); 573 html.push_str("\">[Bluesky Post: "); 574 let _ = escape_html(&mut *html, uri_str); 575 html.push_str("]</div>\n"); 576}