at main 673 lines 23 kB view raw
1use super::{error::ClientRenderError, types::BlobName}; 2use crate::{ 3 Frontmatter, NotebookContext, 4 atproto::embed_renderer::{ 5 fetch_and_render_entry, fetch_and_render_leaflet, fetch_and_render_whitewind_entry, 6 }, 7}; 8use jacquard::{ 9 client::{Agent, AgentSession}, 10 prelude::IdentityResolver, 11 types::string::{AtUri, Cid, Did}, 12}; 13use markdown_weaver::{CowStr as MdCowStr, LinkType, Tag, WeaverAttributes}; 14use std::collections::HashMap; 15use std::sync::Arc; 16use weaver_api::sh_weaver::notebook::entry::Entry; 17use weaver_common::{EntryIndex, ResolvedContent}; 18 19/// Trait for resolving embed content on the client side 20/// 21/// Implementations can fetch from cache, make HTTP requests, or use other sources. 22pub trait EmbedResolver { 23 /// Resolve a profile embed by AT URI 24 fn resolve_profile( 25 &self, 26 uri: &AtUri<'_>, 27 ) -> impl std::future::Future<Output = Result<String, ClientRenderError>>; 28 29 /// Resolve a post/record embed by AT URI 30 fn resolve_post( 31 &self, 32 uri: &AtUri<'_>, 33 ) -> impl std::future::Future<Output = Result<String, ClientRenderError>>; 34 35 /// Resolve a markdown embed from URL 36 /// 37 /// `depth` parameter tracks recursion depth to prevent infinite loops 38 fn resolve_markdown( 39 &self, 40 url: &str, 41 depth: usize, 42 ) -> impl std::future::Future<Output = Result<String, ClientRenderError>>; 43} 44 45/// Default embed resolver that fetches records from PDSs 46/// 47/// This uses the same fetch/render logic as the preprocessor. 48pub struct DefaultEmbedResolver<A: AgentSession + IdentityResolver> { 49 agent: Arc<Agent<A>>, 50} 51 52impl<A: AgentSession + IdentityResolver> DefaultEmbedResolver<A> { 53 pub fn new(agent: Arc<Agent<A>>) -> Self { 54 Self { agent } 55 } 56} 57 58impl<A: AgentSession + IdentityResolver> EmbedResolver for DefaultEmbedResolver<A> { 59 async fn resolve_profile(&self, uri: &AtUri<'_>) -> Result<String, ClientRenderError> { 60 use crate::atproto::fetch_and_render_profile; 61 fetch_and_render_profile(uri.authority(), &*self.agent) 62 .await 63 .map_err(|e| ClientRenderError::EntryFetch { 64 uri: uri.as_ref().to_string(), 65 source: Box::new(e), 66 }) 67 } 68 69 async fn resolve_post(&self, uri: &AtUri<'_>) -> Result<String, ClientRenderError> { 70 use crate::atproto::{fetch_and_render_generic, fetch_and_render_post}; 71 72 // Check if it's a known type 73 if let Some(collection) = uri.collection() { 74 match collection.as_ref() { 75 "app.bsky.feed.post" => { 76 fetch_and_render_post(uri, &*self.agent).await.map_err(|e| { 77 ClientRenderError::EntryFetch { 78 uri: uri.as_ref().to_string(), 79 source: Box::new(e), 80 } 81 }) 82 } 83 "sh.weaver.notebook.entry" => fetch_and_render_entry(uri, &*self.agent) 84 .await 85 .map_err(|e| ClientRenderError::EntryFetch { 86 uri: uri.as_ref().to_string(), 87 source: Box::new(e), 88 }), 89 "pub.leaflet.document" => fetch_and_render_leaflet(uri, &*self.agent) 90 .await 91 .map_err(|e| ClientRenderError::EntryFetch { 92 uri: uri.as_ref().to_string(), 93 source: Box::new(e), 94 }), 95 "com.whtwnd.blog.entry" => fetch_and_render_whitewind_entry(uri, &*self.agent) 96 .await 97 .map_err(|e| ClientRenderError::EntryFetch { 98 uri: uri.as_ref().to_string(), 99 source: Box::new(e), 100 }), 101 _ => fetch_and_render_generic(uri, &*self.agent) 102 .await 103 .map_err(|e| ClientRenderError::EntryFetch { 104 uri: uri.as_ref().to_string(), 105 source: Box::new(e), 106 }), 107 } 108 } else { 109 Err(ClientRenderError::EntryFetch { 110 uri: uri.as_ref().to_string(), 111 source: "AT URI missing collection".into(), 112 }) 113 } 114 } 115 116 async fn resolve_markdown( 117 &self, 118 url: &str, 119 _depth: usize, 120 ) -> Result<String, ClientRenderError> { 121 // TODO: implement HTTP fetch + markdown rendering 122 Err(ClientRenderError::EntryFetch { 123 uri: url.to_string(), 124 source: "Markdown URL embeds not yet implemented".into(), 125 }) 126 } 127} 128 129impl EmbedResolver for () { 130 async fn resolve_profile(&self, _uri: &AtUri<'_>) -> Result<String, ClientRenderError> { 131 Ok("".to_string()) 132 } 133 134 async fn resolve_post(&self, _uri: &AtUri<'_>) -> Result<String, ClientRenderError> { 135 Ok("".to_string()) 136 } 137 138 async fn resolve_markdown( 139 &self, 140 _url: &str, 141 _depth: usize, 142 ) -> Result<String, ClientRenderError> { 143 Ok("".to_string()) 144 } 145} 146 147impl EmbedResolver for ResolvedContent { 148 async fn resolve_profile(&self, uri: &AtUri<'_>) -> Result<String, ClientRenderError> { 149 self.get_embed_content(uri) 150 .map(|s| s.to_string()) 151 .ok_or_else(|| ClientRenderError::EntryFetch { 152 uri: uri.to_string(), 153 source: "Not in pre-resolved content".into(), 154 }) 155 } 156 157 async fn resolve_post(&self, uri: &AtUri<'_>) -> Result<String, ClientRenderError> { 158 self.get_embed_content(uri) 159 .map(|s| s.to_string()) 160 .ok_or_else(|| ClientRenderError::EntryFetch { 161 uri: uri.to_string(), 162 source: "Not in pre-resolved content".into(), 163 }) 164 } 165 166 async fn resolve_markdown( 167 &self, 168 _url: &str, 169 _depth: usize, 170 ) -> Result<String, ClientRenderError> { 171 Ok("".to_string()) 172 } 173} 174 175const MAX_EMBED_DEPTH: usize = 3; 176 177#[derive(Clone)] 178pub struct ClientContext<'a, R = ()> { 179 // Entry being rendered 180 entry: Entry<'a>, 181 creator_did: Did<'a>, 182 183 // Blob resolution 184 blob_map: HashMap<BlobName<'static>, Cid<'static>>, 185 186 // Embed resolution (optional, generic over resolver type) 187 embed_resolver: Option<Arc<R>>, 188 embed_depth: usize, 189 190 // Pre-resolved content for sync rendering 191 entry_index: Option<EntryIndex>, 192 resolved_content: Option<ResolvedContent>, 193 194 // Shared state 195 frontmatter: Frontmatter, 196 title: MdCowStr<'a>, 197} 198 199impl<'a, R: EmbedResolver> ClientContext<'a, R> { 200 pub fn new(entry: Entry<'a>, creator_did: Did<'a>) -> ClientContext<'a, ()> { 201 let blob_map = Self::build_blob_map(&entry); 202 let title = MdCowStr::Boxed(entry.title.as_ref().into()); 203 204 ClientContext { 205 entry, 206 creator_did, 207 blob_map, 208 embed_resolver: None, 209 embed_depth: 0, 210 entry_index: None, 211 resolved_content: None, 212 frontmatter: Frontmatter::default(), 213 title, 214 } 215 } 216 217 /// Add an entry index for wikilink resolution 218 pub fn with_entry_index(mut self, index: EntryIndex) -> Self { 219 self.entry_index = Some(index); 220 self 221 } 222 223 /// Add pre-resolved content for sync rendering 224 pub fn with_resolved_content(mut self, content: ResolvedContent) -> Self { 225 self.resolved_content = Some(content); 226 self 227 } 228} 229 230impl<'a> ClientContext<'a> { 231 /// Add an embed resolver for fetching embed content 232 pub fn with_embed_resolver<R: EmbedResolver>(self, resolver: Arc<R>) -> ClientContext<'a, R> { 233 ClientContext { 234 entry: self.entry, 235 creator_did: self.creator_did, 236 blob_map: self.blob_map, 237 embed_resolver: Some(resolver), 238 embed_depth: self.embed_depth, 239 entry_index: self.entry_index, 240 resolved_content: self.resolved_content, 241 frontmatter: self.frontmatter, 242 title: self.title, 243 } 244 } 245} 246 247impl<'a, R: EmbedResolver> ClientContext<'a, R> { 248 /// Create a child context with incremented embed depth (for recursive embeds) 249 fn with_depth(&self, depth: usize) -> Self 250 where 251 R: Clone, 252 { 253 Self { 254 entry: self.entry.clone(), 255 creator_did: self.creator_did.clone(), 256 blob_map: self.blob_map.clone(), 257 embed_resolver: self.embed_resolver.clone(), 258 embed_depth: depth, 259 entry_index: self.entry_index.clone(), 260 resolved_content: self.resolved_content.clone(), 261 frontmatter: self.frontmatter.clone(), 262 title: self.title.clone(), 263 } 264 } 265 266 /// Build an embed tag with resolved content attached 267 fn build_embed_with_content<'s>( 268 &self, 269 embed_type: markdown_weaver::EmbedType, 270 url: String, 271 title: MdCowStr<'s>, 272 id: MdCowStr<'s>, 273 content: String, 274 is_at_uri: bool, 275 ) -> Tag<'s> { 276 let mut attrs = WeaverAttributes { 277 classes: vec![], 278 attrs: vec![], 279 }; 280 281 attrs.attrs.push(("content".into(), content.into())); 282 283 // Add metadata for client-side enhancement 284 if is_at_uri { 285 attrs 286 .attrs 287 .push(("data-embed-uri".into(), url.clone().into())); 288 289 if let Ok(at_uri) = AtUri::new(&url) { 290 if at_uri.collection().is_none() { 291 attrs 292 .attrs 293 .push(("data-embed-type".into(), "profile".into())); 294 } else { 295 attrs.attrs.push(("data-embed-type".into(), "post".into())); 296 } 297 } 298 } 299 300 Tag::Embed { 301 embed_type, 302 dest_url: MdCowStr::Boxed(url.into_boxed_str()), 303 title, 304 id, 305 attrs: Some(attrs), 306 } 307 } 308 309 fn build_blob_map<'b>(entry: &Entry<'b>) -> HashMap<BlobName<'static>, Cid<'static>> { 310 use jacquard::IntoStatic; 311 312 let mut map = HashMap::new(); 313 if let Some(embeds) = &entry.embeds { 314 if let Some(images) = &embeds.images { 315 for img in &images.images { 316 if let Some(name) = &img.name { 317 let blob_name = BlobName::from_filename(name.as_ref()); 318 map.insert(blob_name, img.image.blob().cid().clone().into_static()); 319 } 320 } 321 } 322 } 323 map 324 } 325 326 pub fn get_blob_cid(&self, name: &str) -> Option<&Cid<'static>> { 327 let blob_name = BlobName::from_filename(name); 328 self.blob_map.get(&blob_name) 329 } 330} 331 332/// Convert an AT URI to a web URL based on collection type 333/// 334/// Maps AT Protocol URIs to their web equivalents: 335/// - Profile: `at://did:plc:xyz` → `https://weaver.sh/did:plc:xyz` 336/// - Bluesky post: `at://{actor}/app.bsky.feed.post/{rkey}` → `https://bsky.app/profile/{actor}/post/{rkey}` 337/// - Bluesky list: `at://{actor}/app.bsky.graph.list/{rkey}` → `https://bsky.app/profile/{actor}/lists/{rkey}` 338/// - Bluesky feed: `at://{actor}/app.bsky.feed.generator/{rkey}` → `https://bsky.app/profile/{actor}/feed/{rkey}` 339/// - Bluesky starterpack: `at://{actor}/app.bsky.graph.starterpack/{rkey}` → `https://bsky.app/starter-pack/{actor}/{rkey}` 340/// - Weaver/other: `at://{actor}/{collection}/{rkey}` → `https://weaver.sh/record/{at_uri}` 341fn at_uri_to_web_url(at_uri: &AtUri<'_>) -> String { 342 let authority = at_uri.authority().as_ref(); 343 344 // Profile-only link (no collection/rkey) 345 if at_uri.collection().is_none() && at_uri.rkey().is_none() { 346 return format!("https://alpha.weaver.sh/{}", authority); 347 } 348 349 // Record link 350 if let (Some(collection), Some(rkey)) = (at_uri.collection(), at_uri.rkey()) { 351 let collection_str = collection.as_ref(); 352 let rkey_str = rkey.as_ref(); 353 354 // Map known Bluesky collections to bsky.app URLs 355 match collection_str { 356 "app.bsky.feed.post" => { 357 format!("https://bsky.app/profile/{}/post/{}", authority, rkey_str) 358 } 359 "app.bsky.graph.list" => { 360 format!("https://bsky.app/profile/{}/lists/{}", authority, rkey_str) 361 } 362 "app.bsky.feed.generator" => { 363 format!("https://bsky.app/profile/{}/feed/{}", authority, rkey_str) 364 } 365 "app.bsky.graph.starterpack" => { 366 format!("https://bsky.app/starter-pack/{}/{}", authority, rkey_str) 367 } 368 "sh.weaver.notebook.entry" => { 369 format!("https://alpha.weaver.sh/{}/e/{}", authority, rkey_str) 370 } 371 "pub.leaflet.document" => { 372 format!("https://alpha.weaver.sh/{}/p/{}", authority, rkey_str) 373 } 374 "com.whtwnd.blog.entry" => { 375 format!("https://alpha.weaver.sh/{}/w/{}", authority, rkey_str) 376 } 377 // Weaver records and unknown collections go to weaver.sh 378 _ => { 379 format!("https://alpha.weaver.sh/record/{}", at_uri) 380 } 381 } 382 } else { 383 // Fallback for malformed URIs 384 format!("https://alpha.weaver.sh/{}", authority) 385 } 386} 387 388// Stub NotebookContext implementation 389impl<'a, R> NotebookContext for ClientContext<'a, R> 390where 391 R: EmbedResolver, 392{ 393 fn set_entry_title(&self, _title: MdCowStr<'_>) { 394 // No-op for client context 395 } 396 397 fn entry_title(&self) -> MdCowStr<'_> { 398 self.title.clone() 399 } 400 401 fn frontmatter(&self) -> Frontmatter { 402 self.frontmatter.clone() 403 } 404 405 fn set_frontmatter(&self, _frontmatter: Frontmatter) { 406 // No-op for client context 407 } 408 409 async fn handle_link<'s>(&self, link: Tag<'s>) -> Tag<'s> { 410 match &link { 411 Tag::Link { 412 link_type, 413 dest_url, 414 title, 415 id, 416 } => { 417 // Handle WikiLinks via EntryIndex 418 if matches!(link_type, LinkType::WikiLink { .. }) { 419 if let Some(index) = &self.entry_index { 420 let url = dest_url.as_ref(); 421 if let Some((path, _title, fragment)) = index.resolve(url) { 422 // Build resolved URL with optional fragment 423 let resolved_url = match fragment { 424 Some(frag) => format!("{}#{}", path, frag), 425 None => path.to_string(), 426 }; 427 428 return Tag::Link { 429 link_type: *link_type, 430 dest_url: MdCowStr::Boxed(resolved_url.into_boxed_str()), 431 title: title.clone(), 432 id: id.clone(), 433 }; 434 } 435 } 436 // Unresolved wikilink - render as broken link 437 return Tag::Link { 438 link_type: *link_type, 439 dest_url: MdCowStr::Boxed(format!("#{}", dest_url).into_boxed_str()), 440 title: title.clone(), 441 id: id.clone(), 442 }; 443 } 444 445 let url = dest_url.as_ref(); 446 447 // Try to parse as AT URI 448 if let Ok(at_uri) = AtUri::new(url) { 449 let web_url = at_uri_to_web_url(&at_uri); 450 451 return Tag::Link { 452 link_type: *link_type, 453 dest_url: MdCowStr::Boxed(web_url.into_boxed_str()), 454 title: title.clone(), 455 id: id.clone(), 456 }; 457 } 458 459 // Entry links starting with / are server-relative, pass through 460 // External links pass through 461 link 462 } 463 _ => link, 464 } 465 } 466 467 async fn handle_image<'s>(&self, image: Tag<'s>) -> Tag<'s> { 468 // Images already have canonical paths like /{notebook}/image/{name} 469 // The server will handle routing these to the actual blobs 470 image 471 } 472 473 async fn handle_embed<'s>(&self, embed: Tag<'s>) -> Tag<'s> { 474 let Tag::Embed { 475 embed_type, 476 dest_url, 477 title, 478 id, 479 attrs, 480 } = &embed 481 else { 482 return embed; 483 }; 484 485 // If content already in attrs (from preprocessor), pass through 486 if let Some(attrs) = attrs { 487 if attrs.attrs.iter().any(|(k, _)| k.as_ref() == "content") { 488 return embed; 489 } 490 } 491 492 // Own the URL to avoid borrow issues 493 let url: String = dest_url.to_string(); 494 495 // Check recursion depth 496 if self.embed_depth >= MAX_EMBED_DEPTH { 497 return embed; 498 } 499 500 // First check for pre-resolved AT URI content 501 if url.starts_with("at://") { 502 if let Ok(at_uri) = AtUri::new(&url) { 503 if let Some(resolved) = &self.resolved_content { 504 if let Some(content) = resolved.get_embed_content(&at_uri) { 505 return self.build_embed_with_content( 506 *embed_type, 507 url.clone(), 508 title.clone(), 509 id.clone(), 510 content.to_string(), 511 true, 512 ); 513 } 514 } 515 } 516 } 517 518 // Check for wikilink-style embed (![[Entry Name]]) via entry index 519 if !url.starts_with("at://") && !url.starts_with("http://") && !url.starts_with("https://") 520 { 521 if let Some(index) = &self.entry_index { 522 if let Some((path, _title, fragment)) = index.resolve(&url) { 523 // Entry embed - link to the entry 524 let resolved_url = match fragment { 525 Some(frag) => format!("{}#{}", path, frag), 526 None => path.to_string(), 527 }; 528 return Tag::Embed { 529 embed_type: *embed_type, 530 dest_url: MdCowStr::Boxed(resolved_url.into_boxed_str()), 531 title: title.clone(), 532 id: id.clone(), 533 attrs: attrs.clone(), 534 }; 535 } 536 } 537 // Unresolved entry embed - pass through 538 return embed; 539 } 540 541 // Fallback to async resolver if available 542 let Some(resolver) = &self.embed_resolver else { 543 return embed; 544 }; 545 546 // Try to fetch content based on URL type 547 let content_result = if url.starts_with("at://") { 548 // AT Protocol embed 549 if let Ok(at_uri) = AtUri::new(&url) { 550 if at_uri.collection().is_none() && at_uri.rkey().is_none() { 551 // Profile embed 552 resolver.resolve_profile(&at_uri).await 553 } else { 554 // Post/record embed 555 resolver.resolve_post(&at_uri).await 556 } 557 } else { 558 return embed; 559 } 560 } else if url.starts_with("http://") || url.starts_with("https://") { 561 // Markdown embed 562 resolver.resolve_markdown(&url, self.embed_depth + 1).await 563 } else { 564 return embed; 565 }; 566 567 // If we got content, attach it 568 if let Ok(content) = content_result { 569 let is_at = url.starts_with("at://"); 570 self.build_embed_with_content( 571 *embed_type, 572 url, 573 title.clone(), 574 id.clone(), 575 content, 576 is_at, 577 ) 578 } else { 579 embed 580 } 581 } 582 583 fn handle_reference(&self, reference: MdCowStr<'_>) -> MdCowStr<'_> { 584 reference.into_static() 585 } 586 587 fn add_reference(&self, _reference: MdCowStr<'_>) { 588 // No-op for client context 589 } 590} 591 592#[cfg(test)] 593mod tests { 594 use super::*; 595 use jacquard::types::string::{Datetime, Did}; 596 use weaver_api::sh_weaver::notebook::entry::Entry; 597 598 #[test] 599 fn test_client_context_creation() { 600 let entry = Entry::new() 601 .title("Test") 602 .path(weaver_common::normalize_title_path("Test")) 603 .content("# Test") 604 .created_at(Datetime::now()) 605 .build(); 606 607 let ctx = ClientContext::<()>::new(entry, Did::new("did:plc:test").unwrap()); 608 assert_eq!(ctx.title.as_ref(), "Test"); 609 } 610 611 #[test] 612 fn test_at_uri_to_web_url_profile() { 613 let uri = AtUri::new("at://did:plc:xyz123").unwrap(); 614 assert_eq!( 615 at_uri_to_web_url(&uri), 616 "https://alpha.weaver.sh/did:plc:xyz123" 617 ); 618 } 619 620 #[test] 621 fn test_at_uri_to_web_url_bsky_post() { 622 let uri = AtUri::new("at://did:plc:xyz123/app.bsky.feed.post/3k7qrw5h2").unwrap(); 623 assert_eq!( 624 at_uri_to_web_url(&uri), 625 "https://bsky.app/profile/did:plc:xyz123/post/3k7qrw5h2" 626 ); 627 } 628 629 #[test] 630 fn test_at_uri_to_web_url_bsky_list() { 631 let uri = AtUri::new("at://alice.bsky.social/app.bsky.graph.list/abc123").unwrap(); 632 assert_eq!( 633 at_uri_to_web_url(&uri), 634 "https://bsky.app/profile/alice.bsky.social/lists/abc123" 635 ); 636 } 637 638 #[test] 639 fn test_at_uri_to_web_url_bsky_feed() { 640 let uri = AtUri::new("at://alice.bsky.social/app.bsky.feed.generator/my-feed").unwrap(); 641 assert_eq!( 642 at_uri_to_web_url(&uri), 643 "https://bsky.app/profile/alice.bsky.social/feed/my-feed" 644 ); 645 } 646 647 #[test] 648 fn test_at_uri_to_web_url_bsky_starterpack() { 649 let uri = AtUri::new("at://alice.bsky.social/app.bsky.graph.starterpack/pack123").unwrap(); 650 assert_eq!( 651 at_uri_to_web_url(&uri), 652 "https://bsky.app/starter-pack/alice.bsky.social/pack123" 653 ); 654 } 655 656 #[test] 657 fn test_at_uri_to_web_url_weaver_entry() { 658 let uri = AtUri::new("at://did:plc:xyz123/sh.weaver.notebook.entry/entry123").unwrap(); 659 assert_eq!( 660 at_uri_to_web_url(&uri), 661 "https://alpha.weaver.sh/did:plc:xyz123/e/entry123" 662 ); 663 } 664 665 #[test] 666 fn test_at_uri_to_web_url_unknown_collection() { 667 let uri = AtUri::new("at://did:plc:xyz123/com.example.unknown/rkey").unwrap(); 668 assert_eq!( 669 at_uri_to_web_url(&uri), 670 "https://alpha.weaver.sh/record/at://did:plc:xyz123/com.example.unknown/rkey" 671 ); 672 } 673}