atproto blogging
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}