//! Fetch and render AT Protocol records as HTML embeds //! //! This module provides functions to fetch records from PDSs and render them //! as HTML strings suitable for embedding in markdown content. //! //! # Reusable render functions //! //! The `render_*` functions can be used standalone for rendering different embed types: //! - `render_external_link` - Link cards with title, description, thumbnail //! - `render_images` - Image galleries //! - `render_quoted_record` - Quoted posts/records //! - `render_author_block` - Author avatar + name + handle use super::error::AtProtoPreprocessError; use jacquard::{ Data, IntoStatic, client::AgentSessionExt, cowstr::ToCowStr, types::{ident::AtIdentifier, string::AtUri}, }; use weaver_api::app_bsky::{ actor::ProfileViewBasic, embed::{ external::ViewExternal, images::ViewImage, record::{ViewRecord, ViewUnionRecord}, }, feed::{PostView, PostViewEmbed, get_posts::GetPosts}, }; use weaver_api::sh_weaver::actor::ProfileDataViewInner; use weaver_common::agent::WeaverExt; /// Fetch and render a profile record as HTML /// /// Resolves handle to DID if needed, then fetches profile data from /// weaver or bsky appview, returning a rich profile view. pub async fn fetch_and_render_profile( ident: &AtIdentifier<'_>, agent: &A, ) -> Result where A: AgentSessionExt, { // Use WeaverExt to get hydrated profile (tries weaver profile first, falls back to bsky) let (_uri, profile_view) = agent .hydrate_profile_view(&ident) .await .map_err(|e| AtProtoPreprocessError::FetchFailed(format!("{:?}", e)))?; // Render based on which profile type we got render_profile_data_view(&profile_view.inner) } /// Fetch and render a Bluesky post as HTML using the appview for rich data pub async fn fetch_and_render_post( uri: &AtUri<'_>, agent: &A, ) -> Result where A: AgentSessionExt, { // Use GetPosts for richer data (author info, engagement counts) let request = GetPosts::new().uris(vec![uri.clone()]).build(); let response = agent.send(request).await; let response = response.map_err(|e| { AtProtoPreprocessError::FetchFailed(format!("getting post from appview {:?}", e)) })?; let output = response .into_output() .map_err(|e| AtProtoPreprocessError::FetchFailed(format!("{:?}", e)))?; let post_view = output .posts .into_iter() .next() .ok_or_else(|| AtProtoPreprocessError::FetchFailed("Post not found".to_string()))?; render_post_view(&post_view, uri) } /// Fetch and render an unknown record type generically /// /// This fetches the record as untyped Data and probes for likely meaningful fields. pub async fn fetch_and_render_generic( uri: &AtUri<'_>, agent: &A, ) -> Result where A: AgentSessionExt, { // Fetch via slingshot (edge-cached, untyped) let output = agent .fetch_record_slingshot(uri) .await .map_err(|e| AtProtoPreprocessError::FetchFailed(format!("{:?}", e)))?; // Probe for meaningful fields render_generic_record(&output.value, uri) } /// Fetch and render a notebook entry with full markdown rendering /// /// Renders the entry content as HTML in a scrollable container with title and author info. pub async fn fetch_and_render_entry( uri: &AtUri<'_>, agent: &A, ) -> Result where A: AgentSessionExt, { use crate::atproto::writer::ClientWriter; use crate::default_md_options; use markdown_weaver::Parser; use weaver_common::agent::WeaverExt; // Get rkey from URI let rkey = uri .rkey() .ok_or_else(|| AtProtoPreprocessError::FetchFailed("Entry URI missing rkey".to_string()))?; // Fetch entry with author info let (entry_view, entry) = agent .fetch_entry_by_rkey(&uri.authority(), rkey.as_ref()) .await .map_err(|e| AtProtoPreprocessError::FetchFailed(e.to_string()))?; // Render the markdown content to HTML let content = entry.content.as_ref(); let parser = Parser::new_ext(content, default_md_options()).into_offset_iter(); let mut content_html = String::new(); ClientWriter::<_, _, ()>::new(parser, &mut content_html, content) .run() .map_err(|e| { AtProtoPreprocessError::FetchFailed(format!("Markdown render failed: {:?}", e)) })?; // Generate unique ID for the toggle checkbox let toggle_id = format!("entry-toggle-{}", rkey.as_ref()); // Build the embed HTML let mut html = String::new(); html.push_str("
"); // Hidden checkbox for expand/collapse (must come before content for CSS sibling selector) html.push_str(""); // Header with title and author html.push_str("
"); // Title html.push_str(""); html.push_str(&html_escape(entry.title.as_ref())); html.push_str(""); // Author info - just show handle (keep it simple for entry embeds) if let Some(author) = entry_view.authors.first() { let handle = match &author.record.inner { ProfileDataViewInner::ProfileView(p) => p.handle.as_ref(), ProfileDataViewInner::ProfileViewDetailed(p) => p.handle.as_ref(), ProfileDataViewInner::TangledProfileView(p) => p.handle.as_ref(), ProfileDataViewInner::Unknown(_) => "", }; if !handle.is_empty() { html.push_str(""); } } html.push_str("
"); // end header // Scrollable content container html.push_str("
"); html.push_str(&content_html); html.push_str("
"); // Expand/collapse label (clickable, targets the checkbox) html.push_str(""); html.push_str("
"); Ok(html) } /// Fetch and render a notebook entry with full markdown rendering /// /// Renders the entry content as HTML in a scrollable container with title and author info. pub async fn fetch_and_render_whitewind_entry
( uri: &AtUri<'_>, agent: &A, ) -> Result where A: AgentSessionExt, { use crate::atproto::writer::ClientWriter; use crate::default_md_options; use markdown_weaver::Parser; use weaver_api::com_whtwnd::blog::entry::Entry as WhitewindEntry; use weaver_common::agent::WeaverExt; let (_, profile) = agent .hydrate_profile_view(uri.authority()) .await .map_err(|e| { AtProtoPreprocessError::FetchFailed(format!("Profile fetch failed: {:?}", e)) })?; let entry_uri = WhitewindEntry::uri(uri.to_cowstr()).map_err(|e| { AtProtoPreprocessError::FetchFailed(format!("Entry URI incorrect: {:?}", e)) })?; let entry = agent .fetch_record(&entry_uri) .await .map_err(|e| AtProtoPreprocessError::FetchFailed(format!("Entry fetch failed: {:?}", e)))?; // Render the markdown content to HTML let content = entry.value.content.as_ref(); let parser = Parser::new_ext(content, default_md_options()).into_offset_iter(); let mut content_html = String::new(); ClientWriter::<_, _, ()>::new(parser, &mut content_html, content) .run() .map_err(|e| { AtProtoPreprocessError::FetchFailed(format!("Markdown render failed: {:?}", e)) })?; // Generate unique ID for the toggle checkbox let toggle_id = format!( "entry-toggle-{}", entry.uri.rkey().expect("valid rkey").as_ref() ); let entry = entry.value; // Build the embed HTML let mut html = String::new(); html.push_str("
"); // Hidden checkbox for expand/collapse (must come before content for CSS sibling selector) html.push_str(""); // Header with title and author html.push_str("
"); // Title html.push_str(""); html.push_str(&html_escape( entry.title.as_ref().unwrap_or(&"".to_cowstr()), )); html.push_str(""); // Author info - just show handle (keep it simple for entry embeds) let handle = match &profile.inner { ProfileDataViewInner::ProfileView(p) => p.handle.as_ref(), ProfileDataViewInner::ProfileViewDetailed(p) => p.handle.as_ref(), ProfileDataViewInner::TangledProfileView(p) => p.handle.as_ref(), ProfileDataViewInner::Unknown(_) => "", }; if !handle.is_empty() { html.push_str(""); } html.push_str("
"); // end header // Scrollable content container html.push_str("
"); html.push_str(&content_html); html.push_str("
"); // Expand/collapse label (clickable, targets the checkbox) html.push_str(""); html.push_str("
"); Ok(html) } /// Fetch and render a Leaflet document as HTML /// /// Renders the document's pages (currently only LinearDocument is supported). pub async fn fetch_and_render_leaflet
( uri: &AtUri<'_>, agent: &A, ) -> Result where A: AgentSessionExt, { use crate::leaflet::{LeafletRenderContext, render_linear_document}; use weaver_api::pub_leaflet::document::{Document, DocumentPagesItem}; use weaver_api::pub_leaflet::publication::Publication; let doc_uri = Document::uri(uri.to_cowstr()).map_err(|e| { AtProtoPreprocessError::FetchFailed(format!("Invalid document URI: {:?}", e)) })?; let doc = agent.fetch_record(&doc_uri).await.map_err(|e| { AtProtoPreprocessError::FetchFailed(format!("Document fetch failed: {:?}", e)) })?; // Fetch publication to get base_path for external link let publication_base_path: Option = if let Some(pub_uri) = &doc.value.publication { if let Ok(pub_typed_uri) = Publication::uri(pub_uri.as_ref()) { agent .fetch_record(&pub_typed_uri) .await .ok() .and_then(|rec| rec.value.base_path.as_ref().map(|p| p.as_ref().to_string())) } else { None } } else { None }; // Get author DID and handle use jacquard::types::string::{Did, Handle}; let (author_did, author_handle): (Did<'static>, Option>) = match &doc.value.author { AtIdentifier::Did(d) => { let did = d.clone().into_static(); let handle = agent .resolve_did_doc_owned(d) .await .ok() .and_then(|doc| doc.handles().first().cloned()); (did, handle) } AtIdentifier::Handle(h) => { let handle = Some(h.clone().into_static()); let did = agent .resolve_handle(h) .await .map(|d| d.into_static()) .map_err(|e| { AtProtoPreprocessError::FetchFailed(format!( "Handle resolution failed: {:?}", e )) })?; (did, handle) } }; let ctx = LeafletRenderContext::new(author_did); // Generate unique toggle ID let rkey = uri.rkey().map(|r| r.as_ref()).unwrap_or("unknown"); let toggle_id = format!("leaflet-toggle-{}", rkey); let mut html = String::new(); html.push_str("
"); // Hidden checkbox for expand/collapse html.push_str(""); // Header with title and author html.push_str("
"); // Title as link if we have the publication base_path if let Some(base_path) = &publication_base_path { html.push_str(""); html.push_str(&html_escape(doc.value.title.as_ref())); html.push_str(""); } else { html.push_str(""); html.push_str(&html_escape(doc.value.title.as_ref())); html.push_str(""); } // Author info if let Some(handle) = &author_handle { html.push_str(""); } html.push_str("
"); // end header // Scrollable content container html.push_str("
"); // Render each page for page in &doc.value.pages { match page { DocumentPagesItem::LinearDocument(linear_doc) => { html.push_str(&render_linear_document(linear_doc, &ctx, agent).await); } DocumentPagesItem::Canvas(_) => { html.push_str("
[Canvas layout not yet supported]
"); } DocumentPagesItem::Unknown(_) => { html.push_str("
[Unknown page type]
"); } } } html.push_str("
"); // end content // Expand/collapse label html.push_str(""); html.push_str("
"); Ok(html) } #[cfg(feature = "pckt")] /// Fetch and render a pckt/site.standard document as HTML /// /// Renders the document's content blocks using the pckt block renderer. /// Supports both `site.standard.document` and `blog.pckt.document` (which wraps site.standard). /// /// TODO: site.standard.document is designed to be a shared envelope for different block formats. /// Currently hardcoded to use pckt block renderer, but should probe the first block's $type /// and dispatch to the appropriate renderer (blog.pckt.block.* → pckt, pub.leaflet.blocks.* → leaflet, /// sh.weaver.block.* → weaver, etc). pub async fn fetch_and_render_pckt( uri: &AtUri<'_>, agent: &A, ) -> Result where A: AgentSessionExt, { use crate::pckt::{PcktRenderContext, render_content_blocks}; use weaver_api::site_standard::document::Document as SiteStandardDocument; // Fetch the record as untyped first to check the structure let output = agent .fetch_record_slingshot(uri) .await .map_err(|e| AtProtoPreprocessError::FetchFailed(format!("{:?}", e)))?; // Extract the site.standard.document - either directly or from blog.pckt.document wrapper let doc: SiteStandardDocument<'_> = if output .value .type_discriminator() .map(|t| t == "blog.pckt.document") .unwrap_or(false) { // blog.pckt.document wraps site.standard.document in a "document" field let pckt_doc = jacquard::from_data::(&output.value) .map_err(|e| { AtProtoPreprocessError::FetchFailed(format!("Parse error: {:?}", e)) })?; pckt_doc.document } else { // Direct site.standard.document jacquard::from_data::(&output.value) .map_err(|e| AtProtoPreprocessError::FetchFailed(format!("Parse error: {:?}", e)))? }; // Fetch publication to get base URL for external link use weaver_api::site_standard::publication::Publication; let Uri::At(uri) = &doc.site else { return Err(AtProtoPreprocessError::FetchFailed( "Invalid site URI".to_string(), )); }; let publication_url: Option = agent .fetch_record_slingshot(uri) .await .ok() .and_then(|rec| { jacquard::from_data::(&rec.value) .ok() .map(|pub_rec| pub_rec.url.as_ref().to_string()) }); // Get author DID and handle from URI authority use jacquard::types::{ string::{Did, Handle}, uri::Uri, }; let (author_did, author_handle): (Did<'static>, Option>) = match uri.authority() { jacquard::types::ident::AtIdentifier::Did(d) => { let did = d.clone().into_static(); let handle = agent .resolve_did_doc_owned(d) .await .ok() .and_then(|doc| doc.handles().first().cloned()); (did, handle) } jacquard::types::ident::AtIdentifier::Handle(h) => { let handle = Some(h.clone().into_static()); let did = agent .resolve_handle(h) .await .map(|d| d.into_static()) .map_err(|e| { AtProtoPreprocessError::FetchFailed(format!( "Handle resolution failed: {:?}", e )) })?; (did, handle) } }; let ctx = PcktRenderContext::new(author_did); // Generate unique toggle ID let rkey = uri.rkey().map(|r| r.as_ref()).unwrap_or("unknown"); let toggle_id = format!("pckt-toggle-{}", rkey); // Document path for URL (use path field if present, otherwise rkey) let doc_path = doc.path.as_ref().map(|p| p.as_ref()).unwrap_or(rkey); let mut html = String::new(); html.push_str("
"); // Hidden checkbox for expand/collapse html.push_str(""); // Header with title and author html.push_str("
"); // Title as link if we have the publication URL if let Some(base_url) = &publication_url { let base_url = base_url.trim_end_matches('/'); html.push_str(""); html.push_str(&html_escape(doc.title.as_ref())); html.push_str(""); } else { html.push_str(""); html.push_str(&html_escape(doc.title.as_ref())); html.push_str(""); } // Author info if let Some(handle) = &author_handle { html.push_str(""); } html.push_str("
"); // end header // Scrollable content container html.push_str("
"); // Render content blocks if present if let Some(content) = &doc.content { html.push_str(&render_content_blocks(vec![content.clone()].as_slice(), &ctx, agent).await); } else if let Some(text_content) = &doc.text_content { // Fallback to text_content if no structured content html.push_str("

"); html.push_str(&html_escape(text_content.as_ref())); html.push_str("

"); } html.push_str("
"); // end content // Expand/collapse label html.push_str(""); html.push_str("
"); Ok(html) } /// Fetch and render any AT URI, dispatching to the appropriate renderer based on collection. /// /// Uses typed fetchers for known collections (posts, profiles) and falls back to /// generic rendering for unknown types. pub async fn fetch_and_render( uri: &AtUri<'_>, agent: &A, ) -> Result where A: AgentSessionExt, { let collection = uri.collection().map(|c| c.as_ref()); match collection { Some("app.bsky.feed.post") => { let result = fetch_and_render_post(uri, agent).await; result } Some("app.bsky.actor.profile") => { // Extract DID from URI authority fetch_and_render_profile(uri.authority(), agent).await } Some("sh.weaver.notebook.entry") => fetch_and_render_entry(uri, agent).await, Some("com.whtwnd.blog.entry") => fetch_and_render_whitewind_entry(uri, agent).await, Some("pub.leaflet.document") => fetch_and_render_leaflet(uri, agent).await, #[cfg(feature = "pckt")] Some("site.standard.document") | Some("blog.pckt.document") => { fetch_and_render_pckt(uri, agent).await } None => fetch_and_render_profile(uri.authority(), agent).await, _ => fetch_and_render_generic(uri, agent).await, } } /// Render any AT Protocol record synchronously from pre-fetched data. /// /// This is the pure sync version of `fetch_and_render`. Takes a URI and the /// record data, dispatches to the appropriate renderer based on collection type. /// /// # Arguments /// /// * `uri` - The AT URI of the record /// * `data` - The record data (either raw record or hydrated view type) /// * `fallback_author` - Optional author profile to use when data is a raw record /// without embedded author info. Used for entries and other content types. /// * `resolved_content` - Optional pre-resolved embeds for rendering markdown with embeds /// /// # Supported collections /// /// **Profiles** (pass hydrated view from appview): /// - `app.bsky.actor.profile` - Bluesky profiles (ProfileViewDetailed from getProfile) /// - `sh.weaver.actor.profile` - Weaver profiles (ProfileView from weaver appview) /// - Tangled profiles also supported via type discriminator /// /// **Posts**: /// - `app.bsky.feed.post` - Posts (PostView from getPosts, or raw record for basic) /// /// **Entries** (pass view type for author info, or provide fallback_author): /// - `sh.weaver.notebook.entry` - Weaver entries (EntryView or raw Entry) /// - `com.whtwnd.blog.entry` - Whitewind entries /// - `pub.leaflet.document` - Leaflet documents /// - `site.standard.document` / `blog.pckt.document` - pckt documents /// /// **Lists & Feeds**: /// - `app.bsky.graph.list` - User lists /// - `app.bsky.feed.generator` - Custom feeds /// - `app.bsky.graph.starterpack` - Starter packs /// - `app.bsky.labeler.service` - Labelers /// /// **Other** - Generic field display for unknown types pub fn render_record( uri: &AtUri<'_>, data: &Data<'_>, fallback_author: Option<&weaver_api::sh_weaver::actor::ProfileDataView<'_>>, resolved_content: Option<&weaver_common::ResolvedContent>, ) -> Result { let collection = uri.collection().map(|c| c.as_ref()); match collection { // No collection = just an identity reference, try as profile None => render_profile_from_data(data, uri), // Profiles - try multiple profile view types Some("app.bsky.actor.profile") | Some("sh.weaver.actor.profile") => { render_profile_from_data(data, uri) } // Posts Some("app.bsky.feed.post") => { // Try PostView first (from getPosts), fall back to raw record if let Ok(post_view) = jacquard::from_data::(data) { render_post_view(&post_view, uri) } else { render_basic_post(data, uri) } } // Lists Some("app.bsky.graph.list") => render_list_record(data, uri), // Custom feeds Some("app.bsky.feed.generator") => render_generator_record(data, uri), // Starter packs Some("app.bsky.graph.starterpack") => render_starterpack_record(data, uri), // Labelers Some("app.bsky.labeler.service") => render_labeler_record(data, uri), // Weaver entries Some("sh.weaver.notebook.entry") => { render_weaver_entry_record(data, uri, fallback_author, resolved_content) } // Whitewind entries Some("com.whtwnd.blog.entry") => { render_whitewind_entry_record(data, uri, fallback_author, resolved_content) } // Leaflet documents Some("pub.leaflet.document") => { render_leaflet_record(data, uri, fallback_author, resolved_content) } // pckt / site.standard documents #[cfg(feature = "pckt")] Some("site.standard.document") | Some("blog.pckt.document") => { render_site_standard_record(data, uri, fallback_author, resolved_content) } // Default: generic rendering _ => render_generic_record(data, uri), } } /// Try to render profile data by detecting the view type. fn render_profile_from_data( data: &Data<'_>, uri: &AtUri<'_>, ) -> Result { // Check type discriminator first for union types if let Some(type_disc) = data.type_discriminator() { match type_disc { "app.bsky.actor.defs#profileViewDetailed" => { if let Ok(profile) = jacquard::from_data::(data) { return render_profile_data_view(&ProfileDataViewInner::ProfileViewDetailed( Box::new(profile), )); } } "sh.weaver.actor.defs#profileView" => { if let Ok(profile) = jacquard::from_data::(data) { return render_profile_data_view(&ProfileDataViewInner::ProfileView(Box::new( profile, ))); } } "sh.weaver.actor.defs#tangledProfileView" => { if let Ok(profile) = jacquard::from_data::(data) { return render_profile_data_view(&ProfileDataViewInner::TangledProfileView( Box::new(profile), )); } } _ => {} } } // Try each type without discriminator if let Ok(profile) = jacquard::from_data::(data) { return render_profile_data_view(&ProfileDataViewInner::ProfileViewDetailed(Box::new( profile, ))); } if let Ok(profile) = jacquard::from_data::(data) { return render_profile_data_view(&ProfileDataViewInner::ProfileView(Box::new(profile))); } if let Ok(profile) = jacquard::from_data::(data) { return render_profile_data_view(&ProfileDataViewInner::TangledProfileView(Box::new( profile, ))); } // Fall back to generic render_generic_record(data, uri) } /// Render a list record. fn render_list_record(data: &Data<'_>, uri: &AtUri<'_>) -> Result { let list = match jacquard::from_data::(data) { Ok(l) => l, Err(_) => return render_generic_record(data, uri), }; let mut html = String::new(); html.push_str(""); html.push_str("List"); html.push_str(""); html.push_str(&html_escape(list.name.as_ref())); html.push_str(""); if let Some(desc) = &list.description { html.push_str(""); html.push_str(&html_escape(desc.as_ref())); html.push_str(""); } html.push_str(""); Ok(html) } /// Render a feed generator record. fn render_generator_record( data: &Data<'_>, uri: &AtUri<'_>, ) -> Result { let generator = match jacquard::from_data::(data) { Ok(g) => g, Err(_) => return render_generic_record(data, uri), }; let mut html = String::new(); html.push_str(""); html.push_str("Custom Feed"); html.push_str(""); html.push_str(&html_escape(generator.display_name.as_ref())); html.push_str(""); if let Some(desc) = &generator.description { html.push_str(""); html.push_str(&html_escape(desc.as_ref())); html.push_str(""); } html.push_str(""); Ok(html) } /// Render a starter pack record. fn render_starterpack_record( data: &Data<'_>, uri: &AtUri<'_>, ) -> Result { let sp = match jacquard::from_data::(data) { Ok(s) => s, Err(_) => return render_generic_record(data, uri), }; let mut html = String::new(); html.push_str(""); html.push_str("Starter Pack"); html.push_str(""); html.push_str(&html_escape(sp.name.as_ref())); html.push_str(""); if let Some(desc) = &sp.description { html.push_str(""); html.push_str(&html_escape(desc.as_ref())); html.push_str(""); } html.push_str(""); Ok(html) } /// Render a labeler service record. fn render_labeler_record( data: &Data<'_>, uri: &AtUri<'_>, ) -> Result { let labeler = match jacquard::from_data::(data) { Ok(l) => l, Err(_) => return render_generic_record(data, uri), }; let mut html = String::new(); html.push_str(""); html.push_str("Labeler"); // Labeler policies html.push_str(""); let label_count = labeler.policies.label_values.len(); html.push_str(""); html.push_str(&label_count.to_string()); html.push_str(" label"); if label_count != 1 { html.push_str("s"); } html.push_str(" defined"); html.push_str(""); html.push_str(""); Ok(html) } /// Render a weaver notebook entry record. /// /// Accepts either: /// - `EntryView` (from appview) - includes author info /// - Raw `Entry` record with optional fallback_author fn render_weaver_entry_record( data: &Data<'_>, uri: &AtUri<'_>, fallback_author: Option<&weaver_api::sh_weaver::actor::ProfileDataView<'_>>, resolved_content: Option<&weaver_common::ResolvedContent>, ) -> Result { use crate::atproto::writer::ClientWriter; use crate::default_md_options; use markdown_weaver::Parser; use weaver_api::sh_weaver::notebook::EntryView; // Try to parse as EntryView first (has author info), then raw Entry let (title, content, author_handle): (String, String, Option) = if let Ok(view) = jacquard::from_data::(data) { // EntryView has embedded record data, extract content from it let content = view .record .query("content") .single() .and_then(|d| d.as_str()) .unwrap_or_default() .to_string(); let title = view .record .query("title") .single() .and_then(|d| d.as_str()) .unwrap_or_default() .to_string(); let handle = view .authors .first() .and_then(|author| extract_handle_from_profile_data_view(&author.record.inner)); (title, content, handle.map(|h| h.to_string())) } else if let Ok(entry) = jacquard::from_data::(data) { let handle = fallback_author.and_then(|p| extract_handle_from_profile_data_view(&p.inner)); ( entry.title.as_ref().to_string(), entry.content.as_ref().to_string(), handle.map(|h| h.to_string()), ) } else { return render_generic_record(data, uri); }; // Render markdown content to HTML using resolved_content for embeds let parser = Parser::new_ext(&content, default_md_options()).into_offset_iter(); let mut content_html = String::new(); if let Some(resolved) = resolved_content { ClientWriter::new(parser, &mut content_html, &content) .with_embed_provider(resolved) .run() .map_err(|e| { AtProtoPreprocessError::FetchFailed(format!("Markdown render failed: {:?}", e)) })?; } else { ClientWriter::<_, _, ()>::new(parser, &mut content_html, &content) .run() .map_err(|e| { AtProtoPreprocessError::FetchFailed(format!("Markdown render failed: {:?}", e)) })?; } // Generate unique ID for the toggle checkbox let rkey = uri.rkey().map(|r| r.as_ref()).unwrap_or("unknown"); let toggle_id = format!("entry-toggle-{}", rkey); // Build the embed HTML - matches fetch_and_render_entry exactly let mut html = String::new(); html.push_str("
"); // Hidden checkbox for expand/collapse (must come before content for CSS sibling selector) html.push_str(""); // Header with title and author html.push_str("
"); // Title html.push_str(""); html.push_str(&html_escape(&title)); html.push_str(""); // Author info - just show handle (keep it simple for entry embeds) if let Some(ref handle) = author_handle { if !handle.is_empty() { html.push_str(""); } } html.push_str("
"); // end header // Scrollable content container html.push_str("
"); html.push_str(&content_html); html.push_str("
"); // Expand/collapse label (clickable, targets the checkbox) html.push_str(""); html.push_str("
"); Ok(html) } /// Extract handle from ProfileDataViewInner. fn extract_handle_from_profile_data_view<'a>( inner: &'a ProfileDataViewInner<'a>, ) -> Option<&'a str> { match inner { ProfileDataViewInner::ProfileView(p) => Some(p.handle.as_ref()), ProfileDataViewInner::ProfileViewDetailed(p) => Some(p.handle.as_ref()), ProfileDataViewInner::TangledProfileView(p) => Some(p.handle.as_ref()), ProfileDataViewInner::Unknown(_) => None, } } fn extract_did_from_profile_data_view( inner: &ProfileDataViewInner<'_>, ) -> Option> { use jacquard::IntoStatic; match inner { ProfileDataViewInner::ProfileView(p) => Some(p.did.clone().into_static()), ProfileDataViewInner::ProfileViewDetailed(p) => Some(p.did.clone().into_static()), ProfileDataViewInner::TangledProfileView(p) => Some(p.did.clone().into_static()), ProfileDataViewInner::Unknown(_) => None, } } /// Render a whitewind blog entry record. /// /// Whitewind entries don't have a view type, so author info comes from fallback_author. fn render_whitewind_entry_record( data: &Data<'_>, uri: &AtUri<'_>, fallback_author: Option<&weaver_api::sh_weaver::actor::ProfileDataView<'_>>, resolved_content: Option<&weaver_common::ResolvedContent>, ) -> Result { use crate::atproto::writer::ClientWriter; use crate::default_md_options; use markdown_weaver::Parser; let entry = match jacquard::from_data::(data) { Ok(e) => e, Err(_) => return render_generic_record(data, uri), }; // Render the markdown content to HTML using resolved_content for embeds let content = entry.content.as_ref(); let parser = Parser::new_ext(content, default_md_options()).into_offset_iter(); let mut content_html = String::new(); if let Some(resolved) = resolved_content { ClientWriter::new(parser, &mut content_html, content) .with_embed_provider(resolved) .run() .map_err(|e| { AtProtoPreprocessError::FetchFailed(format!("Markdown render failed: {:?}", e)) })?; } else { ClientWriter::<_, _, ()>::new(parser, &mut content_html, content) .run() .map_err(|e| { AtProtoPreprocessError::FetchFailed(format!("Markdown render failed: {:?}", e)) })?; } // Generate unique ID for the toggle checkbox let rkey = uri.rkey().map(|r| r.as_ref()).unwrap_or("unknown"); let toggle_id = format!("entry-toggle-{}", rkey); // Build the embed HTML - matches fetch_and_render_whitewind_entry exactly let mut html = String::new(); html.push_str("
"); // Hidden checkbox for expand/collapse (must come before content for CSS sibling selector) html.push_str(""); // Header with title and author html.push_str("
"); // Title html.push_str(""); html.push_str(&html_escape( entry.title.as_ref().map(|t| t.as_ref()).unwrap_or(""), )); html.push_str(""); // Author info - just show handle (keep it simple for entry embeds) if let Some(author) = fallback_author { let handle = extract_handle_from_profile_data_view(&author.inner).unwrap_or(""); if !handle.is_empty() { html.push_str(""); } } html.push_str("
"); // end header // Scrollable content container html.push_str("
"); html.push_str(&content_html); html.push_str("
"); // Expand/collapse label (clickable, targets the checkbox) html.push_str(""); html.push_str("
"); Ok(html) } /// Render a leaflet document record. /// /// Uses the sync block renderer to render page content directly. Embedded posts /// within the document will be looked up from resolved_content by their AT URI. fn render_leaflet_record( data: &Data<'_>, uri: &AtUri<'_>, fallback_author: Option<&weaver_api::sh_weaver::actor::ProfileDataView<'_>>, resolved_content: Option<&weaver_common::ResolvedContent>, ) -> Result { use crate::leaflet::{LeafletRenderContext, render_linear_document_sync}; use weaver_api::pub_leaflet::document::{Document, DocumentPagesItem}; let doc = match jacquard::from_data::(data) { Ok(d) => d, Err(_) => return render_generic_record(data, uri), }; // Get author DID from fallback_author or from document/URI. let author_did = if let Some(author) = fallback_author { extract_did_from_profile_data_view(&author.inner) } else { None } .or_else(|| { // Try to get DID from document author field. match &doc.author { jacquard::types::ident::AtIdentifier::Did(d) => Some(d.clone().into_static()), _ => None, } }) .or_else(|| { // Fall back to URI authority if it's a DID. jacquard::types::string::Did::new(uri.authority().as_ref()) .ok() .map(|d| d.into_static()) }); let ctx = author_did .map(LeafletRenderContext::new) .unwrap_or_else(|| { LeafletRenderContext::new(jacquard::types::string::Did::raw("did:plc:unknown".into())) }); // Generate unique toggle ID. let rkey = uri.rkey().map(|r| r.as_ref()).unwrap_or("unknown"); let toggle_id = format!("leaflet-toggle-{}", rkey); let mut html = String::new(); html.push_str("
"); // Hidden checkbox for expand/collapse. html.push_str(""); // Header with title and author. html.push_str("
"); // Title (no link in sync version since we don't have publication base_path). html.push_str(""); html.push_str(&html_escape(doc.title.as_ref())); html.push_str(""); // Author info. if let Some(author) = fallback_author { let handle = extract_handle_from_profile_data_view(&author.inner).unwrap_or(""); if !handle.is_empty() { html.push_str(""); } } html.push_str("
"); // end header // Scrollable content container. html.push_str("
"); // Render each page using the sync block renderer. for page in &doc.pages { match page { DocumentPagesItem::LinearDocument(linear_doc) => { html.push_str(&render_linear_document_sync( linear_doc, &ctx, resolved_content, )); } DocumentPagesItem::Canvas(_) => { html.push_str( "
[Canvas layout not yet supported]
", ); } DocumentPagesItem::Unknown(_) => { html.push_str("
[Unknown page type]
"); } } } html.push_str("
"); // end content // Expand/collapse label. html.push_str(""); html.push_str("
"); Ok(html) } /// Render a site.standard or blog.pckt document record. /// /// Uses the sync block renderer to render content blocks directly. Embedded posts /// within the document will be looked up from resolved_content by their AT URI. #[cfg(feature = "pckt")] fn render_site_standard_record( data: &Data<'_>, uri: &AtUri<'_>, fallback_author: Option<&weaver_api::sh_weaver::actor::ProfileDataView<'_>>, resolved_content: Option<&weaver_common::ResolvedContent>, ) -> Result { use crate::pckt::{PcktRenderContext, render_content_blocks_sync}; use weaver_api::site_standard::document::Document as SiteStandardDocument; // Extract the document - either directly or from blog.pckt.document wrapper. let doc: SiteStandardDocument<'_> = if data .type_discriminator() .map(|t| t == "blog.pckt.document") .unwrap_or(false) { let pckt_doc = match jacquard::from_data::(data) { Ok(d) => d, Err(_) => return render_generic_record(data, uri), }; pckt_doc.document } else { match jacquard::from_data::(data) { Ok(d) => d, Err(_) => return render_generic_record(data, uri), } }; // Get author DID from fallback_author or from URI authority. let author_did = if let Some(author) = fallback_author { extract_did_from_profile_data_view(&author.inner) } else { None } .or_else(|| { // Fall back to URI authority if it's a DID. jacquard::types::string::Did::new(uri.authority().as_ref()) .ok() .map(|d| d.into_static()) }); let ctx = author_did .map(PcktRenderContext::new) .unwrap_or_else(|| unsafe { PcktRenderContext::new(jacquard::types::string::Did::unchecked( "did:plc:unknown".into(), )) }); let rkey = uri.rkey().map(|r| r.as_ref()).unwrap_or("unknown"); let toggle_id = format!("pckt-toggle-{}", rkey); let mut html = String::new(); html.push_str("
"); // Toggle checkbox. html.push_str(""); // Header. html.push_str("
"); html.push_str(""); html.push_str(&html_escape(doc.title.as_ref())); html.push_str(""); // Author info. if let Some(author) = fallback_author { let handle = extract_handle_from_profile_data_view(&author.inner).unwrap_or(""); if !handle.is_empty() { html.push_str(""); } } html.push_str("
"); // Content. html.push_str("
"); if let Some(content) = &doc.content { // Render actual content blocks using the sync renderer. html.push_str(&render_content_blocks_sync( vec![content.clone()].as_slice(), &ctx, resolved_content, )); } else if let Some(text_content) = &doc.text_content { // Fallback to text_content if no structured blocks. html.push_str("

"); html.push_str(&html_escape(text_content.as_ref())); html.push_str("

"); } html.push_str("
"); // Expand label. html.push_str(""); html.push_str("
"); Ok(html) } /// Render a basic post from record data (no engagement stats or author info). /// /// This is a simpler version than `render_post_view` for cases where you only /// have the raw record, not the full PostView from the appview. fn render_basic_post(data: &Data<'_>, uri: &AtUri<'_>) -> Result { let mut html = String::new(); // Try to parse as Post let post = jacquard::from_data::(data) .map_err(|e| AtProtoPreprocessError::FetchFailed(format!("Parse error: {:?}", e)))?; // Build link to post on Bluesky let authority = uri.authority(); let rkey = uri.rkey().map(|r| r.as_ref()).unwrap_or(""); let bsky_link = format!("https://bsky.app/profile/{}/post/{}", authority, rkey); html.push_str(""); // Background link html.push_str("
"); // Post text html.push_str(""); html.push_str(&html_escape(post.text.as_ref())); html.push_str(""); // Timestamp html.push_str(""); html.push_str(""); html.push_str(&html_escape(&post.created_at.to_string())); html.push_str(""); html.push_str(""); html.push_str(""); Ok(html) } /// Render a profile from ProfileDataViewInner (weaver, bsky, or tangled). /// /// Takes pre-fetched profile data - no network calls. pub fn render_profile_data_view( inner: &ProfileDataViewInner<'_>, ) -> Result { let mut html = String::new(); match inner { ProfileDataViewInner::ProfileView(profile) => { // Weaver profile - link to bsky for now let profile_url = format!("https://bsky.app/profile/{}", profile.handle.as_ref()); html.push_str( "", ); // Background link covers whole card html.push_str(""); html.push_str(""); if let Some(avatar) = &profile.avatar { html.push_str("\"\""); } html.push_str(""); if let Some(display_name) = &profile.display_name { html.push_str(""); html.push_str(&html_escape(display_name.as_ref())); html.push_str(""); } html.push_str("@"); html.push_str(&html_escape(profile.handle.as_ref())); html.push_str(""); html.push_str(""); html.push_str(""); if let Some(description) = &profile.description { html.push_str(""); html.push_str(&html_escape(description.as_ref())); html.push_str(""); } html.push_str(""); } ProfileDataViewInner::ProfileViewDetailed(profile) => { // Bsky profile let profile_url = format!("https://bsky.app/profile/{}", profile.handle.as_ref()); html.push_str( "", ); // Background link covers whole card html.push_str(""); html.push_str(""); if let Some(avatar) = &profile.avatar { html.push_str("\"\""); } html.push_str(""); if let Some(display_name) = &profile.display_name { html.push_str(""); html.push_str(&html_escape(display_name.as_ref())); html.push_str(""); } html.push_str("@"); html.push_str(&html_escape(profile.handle.as_ref())); html.push_str(""); html.push_str(""); html.push_str(""); if let Some(description) = &profile.description { html.push_str(""); html.push_str(&html_escape(description.as_ref())); html.push_str(""); } // Stats for bsky profiles if profile.followers_count.is_some() || profile.follows_count.is_some() { html.push_str(""); html.push_str(""); if let Some(followers) = profile.followers_count { html.push_str(""); html.push_str(&followers.to_string()); html.push_str(" followers"); } if let Some(follows) = profile.follows_count { html.push_str(""); html.push_str(&follows.to_string()); html.push_str(" following"); } html.push_str(""); html.push_str(""); } html.push_str(""); } ProfileDataViewInner::TangledProfileView(profile) => { // Tangled profile - link to tangled let profile_url = format!("https://tangled.sh/@{}", profile.handle.as_ref()); html.push_str( "", ); // Background link covers whole card html.push_str(""); html.push_str(""); html.push_str(""); html.push_str("@"); html.push_str(&html_escape(profile.handle.as_ref())); html.push_str(""); html.push_str(""); html.push_str(""); if let Some(description) = &profile.description { html.push_str(""); html.push_str(&html_escape(description.as_ref())); html.push_str(""); } html.push_str(""); } ProfileDataViewInner::Unknown(data) => { // Unknown - no link, just render html.push_str( "", ); html.push_str(&render_generic_data(data)); html.push_str(""); } } Ok(html) } /// Render a Bluesky post from PostView (rich appview data). /// /// Takes pre-fetched PostView from getPosts - no network calls. pub fn render_post_view<'a>( post: &PostView<'a>, uri: &AtUri<'_>, ) -> Result { let mut html = String::new(); // Build link to post on Bluesky let bsky_link = format!( "https://bsky.app/profile/{}/post/{}", post.author.handle.as_ref(), uri.rkey().map(|r| r.as_ref()).unwrap_or("") ); html.push_str(""); // Background link covers whole card, other links sit on top html.push_str(""); // Author header html.push_str(&render_author_block(&post.author, true)); // Post text (parse record as typed Post) if let Ok(post_record) = jacquard::from_data::(&post.record) { html.push_str(""); html.push_str(&html_escape(post_record.text.as_ref())); html.push_str(""); } // Embedded content (images, links, quotes, etc.) if let Some(embed) = &post.embed { html.push_str(&render_post_embed(embed)); } // Engagement stats and timestamp html.push_str(""); // Stats row html.push_str(""); if let Some(replies) = post.reply_count { html.push_str(""); html.push_str(&replies.to_string()); html.push_str(" replies"); } if let Some(reposts) = post.repost_count { html.push_str(""); html.push_str(&reposts.to_string()); html.push_str(" reposts"); } if let Some(likes) = post.like_count { html.push_str(""); html.push_str(&likes.to_string()); html.push_str(" likes"); } html.push_str(""); // Timestamp html.push_str(""); html.push_str(&html_escape(&post.indexed_at.to_string())); html.push_str(""); html.push_str(""); html.push_str(""); Ok(html) } /// Render a generic record by probing Data for meaningful fields. /// /// Takes pre-fetched record data - no network calls. /// Probes for common fields like name, title, text, description. pub fn render_generic_record( data: &Data<'_>, uri: &AtUri<'_>, ) -> Result { let mut html = String::new(); html.push_str(""); // Show record type as header (full NSID) if let Some(collection) = uri.collection() { html.push_str(""); html.push_str(&html_escape(collection.as_ref())); html.push_str(""); } // Priority fields to show first (in order) let priority_fields = [ "name", "displayName", "title", "text", "description", "content", ]; let mut shown_fields = Vec::new(); if let Some(obj) = data.as_object() { for field_name in priority_fields { if let Some(value) = obj.get(field_name) { if let Some(s) = value.as_str() { let class = match field_name { "name" | "displayName" | "title" => "embed-author-name", "text" | "content" => "embed-content", "description" => "embed-description", _ => "embed-field-value", }; html.push_str(""); // Truncate long content for embed display let display_text = if s.len() > 300 { format!("{}...", &s[..300]) } else { s.to_string() }; html.push_str(&html_escape(&display_text)); html.push_str(""); shown_fields.push(field_name); } } } // Show remaining fields as a simple list html.push_str(""); for (key, value) in obj.iter() { let key_str: &str = key.as_ref(); // Skip already shown, internal fields, and complex nested objects if shown_fields.contains(&key_str) || key_str.starts_with('$') || key_str == "facets" || key_str == "labels" || key_str == "embeds" { continue; } if let Some(formatted) = format_field_value(key_str, value) { html.push_str(""); html.push_str(""); html.push_str(&html_escape(&format_field_name(key_str))); html.push_str(": "); html.push_str(&formatted); html.push_str(""); } } html.push_str(""); } html.push_str(""); Ok(html) } // ============================================================================= // Reusable render functions for embed components // ============================================================================= /// Render an author block (avatar + name + handle) /// /// Used for posts, profiles, and any record with an author. /// When `link_to_profile` is true, avatar, display name, and handle all link to the profile. pub fn render_author_block(author: &ProfileViewBasic<'_>, link_to_profile: bool) -> String { render_author_block_inner( author.avatar.as_ref().map(|u| u.as_ref()), author.display_name.as_ref().map(|s| s.as_ref()), author.handle.as_ref(), link_to_profile, ) } /// Render author block from ProfileView (has same fields as ProfileViewBasic) pub fn render_author_block_full( author: &weaver_api::app_bsky::actor::ProfileView<'_>, link_to_profile: bool, ) -> String { render_author_block_inner( author.avatar.as_ref().map(|u| u.as_ref()), author.display_name.as_ref().map(|s| s.as_ref()), author.handle.as_ref(), link_to_profile, ) } fn render_author_block_inner( avatar: Option<&str>, display_name: Option<&str>, handle: &str, link_to_profile: bool, ) -> String { let mut html = String::new(); let profile_url = format!("https://bsky.app/profile/{}", handle); html.push_str(""); if let Some(avatar_url) = avatar { if link_to_profile { html.push_str(""); html.push_str("\"\""); html.push_str(""); } else { html.push_str("\"\""); } } html.push_str(""); if let Some(name) = display_name { if link_to_profile { html.push_str(""); html.push_str(&html_escape(name)); html.push_str(""); } else { html.push_str(""); html.push_str(&html_escape(name)); html.push_str(""); } } if link_to_profile { html.push_str("@"); html.push_str(&html_escape(handle)); html.push_str(""); } else { html.push_str("@"); html.push_str(&html_escape(handle)); html.push_str(""); } html.push_str(""); html.push_str(""); html } /// Render an external link card (title, description, thumbnail) /// /// Used for link previews in posts and standalone link embeds. pub fn render_external_link(external: &ViewExternal<'_>) -> String { let mut html = String::new(); html.push_str(""); if let Some(thumb) = &external.thumb { html.push_str("\"\""); } html.push_str(""); html.push_str(""); html.push_str(&html_escape(external.title.as_ref())); html.push_str(""); if !external.description.is_empty() { html.push_str(""); html.push_str(&html_escape(external.description.as_ref())); html.push_str(""); } html.push_str(""); // Show just the domain if let Some(domain) = extract_domain(external.uri.as_ref()) { html.push_str(&html_escape(domain)); } else { html.push_str(&html_escape(external.uri.as_ref())); } html.push_str(""); html.push_str(""); html.push_str(""); html } /// Render an image gallery /// /// Used for image embeds in posts. pub fn render_images(images: &[ViewImage<'_>]) -> String { let mut html = String::new(); let class = match images.len() { 1 => "embed-images embed-images-1", 2 => "embed-images embed-images-2", 3 => "embed-images embed-images-3", _ => "embed-images embed-images-4", }; html.push_str(""); for img in images { html.push_str(""); html.push_str("\"");"); html.push_str(""); } html.push_str(""); html } /// Render a quoted/embedded record /// /// Used for quote posts and record embeds. Dispatches based on record type. pub fn render_quoted_record(record: &ViewRecord<'_>) -> String { let mut html = String::new(); html.push_str(""); // Dispatch based on record type match record.value.type_discriminator() { Some("app.bsky.feed.post") => { // Post - show author and text html.push_str(&render_author_block(&record.author, true)); if let Ok(post) = jacquard::from_data::(&record.value) { html.push_str(""); html.push_str(&html_escape(post.text.as_ref())); html.push_str(""); } } Some("app.bsky.feed.generator") => { // Custom feed - show feed info with type label if let Ok(generator) = jacquard::from_data::< weaver_api::app_bsky::feed::generator::Generator, >(&record.value) { html.push_str("Custom Feed"); html.push_str(""); html.push_str(&html_escape(generator.display_name.as_ref())); html.push_str(""); if let Some(desc) = &generator.description { html.push_str(""); html.push_str(&html_escape(desc.as_ref())); html.push_str(""); } html.push_str(&render_author_block(&record.author, true)); } } Some("app.bsky.graph.list") => { // List - show list info if let Ok(list) = jacquard::from_data::(&record.value) { html.push_str("List"); html.push_str(""); html.push_str(&html_escape(list.name.as_ref())); html.push_str(""); if let Some(desc) = &list.description { html.push_str(""); html.push_str(&html_escape(desc.as_ref())); html.push_str(""); } html.push_str(&render_author_block(&record.author, true)); } } Some("app.bsky.graph.starterpack") => { // Starter pack if let Ok(sp) = jacquard::from_data::< weaver_api::app_bsky::graph::starterpack::Starterpack, >(&record.value) { html.push_str("Starter Pack"); html.push_str(""); html.push_str(&html_escape(sp.name.as_ref())); html.push_str(""); if let Some(desc) = &sp.description { html.push_str(""); html.push_str(&html_escape(desc.as_ref())); html.push_str(""); } html.push_str(&render_author_block(&record.author, true)); } } _ => { // Unknown type - show author and probe for common fields html.push_str(&render_author_block(&record.author, true)); html.push_str(&render_generic_data(&record.value)); } } // Render nested embeds if present (applies to all types) if let Some(embeds) = &record.embeds { for embed in embeds { html.push_str(&render_view_record_embed(embed)); } } html.push_str(""); html } /// Render an embed item from a ViewRecord (nested embeds in quotes) fn render_view_record_embed( embed: &weaver_api::app_bsky::embed::record::ViewRecordEmbedsItem<'_>, ) -> String { use weaver_api::app_bsky::embed::record::ViewRecordEmbedsItem; match embed { ViewRecordEmbedsItem::ImagesView(images) => render_images(&images.images), ViewRecordEmbedsItem::ExternalView(external) => render_external_link(&external.external), ViewRecordEmbedsItem::View(record_view) => render_record_embed(&record_view.record), ViewRecordEmbedsItem::RecordWithMediaView(rwm) => { let mut html = String::new(); // Render media first match &rwm.media { weaver_api::app_bsky::embed::record_with_media::ViewMedia::ImagesView(img) => { html.push_str(&render_images(&img.images)); } weaver_api::app_bsky::embed::record_with_media::ViewMedia::ExternalView(ext) => { html.push_str(&render_external_link(&ext.external)); } weaver_api::app_bsky::embed::record_with_media::ViewMedia::VideoView(_) => { html.push_str("[Video]"); } weaver_api::app_bsky::embed::record_with_media::ViewMedia::Unknown(_) => {} } // Then the record html.push_str(&render_record_embed(&rwm.record.record)); html } ViewRecordEmbedsItem::VideoView(_) => { "[Video]".to_string() } ViewRecordEmbedsItem::Unknown(data) => render_generic_data(data), } } /// Render a PostViewEmbed (images, external, record, video, etc.) pub fn render_post_embed(embed: &PostViewEmbed<'_>) -> String { match embed { PostViewEmbed::ImagesView(images) => render_images(&images.images), PostViewEmbed::ExternalView(external) => render_external_link(&external.external), PostViewEmbed::RecordView(record) => render_record_embed(&record.record), PostViewEmbed::RecordWithMediaView(rwm) => { let mut html = String::new(); // Render media first match &rwm.media { weaver_api::app_bsky::embed::record_with_media::ViewMedia::ImagesView(img) => { html.push_str(&render_images(&img.images)); } weaver_api::app_bsky::embed::record_with_media::ViewMedia::ExternalView(ext) => { html.push_str(&render_external_link(&ext.external)); } weaver_api::app_bsky::embed::record_with_media::ViewMedia::VideoView(_) => { html.push_str("[Video]"); } weaver_api::app_bsky::embed::record_with_media::ViewMedia::Unknown(_) => {} } // Then the record html.push_str(&render_record_embed(&rwm.record.record)); html } PostViewEmbed::VideoView(_) => { "[Video]".to_string() } PostViewEmbed::Unknown(data) => render_generic_data(data), } } /// Render a ViewUnionRecord (the actual content of a record embed) fn render_record_embed(record: &ViewUnionRecord<'_>) -> String { match record { ViewUnionRecord::ViewRecord(r) => render_quoted_record(r), ViewUnionRecord::ViewNotFound(_) => { "Record not found".to_string() } ViewUnionRecord::ViewBlocked(_) => { "Content blocked".to_string() } ViewUnionRecord::ViewDetached(_) => { "Content unavailable".to_string() } ViewUnionRecord::GeneratorView(generator) => { let mut html = String::new(); html.push_str(""); // Icon + title + type (like author block layout) html.push_str(""); if let Some(avatar) = &generator.avatar { html.push_str("\"\""); } html.push_str(""); html.push_str(""); html.push_str(&html_escape(generator.display_name.as_ref())); html.push_str(""); html.push_str("Feed"); html.push_str(""); html.push_str(""); // Description if let Some(desc) = &generator.description { html.push_str(""); html.push_str(&html_escape(desc.as_ref())); html.push_str(""); } // Creator html.push_str(&render_author_block_full(&generator.creator, true)); // Stats if let Some(likes) = generator.like_count { html.push_str(""); html.push_str(""); html.push_str(&likes.to_string()); html.push_str(" likes"); html.push_str(""); } html.push_str(""); html } ViewUnionRecord::ListView(list) => { let mut html = String::new(); html.push_str(""); // Icon + title + type (like author block layout) html.push_str(""); if let Some(avatar) = &list.avatar { html.push_str("\"\""); } html.push_str(""); html.push_str(""); html.push_str(&html_escape(list.name.as_ref())); html.push_str(""); html.push_str("List"); html.push_str(""); html.push_str(""); // Description if let Some(desc) = &list.description { html.push_str(""); html.push_str(&html_escape(desc.as_ref())); html.push_str(""); } // Creator html.push_str(&render_author_block_full(&list.creator, true)); // Stats if let Some(count) = list.list_item_count { html.push_str(""); html.push_str(""); html.push_str(&count.to_string()); html.push_str(" members"); html.push_str(""); } html.push_str(""); html } ViewUnionRecord::LabelerView(labeler) => { let mut html = String::new(); html.push_str(""); // Labeler uses creator as the identity, add type label html.push_str(""); if let Some(avatar) = &labeler.creator.avatar { html.push_str("\"\""); } html.push_str(""); if let Some(name) = &labeler.creator.display_name { html.push_str(""); html.push_str(&html_escape(name.as_ref())); html.push_str(""); } html.push_str("Labeler"); html.push_str(""); html.push_str(""); // Stats if let Some(likes) = labeler.like_count { html.push_str(""); html.push_str(""); html.push_str(&likes.to_string()); html.push_str(" likes"); html.push_str(""); } html.push_str(""); html } ViewUnionRecord::StarterPackViewBasic(sp) => { let mut html = String::new(); html.push_str(""); // Use author block layout: avatar + info (name, subtitle) html.push_str(""); if let Some(avatar) = &sp.creator.avatar { html.push_str("\"\""); } html.push_str(""); // Name as title if let Some(name) = sp.record.query("name").single().and_then(|d| d.as_str()) { html.push_str(""); html.push_str(&html_escape(name)); html.push_str(""); } // "Starter pack by @handle" html.push_str("by @"); html.push_str(&html_escape(sp.creator.handle.as_ref())); html.push_str(""); html.push_str(""); // end info html.push_str(""); // end author // Description if let Some(desc) = sp .record .query("description") .single() .and_then(|d| d.as_str()) { html.push_str(""); html.push_str(&html_escape(desc)); html.push_str(""); } // Stats let has_stats = sp.list_item_count.is_some() || sp.joined_all_time_count.is_some(); if has_stats { html.push_str(""); if let Some(count) = sp.list_item_count { html.push_str(""); html.push_str(&count.to_string()); html.push_str(" users"); } if let Some(joined) = sp.joined_all_time_count { html.push_str(""); html.push_str(&joined.to_string()); html.push_str(" joined"); } html.push_str(""); } html.push_str(""); html } ViewUnionRecord::Unknown(data) => render_generic_data(data), } } /// Render generic/unknown data by iterating fields intelligently /// /// Used as fallback for Unknown variants of open unions. fn render_generic_data(data: &Data<'_>) -> String { render_generic_data_with_depth(data, 0) } /// Render generic data with depth tracking for nested objects fn render_generic_data_with_depth(data: &Data<'_>, depth: u8) -> String { let mut html = String::new(); // Only wrap in card at top level let is_nested = depth > 0; if is_nested { html.push_str(""); } else { html.push_str(""); } // Show record type as header if present if let Some(record_type) = data.type_discriminator() { html.push_str(""); html.push_str(&html_escape(record_type)); html.push_str(""); } // Priority fields to show first (in order) let priority_fields = ["name", "displayName", "title", "text", "description"]; let mut shown_fields = Vec::new(); if let Some(obj) = data.as_object() { for field_name in priority_fields { if let Some(value) = obj.get(field_name) { if let Some(s) = value.as_str() { let class = match field_name { "name" | "displayName" | "title" => "embed-author-name", "text" => "embed-content", "description" => "embed-description", _ => "embed-field-value", }; html.push_str(""); html.push_str(&html_escape(s)); html.push_str(""); shown_fields.push(field_name); } } } // Show remaining fields as a simple list if !is_nested { html.push_str(""); } for (key, value) in obj.iter() { let key_str: &str = key.as_ref(); // Skip already shown, internal fields if shown_fields.contains(&key_str) || key_str.starts_with('$') || key_str == "facets" || key_str == "labels" { continue; } if let Some(formatted) = format_field_value_with_depth(key_str, value, depth) { html.push_str(""); html.push_str(""); html.push_str(&html_escape(&format_field_name(key_str))); html.push_str(": "); html.push_str(&formatted); html.push_str(""); } } if !is_nested { html.push_str(""); } } html.push_str(""); html } /// Format a field name for display (camelCase -> "Camel Case") fn format_field_name(name: &str) -> String { let mut result = String::new(); for (i, c) in name.chars().enumerate() { if c.is_uppercase() && i > 0 { result.push(' '); } if i == 0 { result.extend(c.to_uppercase()); } else { result.push(c); } } result } /// Format a field value for display, returning None for complex/unrenderable values fn format_field_value(key: &str, value: &Data<'_>) -> Option { format_field_value_with_depth(key, value, 0) } /// Maximum nesting depth for rendering nested objects const MAX_NESTED_DEPTH: u8 = 2; /// Format a field value for display with depth tracking fn format_field_value_with_depth(key: &str, value: &Data<'_>, depth: u8) -> Option { // String values - detect AT Protocol types if let Some(s) = value.as_str() { return Some(format_string_value(key, s)); } // Numbers if let Some(n) = value.as_integer() { return Some(format!("{}", n)); } // Booleans if let Some(b) = value.as_boolean() { let class = if b { "embed-field-bool-true" } else { "embed-field-bool-false" }; return Some(format!( "{}", class, if b { "yes" } else { "no" } )); } // Arrays - show count or render items if simple if let Some(arr) = value.as_array() { return Some(format_array_value(arr, depth)); } // Nested objects - render if within depth limit if value.as_object().is_some() { if depth < MAX_NESTED_DEPTH { return Some(render_generic_data_with_depth(value, depth + 1)); } else { // At max depth, just show field count let count = value.as_object().map(|o| o.len()).unwrap_or(0); return Some(format!( "{} field{}", count, if count == 1 { "" } else { "s" } )); } } None } /// Format an array value, rendering items if simple enough fn format_array_value(arr: &jacquard::Array<'_>, depth: u8) -> String { let len = arr.len(); // Empty array if len == 0 { return "empty".to_string(); } // For small arrays of simple values, show them inline if len <= 3 && depth < MAX_NESTED_DEPTH { let mut items = Vec::new(); let mut all_simple = true; for item in arr.iter() { if let Some(formatted) = format_simple_value(item) { items.push(formatted); } else { all_simple = false; break; } } if all_simple { return format!( "[{}]", items.join(", ") ); } } // Otherwise just show count format!( "{} item{}", len, if len == 1 { "" } else { "s" } ) } /// Format a simple value (string, number, bool) without field name context fn format_simple_value(value: &Data<'_>) -> Option { if let Some(s) = value.as_str() { // Keep it short for array display let display = if s.len() > 50 { format!("{}…", &s[..50]) } else { s.to_string() }; return Some(format!("\"{}\"", html_escape(&display))); } if let Some(n) = value.as_integer() { return Some(n.to_string()); } if let Some(b) = value.as_boolean() { return Some(if b { "true" } else { "false" }.to_string()); } None } /// Format a string value with smart detection of AT Protocol types fn format_string_value(key: &str, s: &str) -> String { // AT URI - link to record if s.starts_with("at://") { return format!( "{}", html_escape(s), format_aturi_display(s) ); } // DID if s.starts_with("did:") { return format_did_display(s); } // Regular URL if s.starts_with("http://") || s.starts_with("https://") { let domain = extract_domain(s).unwrap_or(s); return format!( "{}", html_escape(s), html_escape(domain) ); } // Datetime fields - show just the date if key.ends_with("At") || key == "createdAt" || key == "indexedAt" { let date_part = s.split('T').next().unwrap_or(s); return format!( "{}", html_escape(date_part) ); } // NSID (e.g., app.bsky.feed.post) if s.contains('.') && s.chars().all(|c| c.is_alphanumeric() || c == '.') && s.matches('.').count() >= 2 { return format!("{}", html_escape(s)); } // Handle (contains dots, no colons or slashes) if s.contains('.') && !s.contains(':') && !s.contains('/') && s.chars() .all(|c| c.is_alphanumeric() || c == '.' || c == '-' || c == '_') { return format!( "@{}", html_escape(s) ); } // Plain string html_escape(s) } /// Format an AT URI for display with highlighted parts fn format_aturi_display(uri: &str) -> String { if let Some(rest) = uri.strip_prefix("at://") { let parts: Vec<&str> = rest.splitn(3, '/').collect(); let mut result = String::from("at://"); if !parts.is_empty() { result.push_str(&format!( "{}", html_escape(parts[0]) )); } if parts.len() > 1 { result.push_str("/"); result.push_str(&format!( "{}", html_escape(parts[1]) )); } if parts.len() > 2 { result.push_str("/"); result.push_str(&format!( "{}", html_escape(parts[2]) )); } result } else { html_escape(uri) } } /// Format a DID for display with highlighted parts fn format_did_display(did: &str) -> String { if let Some(rest) = did.strip_prefix("did:") { if let Some((method, identifier)) = rest.split_once(':') { return format!( "\ did:\ {}\ :\ {}\ ", html_escape(method), html_escape(identifier) ); } } format!( "{}", html_escape(did) ) } // ============================================================================= // Helper functions // ============================================================================= /// Extract domain from a URL fn extract_domain(url: &str) -> Option<&str> { let without_scheme = url .strip_prefix("https://") .or_else(|| url.strip_prefix("http://"))?; without_scheme.split('/').next() } /// Simple HTML escaping fn html_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") }