From 1ce85230f2a853909b17fe22aa0cf8a229ff037f Mon Sep 17 00:00:00 2001 From: Orual Date: Tue, 6 Jan 2026 20:12:28 -0500 Subject: [PATCH] sync embed render methods (with sufficient context) --- crates/weaver-renderer/src/atproto.rs | 3 + crates/weaver-renderer/src/atproto/client.rs | 2 +- .../src/atproto/embed_renderer.rs | 841 +++++++++++++++++- crates/weaver-renderer/src/atproto/writer.rs | 151 ++-- .../src/leaflet/block_renderer.rs | 187 ++++ crates/weaver-renderer/src/leaflet/mod.rs | 5 +- .../src/pckt/block_renderer.rs | 137 +++ crates/weaver-renderer/src/pckt/mod.rs | 5 +- docs/graph-data.json | 40 +- 9 files changed, 1245 insertions(+), 126 deletions(-) diff --git a/crates/weaver-renderer/src/atproto.rs b/crates/weaver-renderer/src/atproto.rs index c7ce034..eb99ca2 100644 --- a/crates/weaver-renderer/src/atproto.rs +++ b/crates/weaver-renderer/src/atproto.rs @@ -14,7 +14,10 @@ mod writer; pub use client::{ClientContext, DefaultEmbedResolver, EmbedResolver}; pub use embed_renderer::{ + // Async fetch-and-render functions (require agent/network) fetch_and_render, fetch_and_render_generic, fetch_and_render_post, fetch_and_render_profile, + // Pure sync render functions (pre-fetched data, no network) + render_generic_record, render_post_view, render_profile_data_view, render_record, }; pub use error::{AtProtoPreprocessError, ClientRenderError}; pub use markdown_writer::MarkdownWriter; diff --git a/crates/weaver-renderer/src/atproto/client.rs b/crates/weaver-renderer/src/atproto/client.rs index 87c9d3a..4e04f8a 100644 --- a/crates/weaver-renderer/src/atproto/client.rs +++ b/crates/weaver-renderer/src/atproto/client.rs @@ -658,7 +658,7 @@ mod tests { let uri = AtUri::new("at://did:plc:xyz123/sh.weaver.notebook.entry/entry123").unwrap(); assert_eq!( at_uri_to_web_url(&uri), - "https://alpha.weaver.sh/record/at://did:plc:xyz123/sh.weaver.notebook.entry/entry123" + "https://alpha.weaver.sh/did:plc:xyz123/e/entry123" ); } diff --git a/crates/weaver-renderer/src/atproto/embed_renderer.rs b/crates/weaver-renderer/src/atproto/embed_renderer.rs index f046ddd..20ed27b 100644 --- a/crates/weaver-renderer/src/atproto/embed_renderer.rs +++ b/crates/weaver-renderer/src/atproto/embed_renderer.rs @@ -454,8 +454,11 @@ where .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)))?; + 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 @@ -477,32 +480,32 @@ where // Get author DID and handle from URI authority use jacquard::types::string::{Did, Handle}; - 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 (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); @@ -511,11 +514,7 @@ where 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 doc_path = doc.path.as_ref().map(|p| p.as_ref()).unwrap_or(rkey); let mut html = String::new(); html.push_str("
"); @@ -612,8 +611,767 @@ where } } -/// Render a profile from ProfileDataViewInner (weaver, bsky, or tangled) -fn render_profile_data_view( +/// 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(|| { + 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(content, &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(); @@ -754,8 +1512,10 @@ fn render_profile_data_view( Ok(html) } -/// Render a Bluesky post from PostView (rich appview data) -fn render_post_view<'a>( +/// 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 { @@ -825,8 +1585,11 @@ fn render_post_view<'a>( Ok(html) } -/// Render a generic record by probing Data for meaningful fields -fn render_generic_record( +/// 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 { diff --git a/crates/weaver-renderer/src/atproto/writer.rs b/crates/weaver-renderer/src/atproto/writer.rs index 9c9e112..4ad586b 100644 --- a/crates/weaver-renderer/src/atproto/writer.rs +++ b/crates/weaver-renderer/src/atproto/writer.rs @@ -5,8 +5,8 @@ use jacquard::types::string::AtUri; use markdown_weaver::{ - Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType, - ParagraphContext, Tag, WeaverAttributes, + Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType, ParagraphContext, + Tag, WeaverAttributes, }; use markdown_weaver_escape::{StrWrite, escape_href, escape_html, escape_html_body_text}; use std::collections::HashMap; @@ -20,24 +20,24 @@ enum WrapperElement { Div, } -/// Synchronous callback for injecting embed content +/// Synchronous callback for injecting embed content. /// /// Takes the embed tag and returns optional HTML content to inject. pub trait EmbedContentProvider { - fn get_embed_content(&self, tag: &Tag<'_>) -> Option; + fn get_embed_content(&self, tag: &Tag<'_>) -> Option<&str>; } impl EmbedContentProvider for () { - fn get_embed_content(&self, _tag: &Tag<'_>) -> Option { + fn get_embed_content(&self, _tag: &Tag<'_>) -> Option<&str> { None } } impl EmbedContentProvider for ResolvedContent { - fn get_embed_content(&self, tag: &Tag<'_>) -> Option { + fn get_embed_content(&self, tag: &Tag<'_>) -> Option<&str> { let url = match tag { Tag::Embed { dest_url, .. } => Some(dest_url.as_ref()), - // WikiLink images with at:// URLs are embeds in disguise + // WikiLink images with at:// URLs are embeds in disguise. Tag::Image { link_type: LinkType::WikiLink { .. }, dest_url, @@ -51,7 +51,8 @@ impl EmbedContentProvider for ResolvedContent { if let Some(url) = url { if url.starts_with("at://") { if let Ok(at_uri) = AtUri::new(url) { - return self.get_embed_content(&at_uri).map(|s| s.to_string()); + // Call the inherent method which returns Option<&str>. + return ResolvedContent::get_embed_content(self, &at_uri); } } } @@ -59,6 +60,12 @@ impl EmbedContentProvider for ResolvedContent { } } +impl EmbedContentProvider for &ResolvedContent { + fn get_embed_content(&self, tag: &Tag<'_>) -> Option<&str> { + ::get_embed_content(*self, tag) + } +} + /// Simple writer that outputs HTML from markdown events /// /// This writer is designed for client-side rendering where embeds may have @@ -498,7 +505,9 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon if checked { self.write("\n")?; } else { - self.write("\n")?; + self.write( + "\n", + )?; } } WeaverBlock(text) => { @@ -772,12 +781,15 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon && (dest_url.starts_with("at://") || dest_url.starts_with("did:")) { tracing::debug!("[ClientWriter] AT embed image detected: {}", dest_url); - if let Some(embed_provider) = &self.embed_provider { + if let Some(ref embed_provider) = self.embed_provider { if let Some(html) = embed_provider.get_embed_content(&tag) { tracing::debug!("[ClientWriter] Got embed content for {}", dest_url); - // Consume events without writing - we're replacing with embed HTML + // Use direct field access to avoid borrow conflict. + self.writer.write_str(html)?; + self.end_newline = html.ends_with('\n'); + // Consume events without writing - we've replaced with embed HTML. self.consume_until_end(); - return self.write(&html); + return Ok(()); } else { tracing::debug!( "[ClientWriter] No embed content from provider for {}", @@ -894,7 +906,11 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon } } - fn end_tag(&mut self, tag: markdown_weaver::TagEnd, range: Range) -> Result<(), W::Error> { + fn end_tag( + &mut self, + tag: markdown_weaver::TagEnd, + range: Range, + ) -> Result<(), W::Error> { use markdown_weaver::TagEnd; match tag { TagEnd::HtmlBlock => self.write("\n"), @@ -1064,20 +1080,16 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon id: CowStr<'_>, attrs: Option>, ) -> Result<(), W::Error> { - // Try to get content from attributes first - let content_from_attrs = if let Some(ref attrs) = attrs { - attrs - .attrs - .iter() - .find(|(k, _)| k.as_ref() == "content") - .map(|(_, v)| v.as_ref().to_string()) - } else { - None - }; + // Try to get content from attributes first. + let content_from_attrs: Option<&str> = attrs + .as_ref() + .and_then(|a| a.attrs.iter().find(|(k, _)| k.as_ref() == "content")) + .map(|(_, v)| v.as_ref()); - // If no content in attrs, try provider - let content = if let Some(content) = content_from_attrs { - Some(content) + // Write content if found in attrs, otherwise try provider, otherwise fallback. + if let Some(content) = content_from_attrs { + self.write(content)?; + self.write_newline()?; } else if let Some(ref provider) = self.embed_provider { let tag = Tag::Embed { embed_type, @@ -1086,51 +1098,62 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon id: id.clone(), attrs: attrs.clone(), }; - provider.get_embed_content(&tag) + if let Some(content) = provider.get_embed_content(&tag) { + // Use direct field access to avoid borrow conflict: + // `provider` borrows self.embed_provider, `content` borrows from provider, + // but self.writer is a different field so we can borrow it independently. + self.writer.write_str(content)?; + self.end_newline = content.ends_with('\n'); + self.writer.write_str("\n")?; + self.end_newline = true; + } else { + self.write_embed_fallback(&dest_url, &title, &id, attrs.as_ref())?; + } } else { - None - }; + self.write_embed_fallback(&dest_url, &title, &id, attrs.as_ref())?; + } + Ok(()) + } - if let Some(html_content) = content { - // Write the pre-rendered content directly - self.write(&html_content)?; - self.write_newline()?; - } else { - // Fallback: render as iframe - self.write("")?; } - Ok(()) + self.write(">") } } diff --git a/crates/weaver-renderer/src/leaflet/block_renderer.rs b/crates/weaver-renderer/src/leaflet/block_renderer.rs index a971236..8bfb00e 100644 --- a/crates/weaver-renderer/src/leaflet/block_renderer.rs +++ b/crates/weaver-renderer/src/leaflet/block_renderer.rs @@ -387,3 +387,190 @@ fn extract_domain(url: &str) -> &str { .and_then(|s| s.split('/').next()) .unwrap_or(url) } + +/// Sync version of render_linear_document that uses pre-resolved embeds. +pub fn render_linear_document_sync( + doc: &LinearDocument<'_>, + ctx: &LeafletRenderContext, + resolved_content: Option<&weaver_common::ResolvedContent>, +) -> String { + let mut html = String::new(); + html.push_str("
"); + + for block in &doc.blocks { + html.push_str(&render_block_sync(block, ctx, resolved_content)); + } + + html.push_str("
"); + html +} + +/// Sync version of render_block that uses pre-resolved embeds for BskyPost blocks. +pub fn render_block_sync( + block: &Block<'_>, + ctx: &LeafletRenderContext, + resolved_content: Option<&weaver_common::ResolvedContent>, +) -> String { + let mut html = String::new(); + + let alignment_class = block + .alignment + .as_ref() + .map(|a| match a.as_ref() { + "pub.leaflet.pages.linearDocument#textAlignCenter" => " align-center", + "pub.leaflet.pages.linearDocument#textAlignRight" => " align-right", + "pub.leaflet.pages.linearDocument#textAlignJustify" => " align-justify", + _ => "", + }) + .unwrap_or(""); + + match &block.block { + BlockBlock::Text(text) => { + render_text_block(&mut html, text, alignment_class); + } + BlockBlock::Header(header) => { + render_header_block(&mut html, header, alignment_class); + } + BlockBlock::Blockquote(quote) => { + render_blockquote_block(&mut html, quote); + } + BlockBlock::Code(code) => { + render_code_block(&mut html, code); + } + BlockBlock::UnorderedList(list) => { + render_unordered_list_sync(&mut html, list, ctx, resolved_content); + } + BlockBlock::Image(image) => { + render_image_block(&mut html, image, ctx); + } + BlockBlock::Website(website) => { + render_website_block(&mut html, website, ctx); + } + BlockBlock::Iframe(iframe) => { + render_iframe_block(&mut html, iframe); + } + BlockBlock::BskyPost(post) => { + render_bsky_post_block_sync(&mut html, post, resolved_content); + } + BlockBlock::Button(button) => { + render_button_block(&mut html, button); + } + BlockBlock::Poll(poll) => { + render_poll_block(&mut html, poll); + } + BlockBlock::HorizontalRule(_) => { + html.push_str("
\n"); + } + BlockBlock::Page(page) => { + render_page_block(&mut html, page); + } + BlockBlock::Math(math) => { + render_math_block(&mut html, math); + } + BlockBlock::Unknown(data) => { + let _ = write!( + html, + "
[Unknown block: {:?}]
\n", + data.type_discriminator() + ); + } + } + + html +} + +fn render_unordered_list_sync( + html: &mut String, + list: &UnorderedList<'_>, + ctx: &LeafletRenderContext, + resolved_content: Option<&weaver_common::ResolvedContent>, +) { + html.push_str("
    \n"); + for item in &list.children { + render_list_item_sync(html, item, ctx, resolved_content); + } + html.push_str("
\n"); +} + +fn render_list_item_sync( + html: &mut String, + item: &ListItem<'_>, + ctx: &LeafletRenderContext, + resolved_content: Option<&weaver_common::ResolvedContent>, +) { + html.push_str("
  • "); + + match &item.content { + ListItemContent::Text(text) => { + html.push_str(&render_faceted_text( + &text.plaintext, + text.facets.as_deref(), + )); + } + ListItemContent::Header(header) => { + let level = header.level.unwrap_or(1).clamp(1, 6); + let _ = write!(html, "", level); + html.push_str(&render_faceted_text( + &header.plaintext, + header.facets.as_deref(), + )); + let _ = write!(html, "", level); + } + ListItemContent::Image(image) => { + render_image_inline(html, image, ctx); + } + ListItemContent::Unknown(data) => { + let _ = write!(html, "[Unknown: {:?}]", data.type_discriminator()); + } + } + + if let Some(children) = &item.children { + html.push_str("\n
      \n"); + for child in children { + render_list_item_sync(html, child, ctx, resolved_content); + } + html.push_str("
    \n"); + } + + html.push_str("
  • \n"); +} + +fn render_bsky_post_block_sync( + html: &mut String, + post: &BskyPost<'_>, + resolved_content: Option<&weaver_common::ResolvedContent>, +) { + let uri_str = post.post_ref.uri.as_ref(); + + // Look up pre-rendered content. + if let Some(resolved) = resolved_content { + if let Ok(at_uri) = AtUri::new(uri_str) { + if let Some(rendered) = resolved.get_embed_content(&at_uri) { + html.push_str(rendered); + return; + } + } + } + + // Fallback: use bsky embed iframe. + // Format: at://did/app.bsky.feed.post/rkey -> https://bsky.app/profile/did/post/rkey + if let Some(rest) = uri_str.strip_prefix("at://") { + if let Some((did, path)) = rest.split_once('/') { + if let Some(rkey) = path.strip_prefix("app.bsky.feed.post/") { + html.push_str("\n"); + return; + } + } + } + + // Last resort: placeholder. + html.push_str("
    [Bluesky Post: "); + let _ = escape_html(&mut *html, uri_str); + html.push_str("]
    \n"); +} diff --git a/crates/weaver-renderer/src/leaflet/mod.rs b/crates/weaver-renderer/src/leaflet/mod.rs index 2cfde6d..d37691a 100644 --- a/crates/weaver-renderer/src/leaflet/mod.rs +++ b/crates/weaver-renderer/src/leaflet/mod.rs @@ -1,5 +1,8 @@ mod block_renderer; mod markdown_converter; -pub use block_renderer::{render_block, render_linear_document, LeafletRenderContext}; +pub use block_renderer::{ + render_block, render_block_sync, render_linear_document, render_linear_document_sync, + LeafletRenderContext, +}; pub use markdown_converter::{convert_block, convert_linear_document, LeafletMarkdownContext}; diff --git a/crates/weaver-renderer/src/pckt/block_renderer.rs b/crates/weaver-renderer/src/pckt/block_renderer.rs index f22a1fd..005df52 100644 --- a/crates/weaver-renderer/src/pckt/block_renderer.rs +++ b/crates/weaver-renderer/src/pckt/block_renderer.rs @@ -334,3 +334,140 @@ fn render_faceted_text( escaped } } + +/// Sync version of render_content_blocks that uses pre-resolved embeds. +pub fn render_content_blocks_sync( + blocks: &[jacquard::types::value::Data<'_>], + ctx: &PcktRenderContext, + resolved_content: Option<&weaver_common::ResolvedContent>, +) -> String { + let mut html = String::new(); + html.push_str("
    "); + for block in blocks { + render_block_sync(&mut html, block, ctx, resolved_content); + } + html.push_str("
    "); + html +} + +/// Sync version of render_block that uses pre-resolved embeds for BlueskyEmbed blocks. +pub fn render_block_sync( + html: &mut String, + block: &jacquard::types::value::Data<'_>, + ctx: &PcktRenderContext, + resolved_content: Option<&weaver_common::ResolvedContent>, +) { + let Some(type_tag) = block.type_discriminator() else { + return; + }; + + match type_tag { + "blog.pckt.block.text" => { + if let Ok(text) = jacquard::from_data::(block) { + render_text_block(html, &text); + } + } + "blog.pckt.block.heading" => { + if let Ok(heading) = jacquard::from_data::(block) { + render_heading_block(html, &heading); + } + } + "blog.pckt.block.blockquote" => { + if let Ok(quote) = jacquard::from_data::
    (block) { + render_blockquote_block(html, "e); + } + } + "blog.pckt.block.codeBlock" => { + if let Ok(code) = jacquard::from_data::(block) { + render_code_block(html, &code); + } + } + "blog.pckt.block.bulletList" => { + if let Ok(list) = jacquard::from_data::(block) { + render_bullet_list(html, &list, ctx); + } + } + "blog.pckt.block.orderedList" => { + if let Ok(list) = jacquard::from_data::(block) { + render_ordered_list(html, &list, ctx); + } + } + "blog.pckt.block.image" => { + if let Ok(image) = jacquard::from_data::(block) { + render_image_block(html, &image, ctx); + } + } + "blog.pckt.block.website" => { + if let Ok(website) = jacquard::from_data::(block) { + render_website_block(html, &website); + } + } + "blog.pckt.block.iframe" => { + if let Ok(iframe) = jacquard::from_data::\n"); + return; + } + } + } + + // Last resort: placeholder link. + html.push_str("
    "); + html.push_str("View post on Bluesky
    \n"); +} diff --git a/crates/weaver-renderer/src/pckt/mod.rs b/crates/weaver-renderer/src/pckt/mod.rs index d362ecf..c8f561b 100644 --- a/crates/weaver-renderer/src/pckt/mod.rs +++ b/crates/weaver-renderer/src/pckt/mod.rs @@ -1,3 +1,6 @@ mod block_renderer; -pub use block_renderer::{render_block, render_content_blocks, PcktRenderContext}; +pub use block_renderer::{ + render_block, render_block_sync, render_content_blocks, render_content_blocks_sync, + PcktRenderContext, +}; diff --git a/docs/graph-data.json b/docs/graph-data.json index 98cd3fe..ace978c 100644 --- a/docs/graph-data.json +++ b/docs/graph-data.json @@ -17,9 +17,9 @@ "node_type": "goal", "title": "Build jacquard - better AT Protocol library for Rust", "description": null, - "status": "pending", + "status": "completed", "created_at": "2026-01-06T09:30:53.847514686-05:00", - "updated_at": "2026-01-06T09:30:53.847514686-05:00", + "updated_at": "2026-01-06T19:33:18.788479749-05:00", "metadata_json": "{\"confidence\":95,\"prompt\":\"Frustrated with atrium library - significant boilerplate, poor ergonomics, maintenance concerns. Wanted something better, figured others did too. Built jacquard with zero-copy patterns, better type system, built-in OAuth, cleaner APIs. Developed largely for weaver but designed to benefit the broader Rust/ATProto ecosystem.\"}" }, { @@ -886,9 +886,9 @@ "node_type": "goal", "title": "Create weaver-editor-browser crate - web_sys DOM layer without Dioxus", "description": null, - "status": "pending", + "status": "completed", "created_at": "2026-01-06T10:39:48.632511769-05:00", - "updated_at": "2026-01-06T10:39:48.632511769-05:00", + "updated_at": "2026-01-06T19:33:18.836659545-05:00", "metadata_json": "{\"confidence\":90}" }, { @@ -952,9 +952,9 @@ "node_type": "goal", "title": "Create weaver-editor-crdt crate - optional Loro integration (+500KB)", "description": null, - "status": "pending", + "status": "completed", "created_at": "2026-01-06T10:40:09.313344768-05:00", - "updated_at": "2026-01-06T10:40:09.313344768-05:00", + "updated_at": "2026-01-06T19:33:18.910494639-05:00", "metadata_json": "{\"confidence\":90}" }, { @@ -985,9 +985,9 @@ "node_type": "goal", "title": "Phase 2: Update weaver-app to use extracted crates", "description": null, - "status": "pending", + "status": "completed", "created_at": "2026-01-06T10:42:07.645111430-05:00", - "updated_at": "2026-01-06T10:42:07.645111430-05:00", + "updated_at": "2026-01-06T19:33:18.927342199-05:00", "metadata_json": "{\"confidence\":85}" }, { @@ -1381,9 +1381,9 @@ "node_type": "decision", "title": "Where should embed worker live?", "description": null, - "status": "pending", + "status": "completed", "created_at": "2026-01-06T12:09:01.110078211-05:00", - "updated_at": "2026-01-06T12:09:01.110078211-05:00", + "updated_at": "2026-01-06T19:33:18.804295632-05:00", "metadata_json": "{\"confidence\":85}" }, { @@ -1601,9 +1601,9 @@ "node_type": "outcome", "title": "Embed worker → weaver-embed-worker crate (decided)", "description": null, - "status": "pending", + "status": "completed", "created_at": "2026-01-06T13:29:10.289780508-05:00", - "updated_at": "2026-01-06T13:29:10.289780508-05:00", + "updated_at": "2026-01-06T19:33:24.777994841-05:00", "metadata_json": "{\"confidence\":100}" }, { @@ -1689,9 +1689,9 @@ "node_type": "goal", "title": "Extract worker manager from collab.rs to weaver-editor-crdt - non-Dioxus coordination logic", "description": null, - "status": "pending", + "status": "completed", "created_at": "2026-01-06T14:34:17.012426477-05:00", - "updated_at": "2026-01-06T14:34:17.012426477-05:00", + "updated_at": "2026-01-06T19:33:24.794295700-05:00", "metadata_json": "{\"confidence\":85}" }, { @@ -1876,9 +1876,9 @@ "node_type": "goal", "title": "Cursor module refactor - make browser cursor functions public and remove app duplicates", "description": null, - "status": "pending", + "status": "completed", "created_at": "2026-01-06T17:07:07.731289055-05:00", - "updated_at": "2026-01-06T17:07:07.731289055-05:00", + "updated_at": "2026-01-06T19:31:34.605897852-05:00", "metadata_json": "{\"confidence\":85}" }, { @@ -1920,9 +1920,9 @@ "node_type": "goal", "title": "Evaluate and organize collab.rs and component.rs - consider extraction to core/browser/crdt crates and embed worker host migration", "description": null, - "status": "pending", + "status": "completed", "created_at": "2026-01-06T17:25:55.550260849-05:00", - "updated_at": "2026-01-06T17:25:55.550260849-05:00", + "updated_at": "2026-01-06T19:31:34.588088316-05:00", "metadata_json": "{\"confidence\":95,\"prompt\":\"User asked to review collab.rs and component.rs for cleanup/organization opportunities, potentially move code to weaver-editor-core, weaver-editor-browser, weaver-editor-crdt. Also consider moving embed worker host side to weaver-embed-worker\"}" }, { @@ -1931,9 +1931,9 @@ "node_type": "action", "title": "Migrate embed worker host side to weaver-embed-worker crate", "description": null, - "status": "pending", + "status": "completed", "created_at": "2026-01-06T17:31:25.829135397-05:00", - "updated_at": "2026-01-06T17:31:25.829135397-05:00", + "updated_at": "2026-01-06T19:33:18.820200881-05:00", "metadata_json": "{\"confidence\":90}" }, { -- knotmirror 0.0.1