···11-//! Atproto renderer
22-//!
33-//! This mode of the renderer renders either an entire notebook or entries in it to files suitable for inclusion
44-//! in a single-page app and uploads them to your Atproto PDS
55-//! It can be accessed via the appview at {your-handle}.weaver.sh/{notebook-name}.
66-//!
77-//! It can also be edited there.
88-//!
99-//! Link altering logic:
1010-//! - Option 1: leave (non-embed) links the same as in the markdown, have the CSM deal with them via some means
1111-//! such as adding "data-did" and "data-cid" attributes to the `<a/>` tag containing the DID and CID
1212-//! Pushes toward having the SPA/Appview do a bit more, but makes this step MUCH simpler
1313-//! - In this scenario, the rendering step can happen upon access (and then be cached) in the appview
1414-//! - More flexible in some ways, less in others
1515-//! - Option 2: alter links to point to other rendered blobs. Requires a certain amount of work to handle
1616-//! scenarios with a complex mesh of internal links, as the CID is altered by the editing of the link.
1717-//! Such cycles are handled in the simplest way, by rendering an absolute url which will make a call to the appview.
11+//! AT Protocol renderer for weaver notebooks
182//!
33+//! Two-stage pipeline: markdown→markdown preprocessing (CLI),
44+//! then client-side markdown→HTML rendering (WASM).
55+66+mod error;
77+mod types;
88+mod markdown_writer;
99+mod preprocess;
1010+mod client;
1111+mod embed_renderer;
1212+mod writer;
1313+1414+pub use error::{AtProtoPreprocessError, ClientRenderError};
1515+pub use types::{BlobName, BlobInfo};
1616+pub use preprocess::AtProtoPreprocessContext;
1717+pub use client::{ClientContext, EmbedResolver, DefaultEmbedResolver};
1818+pub use markdown_writer::MarkdownWriter;
1919+pub use embed_renderer::{fetch_and_render_profile, fetch_and_render_post, fetch_and_render_generic};
2020+pub use writer::{ClientWriter, EmbedContentProvider};
+498
crates/weaver-renderer/src/atproto/client.rs
···11+use crate::{Frontmatter, NotebookContext};
22+use super::{types::BlobName, error::ClientRenderError};
33+use jacquard::{
44+ client::{Agent, AgentSession},
55+ prelude::IdentityResolver,
66+ types::string::{AtUri, Cid, Did},
77+};
88+use markdown_weaver::{Tag, CowStr as MdCowStr, WeaverAttributes};
99+use std::collections::HashMap;
1010+use std::sync::Arc;
1111+use weaver_api::sh_weaver::notebook::entry::Entry;
1212+1313+/// Trait for resolving embed content on the client side
1414+///
1515+/// Implementations can fetch from cache, make HTTP requests, or use other sources.
1616+pub trait EmbedResolver: Send + Sync {
1717+ /// Resolve a profile embed by AT URI
1818+ fn resolve_profile(
1919+ &self,
2020+ uri: &AtUri<'_>,
2121+ ) -> impl std::future::Future<Output = Result<String, ClientRenderError>> + Send;
2222+2323+ /// Resolve a post/record embed by AT URI
2424+ fn resolve_post(
2525+ &self,
2626+ uri: &AtUri<'_>,
2727+ ) -> impl std::future::Future<Output = Result<String, ClientRenderError>> + Send;
2828+2929+ /// Resolve a markdown embed from URL
3030+ ///
3131+ /// `depth` parameter tracks recursion depth to prevent infinite loops
3232+ fn resolve_markdown(
3333+ &self,
3434+ url: &str,
3535+ depth: usize,
3636+ ) -> impl std::future::Future<Output = Result<String, ClientRenderError>> + Send;
3737+}
3838+3939+/// Default embed resolver that fetches records from PDSs
4040+///
4141+/// This uses the same fetch/render logic as the preprocessor.
4242+pub struct DefaultEmbedResolver<A: AgentSession + IdentityResolver> {
4343+ agent: Arc<Agent<A>>,
4444+}
4545+4646+impl<A: AgentSession + IdentityResolver> DefaultEmbedResolver<A> {
4747+ pub fn new(agent: Arc<Agent<A>>) -> Self {
4848+ Self { agent }
4949+ }
5050+}
5151+5252+impl<A: AgentSession + IdentityResolver> EmbedResolver for DefaultEmbedResolver<A> {
5353+ async fn resolve_profile(&self, uri: &AtUri<'_>) -> Result<String, ClientRenderError> {
5454+ use crate::atproto::fetch_and_render_profile;
5555+ use jacquard::types::ident::AtIdentifier;
5656+5757+ // Extract DID from authority
5858+ let did = match uri.authority() {
5959+ AtIdentifier::Did(did) => did,
6060+ AtIdentifier::Handle(_) => {
6161+ return Err(ClientRenderError::EntryFetch {
6262+ uri: uri.as_ref().to_string(),
6363+ source: "Profile URI should use DID not handle".into(),
6464+ });
6565+ }
6666+ };
6767+6868+ fetch_and_render_profile(&did, &*self.agent)
6969+ .await
7070+ .map_err(|e| ClientRenderError::EntryFetch {
7171+ uri: uri.as_ref().to_string(),
7272+ source: Box::new(e),
7373+ })
7474+ }
7575+7676+ async fn resolve_post(&self, uri: &AtUri<'_>) -> Result<String, ClientRenderError> {
7777+ use crate::atproto::{fetch_and_render_post, fetch_and_render_generic};
7878+7979+ // Check if it's a known type
8080+ if let Some(collection) = uri.collection() {
8181+ match collection.as_ref() {
8282+ "app.bsky.feed.post" => {
8383+ fetch_and_render_post(uri, &*self.agent)
8484+ .await
8585+ .map_err(|e| ClientRenderError::EntryFetch {
8686+ uri: uri.as_ref().to_string(),
8787+ source: Box::new(e),
8888+ })
8989+ }
9090+ _ => {
9191+ fetch_and_render_generic(uri, &*self.agent)
9292+ .await
9393+ .map_err(|e| ClientRenderError::EntryFetch {
9494+ uri: uri.as_ref().to_string(),
9595+ source: Box::new(e),
9696+ })
9797+ }
9898+ }
9999+ } else {
100100+ Err(ClientRenderError::EntryFetch {
101101+ uri: uri.as_ref().to_string(),
102102+ source: "AT URI missing collection".into(),
103103+ })
104104+ }
105105+ }
106106+107107+ async fn resolve_markdown(
108108+ &self,
109109+ url: &str,
110110+ _depth: usize,
111111+ ) -> Result<String, ClientRenderError> {
112112+ // TODO: implement HTTP fetch + markdown rendering
113113+ Err(ClientRenderError::EntryFetch {
114114+ uri: url.to_string(),
115115+ source: "Markdown URL embeds not yet implemented".into(),
116116+ })
117117+ }
118118+}
119119+120120+const MAX_EMBED_DEPTH: usize = 3;
121121+122122+pub struct ClientContext<'a, R = ()> {
123123+ // Entry being rendered
124124+ entry: Entry<'a>,
125125+ creator_did: Did<'a>,
126126+127127+ // Blob resolution
128128+ blob_map: HashMap<BlobName<'static>, Cid<'static>>,
129129+130130+ // Embed resolution (optional, generic over resolver type)
131131+ embed_resolver: Option<Arc<R>>,
132132+ embed_depth: usize,
133133+134134+ // Shared state
135135+ frontmatter: Frontmatter,
136136+ title: MdCowStr<'a>,
137137+}
138138+139139+impl<'a> ClientContext<'a, ()> {
140140+ pub fn new(entry: Entry<'a>, creator_did: Did<'a>) -> Self {
141141+ let blob_map = Self::build_blob_map(&entry);
142142+ let title = MdCowStr::Boxed(entry.title.as_ref().into());
143143+144144+ Self {
145145+ entry,
146146+ creator_did,
147147+ blob_map,
148148+ embed_resolver: None,
149149+ embed_depth: 0,
150150+ frontmatter: Frontmatter::default(),
151151+ title,
152152+ }
153153+ }
154154+155155+ /// Add an embed resolver for fetching embed content
156156+ pub fn with_embed_resolver<R: EmbedResolver>(
157157+ self,
158158+ resolver: Arc<R>,
159159+ ) -> ClientContext<'a, R> {
160160+ ClientContext {
161161+ entry: self.entry,
162162+ creator_did: self.creator_did,
163163+ blob_map: self.blob_map,
164164+ embed_resolver: Some(resolver),
165165+ embed_depth: self.embed_depth,
166166+ frontmatter: self.frontmatter,
167167+ title: self.title,
168168+ }
169169+ }
170170+}
171171+172172+impl<'a, R> ClientContext<'a, R> {
173173+ /// Create a child context with incremented embed depth (for recursive embeds)
174174+ fn with_depth(&self, depth: usize) -> Self
175175+ where
176176+ R: Clone,
177177+ {
178178+ Self {
179179+ entry: self.entry.clone(),
180180+ creator_did: self.creator_did.clone(),
181181+ blob_map: self.blob_map.clone(),
182182+ embed_resolver: self.embed_resolver.clone(),
183183+ embed_depth: depth,
184184+ frontmatter: self.frontmatter.clone(),
185185+ title: self.title.clone(),
186186+ }
187187+ }
188188+189189+ fn build_blob_map<'b>(entry: &Entry<'b>) -> HashMap<BlobName<'static>, Cid<'static>> {
190190+ use jacquard::IntoStatic;
191191+192192+ let mut map = HashMap::new();
193193+ if let Some(embeds) = &entry.embeds {
194194+ if let Some(images) = &embeds.images {
195195+ for img in &images.images {
196196+ if let Some(name) = &img.name {
197197+ let blob_name = BlobName::from_filename(name.as_ref());
198198+ map.insert(blob_name, img.image.blob().cid().clone().into_static());
199199+ }
200200+ }
201201+ }
202202+ }
203203+ map
204204+ }
205205+206206+ pub fn get_blob_cid(&self, name: &str) -> Option<&Cid<'static>> {
207207+ let blob_name = BlobName::from_filename(name);
208208+ self.blob_map.get(&blob_name)
209209+ }
210210+}
211211+212212+/// Convert an AT URI to a web URL based on collection type
213213+///
214214+/// Maps AT Protocol URIs to their web equivalents:
215215+/// - Profile: `at://did:plc:xyz` → `https://weaver.sh/did:plc:xyz`
216216+/// - Bluesky post: `at://{actor}/app.bsky.feed.post/{rkey}` → `https://bsky.app/profile/{actor}/post/{rkey}`
217217+/// - Bluesky list: `at://{actor}/app.bsky.graph.list/{rkey}` → `https://bsky.app/profile/{actor}/lists/{rkey}`
218218+/// - Bluesky feed: `at://{actor}/app.bsky.feed.generator/{rkey}` → `https://bsky.app/profile/{actor}/feed/{rkey}`
219219+/// - Bluesky starterpack: `at://{actor}/app.bsky.graph.starterpack/{rkey}` → `https://bsky.app/starter-pack/{actor}/{rkey}`
220220+/// - Weaver/other: `at://{actor}/{collection}/{rkey}` → `https://weaver.sh/{actor}/{collection}/{rkey}`
221221+fn at_uri_to_web_url(at_uri: &AtUri<'_>) -> String {
222222+ let authority = at_uri.authority().as_ref();
223223+224224+ // Profile-only link (no collection/rkey)
225225+ if at_uri.collection().is_none() && at_uri.rkey().is_none() {
226226+ return format!("https://weaver.sh/{}", authority);
227227+ }
228228+229229+ // Record link
230230+ if let (Some(collection), Some(rkey)) = (at_uri.collection(), at_uri.rkey()) {
231231+ let collection_str = collection.as_ref();
232232+ let rkey_str = rkey.as_ref();
233233+234234+ // Map known Bluesky collections to bsky.app URLs
235235+ match collection_str {
236236+ "app.bsky.feed.post" => {
237237+ format!("https://bsky.app/profile/{}/post/{}", authority, rkey_str)
238238+ }
239239+ "app.bsky.graph.list" => {
240240+ format!("https://bsky.app/profile/{}/lists/{}", authority, rkey_str)
241241+ }
242242+ "app.bsky.feed.generator" => {
243243+ format!("https://bsky.app/profile/{}/feed/{}", authority, rkey_str)
244244+ }
245245+ "app.bsky.graph.starterpack" => {
246246+ format!("https://bsky.app/starter-pack/{}/{}", authority, rkey_str)
247247+ }
248248+ // Weaver records and unknown collections go to weaver.sh
249249+ _ => {
250250+ format!("https://weaver.sh/{}/{}/{}", authority, collection_str, rkey_str)
251251+ }
252252+ }
253253+ } else {
254254+ // Fallback for malformed URIs
255255+ format!("https://weaver.sh/{}", authority)
256256+ }
257257+}
258258+259259+// Stub NotebookContext implementation
260260+impl<'a, R> NotebookContext for ClientContext<'a, R>
261261+where
262262+ R: EmbedResolver,
263263+{
264264+ fn set_entry_title(&self, _title: MdCowStr<'_>) {
265265+ // No-op for client context
266266+ }
267267+268268+ fn entry_title(&self) -> MdCowStr<'_> {
269269+ self.title.clone()
270270+ }
271271+272272+ fn frontmatter(&self) -> Frontmatter {
273273+ self.frontmatter.clone()
274274+ }
275275+276276+ fn set_frontmatter(&self, _frontmatter: Frontmatter) {
277277+ // No-op for client context
278278+ }
279279+280280+ async fn handle_link<'s>(&self, link: Tag<'s>) -> Tag<'s> {
281281+ match &link {
282282+ Tag::Link { link_type, dest_url, title, id } => {
283283+ let url = dest_url.as_ref();
284284+285285+ // Try to parse as AT URI
286286+ if let Ok(at_uri) = AtUri::new(url) {
287287+ let web_url = at_uri_to_web_url(&at_uri);
288288+289289+ return Tag::Link {
290290+ link_type: *link_type,
291291+ dest_url: MdCowStr::Boxed(web_url.into_boxed_str()),
292292+ title: title.clone(),
293293+ id: id.clone(),
294294+ };
295295+ }
296296+297297+ // Entry links starting with / are server-relative, pass through
298298+ // External links pass through
299299+ link
300300+ }
301301+ _ => link,
302302+ }
303303+ }
304304+305305+ async fn handle_image<'s>(&self, image: Tag<'s>) -> Tag<'s> {
306306+ // Images already have canonical paths like /{notebook}/image/{name}
307307+ // The server will handle routing these to the actual blobs
308308+ image
309309+ }
310310+311311+ async fn handle_embed<'s>(&self, embed: Tag<'s>) -> Tag<'s> {
312312+ match &embed {
313313+ Tag::Embed {
314314+ embed_type,
315315+ dest_url,
316316+ title,
317317+ id,
318318+ attrs,
319319+ } => {
320320+ // If content already in attrs (from preprocessor), pass through
321321+ if let Some(attrs) = attrs {
322322+ if attrs.attrs.iter().any(|(k, _)| k.as_ref() == "content") {
323323+ return embed;
324324+ }
325325+ }
326326+327327+ // Check if we have a resolver
328328+ let Some(resolver) = &self.embed_resolver else {
329329+ return embed;
330330+ };
331331+332332+ // Check recursion depth
333333+ if self.embed_depth >= MAX_EMBED_DEPTH {
334334+ return embed;
335335+ }
336336+337337+ // Try to fetch content based on URL type
338338+ let content_result = if dest_url.starts_with("at://") {
339339+ // AT Protocol embed
340340+ if let Ok(at_uri) = AtUri::new(dest_url.as_ref()) {
341341+ if at_uri.collection().is_none() && at_uri.rkey().is_none() {
342342+ // Profile embed
343343+ resolver.resolve_profile(&at_uri).await
344344+ } else {
345345+ // Post/record embed
346346+ resolver.resolve_post(&at_uri).await
347347+ }
348348+ } else {
349349+ return embed;
350350+ }
351351+ } else if dest_url.starts_with("http://") || dest_url.starts_with("https://") {
352352+ // Markdown embed (could be other types, but assume markdown for now)
353353+ resolver
354354+ .resolve_markdown(dest_url.as_ref(), self.embed_depth + 1)
355355+ .await
356356+ } else {
357357+ // Local path or other - skip for now
358358+ return embed;
359359+ };
360360+361361+ // If we got content, attach it to attrs
362362+ if let Ok(content) = content_result {
363363+ let mut new_attrs = attrs.clone().unwrap_or_else(|| WeaverAttributes {
364364+ classes: vec![],
365365+ attrs: vec![],
366366+ });
367367+368368+ new_attrs.attrs.push(("content".into(), content.into()));
369369+370370+ // Add metadata for client-side enhancement
371371+ if dest_url.starts_with("at://") {
372372+ new_attrs
373373+ .attrs
374374+ .push(("data-embed-uri".into(), dest_url.clone()));
375375+376376+ if let Ok(at_uri) = AtUri::new(dest_url.as_ref()) {
377377+ if at_uri.collection().is_none() {
378378+ new_attrs
379379+ .attrs
380380+ .push(("data-embed-type".into(), "profile".into()));
381381+ } else {
382382+ new_attrs
383383+ .attrs
384384+ .push(("data-embed-type".into(), "post".into()));
385385+ }
386386+ }
387387+ } else {
388388+ new_attrs
389389+ .attrs
390390+ .push(("data-embed-type".into(), "markdown".into()));
391391+ }
392392+393393+ Tag::Embed {
394394+ embed_type: *embed_type,
395395+ dest_url: dest_url.clone(),
396396+ title: title.clone(),
397397+ id: id.clone(),
398398+ attrs: Some(new_attrs),
399399+ }
400400+ } else {
401401+ // Fetch failed, return original
402402+ embed
403403+ }
404404+ }
405405+ _ => embed,
406406+ }
407407+ }
408408+409409+ fn handle_reference(&self, reference: MdCowStr<'_>) -> MdCowStr<'_> {
410410+ reference.into_static()
411411+ }
412412+413413+ fn add_reference(&self, _reference: MdCowStr<'_>) {
414414+ // No-op for client context
415415+ }
416416+}
417417+418418+#[cfg(test)]
419419+mod tests {
420420+ use super::*;
421421+ use weaver_api::sh_weaver::notebook::entry::Entry;
422422+ use jacquard::types::string::{Did, Datetime};
423423+424424+ #[test]
425425+ fn test_client_context_creation() {
426426+ let entry = Entry::new()
427427+ .title("Test")
428428+ .content("# Test")
429429+ .created_at(Datetime::now())
430430+ .build();
431431+432432+ let ctx = ClientContext::new(entry, Did::new("did:plc:test").unwrap());
433433+ assert_eq!(ctx.title.as_ref(), "Test");
434434+ }
435435+436436+ #[test]
437437+ fn test_at_uri_to_web_url_profile() {
438438+ let uri = AtUri::new("at://did:plc:xyz123").unwrap();
439439+ assert_eq!(
440440+ at_uri_to_web_url(&uri),
441441+ "https://weaver.sh/did:plc:xyz123"
442442+ );
443443+ }
444444+445445+ #[test]
446446+ fn test_at_uri_to_web_url_bsky_post() {
447447+ let uri = AtUri::new("at://did:plc:xyz123/app.bsky.feed.post/3k7qrw5h2").unwrap();
448448+ assert_eq!(
449449+ at_uri_to_web_url(&uri),
450450+ "https://bsky.app/profile/did:plc:xyz123/post/3k7qrw5h2"
451451+ );
452452+ }
453453+454454+ #[test]
455455+ fn test_at_uri_to_web_url_bsky_list() {
456456+ let uri = AtUri::new("at://alice.bsky.social/app.bsky.graph.list/abc123").unwrap();
457457+ assert_eq!(
458458+ at_uri_to_web_url(&uri),
459459+ "https://bsky.app/profile/alice.bsky.social/lists/abc123"
460460+ );
461461+ }
462462+463463+ #[test]
464464+ fn test_at_uri_to_web_url_bsky_feed() {
465465+ let uri = AtUri::new("at://alice.bsky.social/app.bsky.feed.generator/my-feed").unwrap();
466466+ assert_eq!(
467467+ at_uri_to_web_url(&uri),
468468+ "https://bsky.app/profile/alice.bsky.social/feed/my-feed"
469469+ );
470470+ }
471471+472472+ #[test]
473473+ fn test_at_uri_to_web_url_bsky_starterpack() {
474474+ let uri = AtUri::new("at://alice.bsky.social/app.bsky.graph.starterpack/pack123").unwrap();
475475+ assert_eq!(
476476+ at_uri_to_web_url(&uri),
477477+ "https://bsky.app/starter-pack/alice.bsky.social/pack123"
478478+ );
479479+ }
480480+481481+ #[test]
482482+ fn test_at_uri_to_web_url_weaver_entry() {
483483+ let uri = AtUri::new("at://did:plc:xyz123/sh.weaver.notebook.entry/entry123").unwrap();
484484+ assert_eq!(
485485+ at_uri_to_web_url(&uri),
486486+ "https://weaver.sh/did:plc:xyz123/sh.weaver.notebook.entry/entry123"
487487+ );
488488+ }
489489+490490+ #[test]
491491+ fn test_at_uri_to_web_url_unknown_collection() {
492492+ let uri = AtUri::new("at://did:plc:xyz123/com.example.unknown/rkey").unwrap();
493493+ assert_eq!(
494494+ at_uri_to_web_url(&uri),
495495+ "https://weaver.sh/did:plc:xyz123/com.example.unknown/rkey"
496496+ );
497497+ }
498498+}
···11+// Integration tests for AT Protocol rendering pipeline
22+//
33+// These tests verify the full markdown→markdown transformation pipeline:
44+// 1. Parse input markdown
55+// 2. Process through AtProtoPreprocessContext
66+// 3. Upload images to PDS
77+// 4. Canonicalize wikilinks and profile links
88+// 5. Write transformed markdown
99+1010+// NOTE: Full implementation pending processor streaming support
1111+// For now, these are placeholders that will be completed when:
1212+// - NotebookProcessor can stream events through contexts
1313+// - MarkdownWriter can consume event streams
1414+1515+#[cfg(test)]
1616+mod tests {
1717+ #[test]
1818+ #[ignore]
1919+ fn test_markdown_to_markdown_pipeline() {
2020+ // TODO: Implement once processor streaming is available
2121+ // This test should:
2222+ // 1. Create mock vault with test markdown files
2323+ // 2. Set up AtProtoPreprocessContext with test agent
2424+ // 3. Process markdown through the pipeline
2525+ // 4. Verify output contains canonical links
2626+ // 5. Verify blob tracking captured image metadata
2727+ }
2828+2929+ #[test]
3030+ #[ignore]
3131+ fn test_wikilink_canonicalization() {
3232+ // TODO: Test that [[Entry Name]] becomes /{handle}/{notebook}/Entry_Name
3333+ }
3434+3535+ #[test]
3636+ #[ignore]
3737+ fn test_image_upload_and_rewrite() {
3838+ // TODO: Test that  uploads blob and rewrites to /{notebook}/image/{name}
3939+ }
4040+4141+ #[test]
4242+ #[ignore]
4343+ fn test_profile_link_resolution() {
4444+ // TODO: Test that [[@handle]] resolves to /{handle}
4545+ }
4646+}