at main 2574 lines 94 kB view raw
1//! Fetch and render AT Protocol records as HTML embeds 2//! 3//! This module provides functions to fetch records from PDSs and render them 4//! as HTML strings suitable for embedding in markdown content. 5//! 6//! # Reusable render functions 7//! 8//! The `render_*` functions can be used standalone for rendering different embed types: 9//! - `render_external_link` - Link cards with title, description, thumbnail 10//! - `render_images` - Image galleries 11//! - `render_quoted_record` - Quoted posts/records 12//! - `render_author_block` - Author avatar + name + handle 13 14use super::error::AtProtoPreprocessError; 15use jacquard::{ 16 Data, IntoStatic, 17 client::AgentSessionExt, 18 cowstr::ToCowStr, 19 types::{ident::AtIdentifier, string::AtUri}, 20}; 21use weaver_api::app_bsky::{ 22 actor::ProfileViewBasic, 23 embed::{ 24 external::ViewExternal, 25 images::ViewImage, 26 record::{ViewRecord, ViewUnionRecord}, 27 }, 28 feed::{PostView, PostViewEmbed, get_posts::GetPosts}, 29}; 30use weaver_api::sh_weaver::actor::ProfileDataViewInner; 31use weaver_common::agent::WeaverExt; 32 33/// Fetch and render a profile record as HTML 34/// 35/// Resolves handle to DID if needed, then fetches profile data from 36/// weaver or bsky appview, returning a rich profile view. 37pub async fn fetch_and_render_profile<A>( 38 ident: &AtIdentifier<'_>, 39 agent: &A, 40) -> Result<String, AtProtoPreprocessError> 41where 42 A: AgentSessionExt, 43{ 44 // Use WeaverExt to get hydrated profile (tries weaver profile first, falls back to bsky) 45 let (_uri, profile_view) = agent 46 .hydrate_profile_view(&ident) 47 .await 48 .map_err(|e| AtProtoPreprocessError::FetchFailed(format!("{:?}", e)))?; 49 50 // Render based on which profile type we got 51 render_profile_data_view(&profile_view.inner) 52} 53 54/// Fetch and render a Bluesky post as HTML using the appview for rich data 55pub async fn fetch_and_render_post<A>( 56 uri: &AtUri<'_>, 57 agent: &A, 58) -> Result<String, AtProtoPreprocessError> 59where 60 A: AgentSessionExt, 61{ 62 // Use GetPosts for richer data (author info, engagement counts) 63 let request = GetPosts::new().uris(vec![uri.clone()]).build(); 64 let response = agent.send(request).await; 65 let response = response.map_err(|e| { 66 AtProtoPreprocessError::FetchFailed(format!("getting post from appview {:?}", e)) 67 })?; 68 69 let output = response 70 .into_output() 71 .map_err(|e| AtProtoPreprocessError::FetchFailed(format!("{:?}", e)))?; 72 73 let post_view = output 74 .posts 75 .into_iter() 76 .next() 77 .ok_or_else(|| AtProtoPreprocessError::FetchFailed("Post not found".to_string()))?; 78 79 render_post_view(&post_view, uri) 80} 81 82/// Fetch and render an unknown record type generically 83/// 84/// This fetches the record as untyped Data and probes for likely meaningful fields. 85pub async fn fetch_and_render_generic<A>( 86 uri: &AtUri<'_>, 87 agent: &A, 88) -> Result<String, AtProtoPreprocessError> 89where 90 A: AgentSessionExt, 91{ 92 // Fetch via slingshot (edge-cached, untyped) 93 let output = agent 94 .fetch_record_slingshot(uri) 95 .await 96 .map_err(|e| AtProtoPreprocessError::FetchFailed(format!("{:?}", e)))?; 97 98 // Probe for meaningful fields 99 render_generic_record(&output.value, uri) 100} 101 102/// Fetch and render a notebook entry with full markdown rendering 103/// 104/// Renders the entry content as HTML in a scrollable container with title and author info. 105pub async fn fetch_and_render_entry<A>( 106 uri: &AtUri<'_>, 107 agent: &A, 108) -> Result<String, AtProtoPreprocessError> 109where 110 A: AgentSessionExt, 111{ 112 use crate::atproto::writer::ClientWriter; 113 use crate::default_md_options; 114 use markdown_weaver::Parser; 115 use weaver_common::agent::WeaverExt; 116 117 // Get rkey from URI 118 let rkey = uri 119 .rkey() 120 .ok_or_else(|| AtProtoPreprocessError::FetchFailed("Entry URI missing rkey".to_string()))?; 121 122 // Fetch entry with author info 123 let (entry_view, entry) = agent 124 .fetch_entry_by_rkey(&uri.authority(), rkey.as_ref()) 125 .await 126 .map_err(|e| AtProtoPreprocessError::FetchFailed(e.to_string()))?; 127 128 // Render the markdown content to HTML 129 let content = entry.content.as_ref(); 130 let parser = Parser::new_ext(content, default_md_options()).into_offset_iter(); 131 let mut content_html = String::new(); 132 ClientWriter::<_, _, ()>::new(parser, &mut content_html, content) 133 .run() 134 .map_err(|e| { 135 AtProtoPreprocessError::FetchFailed(format!("Markdown render failed: {:?}", e)) 136 })?; 137 138 // Generate unique ID for the toggle checkbox 139 let toggle_id = format!("entry-toggle-{}", rkey.as_ref()); 140 141 // Build the embed HTML 142 let mut html = String::new(); 143 html.push_str("<div class=\"atproto-embed atproto-entry\" contenteditable=\"false\">"); 144 145 // Hidden checkbox for expand/collapse (must come before content for CSS sibling selector) 146 html.push_str("<input type=\"checkbox\" class=\"embed-entry-toggle\" id=\""); 147 html.push_str(&toggle_id); 148 html.push_str("\">"); 149 150 // Header with title and author 151 html.push_str("<div class=\"embed-entry-header\">"); 152 153 // Title 154 html.push_str("<span class=\"embed-entry-title\">"); 155 html.push_str(&html_escape(entry.title.as_ref())); 156 html.push_str("</span>"); 157 158 // Author info - just show handle (keep it simple for entry embeds) 159 if let Some(author) = entry_view.authors.first() { 160 let handle = match &author.record.inner { 161 ProfileDataViewInner::ProfileView(p) => p.handle.as_ref(), 162 ProfileDataViewInner::ProfileViewDetailed(p) => p.handle.as_ref(), 163 ProfileDataViewInner::TangledProfileView(p) => p.handle.as_ref(), 164 ProfileDataViewInner::Unknown(_) => "", 165 }; 166 if !handle.is_empty() { 167 html.push_str("<span class=\"embed-entry-author\">@"); 168 html.push_str(&html_escape(handle)); 169 html.push_str("</span>"); 170 } 171 } 172 173 html.push_str("</div>"); // end header 174 175 // Scrollable content container 176 html.push_str("<div class=\"embed-entry-content\">"); 177 html.push_str(&content_html); 178 html.push_str("</div>"); 179 180 // Expand/collapse label (clickable, targets the checkbox) 181 html.push_str("<label class=\"embed-entry-expand\" for=\""); 182 html.push_str(&toggle_id); 183 html.push_str("\"></label>"); 184 185 html.push_str("</div>"); 186 187 Ok(html) 188} 189 190/// Fetch and render a notebook entry with full markdown rendering 191/// 192/// Renders the entry content as HTML in a scrollable container with title and author info. 193pub async fn fetch_and_render_whitewind_entry<A>( 194 uri: &AtUri<'_>, 195 agent: &A, 196) -> Result<String, AtProtoPreprocessError> 197where 198 A: AgentSessionExt, 199{ 200 use crate::atproto::writer::ClientWriter; 201 use crate::default_md_options; 202 use markdown_weaver::Parser; 203 use weaver_api::com_whtwnd::blog::entry::Entry as WhitewindEntry; 204 use weaver_common::agent::WeaverExt; 205 206 let (_, profile) = agent 207 .hydrate_profile_view(uri.authority()) 208 .await 209 .map_err(|e| { 210 AtProtoPreprocessError::FetchFailed(format!("Profile fetch failed: {:?}", e)) 211 })?; 212 let entry_uri = WhitewindEntry::uri(uri.to_cowstr()).map_err(|e| { 213 AtProtoPreprocessError::FetchFailed(format!("Entry URI incorrect: {:?}", e)) 214 })?; 215 let entry = agent 216 .fetch_record(&entry_uri) 217 .await 218 .map_err(|e| AtProtoPreprocessError::FetchFailed(format!("Entry fetch failed: {:?}", e)))?; 219 220 // Render the markdown content to HTML 221 let content = entry.value.content.as_ref(); 222 let parser = Parser::new_ext(content, default_md_options()).into_offset_iter(); 223 let mut content_html = String::new(); 224 ClientWriter::<_, _, ()>::new(parser, &mut content_html, content) 225 .run() 226 .map_err(|e| { 227 AtProtoPreprocessError::FetchFailed(format!("Markdown render failed: {:?}", e)) 228 })?; 229 230 // Generate unique ID for the toggle checkbox 231 let toggle_id = format!( 232 "entry-toggle-{}", 233 entry.uri.rkey().expect("valid rkey").as_ref() 234 ); 235 let entry = entry.value; 236 237 // Build the embed HTML 238 let mut html = String::new(); 239 html.push_str("<div class=\"atproto-embed atproto-entry\" contenteditable=\"false\">"); 240 241 // Hidden checkbox for expand/collapse (must come before content for CSS sibling selector) 242 html.push_str("<input type=\"checkbox\" class=\"embed-entry-toggle\" id=\""); 243 html.push_str(&toggle_id); 244 html.push_str("\">"); 245 246 // Header with title and author 247 html.push_str("<div class=\"embed-entry-header\">"); 248 249 // Title 250 html.push_str("<span class=\"embed-entry-title\">"); 251 html.push_str(&html_escape( 252 entry.title.as_ref().unwrap_or(&"".to_cowstr()), 253 )); 254 html.push_str("</span>"); 255 256 // Author info - just show handle (keep it simple for entry embeds) 257 let handle = match &profile.inner { 258 ProfileDataViewInner::ProfileView(p) => p.handle.as_ref(), 259 ProfileDataViewInner::ProfileViewDetailed(p) => p.handle.as_ref(), 260 ProfileDataViewInner::TangledProfileView(p) => p.handle.as_ref(), 261 ProfileDataViewInner::Unknown(_) => "", 262 }; 263 if !handle.is_empty() { 264 html.push_str("<span class=\"embed-entry-author\">@"); 265 html.push_str(&html_escape(handle)); 266 html.push_str("</span>"); 267 } 268 269 html.push_str("</div>"); // end header 270 271 // Scrollable content container 272 html.push_str("<div class=\"embed-entry-content\">"); 273 html.push_str(&content_html); 274 html.push_str("</div>"); 275 276 // Expand/collapse label (clickable, targets the checkbox) 277 html.push_str("<label class=\"embed-entry-expand\" for=\""); 278 html.push_str(&toggle_id); 279 html.push_str("\"></label>"); 280 281 html.push_str("</div>"); 282 283 Ok(html) 284} 285 286/// Fetch and render a Leaflet document as HTML 287/// 288/// Renders the document's pages (currently only LinearDocument is supported). 289pub async fn fetch_and_render_leaflet<A>( 290 uri: &AtUri<'_>, 291 agent: &A, 292) -> Result<String, AtProtoPreprocessError> 293where 294 A: AgentSessionExt, 295{ 296 use crate::leaflet::{LeafletRenderContext, render_linear_document}; 297 use weaver_api::pub_leaflet::document::{Document, DocumentPagesItem}; 298 use weaver_api::pub_leaflet::publication::Publication; 299 300 let doc_uri = Document::uri(uri.to_cowstr()).map_err(|e| { 301 AtProtoPreprocessError::FetchFailed(format!("Invalid document URI: {:?}", e)) 302 })?; 303 304 let doc = agent.fetch_record(&doc_uri).await.map_err(|e| { 305 AtProtoPreprocessError::FetchFailed(format!("Document fetch failed: {:?}", e)) 306 })?; 307 308 // Fetch publication to get base_path for external link 309 let publication_base_path: Option<String> = if let Some(pub_uri) = &doc.value.publication { 310 if let Ok(pub_typed_uri) = Publication::uri(pub_uri.as_ref()) { 311 agent 312 .fetch_record(&pub_typed_uri) 313 .await 314 .ok() 315 .and_then(|rec| rec.value.base_path.as_ref().map(|p| p.as_ref().to_string())) 316 } else { 317 None 318 } 319 } else { 320 None 321 }; 322 323 // Get author DID and handle 324 use jacquard::types::string::{Did, Handle}; 325 let (author_did, author_handle): (Did<'static>, Option<Handle<'static>>) = 326 match &doc.value.author { 327 AtIdentifier::Did(d) => { 328 let did = d.clone().into_static(); 329 let handle = agent 330 .resolve_did_doc_owned(d) 331 .await 332 .ok() 333 .and_then(|doc| doc.handles().first().cloned()); 334 (did, handle) 335 } 336 AtIdentifier::Handle(h) => { 337 let handle = Some(h.clone().into_static()); 338 let did = agent 339 .resolve_handle(h) 340 .await 341 .map(|d| d.into_static()) 342 .map_err(|e| { 343 AtProtoPreprocessError::FetchFailed(format!( 344 "Handle resolution failed: {:?}", 345 e 346 )) 347 })?; 348 (did, handle) 349 } 350 }; 351 352 let ctx = LeafletRenderContext::new(author_did); 353 354 // Generate unique toggle ID 355 let rkey = uri.rkey().map(|r| r.as_ref()).unwrap_or("unknown"); 356 let toggle_id = format!("leaflet-toggle-{}", rkey); 357 358 let mut html = String::new(); 359 html.push_str("<div class=\"atproto-embed atproto-entry\" contenteditable=\"false\">"); 360 361 // Hidden checkbox for expand/collapse 362 html.push_str("<input type=\"checkbox\" class=\"embed-entry-toggle\" id=\""); 363 html.push_str(&toggle_id); 364 html.push_str("\">"); 365 366 // Header with title and author 367 html.push_str("<div class=\"embed-entry-header\">"); 368 369 // Title as link if we have the publication base_path 370 if let Some(base_path) = &publication_base_path { 371 html.push_str("<a class=\"embed-entry-title\" href=\"https://"); 372 html.push_str(&html_escape(base_path)); 373 html.push('/'); 374 html.push_str(&html_escape(rkey)); 375 html.push_str("\" target=\"_blank\" rel=\"noopener\">"); 376 html.push_str(&html_escape(doc.value.title.as_ref())); 377 html.push_str("</a>"); 378 } else { 379 html.push_str("<span class=\"embed-entry-title\">"); 380 html.push_str(&html_escape(doc.value.title.as_ref())); 381 html.push_str("</span>"); 382 } 383 384 // Author info 385 if let Some(handle) = &author_handle { 386 html.push_str("<span class=\"embed-entry-author\">@"); 387 html.push_str(&html_escape(handle.as_ref())); 388 html.push_str("</span>"); 389 } 390 391 html.push_str("</div>"); // end header 392 393 // Scrollable content container 394 html.push_str("<div class=\"embed-entry-content\">"); 395 396 // Render each page 397 for page in &doc.value.pages { 398 match page { 399 DocumentPagesItem::LinearDocument(linear_doc) => { 400 html.push_str(&render_linear_document(linear_doc, &ctx, agent).await); 401 } 402 DocumentPagesItem::Canvas(_) => { 403 html.push_str("<div class=\"embed-video-placeholder\">[Canvas layout not yet supported]</div>"); 404 } 405 DocumentPagesItem::Unknown(_) => { 406 html.push_str("<div class=\"embed-video-placeholder\">[Unknown page type]</div>"); 407 } 408 } 409 } 410 411 html.push_str("</div>"); // end content 412 413 // Expand/collapse label 414 html.push_str("<label class=\"embed-entry-expand\" for=\""); 415 html.push_str(&toggle_id); 416 html.push_str("\"></label>"); 417 418 html.push_str("</div>"); 419 420 Ok(html) 421} 422 423#[cfg(feature = "pckt")] 424/// Fetch and render a pckt/site.standard document as HTML 425/// 426/// Renders the document's content blocks using the pckt block renderer. 427/// Supports both `site.standard.document` and `blog.pckt.document` (which wraps site.standard). 428/// 429/// TODO: site.standard.document is designed to be a shared envelope for different block formats. 430/// Currently hardcoded to use pckt block renderer, but should probe the first block's $type 431/// and dispatch to the appropriate renderer (blog.pckt.block.* → pckt, pub.leaflet.blocks.* → leaflet, 432/// sh.weaver.block.* → weaver, etc). 433pub async fn fetch_and_render_pckt<A>( 434 uri: &AtUri<'_>, 435 agent: &A, 436) -> Result<String, AtProtoPreprocessError> 437where 438 A: AgentSessionExt, 439{ 440 use crate::pckt::{PcktRenderContext, render_content_blocks}; 441 use weaver_api::site_standard::document::Document as SiteStandardDocument; 442 443 // Fetch the record as untyped first to check the structure 444 let output = agent 445 .fetch_record_slingshot(uri) 446 .await 447 .map_err(|e| AtProtoPreprocessError::FetchFailed(format!("{:?}", e)))?; 448 449 // Extract the site.standard.document - either directly or from blog.pckt.document wrapper 450 let doc: SiteStandardDocument<'_> = if output 451 .value 452 .type_discriminator() 453 .map(|t| t == "blog.pckt.document") 454 .unwrap_or(false) 455 { 456 // blog.pckt.document wraps site.standard.document in a "document" field 457 let pckt_doc = 458 jacquard::from_data::<weaver_api::blog_pckt::document::Document>(&output.value) 459 .map_err(|e| { 460 AtProtoPreprocessError::FetchFailed(format!("Parse error: {:?}", e)) 461 })?; 462 pckt_doc.document 463 } else { 464 // Direct site.standard.document 465 jacquard::from_data::<SiteStandardDocument>(&output.value) 466 .map_err(|e| AtProtoPreprocessError::FetchFailed(format!("Parse error: {:?}", e)))? 467 }; 468 469 // Fetch publication to get base URL for external link 470 use weaver_api::site_standard::publication::Publication; 471 let Uri::At(uri) = &doc.site else { 472 return Err(AtProtoPreprocessError::FetchFailed( 473 "Invalid site URI".to_string(), 474 )); 475 }; 476 let publication_url: Option<String> = 477 agent 478 .fetch_record_slingshot(uri) 479 .await 480 .ok() 481 .and_then(|rec| { 482 jacquard::from_data::<Publication>(&rec.value) 483 .ok() 484 .map(|pub_rec| pub_rec.url.as_ref().to_string()) 485 }); 486 487 // Get author DID and handle from URI authority 488 use jacquard::types::{ 489 string::{Did, Handle}, 490 uri::Uri, 491 }; 492 let (author_did, author_handle): (Did<'static>, Option<Handle<'static>>) = match uri.authority() 493 { 494 jacquard::types::ident::AtIdentifier::Did(d) => { 495 let did = d.clone().into_static(); 496 let handle = agent 497 .resolve_did_doc_owned(d) 498 .await 499 .ok() 500 .and_then(|doc| doc.handles().first().cloned()); 501 (did, handle) 502 } 503 jacquard::types::ident::AtIdentifier::Handle(h) => { 504 let handle = Some(h.clone().into_static()); 505 let did = agent 506 .resolve_handle(h) 507 .await 508 .map(|d| d.into_static()) 509 .map_err(|e| { 510 AtProtoPreprocessError::FetchFailed(format!( 511 "Handle resolution failed: {:?}", 512 e 513 )) 514 })?; 515 (did, handle) 516 } 517 }; 518 519 let ctx = PcktRenderContext::new(author_did); 520 521 // Generate unique toggle ID 522 let rkey = uri.rkey().map(|r| r.as_ref()).unwrap_or("unknown"); 523 let toggle_id = format!("pckt-toggle-{}", rkey); 524 525 // Document path for URL (use path field if present, otherwise rkey) 526 let doc_path = doc.path.as_ref().map(|p| p.as_ref()).unwrap_or(rkey); 527 528 let mut html = String::new(); 529 html.push_str("<div class=\"atproto-embed atproto-entry\" contenteditable=\"false\">"); 530 531 // Hidden checkbox for expand/collapse 532 html.push_str("<input type=\"checkbox\" class=\"embed-entry-toggle\" id=\""); 533 html.push_str(&toggle_id); 534 html.push_str("\">"); 535 536 // Header with title and author 537 html.push_str("<div class=\"embed-entry-header\">"); 538 539 // Title as link if we have the publication URL 540 if let Some(base_url) = &publication_url { 541 let base_url = base_url.trim_end_matches('/'); 542 html.push_str("<a class=\"embed-entry-title\" href=\""); 543 html.push_str(&html_escape(base_url)); 544 html.push('/'); 545 html.push_str(&html_escape(doc_path)); 546 html.push_str("\" target=\"_blank\" rel=\"noopener\">"); 547 html.push_str(&html_escape(doc.title.as_ref())); 548 html.push_str("</a>"); 549 } else { 550 html.push_str("<span class=\"embed-entry-title\">"); 551 html.push_str(&html_escape(doc.title.as_ref())); 552 html.push_str("</span>"); 553 } 554 555 // Author info 556 if let Some(handle) = &author_handle { 557 html.push_str("<span class=\"embed-entry-author\">@"); 558 html.push_str(&html_escape(handle.as_ref())); 559 html.push_str("</span>"); 560 } 561 562 html.push_str("</div>"); // end header 563 564 // Scrollable content container 565 html.push_str("<div class=\"embed-entry-content\">"); 566 567 // Render content blocks if present 568 if let Some(content) = &doc.content { 569 html.push_str(&render_content_blocks(vec![content.clone()].as_slice(), &ctx, agent).await); 570 } else if let Some(text_content) = &doc.text_content { 571 // Fallback to text_content if no structured content 572 html.push_str("<p>"); 573 html.push_str(&html_escape(text_content.as_ref())); 574 html.push_str("</p>"); 575 } 576 577 html.push_str("</div>"); // end content 578 579 // Expand/collapse label 580 html.push_str("<label class=\"embed-entry-expand\" for=\""); 581 html.push_str(&toggle_id); 582 html.push_str("\"></label>"); 583 584 html.push_str("</div>"); 585 586 Ok(html) 587} 588 589/// Fetch and render any AT URI, dispatching to the appropriate renderer based on collection. 590/// 591/// Uses typed fetchers for known collections (posts, profiles) and falls back to 592/// generic rendering for unknown types. 593pub async fn fetch_and_render<A>( 594 uri: &AtUri<'_>, 595 agent: &A, 596) -> Result<String, AtProtoPreprocessError> 597where 598 A: AgentSessionExt, 599{ 600 let collection = uri.collection().map(|c| c.as_ref()); 601 602 match collection { 603 Some("app.bsky.feed.post") => { 604 let result = fetch_and_render_post(uri, agent).await; 605 result 606 } 607 Some("app.bsky.actor.profile") => { 608 // Extract DID from URI authority 609 fetch_and_render_profile(uri.authority(), agent).await 610 } 611 Some("sh.weaver.notebook.entry") => fetch_and_render_entry(uri, agent).await, 612 Some("com.whtwnd.blog.entry") => fetch_and_render_whitewind_entry(uri, agent).await, 613 Some("pub.leaflet.document") => fetch_and_render_leaflet(uri, agent).await, 614 #[cfg(feature = "pckt")] 615 Some("site.standard.document") | Some("blog.pckt.document") => { 616 fetch_and_render_pckt(uri, agent).await 617 } 618 None => fetch_and_render_profile(uri.authority(), agent).await, 619 _ => fetch_and_render_generic(uri, agent).await, 620 } 621} 622 623/// Render any AT Protocol record synchronously from pre-fetched data. 624/// 625/// This is the pure sync version of `fetch_and_render`. Takes a URI and the 626/// record data, dispatches to the appropriate renderer based on collection type. 627/// 628/// # Arguments 629/// 630/// * `uri` - The AT URI of the record 631/// * `data` - The record data (either raw record or hydrated view type) 632/// * `fallback_author` - Optional author profile to use when data is a raw record 633/// without embedded author info. Used for entries and other content types. 634/// * `resolved_content` - Optional pre-resolved embeds for rendering markdown with embeds 635/// 636/// # Supported collections 637/// 638/// **Profiles** (pass hydrated view from appview): 639/// - `app.bsky.actor.profile` - Bluesky profiles (ProfileViewDetailed from getProfile) 640/// - `sh.weaver.actor.profile` - Weaver profiles (ProfileView from weaver appview) 641/// - Tangled profiles also supported via type discriminator 642/// 643/// **Posts**: 644/// - `app.bsky.feed.post` - Posts (PostView from getPosts, or raw record for basic) 645/// 646/// **Entries** (pass view type for author info, or provide fallback_author): 647/// - `sh.weaver.notebook.entry` - Weaver entries (EntryView or raw Entry) 648/// - `com.whtwnd.blog.entry` - Whitewind entries 649/// - `pub.leaflet.document` - Leaflet documents 650/// - `site.standard.document` / `blog.pckt.document` - pckt documents 651/// 652/// **Lists & Feeds**: 653/// - `app.bsky.graph.list` - User lists 654/// - `app.bsky.feed.generator` - Custom feeds 655/// - `app.bsky.graph.starterpack` - Starter packs 656/// - `app.bsky.labeler.service` - Labelers 657/// 658/// **Other** - Generic field display for unknown types 659pub fn render_record( 660 uri: &AtUri<'_>, 661 data: &Data<'_>, 662 fallback_author: Option<&weaver_api::sh_weaver::actor::ProfileDataView<'_>>, 663 resolved_content: Option<&weaver_common::ResolvedContent>, 664) -> Result<String, AtProtoPreprocessError> { 665 let collection = uri.collection().map(|c| c.as_ref()); 666 667 match collection { 668 // No collection = just an identity reference, try as profile 669 None => render_profile_from_data(data, uri), 670 671 // Profiles - try multiple profile view types 672 Some("app.bsky.actor.profile") | Some("sh.weaver.actor.profile") => { 673 render_profile_from_data(data, uri) 674 } 675 676 // Posts 677 Some("app.bsky.feed.post") => { 678 // Try PostView first (from getPosts), fall back to raw record 679 if let Ok(post_view) = jacquard::from_data::<PostView>(data) { 680 render_post_view(&post_view, uri) 681 } else { 682 render_basic_post(data, uri) 683 } 684 } 685 686 // Lists 687 Some("app.bsky.graph.list") => render_list_record(data, uri), 688 689 // Custom feeds 690 Some("app.bsky.feed.generator") => render_generator_record(data, uri), 691 692 // Starter packs 693 Some("app.bsky.graph.starterpack") => render_starterpack_record(data, uri), 694 695 // Labelers 696 Some("app.bsky.labeler.service") => render_labeler_record(data, uri), 697 698 // Weaver entries 699 Some("sh.weaver.notebook.entry") => { 700 render_weaver_entry_record(data, uri, fallback_author, resolved_content) 701 } 702 703 // Whitewind entries 704 Some("com.whtwnd.blog.entry") => { 705 render_whitewind_entry_record(data, uri, fallback_author, resolved_content) 706 } 707 708 // Leaflet documents 709 Some("pub.leaflet.document") => { 710 render_leaflet_record(data, uri, fallback_author, resolved_content) 711 } 712 713 // pckt / site.standard documents 714 #[cfg(feature = "pckt")] 715 Some("site.standard.document") | Some("blog.pckt.document") => { 716 render_site_standard_record(data, uri, fallback_author, resolved_content) 717 } 718 719 // Default: generic rendering 720 _ => render_generic_record(data, uri), 721 } 722} 723 724/// Try to render profile data by detecting the view type. 725fn render_profile_from_data( 726 data: &Data<'_>, 727 uri: &AtUri<'_>, 728) -> Result<String, AtProtoPreprocessError> { 729 // Check type discriminator first for union types 730 if let Some(type_disc) = data.type_discriminator() { 731 match type_disc { 732 "app.bsky.actor.defs#profileViewDetailed" => { 733 if let Ok(profile) = 734 jacquard::from_data::<weaver_api::app_bsky::actor::ProfileViewDetailed>(data) 735 { 736 return render_profile_data_view(&ProfileDataViewInner::ProfileViewDetailed( 737 Box::new(profile), 738 )); 739 } 740 } 741 "sh.weaver.actor.defs#profileView" => { 742 if let Ok(profile) = 743 jacquard::from_data::<weaver_api::sh_weaver::actor::ProfileView>(data) 744 { 745 return render_profile_data_view(&ProfileDataViewInner::ProfileView(Box::new( 746 profile, 747 ))); 748 } 749 } 750 "sh.weaver.actor.defs#tangledProfileView" => { 751 if let Ok(profile) = 752 jacquard::from_data::<weaver_api::sh_weaver::actor::TangledProfileView>(data) 753 { 754 return render_profile_data_view(&ProfileDataViewInner::TangledProfileView( 755 Box::new(profile), 756 )); 757 } 758 } 759 _ => {} 760 } 761 } 762 763 // Try each type without discriminator 764 if let Ok(profile) = 765 jacquard::from_data::<weaver_api::app_bsky::actor::ProfileViewDetailed>(data) 766 { 767 return render_profile_data_view(&ProfileDataViewInner::ProfileViewDetailed(Box::new( 768 profile, 769 ))); 770 } 771 if let Ok(profile) = jacquard::from_data::<weaver_api::sh_weaver::actor::ProfileView>(data) { 772 return render_profile_data_view(&ProfileDataViewInner::ProfileView(Box::new(profile))); 773 } 774 if let Ok(profile) = 775 jacquard::from_data::<weaver_api::sh_weaver::actor::TangledProfileView>(data) 776 { 777 return render_profile_data_view(&ProfileDataViewInner::TangledProfileView(Box::new( 778 profile, 779 ))); 780 } 781 782 // Fall back to generic 783 render_generic_record(data, uri) 784} 785 786/// Render a list record. 787fn render_list_record(data: &Data<'_>, uri: &AtUri<'_>) -> Result<String, AtProtoPreprocessError> { 788 let list = match jacquard::from_data::<weaver_api::app_bsky::graph::list::List>(data) { 789 Ok(l) => l, 790 Err(_) => return render_generic_record(data, uri), 791 }; 792 793 let mut html = String::new(); 794 html.push_str("<span class=\"atproto-embed atproto-record\" contenteditable=\"false\">"); 795 html.push_str("<span class=\"embed-type\">List</span>"); 796 html.push_str("<span class=\"embed-author-name\">"); 797 html.push_str(&html_escape(list.name.as_ref())); 798 html.push_str("</span>"); 799 if let Some(desc) = &list.description { 800 html.push_str("<span class=\"embed-description\">"); 801 html.push_str(&html_escape(desc.as_ref())); 802 html.push_str("</span>"); 803 } 804 html.push_str("</span>"); 805 806 Ok(html) 807} 808 809/// Render a feed generator record. 810fn render_generator_record( 811 data: &Data<'_>, 812 uri: &AtUri<'_>, 813) -> Result<String, AtProtoPreprocessError> { 814 let generator = 815 match jacquard::from_data::<weaver_api::app_bsky::feed::generator::Generator>(data) { 816 Ok(g) => g, 817 Err(_) => return render_generic_record(data, uri), 818 }; 819 820 let mut html = String::new(); 821 html.push_str("<span class=\"atproto-embed atproto-record\" contenteditable=\"false\">"); 822 html.push_str("<span class=\"embed-type\">Custom Feed</span>"); 823 html.push_str("<span class=\"embed-author-name\">"); 824 html.push_str(&html_escape(generator.display_name.as_ref())); 825 html.push_str("</span>"); 826 if let Some(desc) = &generator.description { 827 html.push_str("<span class=\"embed-description\">"); 828 html.push_str(&html_escape(desc.as_ref())); 829 html.push_str("</span>"); 830 } 831 html.push_str("</span>"); 832 833 Ok(html) 834} 835 836/// Render a starter pack record. 837fn render_starterpack_record( 838 data: &Data<'_>, 839 uri: &AtUri<'_>, 840) -> Result<String, AtProtoPreprocessError> { 841 let sp = 842 match jacquard::from_data::<weaver_api::app_bsky::graph::starterpack::Starterpack>(data) { 843 Ok(s) => s, 844 Err(_) => return render_generic_record(data, uri), 845 }; 846 847 let mut html = String::new(); 848 html.push_str("<span class=\"atproto-embed atproto-record\" contenteditable=\"false\">"); 849 html.push_str("<span class=\"embed-type\">Starter Pack</span>"); 850 html.push_str("<span class=\"embed-author-name\">"); 851 html.push_str(&html_escape(sp.name.as_ref())); 852 html.push_str("</span>"); 853 if let Some(desc) = &sp.description { 854 html.push_str("<span class=\"embed-description\">"); 855 html.push_str(&html_escape(desc.as_ref())); 856 html.push_str("</span>"); 857 } 858 html.push_str("</span>"); 859 860 Ok(html) 861} 862 863/// Render a labeler service record. 864fn render_labeler_record( 865 data: &Data<'_>, 866 uri: &AtUri<'_>, 867) -> Result<String, AtProtoPreprocessError> { 868 let labeler = match jacquard::from_data::<weaver_api::app_bsky::labeler::service::Service>(data) 869 { 870 Ok(l) => l, 871 Err(_) => return render_generic_record(data, uri), 872 }; 873 874 let mut html = String::new(); 875 html.push_str("<span class=\"atproto-embed atproto-record\" contenteditable=\"false\">"); 876 html.push_str("<span class=\"embed-type\">Labeler</span>"); 877 878 // Labeler policies 879 html.push_str("<span class=\"embed-fields\">"); 880 let label_count = labeler.policies.label_values.len(); 881 html.push_str("<span class=\"embed-field\">"); 882 html.push_str(&label_count.to_string()); 883 html.push_str(" label"); 884 if label_count != 1 { 885 html.push_str("s"); 886 } 887 html.push_str(" defined</span>"); 888 html.push_str("</span>"); 889 890 html.push_str("</span>"); 891 892 Ok(html) 893} 894 895/// Render a weaver notebook entry record. 896/// 897/// Accepts either: 898/// - `EntryView` (from appview) - includes author info 899/// - Raw `Entry` record with optional fallback_author 900fn render_weaver_entry_record( 901 data: &Data<'_>, 902 uri: &AtUri<'_>, 903 fallback_author: Option<&weaver_api::sh_weaver::actor::ProfileDataView<'_>>, 904 resolved_content: Option<&weaver_common::ResolvedContent>, 905) -> Result<String, AtProtoPreprocessError> { 906 use crate::atproto::writer::ClientWriter; 907 use crate::default_md_options; 908 use markdown_weaver::Parser; 909 use weaver_api::sh_weaver::notebook::EntryView; 910 911 // Try to parse as EntryView first (has author info), then raw Entry 912 let (title, content, author_handle): (String, String, Option<String>) = if let Ok(view) = 913 jacquard::from_data::<EntryView>(data) 914 { 915 // EntryView has embedded record data, extract content from it 916 let content = view 917 .record 918 .query("content") 919 .single() 920 .and_then(|d| d.as_str()) 921 .unwrap_or_default() 922 .to_string(); 923 let title = view 924 .record 925 .query("title") 926 .single() 927 .and_then(|d| d.as_str()) 928 .unwrap_or_default() 929 .to_string(); 930 let handle = view 931 .authors 932 .first() 933 .and_then(|author| extract_handle_from_profile_data_view(&author.record.inner)); 934 (title, content, handle.map(|h| h.to_string())) 935 } else if let Ok(entry) = 936 jacquard::from_data::<weaver_api::sh_weaver::notebook::entry::Entry>(data) 937 { 938 let handle = fallback_author.and_then(|p| extract_handle_from_profile_data_view(&p.inner)); 939 ( 940 entry.title.as_ref().to_string(), 941 entry.content.as_ref().to_string(), 942 handle.map(|h| h.to_string()), 943 ) 944 } else { 945 return render_generic_record(data, uri); 946 }; 947 948 // Render markdown content to HTML using resolved_content for embeds 949 let parser = Parser::new_ext(&content, default_md_options()).into_offset_iter(); 950 let mut content_html = String::new(); 951 if let Some(resolved) = resolved_content { 952 ClientWriter::new(parser, &mut content_html, &content) 953 .with_embed_provider(resolved) 954 .run() 955 .map_err(|e| { 956 AtProtoPreprocessError::FetchFailed(format!("Markdown render failed: {:?}", e)) 957 })?; 958 } else { 959 ClientWriter::<_, _, ()>::new(parser, &mut content_html, &content) 960 .run() 961 .map_err(|e| { 962 AtProtoPreprocessError::FetchFailed(format!("Markdown render failed: {:?}", e)) 963 })?; 964 } 965 966 // Generate unique ID for the toggle checkbox 967 let rkey = uri.rkey().map(|r| r.as_ref()).unwrap_or("unknown"); 968 let toggle_id = format!("entry-toggle-{}", rkey); 969 970 // Build the embed HTML - matches fetch_and_render_entry exactly 971 let mut html = String::new(); 972 html.push_str("<div class=\"atproto-embed atproto-entry\" contenteditable=\"false\">"); 973 974 // Hidden checkbox for expand/collapse (must come before content for CSS sibling selector) 975 html.push_str("<input type=\"checkbox\" class=\"embed-entry-toggle\" id=\""); 976 html.push_str(&toggle_id); 977 html.push_str("\">"); 978 979 // Header with title and author 980 html.push_str("<div class=\"embed-entry-header\">"); 981 982 // Title 983 html.push_str("<span class=\"embed-entry-title\">"); 984 html.push_str(&html_escape(&title)); 985 html.push_str("</span>"); 986 987 // Author info - just show handle (keep it simple for entry embeds) 988 if let Some(ref handle) = author_handle { 989 if !handle.is_empty() { 990 html.push_str("<span class=\"embed-entry-author\">@"); 991 html.push_str(&html_escape(handle)); 992 html.push_str("</span>"); 993 } 994 } 995 996 html.push_str("</div>"); // end header 997 998 // Scrollable content container 999 html.push_str("<div class=\"embed-entry-content\">"); 1000 html.push_str(&content_html); 1001 html.push_str("</div>"); 1002 1003 // Expand/collapse label (clickable, targets the checkbox) 1004 html.push_str("<label class=\"embed-entry-expand\" for=\""); 1005 html.push_str(&toggle_id); 1006 html.push_str("\"></label>"); 1007 1008 html.push_str("</div>"); 1009 1010 Ok(html) 1011} 1012 1013/// Extract handle from ProfileDataViewInner. 1014fn extract_handle_from_profile_data_view<'a>( 1015 inner: &'a ProfileDataViewInner<'a>, 1016) -> Option<&'a str> { 1017 match inner { 1018 ProfileDataViewInner::ProfileView(p) => Some(p.handle.as_ref()), 1019 ProfileDataViewInner::ProfileViewDetailed(p) => Some(p.handle.as_ref()), 1020 ProfileDataViewInner::TangledProfileView(p) => Some(p.handle.as_ref()), 1021 ProfileDataViewInner::Unknown(_) => None, 1022 } 1023} 1024 1025fn extract_did_from_profile_data_view( 1026 inner: &ProfileDataViewInner<'_>, 1027) -> Option<jacquard::types::string::Did<'static>> { 1028 use jacquard::IntoStatic; 1029 match inner { 1030 ProfileDataViewInner::ProfileView(p) => Some(p.did.clone().into_static()), 1031 ProfileDataViewInner::ProfileViewDetailed(p) => Some(p.did.clone().into_static()), 1032 ProfileDataViewInner::TangledProfileView(p) => Some(p.did.clone().into_static()), 1033 ProfileDataViewInner::Unknown(_) => None, 1034 } 1035} 1036 1037/// Render a whitewind blog entry record. 1038/// 1039/// Whitewind entries don't have a view type, so author info comes from fallback_author. 1040fn render_whitewind_entry_record( 1041 data: &Data<'_>, 1042 uri: &AtUri<'_>, 1043 fallback_author: Option<&weaver_api::sh_weaver::actor::ProfileDataView<'_>>, 1044 resolved_content: Option<&weaver_common::ResolvedContent>, 1045) -> Result<String, AtProtoPreprocessError> { 1046 use crate::atproto::writer::ClientWriter; 1047 use crate::default_md_options; 1048 use markdown_weaver::Parser; 1049 1050 let entry = match jacquard::from_data::<weaver_api::com_whtwnd::blog::entry::Entry>(data) { 1051 Ok(e) => e, 1052 Err(_) => return render_generic_record(data, uri), 1053 }; 1054 1055 // Render the markdown content to HTML using resolved_content for embeds 1056 let content = entry.content.as_ref(); 1057 let parser = Parser::new_ext(content, default_md_options()).into_offset_iter(); 1058 let mut content_html = String::new(); 1059 if let Some(resolved) = resolved_content { 1060 ClientWriter::new(parser, &mut content_html, content) 1061 .with_embed_provider(resolved) 1062 .run() 1063 .map_err(|e| { 1064 AtProtoPreprocessError::FetchFailed(format!("Markdown render failed: {:?}", e)) 1065 })?; 1066 } else { 1067 ClientWriter::<_, _, ()>::new(parser, &mut content_html, content) 1068 .run() 1069 .map_err(|e| { 1070 AtProtoPreprocessError::FetchFailed(format!("Markdown render failed: {:?}", e)) 1071 })?; 1072 } 1073 1074 // Generate unique ID for the toggle checkbox 1075 let rkey = uri.rkey().map(|r| r.as_ref()).unwrap_or("unknown"); 1076 let toggle_id = format!("entry-toggle-{}", rkey); 1077 1078 // Build the embed HTML - matches fetch_and_render_whitewind_entry exactly 1079 let mut html = String::new(); 1080 html.push_str("<div class=\"atproto-embed atproto-entry\" contenteditable=\"false\">"); 1081 1082 // Hidden checkbox for expand/collapse (must come before content for CSS sibling selector) 1083 html.push_str("<input type=\"checkbox\" class=\"embed-entry-toggle\" id=\""); 1084 html.push_str(&toggle_id); 1085 html.push_str("\">"); 1086 1087 // Header with title and author 1088 html.push_str("<div class=\"embed-entry-header\">"); 1089 1090 // Title 1091 html.push_str("<span class=\"embed-entry-title\">"); 1092 html.push_str(&html_escape( 1093 entry.title.as_ref().map(|t| t.as_ref()).unwrap_or(""), 1094 )); 1095 html.push_str("</span>"); 1096 1097 // Author info - just show handle (keep it simple for entry embeds) 1098 if let Some(author) = fallback_author { 1099 let handle = extract_handle_from_profile_data_view(&author.inner).unwrap_or(""); 1100 if !handle.is_empty() { 1101 html.push_str("<span class=\"embed-entry-author\">@"); 1102 html.push_str(&html_escape(handle)); 1103 html.push_str("</span>"); 1104 } 1105 } 1106 1107 html.push_str("</div>"); // end header 1108 1109 // Scrollable content container 1110 html.push_str("<div class=\"embed-entry-content\">"); 1111 html.push_str(&content_html); 1112 html.push_str("</div>"); 1113 1114 // Expand/collapse label (clickable, targets the checkbox) 1115 html.push_str("<label class=\"embed-entry-expand\" for=\""); 1116 html.push_str(&toggle_id); 1117 html.push_str("\"></label>"); 1118 1119 html.push_str("</div>"); 1120 1121 Ok(html) 1122} 1123 1124/// Render a leaflet document record. 1125/// 1126/// Uses the sync block renderer to render page content directly. Embedded posts 1127/// within the document will be looked up from resolved_content by their AT URI. 1128fn render_leaflet_record( 1129 data: &Data<'_>, 1130 uri: &AtUri<'_>, 1131 fallback_author: Option<&weaver_api::sh_weaver::actor::ProfileDataView<'_>>, 1132 resolved_content: Option<&weaver_common::ResolvedContent>, 1133) -> Result<String, AtProtoPreprocessError> { 1134 use crate::leaflet::{LeafletRenderContext, render_linear_document_sync}; 1135 use weaver_api::pub_leaflet::document::{Document, DocumentPagesItem}; 1136 1137 let doc = match jacquard::from_data::<Document>(data) { 1138 Ok(d) => d, 1139 Err(_) => return render_generic_record(data, uri), 1140 }; 1141 1142 // Get author DID from fallback_author or from document/URI. 1143 let author_did = if let Some(author) = fallback_author { 1144 extract_did_from_profile_data_view(&author.inner) 1145 } else { 1146 None 1147 } 1148 .or_else(|| { 1149 // Try to get DID from document author field. 1150 match &doc.author { 1151 jacquard::types::ident::AtIdentifier::Did(d) => Some(d.clone().into_static()), 1152 _ => None, 1153 } 1154 }) 1155 .or_else(|| { 1156 // Fall back to URI authority if it's a DID. 1157 jacquard::types::string::Did::new(uri.authority().as_ref()) 1158 .ok() 1159 .map(|d| d.into_static()) 1160 }); 1161 1162 let ctx = author_did 1163 .map(LeafletRenderContext::new) 1164 .unwrap_or_else(|| { 1165 LeafletRenderContext::new(jacquard::types::string::Did::raw("did:plc:unknown".into())) 1166 }); 1167 1168 // Generate unique toggle ID. 1169 let rkey = uri.rkey().map(|r| r.as_ref()).unwrap_or("unknown"); 1170 let toggle_id = format!("leaflet-toggle-{}", rkey); 1171 1172 let mut html = String::new(); 1173 html.push_str("<div class=\"atproto-embed atproto-entry\" contenteditable=\"false\">"); 1174 1175 // Hidden checkbox for expand/collapse. 1176 html.push_str("<input type=\"checkbox\" class=\"embed-entry-toggle\" id=\""); 1177 html.push_str(&toggle_id); 1178 html.push_str("\">"); 1179 1180 // Header with title and author. 1181 html.push_str("<div class=\"embed-entry-header\">"); 1182 1183 // Title (no link in sync version since we don't have publication base_path). 1184 html.push_str("<span class=\"embed-entry-title\">"); 1185 html.push_str(&html_escape(doc.title.as_ref())); 1186 html.push_str("</span>"); 1187 1188 // Author info. 1189 if let Some(author) = fallback_author { 1190 let handle = extract_handle_from_profile_data_view(&author.inner).unwrap_or(""); 1191 if !handle.is_empty() { 1192 html.push_str("<span class=\"embed-entry-author\">@"); 1193 html.push_str(&html_escape(handle)); 1194 html.push_str("</span>"); 1195 } 1196 } 1197 1198 html.push_str("</div>"); // end header 1199 1200 // Scrollable content container. 1201 html.push_str("<div class=\"embed-entry-content\">"); 1202 1203 // Render each page using the sync block renderer. 1204 for page in &doc.pages { 1205 match page { 1206 DocumentPagesItem::LinearDocument(linear_doc) => { 1207 html.push_str(&render_linear_document_sync( 1208 linear_doc, 1209 &ctx, 1210 resolved_content, 1211 )); 1212 } 1213 DocumentPagesItem::Canvas(_) => { 1214 html.push_str( 1215 "<div class=\"embed-video-placeholder\">[Canvas layout not yet supported]</div>", 1216 ); 1217 } 1218 DocumentPagesItem::Unknown(_) => { 1219 html.push_str("<div class=\"embed-video-placeholder\">[Unknown page type]</div>"); 1220 } 1221 } 1222 } 1223 1224 html.push_str("</div>"); // end content 1225 1226 // Expand/collapse label. 1227 html.push_str("<label class=\"embed-entry-expand\" for=\""); 1228 html.push_str(&toggle_id); 1229 html.push_str("\"></label>"); 1230 1231 html.push_str("</div>"); 1232 1233 Ok(html) 1234} 1235 1236/// Render a site.standard or blog.pckt document record. 1237/// 1238/// Uses the sync block renderer to render content blocks directly. Embedded posts 1239/// within the document will be looked up from resolved_content by their AT URI. 1240#[cfg(feature = "pckt")] 1241fn render_site_standard_record( 1242 data: &Data<'_>, 1243 uri: &AtUri<'_>, 1244 fallback_author: Option<&weaver_api::sh_weaver::actor::ProfileDataView<'_>>, 1245 resolved_content: Option<&weaver_common::ResolvedContent>, 1246) -> Result<String, AtProtoPreprocessError> { 1247 use crate::pckt::{PcktRenderContext, render_content_blocks_sync}; 1248 use weaver_api::site_standard::document::Document as SiteStandardDocument; 1249 1250 // Extract the document - either directly or from blog.pckt.document wrapper. 1251 let doc: SiteStandardDocument<'_> = if data 1252 .type_discriminator() 1253 .map(|t| t == "blog.pckt.document") 1254 .unwrap_or(false) 1255 { 1256 let pckt_doc = match jacquard::from_data::<weaver_api::blog_pckt::document::Document>(data) 1257 { 1258 Ok(d) => d, 1259 Err(_) => return render_generic_record(data, uri), 1260 }; 1261 pckt_doc.document 1262 } else { 1263 match jacquard::from_data::<SiteStandardDocument>(data) { 1264 Ok(d) => d, 1265 Err(_) => return render_generic_record(data, uri), 1266 } 1267 }; 1268 1269 // Get author DID from fallback_author or from URI authority. 1270 let author_did = if let Some(author) = fallback_author { 1271 extract_did_from_profile_data_view(&author.inner) 1272 } else { 1273 None 1274 } 1275 .or_else(|| { 1276 // Fall back to URI authority if it's a DID. 1277 jacquard::types::string::Did::new(uri.authority().as_ref()) 1278 .ok() 1279 .map(|d| d.into_static()) 1280 }); 1281 1282 let ctx = author_did 1283 .map(PcktRenderContext::new) 1284 .unwrap_or_else(|| unsafe { 1285 PcktRenderContext::new(jacquard::types::string::Did::unchecked( 1286 "did:plc:unknown".into(), 1287 )) 1288 }); 1289 1290 let rkey = uri.rkey().map(|r| r.as_ref()).unwrap_or("unknown"); 1291 let toggle_id = format!("pckt-toggle-{}", rkey); 1292 1293 let mut html = String::new(); 1294 html.push_str("<div class=\"atproto-embed atproto-entry\" contenteditable=\"false\">"); 1295 1296 // Toggle checkbox. 1297 html.push_str("<input type=\"checkbox\" class=\"embed-entry-toggle\" id=\""); 1298 html.push_str(&toggle_id); 1299 html.push_str("\">"); 1300 1301 // Header. 1302 html.push_str("<div class=\"embed-entry-header\">"); 1303 html.push_str("<span class=\"embed-entry-title\">"); 1304 html.push_str(&html_escape(doc.title.as_ref())); 1305 html.push_str("</span>"); 1306 1307 // Author info. 1308 if let Some(author) = fallback_author { 1309 let handle = extract_handle_from_profile_data_view(&author.inner).unwrap_or(""); 1310 if !handle.is_empty() { 1311 html.push_str("<span class=\"embed-entry-author\">@"); 1312 html.push_str(&html_escape(handle)); 1313 html.push_str("</span>"); 1314 } 1315 } 1316 1317 html.push_str("</div>"); 1318 1319 // Content. 1320 html.push_str("<div class=\"embed-entry-content\">"); 1321 if let Some(content) = &doc.content { 1322 // Render actual content blocks using the sync renderer. 1323 html.push_str(&render_content_blocks_sync( 1324 vec![content.clone()].as_slice(), 1325 &ctx, 1326 resolved_content, 1327 )); 1328 } else if let Some(text_content) = &doc.text_content { 1329 // Fallback to text_content if no structured blocks. 1330 html.push_str("<p>"); 1331 html.push_str(&html_escape(text_content.as_ref())); 1332 html.push_str("</p>"); 1333 } 1334 html.push_str("</div>"); 1335 1336 // Expand label. 1337 html.push_str("<label class=\"embed-entry-expand\" for=\""); 1338 html.push_str(&toggle_id); 1339 html.push_str("\"></label>"); 1340 1341 html.push_str("</div>"); 1342 1343 Ok(html) 1344} 1345 1346/// Render a basic post from record data (no engagement stats or author info). 1347/// 1348/// This is a simpler version than `render_post_view` for cases where you only 1349/// have the raw record, not the full PostView from the appview. 1350fn render_basic_post(data: &Data<'_>, uri: &AtUri<'_>) -> Result<String, AtProtoPreprocessError> { 1351 let mut html = String::new(); 1352 1353 // Try to parse as Post 1354 let post = jacquard::from_data::<weaver_api::app_bsky::feed::post::Post>(data) 1355 .map_err(|e| AtProtoPreprocessError::FetchFailed(format!("Parse error: {:?}", e)))?; 1356 1357 // Build link to post on Bluesky 1358 let authority = uri.authority(); 1359 let rkey = uri.rkey().map(|r| r.as_ref()).unwrap_or(""); 1360 let bsky_link = format!("https://bsky.app/profile/{}/post/{}", authority, rkey); 1361 1362 html.push_str("<span class=\"atproto-embed atproto-post\" contenteditable=\"false\">"); 1363 1364 // Background link 1365 html.push_str("<a class=\"embed-card-link\" href=\""); 1366 html.push_str(&html_escape(&bsky_link)); 1367 html.push_str("\" target=\"_blank\" rel=\"noopener\" aria-label=\"View post on Bluesky\"></a>"); 1368 1369 // Post text 1370 html.push_str("<span class=\"embed-content\">"); 1371 html.push_str(&html_escape(post.text.as_ref())); 1372 html.push_str("</span>"); 1373 1374 // Timestamp 1375 html.push_str("<span class=\"embed-meta\">"); 1376 html.push_str("<span class=\"embed-time\">"); 1377 html.push_str(&html_escape(&post.created_at.to_string())); 1378 html.push_str("</span>"); 1379 html.push_str("</span>"); 1380 1381 html.push_str("</span>"); 1382 1383 Ok(html) 1384} 1385 1386/// Render a profile from ProfileDataViewInner (weaver, bsky, or tangled). 1387/// 1388/// Takes pre-fetched profile data - no network calls. 1389pub fn render_profile_data_view( 1390 inner: &ProfileDataViewInner<'_>, 1391) -> Result<String, AtProtoPreprocessError> { 1392 let mut html = String::new(); 1393 1394 match inner { 1395 ProfileDataViewInner::ProfileView(profile) => { 1396 // Weaver profile - link to bsky for now 1397 let profile_url = format!("https://bsky.app/profile/{}", profile.handle.as_ref()); 1398 html.push_str( 1399 "<span class=\"atproto-embed atproto-profile\" contenteditable=\"false\">", 1400 ); 1401 1402 // Background link covers whole card 1403 html.push_str("<a class=\"embed-card-link\" href=\""); 1404 html.push_str(&html_escape(&profile_url)); 1405 html.push_str("\" target=\"_blank\" rel=\"noopener\" aria-label=\"View profile\"></a>"); 1406 1407 html.push_str("<span class=\"embed-author\">"); 1408 if let Some(avatar) = &profile.avatar { 1409 html.push_str("<img class=\"embed-avatar\" src=\""); 1410 html.push_str(&html_escape(avatar.as_ref())); 1411 html.push_str("\" alt=\"\" width=\"42\" height=\"42\" />"); 1412 } 1413 html.push_str("<span class=\"embed-author-info\">"); 1414 if let Some(display_name) = &profile.display_name { 1415 html.push_str("<span class=\"embed-author-name\">"); 1416 html.push_str(&html_escape(display_name.as_ref())); 1417 html.push_str("</span>"); 1418 } 1419 html.push_str("<span class=\"embed-author-handle\">@"); 1420 html.push_str(&html_escape(profile.handle.as_ref())); 1421 html.push_str("</span>"); 1422 html.push_str("</span>"); 1423 html.push_str("</span>"); 1424 1425 if let Some(description) = &profile.description { 1426 html.push_str("<span class=\"embed-description\">"); 1427 html.push_str(&html_escape(description.as_ref())); 1428 html.push_str("</span>"); 1429 } 1430 1431 html.push_str("</span>"); 1432 } 1433 ProfileDataViewInner::ProfileViewDetailed(profile) => { 1434 // Bsky profile 1435 let profile_url = format!("https://bsky.app/profile/{}", profile.handle.as_ref()); 1436 html.push_str( 1437 "<span class=\"atproto-embed atproto-profile\" contenteditable=\"false\">", 1438 ); 1439 1440 // Background link covers whole card 1441 html.push_str("<a class=\"embed-card-link\" href=\""); 1442 html.push_str(&html_escape(&profile_url)); 1443 html.push_str("\" target=\"_blank\" rel=\"noopener\" aria-label=\"View profile\"></a>"); 1444 1445 html.push_str("<span class=\"embed-author\">"); 1446 if let Some(avatar) = &profile.avatar { 1447 html.push_str("<img class=\"embed-avatar\" src=\""); 1448 html.push_str(&html_escape(avatar.as_ref())); 1449 html.push_str("\" alt=\"\" width=\"42\" height=\"42\" />"); 1450 } 1451 html.push_str("<span class=\"embed-author-info\">"); 1452 if let Some(display_name) = &profile.display_name { 1453 html.push_str("<span class=\"embed-author-name\">"); 1454 html.push_str(&html_escape(display_name.as_ref())); 1455 html.push_str("</span>"); 1456 } 1457 html.push_str("<span class=\"embed-author-handle\">@"); 1458 html.push_str(&html_escape(profile.handle.as_ref())); 1459 html.push_str("</span>"); 1460 html.push_str("</span>"); 1461 html.push_str("</span>"); 1462 1463 if let Some(description) = &profile.description { 1464 html.push_str("<span class=\"embed-description\">"); 1465 html.push_str(&html_escape(description.as_ref())); 1466 html.push_str("</span>"); 1467 } 1468 1469 // Stats for bsky profiles 1470 if profile.followers_count.is_some() || profile.follows_count.is_some() { 1471 html.push_str("<span class=\"embed-meta\">"); 1472 html.push_str("<span class=\"embed-stats\">"); 1473 if let Some(followers) = profile.followers_count { 1474 html.push_str("<span class=\"embed-stat\">"); 1475 html.push_str(&followers.to_string()); 1476 html.push_str(" followers</span>"); 1477 } 1478 if let Some(follows) = profile.follows_count { 1479 html.push_str("<span class=\"embed-stat\">"); 1480 html.push_str(&follows.to_string()); 1481 html.push_str(" following</span>"); 1482 } 1483 html.push_str("</span>"); 1484 html.push_str("</span>"); 1485 } 1486 1487 html.push_str("</span>"); 1488 } 1489 ProfileDataViewInner::TangledProfileView(profile) => { 1490 // Tangled profile - link to tangled 1491 let profile_url = format!("https://tangled.sh/@{}", profile.handle.as_ref()); 1492 html.push_str( 1493 "<span class=\"atproto-embed atproto-profile\" contenteditable=\"false\">", 1494 ); 1495 1496 // Background link covers whole card 1497 html.push_str("<a class=\"embed-card-link\" href=\""); 1498 html.push_str(&html_escape(&profile_url)); 1499 html.push_str("\" target=\"_blank\" rel=\"noopener\" aria-label=\"View profile\"></a>"); 1500 1501 html.push_str("<span class=\"embed-author\">"); 1502 html.push_str("<span class=\"embed-author-info\">"); 1503 html.push_str("<span class=\"embed-author-handle\">@"); 1504 html.push_str(&html_escape(profile.handle.as_ref())); 1505 html.push_str("</span>"); 1506 html.push_str("</span>"); 1507 html.push_str("</span>"); 1508 1509 if let Some(description) = &profile.description { 1510 html.push_str("<span class=\"embed-description\">"); 1511 html.push_str(&html_escape(description.as_ref())); 1512 html.push_str("</span>"); 1513 } 1514 1515 html.push_str("</span>"); 1516 } 1517 ProfileDataViewInner::Unknown(data) => { 1518 // Unknown - no link, just render 1519 html.push_str( 1520 "<span class=\"atproto-embed atproto-profile\" contenteditable=\"false\">", 1521 ); 1522 html.push_str(&render_generic_data(data)); 1523 html.push_str("</span>"); 1524 } 1525 } 1526 1527 Ok(html) 1528} 1529 1530/// Render a Bluesky post from PostView (rich appview data). 1531/// 1532/// Takes pre-fetched PostView from getPosts - no network calls. 1533pub fn render_post_view<'a>( 1534 post: &PostView<'a>, 1535 uri: &AtUri<'_>, 1536) -> Result<String, AtProtoPreprocessError> { 1537 let mut html = String::new(); 1538 1539 // Build link to post on Bluesky 1540 let bsky_link = format!( 1541 "https://bsky.app/profile/{}/post/{}", 1542 post.author.handle.as_ref(), 1543 uri.rkey().map(|r| r.as_ref()).unwrap_or("") 1544 ); 1545 1546 html.push_str("<span class=\"atproto-embed atproto-post\" contenteditable=\"false\">"); 1547 1548 // Background link covers whole card, other links sit on top 1549 html.push_str("<a class=\"embed-card-link\" href=\""); 1550 html.push_str(&html_escape(&bsky_link)); 1551 html.push_str("\" target=\"_blank\" rel=\"noopener\" aria-label=\"View post on Bluesky\"></a>"); 1552 1553 // Author header 1554 html.push_str(&render_author_block(&post.author, true)); 1555 1556 // Post text (parse record as typed Post) 1557 if let Ok(post_record) = 1558 jacquard::from_data::<weaver_api::app_bsky::feed::post::Post>(&post.record) 1559 { 1560 html.push_str("<span class=\"embed-content\">"); 1561 html.push_str(&html_escape(post_record.text.as_ref())); 1562 html.push_str("</span>"); 1563 } 1564 1565 // Embedded content (images, links, quotes, etc.) 1566 if let Some(embed) = &post.embed { 1567 html.push_str(&render_post_embed(embed)); 1568 } 1569 1570 // Engagement stats and timestamp 1571 html.push_str("<span class=\"embed-meta\">"); 1572 1573 // Stats row 1574 html.push_str("<span class=\"embed-stats\">"); 1575 if let Some(replies) = post.reply_count { 1576 html.push_str("<span class=\"embed-stat\">"); 1577 html.push_str(&replies.to_string()); 1578 html.push_str(" replies</span>"); 1579 } 1580 if let Some(reposts) = post.repost_count { 1581 html.push_str("<span class=\"embed-stat\">"); 1582 html.push_str(&reposts.to_string()); 1583 html.push_str(" reposts</span>"); 1584 } 1585 if let Some(likes) = post.like_count { 1586 html.push_str("<span class=\"embed-stat\">"); 1587 html.push_str(&likes.to_string()); 1588 html.push_str(" likes</span>"); 1589 } 1590 html.push_str("</span>"); 1591 1592 // Timestamp 1593 html.push_str("<span class=\"embed-time\">"); 1594 html.push_str(&html_escape(&post.indexed_at.to_string())); 1595 html.push_str("</span>"); 1596 1597 html.push_str("</span>"); 1598 html.push_str("</span>"); 1599 1600 Ok(html) 1601} 1602 1603/// Render a generic record by probing Data for meaningful fields. 1604/// 1605/// Takes pre-fetched record data - no network calls. 1606/// Probes for common fields like name, title, text, description. 1607pub fn render_generic_record( 1608 data: &Data<'_>, 1609 uri: &AtUri<'_>, 1610) -> Result<String, AtProtoPreprocessError> { 1611 let mut html = String::new(); 1612 1613 html.push_str("<span class=\"atproto-embed atproto-record\" contenteditable=\"false\">"); 1614 1615 // Show record type as header (full NSID) 1616 if let Some(collection) = uri.collection() { 1617 html.push_str("<span class=\"embed-author-handle\">"); 1618 html.push_str(&html_escape(collection.as_ref())); 1619 html.push_str("</span>"); 1620 } 1621 1622 // Priority fields to show first (in order) 1623 let priority_fields = [ 1624 "name", 1625 "displayName", 1626 "title", 1627 "text", 1628 "description", 1629 "content", 1630 ]; 1631 let mut shown_fields = Vec::new(); 1632 1633 if let Some(obj) = data.as_object() { 1634 for field_name in priority_fields { 1635 if let Some(value) = obj.get(field_name) { 1636 if let Some(s) = value.as_str() { 1637 let class = match field_name { 1638 "name" | "displayName" | "title" => "embed-author-name", 1639 "text" | "content" => "embed-content", 1640 "description" => "embed-description", 1641 _ => "embed-field-value", 1642 }; 1643 html.push_str("<span class=\""); 1644 html.push_str(class); 1645 html.push_str("\">"); 1646 // Truncate long content for embed display 1647 let display_text = if s.len() > 300 { 1648 format!("{}...", &s[..300]) 1649 } else { 1650 s.to_string() 1651 }; 1652 html.push_str(&html_escape(&display_text)); 1653 html.push_str("</span>"); 1654 shown_fields.push(field_name); 1655 } 1656 } 1657 } 1658 1659 // Show remaining fields as a simple list 1660 html.push_str("<span class=\"embed-fields\">"); 1661 for (key, value) in obj.iter() { 1662 let key_str: &str = key.as_ref(); 1663 1664 // Skip already shown, internal fields, and complex nested objects 1665 if shown_fields.contains(&key_str) 1666 || key_str.starts_with('$') 1667 || key_str == "facets" 1668 || key_str == "labels" 1669 || key_str == "embeds" 1670 { 1671 continue; 1672 } 1673 1674 if let Some(formatted) = format_field_value(key_str, value) { 1675 html.push_str("<span class=\"embed-field\">"); 1676 html.push_str("<span class=\"embed-field-name\">"); 1677 html.push_str(&html_escape(&format_field_name(key_str))); 1678 html.push_str(":</span> "); 1679 html.push_str(&formatted); 1680 html.push_str("</span>"); 1681 } 1682 } 1683 html.push_str("</span>"); 1684 } 1685 1686 html.push_str("</span>"); 1687 1688 Ok(html) 1689} 1690 1691// ============================================================================= 1692// Reusable render functions for embed components 1693// ============================================================================= 1694 1695/// Render an author block (avatar + name + handle) 1696/// 1697/// Used for posts, profiles, and any record with an author. 1698/// When `link_to_profile` is true, avatar, display name, and handle all link to the profile. 1699pub fn render_author_block(author: &ProfileViewBasic<'_>, link_to_profile: bool) -> String { 1700 render_author_block_inner( 1701 author.avatar.as_ref().map(|u| u.as_ref()), 1702 author.display_name.as_ref().map(|s| s.as_ref()), 1703 author.handle.as_ref(), 1704 link_to_profile, 1705 ) 1706} 1707 1708/// Render author block from ProfileView (has same fields as ProfileViewBasic) 1709pub fn render_author_block_full( 1710 author: &weaver_api::app_bsky::actor::ProfileView<'_>, 1711 link_to_profile: bool, 1712) -> String { 1713 render_author_block_inner( 1714 author.avatar.as_ref().map(|u| u.as_ref()), 1715 author.display_name.as_ref().map(|s| s.as_ref()), 1716 author.handle.as_ref(), 1717 link_to_profile, 1718 ) 1719} 1720 1721fn render_author_block_inner( 1722 avatar: Option<&str>, 1723 display_name: Option<&str>, 1724 handle: &str, 1725 link_to_profile: bool, 1726) -> String { 1727 let mut html = String::new(); 1728 let profile_url = format!("https://bsky.app/profile/{}", handle); 1729 1730 html.push_str("<span class=\"embed-author\">"); 1731 1732 if let Some(avatar_url) = avatar { 1733 if link_to_profile { 1734 html.push_str("<a class=\"embed-avatar-link\" href=\""); 1735 html.push_str(&html_escape(&profile_url)); 1736 html.push_str("\" target=\"_blank\" rel=\"noopener\">"); 1737 html.push_str("<img class=\"embed-avatar\" src=\""); 1738 html.push_str(&html_escape(avatar_url)); 1739 html.push_str("\" alt=\"\" width=\"42\" height=\"42\" />"); 1740 html.push_str("</a>"); 1741 } else { 1742 html.push_str("<img class=\"embed-avatar\" src=\""); 1743 html.push_str(&html_escape(avatar_url)); 1744 html.push_str("\" alt=\"\" width=\"42\" height=\"42\" />"); 1745 } 1746 } 1747 1748 html.push_str("<span class=\"embed-author-info\">"); 1749 1750 if let Some(name) = display_name { 1751 if link_to_profile { 1752 html.push_str("<a class=\"embed-author-name\" href=\""); 1753 html.push_str(&html_escape(&profile_url)); 1754 html.push_str("\" target=\"_blank\" rel=\"noopener\">"); 1755 html.push_str(&html_escape(name)); 1756 html.push_str("</a>"); 1757 } else { 1758 html.push_str("<span class=\"embed-author-name\">"); 1759 html.push_str(&html_escape(name)); 1760 html.push_str("</span>"); 1761 } 1762 } 1763 1764 if link_to_profile { 1765 html.push_str("<a class=\"embed-author-handle\" href=\""); 1766 html.push_str(&html_escape(&profile_url)); 1767 html.push_str("\" target=\"_blank\" rel=\"noopener\">@"); 1768 html.push_str(&html_escape(handle)); 1769 html.push_str("</a>"); 1770 } else { 1771 html.push_str("<span class=\"embed-author-handle\">@"); 1772 html.push_str(&html_escape(handle)); 1773 html.push_str("</span>"); 1774 } 1775 1776 html.push_str("</span>"); 1777 html.push_str("</span>"); 1778 1779 html 1780} 1781 1782/// Render an external link card (title, description, thumbnail) 1783/// 1784/// Used for link previews in posts and standalone link embeds. 1785pub fn render_external_link(external: &ViewExternal<'_>) -> String { 1786 let mut html = String::new(); 1787 1788 html.push_str("<a class=\"embed-external\" href=\""); 1789 html.push_str(&html_escape(external.uri.as_ref())); 1790 html.push_str("\" target=\"_blank\" rel=\"noopener\">"); 1791 1792 if let Some(thumb) = &external.thumb { 1793 html.push_str("<img class=\"embed-external-thumb\" src=\""); 1794 html.push_str(&html_escape(thumb.as_ref())); 1795 html.push_str("\" alt=\"\" />"); 1796 } 1797 1798 html.push_str("<span class=\"embed-external-info\">"); 1799 html.push_str("<span class=\"embed-external-title\">"); 1800 html.push_str(&html_escape(external.title.as_ref())); 1801 html.push_str("</span>"); 1802 1803 if !external.description.is_empty() { 1804 html.push_str("<span class=\"embed-external-description\">"); 1805 html.push_str(&html_escape(external.description.as_ref())); 1806 html.push_str("</span>"); 1807 } 1808 1809 html.push_str("<span class=\"embed-external-url\">"); 1810 // Show just the domain 1811 if let Some(domain) = extract_domain(external.uri.as_ref()) { 1812 html.push_str(&html_escape(domain)); 1813 } else { 1814 html.push_str(&html_escape(external.uri.as_ref())); 1815 } 1816 html.push_str("</span>"); 1817 1818 html.push_str("</span>"); 1819 html.push_str("</a>"); 1820 1821 html 1822} 1823 1824/// Render an image gallery 1825/// 1826/// Used for image embeds in posts. 1827pub fn render_images(images: &[ViewImage<'_>]) -> String { 1828 let mut html = String::new(); 1829 1830 let class = match images.len() { 1831 1 => "embed-images embed-images-1", 1832 2 => "embed-images embed-images-2", 1833 3 => "embed-images embed-images-3", 1834 _ => "embed-images embed-images-4", 1835 }; 1836 1837 html.push_str("<span class=\""); 1838 html.push_str(class); 1839 html.push_str("\">"); 1840 1841 for img in images { 1842 html.push_str("<a class=\"embed-image-link\" href=\""); 1843 html.push_str(&html_escape(img.fullsize.as_ref())); 1844 html.push_str("\" target=\"_blank\""); 1845 1846 // Add aspect-ratio style if available 1847 if let Some(aspect) = &img.aspect_ratio { 1848 html.push_str(" style=\"aspect-ratio: "); 1849 html.push_str(&aspect.width.to_string()); 1850 html.push_str(" / "); 1851 html.push_str(&aspect.height.to_string()); 1852 html.push_str(";\""); 1853 } 1854 1855 html.push_str(">"); 1856 html.push_str("<img class=\"embed-image\" src=\""); 1857 html.push_str(&html_escape(img.thumb.as_ref())); 1858 html.push_str("\" alt=\""); 1859 html.push_str(&html_escape(img.alt.as_ref())); 1860 html.push_str("\" />"); 1861 html.push_str("</a>"); 1862 } 1863 1864 html.push_str("</span>"); 1865 1866 html 1867} 1868 1869/// Render a quoted/embedded record 1870/// 1871/// Used for quote posts and record embeds. Dispatches based on record type. 1872pub fn render_quoted_record(record: &ViewRecord<'_>) -> String { 1873 let mut html = String::new(); 1874 1875 html.push_str("<span class=\"embed-quote\">"); 1876 1877 // Dispatch based on record type 1878 match record.value.type_discriminator() { 1879 Some("app.bsky.feed.post") => { 1880 // Post - show author and text 1881 html.push_str(&render_author_block(&record.author, true)); 1882 if let Ok(post) = 1883 jacquard::from_data::<weaver_api::app_bsky::feed::post::Post>(&record.value) 1884 { 1885 html.push_str("<span class=\"embed-content\">"); 1886 html.push_str(&html_escape(post.text.as_ref())); 1887 html.push_str("</span>"); 1888 } 1889 } 1890 Some("app.bsky.feed.generator") => { 1891 // Custom feed - show feed info with type label 1892 if let Ok(generator) = jacquard::from_data::< 1893 weaver_api::app_bsky::feed::generator::Generator, 1894 >(&record.value) 1895 { 1896 html.push_str("<span class=\"embed-type\">Custom Feed</span>"); 1897 html.push_str("<span class=\"embed-author-name\">"); 1898 html.push_str(&html_escape(generator.display_name.as_ref())); 1899 html.push_str("</span>"); 1900 if let Some(desc) = &generator.description { 1901 html.push_str("<span class=\"embed-description\">"); 1902 html.push_str(&html_escape(desc.as_ref())); 1903 html.push_str("</span>"); 1904 } 1905 html.push_str(&render_author_block(&record.author, true)); 1906 } 1907 } 1908 Some("app.bsky.graph.list") => { 1909 // List - show list info 1910 if let Ok(list) = 1911 jacquard::from_data::<weaver_api::app_bsky::graph::list::List>(&record.value) 1912 { 1913 html.push_str("<span class=\"embed-type\">List</span>"); 1914 html.push_str("<span class=\"embed-author-name\">"); 1915 html.push_str(&html_escape(list.name.as_ref())); 1916 html.push_str("</span>"); 1917 if let Some(desc) = &list.description { 1918 html.push_str("<span class=\"embed-description\">"); 1919 html.push_str(&html_escape(desc.as_ref())); 1920 html.push_str("</span>"); 1921 } 1922 html.push_str(&render_author_block(&record.author, true)); 1923 } 1924 } 1925 Some("app.bsky.graph.starterpack") => { 1926 // Starter pack 1927 if let Ok(sp) = jacquard::from_data::< 1928 weaver_api::app_bsky::graph::starterpack::Starterpack, 1929 >(&record.value) 1930 { 1931 html.push_str("<span class=\"embed-type\">Starter Pack</span>"); 1932 html.push_str("<span class=\"embed-author-name\">"); 1933 html.push_str(&html_escape(sp.name.as_ref())); 1934 html.push_str("</span>"); 1935 if let Some(desc) = &sp.description { 1936 html.push_str("<span class=\"embed-description\">"); 1937 html.push_str(&html_escape(desc.as_ref())); 1938 html.push_str("</span>"); 1939 } 1940 html.push_str(&render_author_block(&record.author, true)); 1941 } 1942 } 1943 _ => { 1944 // Unknown type - show author and probe for common fields 1945 html.push_str(&render_author_block(&record.author, true)); 1946 html.push_str(&render_generic_data(&record.value)); 1947 } 1948 } 1949 1950 // Render nested embeds if present (applies to all types) 1951 if let Some(embeds) = &record.embeds { 1952 for embed in embeds { 1953 html.push_str(&render_view_record_embed(embed)); 1954 } 1955 } 1956 1957 html.push_str("</span>"); 1958 1959 html 1960} 1961 1962/// Render an embed item from a ViewRecord (nested embeds in quotes) 1963fn render_view_record_embed( 1964 embed: &weaver_api::app_bsky::embed::record::ViewRecordEmbedsItem<'_>, 1965) -> String { 1966 use weaver_api::app_bsky::embed::record::ViewRecordEmbedsItem; 1967 1968 match embed { 1969 ViewRecordEmbedsItem::ImagesView(images) => render_images(&images.images), 1970 ViewRecordEmbedsItem::ExternalView(external) => render_external_link(&external.external), 1971 ViewRecordEmbedsItem::View(record_view) => render_record_embed(&record_view.record), 1972 ViewRecordEmbedsItem::RecordWithMediaView(rwm) => { 1973 let mut html = String::new(); 1974 // Render media first 1975 match &rwm.media { 1976 weaver_api::app_bsky::embed::record_with_media::ViewMedia::ImagesView(img) => { 1977 html.push_str(&render_images(&img.images)); 1978 } 1979 weaver_api::app_bsky::embed::record_with_media::ViewMedia::ExternalView(ext) => { 1980 html.push_str(&render_external_link(&ext.external)); 1981 } 1982 weaver_api::app_bsky::embed::record_with_media::ViewMedia::VideoView(_) => { 1983 html.push_str("<span class=\"embed-video-placeholder\">[Video]</span>"); 1984 } 1985 weaver_api::app_bsky::embed::record_with_media::ViewMedia::Unknown(_) => {} 1986 } 1987 // Then the record 1988 html.push_str(&render_record_embed(&rwm.record.record)); 1989 html 1990 } 1991 ViewRecordEmbedsItem::VideoView(_) => { 1992 "<span class=\"embed-video-placeholder\">[Video]</span>".to_string() 1993 } 1994 ViewRecordEmbedsItem::Unknown(data) => render_generic_data(data), 1995 } 1996} 1997 1998/// Render a PostViewEmbed (images, external, record, video, etc.) 1999pub fn render_post_embed(embed: &PostViewEmbed<'_>) -> String { 2000 match embed { 2001 PostViewEmbed::ImagesView(images) => render_images(&images.images), 2002 PostViewEmbed::ExternalView(external) => render_external_link(&external.external), 2003 PostViewEmbed::RecordView(record) => render_record_embed(&record.record), 2004 PostViewEmbed::RecordWithMediaView(rwm) => { 2005 let mut html = String::new(); 2006 // Render media first 2007 match &rwm.media { 2008 weaver_api::app_bsky::embed::record_with_media::ViewMedia::ImagesView(img) => { 2009 html.push_str(&render_images(&img.images)); 2010 } 2011 weaver_api::app_bsky::embed::record_with_media::ViewMedia::ExternalView(ext) => { 2012 html.push_str(&render_external_link(&ext.external)); 2013 } 2014 weaver_api::app_bsky::embed::record_with_media::ViewMedia::VideoView(_) => { 2015 html.push_str("<span class=\"embed-video-placeholder\">[Video]</span>"); 2016 } 2017 weaver_api::app_bsky::embed::record_with_media::ViewMedia::Unknown(_) => {} 2018 } 2019 // Then the record 2020 html.push_str(&render_record_embed(&rwm.record.record)); 2021 html 2022 } 2023 PostViewEmbed::VideoView(_) => { 2024 "<span class=\"embed-video-placeholder\">[Video]</span>".to_string() 2025 } 2026 PostViewEmbed::Unknown(data) => render_generic_data(data), 2027 } 2028} 2029 2030/// Render a ViewUnionRecord (the actual content of a record embed) 2031fn render_record_embed(record: &ViewUnionRecord<'_>) -> String { 2032 match record { 2033 ViewUnionRecord::ViewRecord(r) => render_quoted_record(r), 2034 ViewUnionRecord::ViewNotFound(_) => { 2035 "<span class=\"embed-not-found\">Record not found</span>".to_string() 2036 } 2037 ViewUnionRecord::ViewBlocked(_) => { 2038 "<span class=\"embed-blocked\">Content blocked</span>".to_string() 2039 } 2040 ViewUnionRecord::ViewDetached(_) => { 2041 "<span class=\"embed-detached\">Content unavailable</span>".to_string() 2042 } 2043 ViewUnionRecord::GeneratorView(generator) => { 2044 let mut html = String::new(); 2045 html.push_str("<span class=\"embed-record-card\">"); 2046 2047 // Icon + title + type (like author block layout) 2048 html.push_str("<span class=\"embed-author\">"); 2049 if let Some(avatar) = &generator.avatar { 2050 html.push_str("<img class=\"embed-avatar\" src=\""); 2051 html.push_str(&html_escape(avatar.as_ref())); 2052 html.push_str("\" alt=\"\" width=\"42\" height=\"42\" />"); 2053 } 2054 html.push_str("<span class=\"embed-author-info\">"); 2055 html.push_str("<span class=\"embed-author-name\">"); 2056 html.push_str(&html_escape(generator.display_name.as_ref())); 2057 html.push_str("</span>"); 2058 html.push_str("<span class=\"embed-author-handle\">Feed</span>"); 2059 html.push_str("</span>"); 2060 html.push_str("</span>"); 2061 2062 // Description 2063 if let Some(desc) = &generator.description { 2064 html.push_str("<span class=\"embed-description\">"); 2065 html.push_str(&html_escape(desc.as_ref())); 2066 html.push_str("</span>"); 2067 } 2068 2069 // Creator 2070 html.push_str(&render_author_block_full(&generator.creator, true)); 2071 2072 // Stats 2073 if let Some(likes) = generator.like_count { 2074 html.push_str("<span class=\"embed-stats\">"); 2075 html.push_str("<span class=\"embed-stat\">"); 2076 html.push_str(&likes.to_string()); 2077 html.push_str(" likes</span>"); 2078 html.push_str("</span>"); 2079 } 2080 2081 html.push_str("</span>"); 2082 html 2083 } 2084 ViewUnionRecord::ListView(list) => { 2085 let mut html = String::new(); 2086 html.push_str("<span class=\"embed-record-card\">"); 2087 2088 // Icon + title + type (like author block layout) 2089 html.push_str("<span class=\"embed-author\">"); 2090 if let Some(avatar) = &list.avatar { 2091 html.push_str("<img class=\"embed-avatar\" src=\""); 2092 html.push_str(&html_escape(avatar.as_ref())); 2093 html.push_str("\" alt=\"\" width=\"42\" height=\"42\" />"); 2094 } 2095 html.push_str("<span class=\"embed-author-info\">"); 2096 html.push_str("<span class=\"embed-author-name\">"); 2097 html.push_str(&html_escape(list.name.as_ref())); 2098 html.push_str("</span>"); 2099 html.push_str("<span class=\"embed-author-handle\">List</span>"); 2100 html.push_str("</span>"); 2101 html.push_str("</span>"); 2102 2103 // Description 2104 if let Some(desc) = &list.description { 2105 html.push_str("<span class=\"embed-description\">"); 2106 html.push_str(&html_escape(desc.as_ref())); 2107 html.push_str("</span>"); 2108 } 2109 2110 // Creator 2111 html.push_str(&render_author_block_full(&list.creator, true)); 2112 2113 // Stats 2114 if let Some(count) = list.list_item_count { 2115 html.push_str("<span class=\"embed-stats\">"); 2116 html.push_str("<span class=\"embed-stat\">"); 2117 html.push_str(&count.to_string()); 2118 html.push_str(" members</span>"); 2119 html.push_str("</span>"); 2120 } 2121 2122 html.push_str("</span>"); 2123 html 2124 } 2125 ViewUnionRecord::LabelerView(labeler) => { 2126 let mut html = String::new(); 2127 html.push_str("<span class=\"embed-record-card\">"); 2128 2129 // Labeler uses creator as the identity, add type label 2130 html.push_str("<span class=\"embed-author\">"); 2131 if let Some(avatar) = &labeler.creator.avatar { 2132 html.push_str("<img class=\"embed-avatar\" src=\""); 2133 html.push_str(&html_escape(avatar.as_ref())); 2134 html.push_str("\" alt=\"\" width=\"42\" height=\"42\" />"); 2135 } 2136 html.push_str("<span class=\"embed-author-info\">"); 2137 if let Some(name) = &labeler.creator.display_name { 2138 html.push_str("<span class=\"embed-author-name\">"); 2139 html.push_str(&html_escape(name.as_ref())); 2140 html.push_str("</span>"); 2141 } 2142 html.push_str("<span class=\"embed-author-handle\">Labeler</span>"); 2143 html.push_str("</span>"); 2144 html.push_str("</span>"); 2145 2146 // Stats 2147 if let Some(likes) = labeler.like_count { 2148 html.push_str("<span class=\"embed-stats\">"); 2149 html.push_str("<span class=\"embed-stat\">"); 2150 html.push_str(&likes.to_string()); 2151 html.push_str(" likes</span>"); 2152 html.push_str("</span>"); 2153 } 2154 2155 html.push_str("</span>"); 2156 html 2157 } 2158 ViewUnionRecord::StarterPackViewBasic(sp) => { 2159 let mut html = String::new(); 2160 html.push_str("<span class=\"embed-record-card\">"); 2161 2162 // Use author block layout: avatar + info (name, subtitle) 2163 html.push_str("<span class=\"embed-author\">"); 2164 if let Some(avatar) = &sp.creator.avatar { 2165 html.push_str("<img class=\"embed-avatar\" src=\""); 2166 html.push_str(&html_escape(avatar.as_ref())); 2167 html.push_str("\" alt=\"\" width=\"42\" height=\"42\" />"); 2168 } 2169 html.push_str("<span class=\"embed-author-info\">"); 2170 2171 // Name as title 2172 if let Some(name) = sp.record.query("name").single().and_then(|d| d.as_str()) { 2173 html.push_str("<span class=\"embed-author-name\">"); 2174 html.push_str(&html_escape(name)); 2175 html.push_str("</span>"); 2176 } 2177 2178 // "Starter pack by @handle" 2179 html.push_str("<span class=\"embed-author-handle\">by @"); 2180 html.push_str(&html_escape(sp.creator.handle.as_ref())); 2181 html.push_str("</span>"); 2182 2183 html.push_str("</span>"); // end info 2184 html.push_str("</span>"); // end author 2185 2186 // Description 2187 if let Some(desc) = sp 2188 .record 2189 .query("description") 2190 .single() 2191 .and_then(|d| d.as_str()) 2192 { 2193 html.push_str("<span class=\"embed-description\">"); 2194 html.push_str(&html_escape(desc)); 2195 html.push_str("</span>"); 2196 } 2197 2198 // Stats 2199 let has_stats = sp.list_item_count.is_some() || sp.joined_all_time_count.is_some(); 2200 if has_stats { 2201 html.push_str("<span class=\"embed-stats\">"); 2202 if let Some(count) = sp.list_item_count { 2203 html.push_str("<span class=\"embed-stat\">"); 2204 html.push_str(&count.to_string()); 2205 html.push_str(" users</span>"); 2206 } 2207 if let Some(joined) = sp.joined_all_time_count { 2208 html.push_str("<span class=\"embed-stat\">"); 2209 html.push_str(&joined.to_string()); 2210 html.push_str(" joined</span>"); 2211 } 2212 html.push_str("</span>"); 2213 } 2214 2215 html.push_str("</span>"); 2216 html 2217 } 2218 ViewUnionRecord::Unknown(data) => render_generic_data(data), 2219 } 2220} 2221 2222/// Render generic/unknown data by iterating fields intelligently 2223/// 2224/// Used as fallback for Unknown variants of open unions. 2225fn render_generic_data(data: &Data<'_>) -> String { 2226 render_generic_data_with_depth(data, 0) 2227} 2228 2229/// Render generic data with depth tracking for nested objects 2230fn render_generic_data_with_depth(data: &Data<'_>, depth: u8) -> String { 2231 let mut html = String::new(); 2232 2233 // Only wrap in card at top level 2234 let is_nested = depth > 0; 2235 if is_nested { 2236 html.push_str("<span class=\"embed-fields\">"); 2237 } else { 2238 html.push_str("<span class=\"embed-record-card\">"); 2239 } 2240 2241 // Show record type as header if present 2242 if let Some(record_type) = data.type_discriminator() { 2243 html.push_str("<span class=\"embed-author-handle\">"); 2244 html.push_str(&html_escape(record_type)); 2245 html.push_str("</span>"); 2246 } 2247 2248 // Priority fields to show first (in order) 2249 let priority_fields = ["name", "displayName", "title", "text", "description"]; 2250 let mut shown_fields = Vec::new(); 2251 2252 if let Some(obj) = data.as_object() { 2253 for field_name in priority_fields { 2254 if let Some(value) = obj.get(field_name) { 2255 if let Some(s) = value.as_str() { 2256 let class = match field_name { 2257 "name" | "displayName" | "title" => "embed-author-name", 2258 "text" => "embed-content", 2259 "description" => "embed-description", 2260 _ => "embed-field-value", 2261 }; 2262 html.push_str("<span class=\""); 2263 html.push_str(class); 2264 html.push_str("\">"); 2265 html.push_str(&html_escape(s)); 2266 html.push_str("</span>"); 2267 shown_fields.push(field_name); 2268 } 2269 } 2270 } 2271 2272 // Show remaining fields as a simple list 2273 if !is_nested { 2274 html.push_str("<span class=\"embed-fields\">"); 2275 } 2276 for (key, value) in obj.iter() { 2277 let key_str: &str = key.as_ref(); 2278 2279 // Skip already shown, internal fields 2280 if shown_fields.contains(&key_str) 2281 || key_str.starts_with('$') 2282 || key_str == "facets" 2283 || key_str == "labels" 2284 { 2285 continue; 2286 } 2287 2288 if let Some(formatted) = format_field_value_with_depth(key_str, value, depth) { 2289 html.push_str("<span class=\"embed-field\">"); 2290 html.push_str("<span class=\"embed-field-name\">"); 2291 html.push_str(&html_escape(&format_field_name(key_str))); 2292 html.push_str(":</span> "); 2293 html.push_str(&formatted); 2294 html.push_str("</span>"); 2295 } 2296 } 2297 if !is_nested { 2298 html.push_str("</span>"); 2299 } 2300 } 2301 2302 html.push_str("</span>"); 2303 html 2304} 2305 2306/// Format a field name for display (camelCase -> "Camel Case") 2307fn format_field_name(name: &str) -> String { 2308 let mut result = String::new(); 2309 for (i, c) in name.chars().enumerate() { 2310 if c.is_uppercase() && i > 0 { 2311 result.push(' '); 2312 } 2313 if i == 0 { 2314 result.extend(c.to_uppercase()); 2315 } else { 2316 result.push(c); 2317 } 2318 } 2319 result 2320} 2321 2322/// Format a field value for display, returning None for complex/unrenderable values 2323fn format_field_value(key: &str, value: &Data<'_>) -> Option<String> { 2324 format_field_value_with_depth(key, value, 0) 2325} 2326 2327/// Maximum nesting depth for rendering nested objects 2328const MAX_NESTED_DEPTH: u8 = 2; 2329 2330/// Format a field value for display with depth tracking 2331fn format_field_value_with_depth(key: &str, value: &Data<'_>, depth: u8) -> Option<String> { 2332 // String values - detect AT Protocol types 2333 if let Some(s) = value.as_str() { 2334 return Some(format_string_value(key, s)); 2335 } 2336 2337 // Numbers 2338 if let Some(n) = value.as_integer() { 2339 return Some(format!("<span class=\"embed-field-number\">{}</span>", n)); 2340 } 2341 2342 // Booleans 2343 if let Some(b) = value.as_boolean() { 2344 let class = if b { 2345 "embed-field-bool-true" 2346 } else { 2347 "embed-field-bool-false" 2348 }; 2349 return Some(format!( 2350 "<span class=\"{}\">{}</span>", 2351 class, 2352 if b { "yes" } else { "no" } 2353 )); 2354 } 2355 2356 // Arrays - show count or render items if simple 2357 if let Some(arr) = value.as_array() { 2358 return Some(format_array_value(arr, depth)); 2359 } 2360 2361 // Nested objects - render if within depth limit 2362 if value.as_object().is_some() { 2363 if depth < MAX_NESTED_DEPTH { 2364 return Some(render_generic_data_with_depth(value, depth + 1)); 2365 } else { 2366 // At max depth, just show field count 2367 let count = value.as_object().map(|o| o.len()).unwrap_or(0); 2368 return Some(format!( 2369 "<span class=\"embed-field-count\">{} field{}</span>", 2370 count, 2371 if count == 1 { "" } else { "s" } 2372 )); 2373 } 2374 } 2375 2376 None 2377} 2378 2379/// Format an array value, rendering items if simple enough 2380fn format_array_value(arr: &jacquard::Array<'_>, depth: u8) -> String { 2381 let len = arr.len(); 2382 2383 // Empty array 2384 if len == 0 { 2385 return "<span class=\"embed-field-count\">empty</span>".to_string(); 2386 } 2387 2388 // For small arrays of simple values, show them inline 2389 if len <= 3 && depth < MAX_NESTED_DEPTH { 2390 let mut items = Vec::new(); 2391 let mut all_simple = true; 2392 2393 for item in arr.iter() { 2394 if let Some(formatted) = format_simple_value(item) { 2395 items.push(formatted); 2396 } else { 2397 all_simple = false; 2398 break; 2399 } 2400 } 2401 2402 if all_simple { 2403 return format!( 2404 "<span class=\"embed-field-value\">[{}]</span>", 2405 items.join(", ") 2406 ); 2407 } 2408 } 2409 2410 // Otherwise just show count 2411 format!( 2412 "<span class=\"embed-field-count\">{} item{}</span>", 2413 len, 2414 if len == 1 { "" } else { "s" } 2415 ) 2416} 2417 2418/// Format a simple value (string, number, bool) without field name context 2419fn format_simple_value(value: &Data<'_>) -> Option<String> { 2420 if let Some(s) = value.as_str() { 2421 // Keep it short for array display 2422 let display = if s.len() > 50 { 2423 format!("{}", &s[..50]) 2424 } else { 2425 s.to_string() 2426 }; 2427 return Some(format!("\"{}\"", html_escape(&display))); 2428 } 2429 2430 if let Some(n) = value.as_integer() { 2431 return Some(n.to_string()); 2432 } 2433 2434 if let Some(b) = value.as_boolean() { 2435 return Some(if b { "true" } else { "false" }.to_string()); 2436 } 2437 2438 None 2439} 2440 2441/// Format a string value with smart detection of AT Protocol types 2442fn format_string_value(key: &str, s: &str) -> String { 2443 // AT URI - link to record 2444 if s.starts_with("at://") { 2445 return format!( 2446 "<a class=\"embed-field-aturi\" href=\"{}\">{}</a>", 2447 html_escape(s), 2448 format_aturi_display(s) 2449 ); 2450 } 2451 2452 // DID 2453 if s.starts_with("did:") { 2454 return format_did_display(s); 2455 } 2456 2457 // Regular URL 2458 if s.starts_with("http://") || s.starts_with("https://") { 2459 let domain = extract_domain(s).unwrap_or(s); 2460 return format!( 2461 "<a class=\"embed-field-link\" href=\"{}\">{}</a>", 2462 html_escape(s), 2463 html_escape(domain) 2464 ); 2465 } 2466 2467 // Datetime fields - show just the date 2468 if key.ends_with("At") || key == "createdAt" || key == "indexedAt" { 2469 let date_part = s.split('T').next().unwrap_or(s); 2470 return format!( 2471 "<span class=\"embed-field-date\">{}</span>", 2472 html_escape(date_part) 2473 ); 2474 } 2475 2476 // NSID (e.g., app.bsky.feed.post) 2477 if s.contains('.') 2478 && s.chars().all(|c| c.is_alphanumeric() || c == '.') 2479 && s.matches('.').count() >= 2 2480 { 2481 return format!("<span class=\"embed-field-nsid\">{}</span>", html_escape(s)); 2482 } 2483 2484 // Handle (contains dots, no colons or slashes) 2485 if s.contains('.') 2486 && !s.contains(':') 2487 && !s.contains('/') 2488 && s.chars() 2489 .all(|c| c.is_alphanumeric() || c == '.' || c == '-' || c == '_') 2490 { 2491 return format!( 2492 "<span class=\"embed-field-handle\">@{}</span>", 2493 html_escape(s) 2494 ); 2495 } 2496 2497 // Plain string 2498 html_escape(s) 2499} 2500 2501/// Format an AT URI for display with highlighted parts 2502fn format_aturi_display(uri: &str) -> String { 2503 if let Some(rest) = uri.strip_prefix("at://") { 2504 let parts: Vec<&str> = rest.splitn(3, '/').collect(); 2505 let mut result = String::from("<span class=\"aturi-scheme\">at://</span>"); 2506 2507 if !parts.is_empty() { 2508 result.push_str(&format!( 2509 "<span class=\"aturi-authority\">{}</span>", 2510 html_escape(parts[0]) 2511 )); 2512 } 2513 if parts.len() > 1 { 2514 result.push_str("<span class=\"aturi-slash\">/</span>"); 2515 result.push_str(&format!( 2516 "<span class=\"aturi-collection\">{}</span>", 2517 html_escape(parts[1]) 2518 )); 2519 } 2520 if parts.len() > 2 { 2521 result.push_str("<span class=\"aturi-slash\">/</span>"); 2522 result.push_str(&format!( 2523 "<span class=\"aturi-rkey\">{}</span>", 2524 html_escape(parts[2]) 2525 )); 2526 } 2527 result 2528 } else { 2529 html_escape(uri) 2530 } 2531} 2532 2533/// Format a DID for display with highlighted parts 2534fn format_did_display(did: &str) -> String { 2535 if let Some(rest) = did.strip_prefix("did:") { 2536 if let Some((method, identifier)) = rest.split_once(':') { 2537 return format!( 2538 "<span class=\"embed-field-did\">\ 2539 <span class=\"did-scheme\">did:</span>\ 2540 <span class=\"did-method\">{}</span>\ 2541 <span class=\"did-separator\">:</span>\ 2542 <span class=\"did-identifier\">{}</span>\ 2543 </span>", 2544 html_escape(method), 2545 html_escape(identifier) 2546 ); 2547 } 2548 } 2549 format!( 2550 "<span class=\"embed-field-did\">{}</span>", 2551 html_escape(did) 2552 ) 2553} 2554 2555// ============================================================================= 2556// Helper functions 2557// ============================================================================= 2558 2559/// Extract domain from a URL 2560fn extract_domain(url: &str) -> Option<&str> { 2561 let without_scheme = url 2562 .strip_prefix("https://") 2563 .or_else(|| url.strip_prefix("http://"))?; 2564 without_scheme.split('/').next() 2565} 2566 2567/// Simple HTML escaping 2568fn html_escape(s: &str) -> String { 2569 s.replace('&', "&amp;") 2570 .replace('<', "&lt;") 2571 .replace('>', "&gt;") 2572 .replace('"', "&quot;") 2573 .replace('\'', "&#39;") 2574}