use jacquard::client::AgentSessionExt; use jacquard::types::string::{AtUri, Did}; use jacquard::types::value::Data; use markdown_weaver_escape::escape_html; use weaver_api::blog_pckt::block::{ blockquote::Blockquote, bluesky_embed::BlueskyEmbed, bullet_list::BulletList, code_block::CodeBlock, heading::Heading, horizontal_rule::HorizontalRule, iframe::Iframe, image::Image, list_item::ListItemContentItem, ordered_list::OrderedList, text::Text, website::Website, }; use weaver_api::blog_pckt::content::Content as PcktContent; use weaver_api::sh_weaver::notebook::{BookEntryView, entry::Entry}; use crate::facet::{NormalizedFacet, render_faceted_html}; pub struct PcktRenderContext { pub author_did: Did<'static>, } impl PcktRenderContext { pub fn new(author_did: Did<'static>) -> Self { Self { author_did } } fn blob_url(&self, cid: &jacquard::types::cid::Cid<'_>) -> String { format!( "https://cdn.bsky.app/img/feed_fullsize/plain/{}/{}@jpeg", self.author_did.as_ref(), cid.as_ref() ) } } pub async fn render_content_blocks( blocks: &[Data<'_>], ctx: &PcktRenderContext, agent: &A, ) -> String { let mut html = String::new(); html.push_str("
"); for block in blocks { render_block(&mut html, block, ctx, agent).await; } html.push_str("
"); html } pub async fn render_block( html: &mut String, block: &Data<'_>, ctx: &PcktRenderContext, agent: &A, ) { 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"); } async fn render_bluesky_embed( html: &mut String, embed: &BlueskyEmbed<'_>, agent: &A, ) { let uri_str = embed.post_ref.uri.as_ref(); // Try to fetch and render the actual post if let Ok(uri) = AtUri::new(uri_str) { match crate::atproto::fetch_and_render_post(&uri, agent).await { Ok(rendered) => { html.push_str(&rendered); return; } Err(e) => { tracing::warn!("Failed to fetch embedded post {}: {:?}", uri_str, e); } } } // Fallback: render a placeholder link html.push_str("
"); html.push_str("View post on Bluesky
\n"); } fn render_faceted_text( text: &str, facets: Option<&[weaver_api::blog_pckt::richtext::facet::Facet<'_>]>, ) -> String { if let Some(facets) = facets { let normalized: Vec> = facets.iter().map(NormalizedFacet::from).collect(); render_faceted_html(text, &normalized).unwrap_or_else(|_| { let mut escaped = String::new(); let _ = escape_html(&mut escaped, text); escaped }) } else { let mut escaped = String::new(); let _ = escape_html(&mut escaped, 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"); } /// Render document content by dispatching on $type. /// This is the main entry point for site.standard.document.content rendering. pub async fn render_document_content( content: &Data<'_>, ctx: &PcktRenderContext, agent: &A, ) -> String { let Some(type_tag) = content.type_discriminator() else { return String::from("

Unknown content type

"); }; match type_tag { "blog.pckt.content" => { if let Ok(pckt_content) = jacquard::from_data::(content) { render_content_blocks(&pckt_content.items, ctx, agent).await } else { String::from("

Failed to parse pckt content

") } } "sh.weaver.notebook.defs#bookEntryView" => { if let Ok(book_entry) = jacquard::from_data::(content) { render_book_entry_view_async(&book_entry, ctx, agent).await } else { String::from("

Failed to parse book entry

") } } "sh.weaver.notebook.entry" => { if let Ok(entry) = jacquard::from_data::(content) { render_entry_async(&entry, ctx, agent).await } else { String::from("

Failed to parse entry

") } } _ => { tracing::debug!("Unknown document content type: {}", type_tag); String::from("

Unsupported content type

") } } } /// Sync version of render_document_content. pub fn render_document_content_sync( content: &Data<'_>, ctx: &PcktRenderContext, resolved_content: Option<&weaver_common::ResolvedContent>, ) -> String { let Some(type_tag) = content.type_discriminator() else { return String::from("

Unknown content type

"); }; match type_tag { "blog.pckt.content" => { if let Ok(pckt_content) = jacquard::from_data::(content) { render_content_blocks_sync(&pckt_content.items, ctx, resolved_content) } else { String::from("

Failed to parse pckt content

") } } "sh.weaver.notebook.defs#bookEntryView" => { if let Ok(book_entry) = jacquard::from_data::(content) { render_book_entry_view_sync(&book_entry, ctx, resolved_content) } else { String::from("

Failed to parse book entry

") } } "sh.weaver.notebook.entry" => { if let Ok(entry) = jacquard::from_data::(content) { render_entry_sync(&entry, ctx, resolved_content) } else { String::from("

Failed to parse entry

") } } _ => { tracing::debug!("Unknown document content type: {}", type_tag); String::from("

Unsupported content type

") } } } /// Async render for BookEntryView - fetches embeds on demand. /// Uses ctx.author_did for blob URLs and identity context. async fn render_book_entry_view_async( book_entry: &BookEntryView<'_>, ctx: &PcktRenderContext, agent: &A, ) -> String { use crate::atproto::{ClientContext, ClientWriter}; use crate::{ContextIterator, NotebookProcessor}; use markdown_weaver::Parser; use n0_future::stream::StreamExt; let Ok(entry) = jacquard::from_data::(&book_entry.entry.record) else { return String::from("

Failed to parse entry

"); }; let resolved_content = prefetch_entry_embeds(&entry, agent).await; let client_ctx = ClientContext::<()>::new(entry.clone(), ctx.author_did.clone()); let parser = Parser::new_ext(&entry.content, crate::default_md_options()).into_offset_iter(); let iter = ContextIterator::default(parser); let processor = NotebookProcessor::new(client_ctx, iter); let events: Vec<_> = StreamExt::collect(processor).await; let mut html = String::new(); ClientWriter::new(events.into_iter(), &mut html, &entry.content) .with_embed_provider(resolved_content) .run() .ok(); html } /// Sync render - uses pre-resolved content, no async processing. fn render_book_entry_view_sync( book_entry: &BookEntryView<'_>, _ctx: &PcktRenderContext, resolved_content: Option<&weaver_common::ResolvedContent>, ) -> String { use crate::atproto::ClientWriter; use markdown_weaver::Parser; let Ok(entry) = jacquard::from_data::(&book_entry.entry.record) else { return String::from("

Failed to parse entry

"); }; let parser = Parser::new_ext(&entry.content, crate::default_md_options()).into_offset_iter(); let mut html = String::new(); if let Some(resolved) = resolved_content { ClientWriter::new(parser, &mut html, &entry.content) .with_embed_provider(resolved.clone()) .run() .ok(); } else { ClientWriter::<_, _, ()>::new(parser, &mut html, &entry.content) .run() .ok(); } html } /// Async render for direct Entry (not wrapped in BookEntryView). /// Uses ctx.author_did for identity context. async fn render_entry_async( entry: &Entry<'_>, ctx: &PcktRenderContext, agent: &A, ) -> String { use crate::atproto::{ClientContext, ClientWriter}; use crate::{ContextIterator, NotebookProcessor}; use jacquard::IntoStatic; use markdown_weaver::Parser; use n0_future::stream::StreamExt; let resolved_content = prefetch_entry_embeds(entry, agent).await; let client_ctx = ClientContext::<()>::new(entry.clone().into_static(), ctx.author_did.clone()); let parser = Parser::new_ext(&entry.content, crate::default_md_options()).into_offset_iter(); let iter = ContextIterator::default(parser); let processor = NotebookProcessor::new(client_ctx, iter); let events: Vec<_> = StreamExt::collect(processor).await; let mut html = String::new(); ClientWriter::new(events.into_iter(), &mut html, &entry.content) .with_embed_provider(resolved_content) .run() .ok(); html } /// Sync render for direct Entry. fn render_entry_sync( entry: &Entry<'_>, _ctx: &PcktRenderContext, resolved_content: Option<&weaver_common::ResolvedContent>, ) -> String { use crate::atproto::ClientWriter; use markdown_weaver::Parser; let parser = Parser::new_ext(&entry.content, crate::default_md_options()).into_offset_iter(); let mut html = String::new(); if let Some(resolved) = resolved_content { ClientWriter::new(parser, &mut html, &entry.content) .with_embed_provider(resolved.clone()) .run() .ok(); } else { ClientWriter::<_, _, ()>::new(parser, &mut html, &entry.content) .run() .ok(); } html } /// Extract and pre-fetch embeds from a weaver entry. async fn prefetch_entry_embeds( entry: &Entry<'_>, agent: &A, ) -> weaver_common::ResolvedContent { use crate::atproto::fetch_and_render; use jacquard::IntoStatic; let mut resolved = weaver_common::ResolvedContent::new(); // Try stored embeds first. if let Some(ref embeds) = entry.embeds { if let Some(ref records) = embeds.records { for record in &records.records { let uri = record.record.uri.clone().into_static(); if let Ok(html) = fetch_and_render(&uri, agent).await { resolved.add_embed(uri, html, None); } } return resolved; } } // Fall back to parsing markdown for at:// URIs. #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] use regex::Regex; #[cfg(all(target_family = "wasm", target_os = "unknown"))] use regex_lite::Regex; use std::sync::LazyLock; static AT_URI_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"at://[^\s\)\]]+").unwrap()); for m in AT_URI_REGEX.find_iter(&entry.content) { let uri_str: &str = m.as_str(); if let Ok(uri) = jacquard::types::aturi::AtUri::new(uri_str) { let uri = uri.into_static(); if let Ok(html) = fetch_and_render(&uri, agent).await { resolved.add_embed(uri, html, None); } } } resolved }