atproto preprocessor and renderer

Orual 0e3b5d2b f040eae6

+2990 -114
+6
Cargo.lock
··· 8695 8695 "jacquard", 8696 8696 "jacquard-api", 8697 8697 "kdl", 8698 + "markdown-weaver", 8699 + "markdown-weaver-escape", 8698 8700 "miette 7.6.0", 8701 + "n0-future", 8699 8702 "tokio", 8703 + "weaver-api", 8700 8704 "weaver-common", 8701 8705 "weaver-renderer", 8702 8706 ] ··· 8739 8743 "http", 8740 8744 "ignore", 8741 8745 "insta", 8746 + "jacquard", 8742 8747 "markdown-weaver", 8743 8748 "markdown-weaver-escape", 8744 8749 "miette 7.6.0", 8750 + "mime-sniffer", 8745 8751 "n0-future", 8746 8752 "pin-project", 8747 8753 "pin-utils",
+5
crates/weaver-cli/Cargo.toml
··· 13 13 clap = { version = "4.5", features = ["derive", "env", "cargo", "unicode"] } 14 14 weaver-common = { path = "../weaver-common", features = ["native"] } 15 15 weaver-renderer = { path = "../weaver-renderer" } 16 + weaver-api = { path = "../weaver-api" } 16 17 miette = { workspace = true, features = ["fancy"] } 17 18 18 19 jacquard = { workspace = true, features = ["loopback", "dns"] } 19 20 jacquard-api = { workspace = true, features = ["sh_weaver"] } 21 + 22 + markdown-weaver = { workspace = true } 23 + markdown-weaver-escape = { workspace = true } 24 + n0-future = { workspace = true } 20 25 21 26 tokio = { version = "1.45.0", features = ["full"] } 22 27 dirs = "6.0.0"
+210 -5
crates/weaver-cli/src/main.rs
··· 2 2 use jacquard::identity::JacquardResolver; 3 3 use jacquard::oauth::client::{OAuthClient, OAuthSession}; 4 4 use jacquard::oauth::loopback::LoopbackConfig; 5 - use jacquard::prelude::XrpcClient; 6 - use jacquard::types::ident::AtIdentifier; 7 - use jacquard::types::string::CowStr; 8 - use jacquard_api::app_bsky::actor::get_profile::GetProfile; 5 + use jacquard::prelude::*; 6 + use jacquard::types::string::Handle; 7 + use jacquard::IntoStatic; 9 8 use miette::{IntoDiagnostic, Result}; 9 + use std::io::BufRead; 10 10 use std::path::PathBuf; 11 + use std::sync::Arc; 11 12 use weaver_renderer::static_site::StaticSiteWriter; 13 + use weaver_renderer::walker::{WalkOptions, vault_contents}; 14 + use weaver_renderer::atproto::AtProtoPreprocessContext; 15 + use weaver_renderer::utils::VaultBrokenLinkCallback; 12 16 13 17 use clap::{Parser, Subcommand}; 14 18 ··· 41 45 #[arg(long)] 42 46 store: Option<PathBuf>, 43 47 }, 48 + /// Publish notebook to AT Protocol 49 + Publish { 50 + /// Path to notebook directory 51 + source: PathBuf, 52 + 53 + /// Notebook title 54 + #[arg(long)] 55 + title: String, 56 + 57 + /// Path to auth store file 58 + #[arg(long)] 59 + store: Option<PathBuf>, 60 + }, 44 61 } 45 62 46 63 #[tokio::main] ··· 53 70 Some(Commands::Auth { handle, store }) => { 54 71 let store_path = store.unwrap_or_else(default_auth_store_path); 55 72 authenticate(handle, store_path).await?; 73 + } 74 + Some(Commands::Publish { source, title, store }) => { 75 + let store_path = store.unwrap_or_else(default_auth_store_path); 76 + publish_notebook(source, title, store_path).await?; 56 77 } 57 78 None => { 58 79 // Render command (default) ··· 176 197 .join("auth.json") 177 198 } 178 199 200 + async fn publish_notebook(source: PathBuf, title: String, store_path: PathBuf) -> Result<()> { 201 + println!("Publishing notebook from: {}", source.display()); 202 + println!("Title: {}", title); 203 + 204 + // Validate source exists 205 + if !source.exists() { 206 + return Err(miette::miette!( 207 + "Source directory not found: {}", 208 + source.display() 209 + )); 210 + } 211 + 212 + // Try to load session, trigger auth if needed 213 + let session = match try_load_session(&store_path).await { 214 + Some(session) => { 215 + println!("✓ Authenticated"); 216 + session 217 + } 218 + None => { 219 + println!("⚠ No authentication found"); 220 + println!("Please enter your handle to authenticate:"); 221 + 222 + let mut handle = String::new(); 223 + let stdin = std::io::stdin(); 224 + stdin.lock().read_line(&mut handle).into_diagnostic()?; 225 + let handle = handle.trim().to_string(); 226 + 227 + authenticate(handle, store_path.clone()).await?; 228 + 229 + // Load the session we just created 230 + try_load_session(&store_path).await 231 + .ok_or_else(|| miette::miette!("Failed to load session after authentication"))? 232 + } 233 + }; 234 + 235 + // Get user DID and handle from session 236 + 237 + // Create agent and resolve DID document to get handle 238 + let agent = Agent::new(session); 239 + let (did, _session_id) = agent.info().await 240 + .ok_or_else(|| miette::miette!("No session info available"))?; 241 + let did_doc_response = agent.resolve_did_doc(&did).await?; 242 + let did_doc = did_doc_response.parse()?; 243 + 244 + // Extract handle from alsoKnownAs 245 + let aka_vec = did_doc 246 + .also_known_as 247 + .ok_or_else(|| miette::miette!("No alsoKnownAs in DID document"))?; 248 + let handle_str = aka_vec 249 + .get(0) 250 + .and_then(|aka| aka.as_ref().strip_prefix("at://")) 251 + .ok_or_else(|| miette::miette!("No handle found in DID document"))?; 252 + let handle = Handle::new(handle_str)?; 253 + 254 + println!("Publishing as @{}", handle.as_ref()); 255 + 256 + // Walk vault directory 257 + println!("→ Scanning vault..."); 258 + let contents = vault_contents(&source, WalkOptions::new())?; 259 + 260 + // Convert to Arc first 261 + let agent = Arc::new(agent); 262 + let vault_arc: Arc<[PathBuf]> = contents.into(); 263 + 264 + // Filter markdown files after converting to Arc 265 + let md_files: Vec<PathBuf> = vault_arc 266 + .iter() 267 + .filter(|path| { 268 + path.extension() 269 + .and_then(|ext| ext.to_str()) 270 + .map(|ext| ext == "md" || ext == "markdown") 271 + .unwrap_or(false) 272 + }) 273 + .cloned() 274 + .collect(); 275 + 276 + println!("Found {} markdown files", md_files.len()); 277 + 278 + // Create preprocessing context 279 + let context = AtProtoPreprocessContext::new( 280 + vault_arc.clone(), 281 + title.clone(), 282 + agent.clone(), 283 + ).with_creator(did.clone().into_static(), handle.clone().into_static()); 284 + 285 + // Process each file 286 + for file_path in &md_files { 287 + println!("Processing: {}", file_path.display()); 288 + 289 + // Read file content 290 + let contents = tokio::fs::read_to_string(&file_path) 291 + .await 292 + .into_diagnostic()?; 293 + 294 + // Clone context for this file 295 + let mut file_context = context.clone(); 296 + file_context.set_current_path(file_path.clone()); 297 + let callback = Some(VaultBrokenLinkCallback { 298 + vault_contents: vault_arc.clone(), 299 + }); 300 + 301 + // Parse markdown 302 + use weaver_renderer::default_md_options; 303 + use markdown_weaver::Parser; 304 + let parser = Parser::new_with_broken_link_callback(&contents, default_md_options(), callback); 305 + let iterator = weaver_renderer::ContextIterator::default(parser); 306 + 307 + // Process through NotebookProcessor 308 + use weaver_renderer::{NotebookProcessor, NotebookContext}; 309 + use n0_future::StreamExt; 310 + let mut processor = NotebookProcessor::new(file_context.clone(), iterator); 311 + 312 + // Write canonical markdown with MarkdownWriter 313 + use markdown_weaver_escape::FmtWriter; 314 + use weaver_renderer::atproto::MarkdownWriter; 315 + let mut output = String::new(); 316 + let mut md_writer = MarkdownWriter::new(FmtWriter(&mut output)); 317 + 318 + // Process all events 319 + while let Some(event) = processor.next().await { 320 + md_writer.write_event(event).map_err(|e| { 321 + miette::miette!("Failed to write markdown: {:?}", e) 322 + })?; 323 + } 324 + 325 + // Extract blobs and entry metadata 326 + let blobs = file_context.blobs(); 327 + let entry_title = file_context.entry_title(); 328 + 329 + // Build Entry record with blobs 330 + use weaver_api::sh_weaver::notebook::entry::{Entry, EntryEmbeds}; 331 + use weaver_api::sh_weaver::embed::images::{Images, Image}; 332 + use jacquard::types::string::Datetime; 333 + use jacquard::types::blob::BlobRef; 334 + 335 + let embeds = if !blobs.is_empty() { 336 + // Build images from blobs 337 + let images: Vec<Image> = blobs 338 + .iter() 339 + .map(|blob_info| { 340 + Image::new() 341 + .image(BlobRef::Blob(blob_info.blob.clone())) 342 + .alt(blob_info.alt.as_ref().map(|a| a.as_ref()).unwrap_or("")) 343 + .maybe_name(Some(blob_info.name.as_str().into())) 344 + .build() 345 + }) 346 + .collect(); 347 + 348 + Some(EntryEmbeds { 349 + images: Some(Images::new().images(images).build()), 350 + externals: None, 351 + records: None, 352 + records_with_media: None, 353 + videos: None, 354 + extra_data: None, 355 + }) 356 + } else { 357 + None 358 + }; 359 + 360 + let entry = Entry::new() 361 + .content(output.as_str()) 362 + .title(entry_title.as_ref()) 363 + .created_at(Datetime::now()) 364 + .maybe_embeds(embeds) 365 + .build(); 366 + 367 + // Use WeaverExt to upsert entry (handles notebook + entry creation/updates) 368 + use weaver_common::WeaverExt; 369 + let (entry_uri, was_created) = (*agent) 370 + .upsert_entry(&title, entry_title.as_ref(), entry) 371 + .await?; 372 + 373 + if was_created { 374 + println!(" ✓ Created new entry: {}", entry_uri.as_ref()); 375 + } else { 376 + println!(" ✓ Updated existing entry: {}", entry_uri.as_ref()); 377 + } 378 + } 379 + 380 + println!("✓ Published {} entries", md_files.len()); 381 + 382 + Ok(()) 383 + } 384 + 179 385 fn init_miette() { 180 386 miette::set_hook(Box::new(|_| { 181 387 Box::new( 182 388 miette::MietteHandlerOpts::new() 183 389 .terminal_links(true) 184 390 .with_cause_chain() 185 - .with_syntax_highlighting(miette::highlighters::SyntectHighlighter::default()) 186 391 .color(true) 187 392 .context_lines(5) 188 393 .tab_width(2)
+410 -4
crates/weaver-common/src/lib.rs
··· 15 15 use jacquard::bytes::Bytes; 16 16 use jacquard::client::{Agent, AgentError, AgentErrorKind, AgentSession, AgentSessionExt}; 17 17 use jacquard::prelude::*; 18 - use jacquard::smol_str::SmolStr; 19 18 use jacquard::types::blob::{BlobRef, MimeType}; 20 19 use jacquard::types::string::{AtUri, Cid, Did, Handle, RecordKey}; 21 20 use jacquard::xrpc::Response; ··· 25 24 use tokio::sync::Mutex; 26 25 use weaver_api::com_atproto::repo::get_record::GetRecordResponse; 27 26 use weaver_api::com_atproto::repo::strong_ref::StrongRef; 28 - use weaver_api::sh_weaver::notebook::{book, chapter, entry}; 27 + use weaver_api::sh_weaver::notebook::entry; 29 28 use weaver_api::sh_weaver::publish::blob::Blob as PublishedBlob; 30 - 31 - use crate::error::ParseError; 32 29 33 30 static W_TICKER: LazyLock<Mutex<Ticker>> = LazyLock::new(|| Mutex::new(Ticker::new())); 34 31 ··· 78 75 &self, 79 76 uri: &AtUri<'_>, 80 77 ) -> impl Future<Output = Result<StrongRef<'_>, WeaverError>>; 78 + 79 + /// Find or create a notebook by title, returning its URI and entry list 80 + /// 81 + /// If the notebook doesn't exist, creates it with the given DID as author. 82 + fn upsert_notebook( 83 + &self, 84 + title: &str, 85 + author_did: &Did<'_>, 86 + ) -> impl Future<Output = Result<(AtUri<'static>, Vec<StrongRef<'static>>), WeaverError>>; 87 + 88 + /// Find or create an entry within a notebook by title 89 + /// 90 + /// Multi-step workflow: 91 + /// 1. Find the notebook by title 92 + /// 2. Check notebook's entry_list for entry with matching title 93 + /// 3. If found: update the entry with new content 94 + /// 4. If not found: create new entry and append to notebook's entry_list 95 + /// 96 + /// Returns (entry_uri, was_created) 97 + fn upsert_entry( 98 + &self, 99 + notebook_title: &str, 100 + entry_title: &str, 101 + entry: entry::Entry<'_>, 102 + ) -> impl Future<Output = Result<(AtUri<'static>, bool), WeaverError>>; 103 + 104 + /// View functions - generic versions that work with any Agent 105 + 106 + /// Fetch a notebook and construct NotebookView with author profiles 107 + fn view_notebook( 108 + &self, 109 + uri: &AtUri<'_>, 110 + ) -> impl Future<Output = Result<(view::NotebookView<'static>, Vec<StrongRef<'static>>), WeaverError>>; 111 + 112 + /// Fetch an entry and construct EntryView 113 + fn fetch_entry_view<'a>( 114 + &self, 115 + notebook: &view::NotebookView<'a>, 116 + entry_ref: &StrongRef<'_>, 117 + ) -> impl Future<Output = Result<view::EntryView<'a>, WeaverError>>; 118 + 119 + /// Search for an entry by title within a notebook's entry list 120 + fn entry_by_title<'a>( 121 + &self, 122 + notebook: &view::NotebookView<'a>, 123 + entries: &[StrongRef<'_>], 124 + title: &str, 125 + ) -> impl Future<Output = Result<Option<(view::BookEntryView<'a>, entry::Entry<'a>)>, WeaverError>>; 126 + 127 + /// Search for a notebook by title for a given DID or handle 128 + fn notebook_by_title( 129 + &self, 130 + ident: &jacquard::types::ident::AtIdentifier<'_>, 131 + title: &str, 132 + ) -> impl Future<Output = Result<Option<(view::NotebookView<'static>, Vec<StrongRef<'static>>)>, WeaverError>>; 81 133 } 82 134 83 135 impl<A: AgentSession + IdentityResolver> WeaverExt for Agent<A> { ··· 109 161 let strong_ref = StrongRef::new().uri(record.uri).cid(record.cid).build(); 110 162 111 163 Ok((strong_ref, publish_record)) 164 + } 165 + 166 + async fn upsert_notebook( 167 + &self, 168 + title: &str, 169 + author_did: &Did<'_>, 170 + ) -> Result<(AtUri<'static>, Vec<StrongRef<'static>>), WeaverError> { 171 + use jacquard::types::collection::Collection; 172 + use jacquard::types::nsid::Nsid; 173 + use jacquard::xrpc::XrpcExt; 174 + use weaver_api::com_atproto::repo::list_records::ListRecords; 175 + use weaver_api::sh_weaver::notebook::book::Book; 176 + 177 + // Find the PDS for this DID 178 + let pds_url = self.pds_for_did(author_did).await.map_err(|e| { 179 + AgentError::from(ClientError::from(e).with_context("Failed to resolve PDS for DID")) 180 + })?; 181 + 182 + // Search for existing notebook with this title 183 + let resp = self 184 + .xrpc(pds_url) 185 + .send( 186 + &ListRecords::new() 187 + .repo(author_did.clone()) 188 + .collection(Nsid::raw(Book::NSID)) 189 + .limit(100) 190 + .build(), 191 + ) 192 + .await 193 + .map_err(|e| AgentError::from(ClientError::from(e)))?; 194 + 195 + if let Ok(list) = resp.parse() { 196 + for record in list.records { 197 + let notebook: Book = jacquard::from_data(&record.value).map_err(|_| { 198 + AgentError::from(ClientError::invalid_request("Failed to parse notebook record")) 199 + })?; 200 + if let Some(book_title) = notebook.title 201 + && book_title == title 202 + { 203 + let entries = notebook 204 + .entry_list 205 + .iter() 206 + .cloned() 207 + .map(IntoStatic::into_static) 208 + .collect(); 209 + return Ok((record.uri.into_static(), entries)); 210 + } 211 + } 212 + } 213 + 214 + // Notebook doesn't exist, create it 215 + use weaver_api::sh_weaver::actor::Author; 216 + let author = Author::new().did(author_did.clone()).build(); 217 + let book = Book::new() 218 + .authors(vec![author]) 219 + .entry_list(vec![]) 220 + .maybe_title(Some(title.into())) 221 + .maybe_created_at(Some(jacquard::types::string::Datetime::now())) 222 + .build(); 223 + 224 + let response = self.create_record(book, None).await?; 225 + Ok((response.uri, Vec::new())) 226 + } 227 + 228 + async fn upsert_entry( 229 + &self, 230 + notebook_title: &str, 231 + entry_title: &str, 232 + entry: entry::Entry<'_>, 233 + ) -> Result<(AtUri<'static>, bool), WeaverError> { 234 + // Get our own DID 235 + let (did, _) = self.info().await.ok_or_else(|| { 236 + AgentError::from(ClientError::invalid_request("No session info available")) 237 + })?; 238 + 239 + // Find or create notebook 240 + let (notebook_uri, entry_refs) = self.upsert_notebook(notebook_title, &did).await?; 241 + 242 + // Check if entry with this title exists in the notebook 243 + for entry_ref in &entry_refs { 244 + let existing = self 245 + .get_record::<entry::Entry>(&entry_ref.uri) 246 + .await 247 + .map_err(|e| AgentError::from(ClientError::from(e)))?; 248 + if let Ok(existing_entry) = existing.parse() { 249 + if existing_entry.value.title == entry_title { 250 + // Update existing entry 251 + self.update_record::<entry::Entry>(&entry_ref.uri, |e| { 252 + e.content = entry.content.clone(); 253 + e.embeds = entry.embeds.clone(); 254 + e.tags = entry.tags.clone(); 255 + }) 256 + .await?; 257 + return Ok((entry_ref.uri.clone().into_static(), false)); 258 + } 259 + } 260 + } 261 + 262 + // Entry doesn't exist, create it 263 + let response = self.create_record(entry, None).await?; 264 + let entry_uri = response.uri.clone(); 265 + 266 + // Add to notebook's entry_list 267 + use weaver_api::sh_weaver::notebook::book::Book; 268 + let new_ref = StrongRef::new() 269 + .uri(response.uri) 270 + .cid(response.cid) 271 + .build(); 272 + 273 + self.update_record::<Book>(&notebook_uri, |book| { 274 + book.entry_list.push(new_ref); 275 + }) 276 + .await?; 277 + 278 + Ok((entry_uri, true)) 279 + } 280 + 281 + async fn view_notebook( 282 + &self, 283 + uri: &AtUri<'_>, 284 + ) -> Result<(view::NotebookView<'static>, Vec<StrongRef<'static>>), WeaverError> { 285 + use weaver_api::app_bsky::actor::profile::Profile as BskyProfile; 286 + use weaver_api::sh_weaver::notebook::book::Book; 287 + use weaver_api::sh_weaver::notebook::AuthorListView; 288 + use jacquard::to_data; 289 + 290 + let notebook = self 291 + .get_record::<Book>(uri) 292 + .await 293 + .map_err(|e| AgentError::from(e))? 294 + .into_output() 295 + .map_err(|_| { 296 + AgentError::from(ClientError::invalid_request("Failed to parse Book record")) 297 + })?; 298 + 299 + let title = notebook.value.title.clone(); 300 + let tags = notebook.value.tags.clone(); 301 + 302 + let mut authors = Vec::new(); 303 + 304 + for (index, author) in notebook.value.authors.iter().enumerate() { 305 + let author_uri = BskyProfile::uri(format!("at://{}/app.bsky.actor.profile/self", author.did)) 306 + .map_err(|_| AgentError::from(ClientError::invalid_request("Invalid author profile URI")))?; 307 + let author_profile = self.fetch_record(&author_uri).await?; 308 + 309 + authors.push( 310 + AuthorListView::new() 311 + .uri(author_uri.as_uri().clone()) 312 + .record(to_data(&author_profile).map_err(|_| { 313 + AgentError::from(ClientError::invalid_request("Failed to serialize author profile")) 314 + })?) 315 + .index(index as i64) 316 + .build(), 317 + ); 318 + } 319 + let entries = notebook 320 + .value 321 + .entry_list 322 + .iter() 323 + .cloned() 324 + .map(IntoStatic::into_static) 325 + .collect(); 326 + 327 + Ok(( 328 + view::NotebookView::new() 329 + .cid(notebook.cid.ok_or_else(|| { 330 + AgentError::from(ClientError::invalid_request("Notebook missing CID")) 331 + })?) 332 + .uri(notebook.uri) 333 + .indexed_at(jacquard::types::string::Datetime::now()) 334 + .maybe_title(title) 335 + .maybe_tags(tags) 336 + .authors(authors) 337 + .record(to_data(&notebook.value).map_err(|_| { 338 + AgentError::from(ClientError::invalid_request("Failed to serialize notebook")) 339 + })?) 340 + .build(), 341 + entries, 342 + )) 343 + } 344 + 345 + async fn fetch_entry_view<'a>( 346 + &self, 347 + notebook: &view::NotebookView<'a>, 348 + entry_ref: &StrongRef<'_>, 349 + ) -> Result<view::EntryView<'a>, WeaverError> { 350 + use weaver_api::sh_weaver::notebook::entry::Entry; 351 + use jacquard::to_data; 352 + 353 + let entry_uri = Entry::uri(entry_ref.uri.clone()) 354 + .map_err(|_| AgentError::from(ClientError::invalid_request("Invalid entry URI")))?; 355 + let entry = self.fetch_record(&entry_uri).await?; 356 + 357 + let title = entry.value.title.clone(); 358 + let tags = entry.value.tags.clone(); 359 + 360 + Ok(view::EntryView::new() 361 + .cid(entry.cid.ok_or_else(|| { 362 + AgentError::from(ClientError::invalid_request("Entry missing CID")) 363 + })?) 364 + .uri(entry.uri) 365 + .indexed_at(jacquard::types::string::Datetime::now()) 366 + .record(to_data(&entry.value).map_err(|_| { 367 + AgentError::from(ClientError::invalid_request("Failed to serialize entry")) 368 + })?) 369 + .maybe_tags(tags) 370 + .title(title) 371 + .authors(notebook.authors.clone()) 372 + .build()) 373 + } 374 + 375 + async fn entry_by_title<'a>( 376 + &self, 377 + notebook: &view::NotebookView<'a>, 378 + entries: &[StrongRef<'_>], 379 + title: &str, 380 + ) -> Result<Option<(view::BookEntryView<'a>, entry::Entry<'a>)>, WeaverError> { 381 + use weaver_api::sh_weaver::notebook::entry::Entry; 382 + use weaver_api::sh_weaver::notebook::BookEntryRef; 383 + 384 + for (index, entry_ref) in entries.iter().enumerate() { 385 + let resp = self 386 + .get_record::<Entry>(&entry_ref.uri) 387 + .await 388 + .map_err(|e| AgentError::from(e))?; 389 + if let Ok(entry) = resp.parse() { 390 + if entry.value.title == title { 391 + // Build BookEntryView with prev/next 392 + let entry_view = self.fetch_entry_view(notebook, entry_ref).await?; 393 + 394 + let prev_entry = if index > 0 { 395 + let prev_entry_ref = &entries[index - 1]; 396 + self.fetch_entry_view(notebook, prev_entry_ref).await.ok() 397 + } else { 398 + None 399 + } 400 + .map(|e| BookEntryRef::new().entry(e).build()); 401 + 402 + let next_entry = if index < entries.len() - 1 { 403 + let next_entry_ref = &entries[index + 1]; 404 + self.fetch_entry_view(notebook, next_entry_ref).await.ok() 405 + } else { 406 + None 407 + } 408 + .map(|e| BookEntryRef::new().entry(e).build()); 409 + 410 + let book_entry_view = view::BookEntryView::new() 411 + .entry(entry_view) 412 + .maybe_next(next_entry) 413 + .maybe_prev(prev_entry) 414 + .index(index as i64) 415 + .build(); 416 + 417 + return Ok(Some((book_entry_view, entry.value.into_static()))); 418 + } 419 + } 420 + } 421 + Ok(None) 422 + } 423 + 424 + async fn notebook_by_title( 425 + &self, 426 + ident: &jacquard::types::ident::AtIdentifier<'_>, 427 + title: &str, 428 + ) -> Result<Option<(view::NotebookView<'static>, Vec<StrongRef<'static>>)>, WeaverError> { 429 + use jacquard::types::collection::Collection; 430 + use jacquard::types::nsid::Nsid; 431 + use jacquard::xrpc::XrpcExt; 432 + use weaver_api::com_atproto::repo::list_records::ListRecords; 433 + use weaver_api::sh_weaver::notebook::book::Book; 434 + use weaver_api::app_bsky::actor::profile::Profile as BskyProfile; 435 + use weaver_api::sh_weaver::notebook::AuthorListView; 436 + use jacquard::to_data; 437 + 438 + let (repo_did, pds_url) = match ident { 439 + jacquard::types::ident::AtIdentifier::Did(did) => { 440 + let pds = self.pds_for_did(did).await.map_err(|e| { 441 + AgentError::from(ClientError::from(e).with_context("Failed to resolve PDS for DID")) 442 + })?; 443 + (did.clone(), pds) 444 + } 445 + jacquard::types::ident::AtIdentifier::Handle(handle) => self.pds_for_handle(handle).await.map_err(|e| { 446 + AgentError::from(ClientError::from(e).with_context("Failed to resolve handle")) 447 + })?, 448 + }; 449 + 450 + // TODO: use the cursor to search through all records with this NSID for the repo 451 + let resp = self 452 + .xrpc(pds_url) 453 + .send( 454 + &ListRecords::new() 455 + .repo(repo_did) 456 + .collection(Nsid::raw(Book::NSID)) 457 + .limit(100) 458 + .build(), 459 + ) 460 + .await 461 + .map_err(|e| AgentError::from(ClientError::from(e)))?; 462 + 463 + if let Ok(list) = resp.parse() { 464 + for record in list.records { 465 + let notebook: Book = jacquard::from_data(&record.value).map_err(|_| { 466 + AgentError::from(ClientError::invalid_request("Failed to parse notebook record")) 467 + })?; 468 + if let Some(book_title) = notebook.title 469 + && book_title == title 470 + { 471 + let tags = notebook.tags.clone(); 472 + 473 + let mut authors = Vec::new(); 474 + 475 + for (index, author) in notebook.authors.iter().enumerate() { 476 + let author_uri = BskyProfile::uri(format!( 477 + "at://{}/app.bsky.actor.profile/self", 478 + author.did 479 + )) 480 + .map_err(|_| AgentError::from(ClientError::invalid_request("Invalid author profile URI")))?; 481 + let author_profile = self.fetch_record(&author_uri).await?; 482 + 483 + authors.push( 484 + AuthorListView::new() 485 + .uri(author_uri.as_uri().clone()) 486 + .record(to_data(&author_profile).map_err(|_| { 487 + AgentError::from(ClientError::invalid_request("Failed to serialize author profile")) 488 + })?) 489 + .index(index as i64) 490 + .build(), 491 + ); 492 + } 493 + let entries = notebook 494 + .entry_list 495 + .iter() 496 + .cloned() 497 + .map(IntoStatic::into_static) 498 + .collect(); 499 + 500 + return Ok(Some(( 501 + view::NotebookView::new() 502 + .cid(record.cid) 503 + .uri(record.uri) 504 + .indexed_at(jacquard::types::string::Datetime::now()) 505 + .title(book_title) 506 + .maybe_tags(tags) 507 + .authors(authors) 508 + .record(record.value.clone()) 509 + .build() 510 + .into_static(), 511 + entries, 512 + ))); 513 + } 514 + } 515 + } 516 + 517 + Ok(None) 112 518 } 113 519 114 520 async fn confirm_record_ref(&self, uri: &AtUri<'_>) -> Result<StrongRef<'_>, WeaverError> {
+7 -5
crates/weaver-common/src/view.rs
··· 7 7 prelude::IdentityResolver, 8 8 to_data, 9 9 types::{ 10 - aturi::AtUri, collection::Collection, did::Did, ident::AtIdentifier, nsid::Nsid, 10 + aturi::AtUri, collection::Collection, ident::AtIdentifier, nsid::Nsid, 11 11 string::Datetime, 12 12 }, 13 13 xrpc::XrpcExt, ··· 16 16 use weaver_api::{ 17 17 app_bsky::actor::profile::Profile as BskyProfile, 18 18 com_atproto::repo::{list_records::ListRecords, strong_ref::StrongRef}, 19 - sh_weaver::notebook::{ 20 - AuthorListView, BookEntryRef, BookEntryView, EntryView, NotebookView, book::Book, 21 - entry::Entry, page::Page, 22 - }, 19 + sh_weaver::notebook::{book::Book, entry::Entry, page::Page}, 20 + }; 21 + 22 + // Re-export view types for use elsewhere 23 + pub use weaver_api::sh_weaver::notebook::{ 24 + AuthorListView, BookEntryRef, BookEntryView, EntryView, NotebookView, 23 25 }; 24 26 25 27 pub async fn view_notebook(
+2
crates/weaver-renderer/Cargo.toml
··· 9 9 n0-future.workspace = true 10 10 weaver-common = { path = "../weaver-common" } 11 11 weaver-api = { path = "../weaver-api" } 12 + jacquard.workspace = true 12 13 markdown-weaver = { workspace = true } 13 14 compact_string = "0.1.0" 14 15 http = "1.3.1" ··· 27 28 pin-project = "1.1.10" 28 29 dynosaur = "0.2.0" 29 30 smol_str = { version = "0.3", features = ["serde"] } 31 + mime-sniffer = "0.1.3" 30 32 reqwest = { version = "0.12.7", default-features = false, features = [ 31 33 "json", 32 34 "rustls-tls",
+19 -17
crates/weaver-renderer/src/atproto.rs
··· 1 - //! Atproto renderer 2 - //! 3 - //! This mode of the renderer renders either an entire notebook or entries in it to files suitable for inclusion 4 - //! in a single-page app and uploads them to your Atproto PDS 5 - //! It can be accessed via the appview at {your-handle}.weaver.sh/{notebook-name}. 6 - //! 7 - //! It can also be edited there. 8 - //! 9 - //! Link altering logic: 10 - //! - Option 1: leave (non-embed) links the same as in the markdown, have the CSM deal with them via some means 11 - //! such as adding "data-did" and "data-cid" attributes to the `<a/>` tag containing the DID and CID 12 - //! Pushes toward having the SPA/Appview do a bit more, but makes this step MUCH simpler 13 - //! - In this scenario, the rendering step can happen upon access (and then be cached) in the appview 14 - //! - More flexible in some ways, less in others 15 - //! - Option 2: alter links to point to other rendered blobs. Requires a certain amount of work to handle 16 - //! scenarios with a complex mesh of internal links, as the CID is altered by the editing of the link. 17 - //! Such cycles are handled in the simplest way, by rendering an absolute url which will make a call to the appview. 1 + //! AT Protocol renderer for weaver notebooks 18 2 //! 3 + //! Two-stage pipeline: markdown→markdown preprocessing (CLI), 4 + //! then client-side markdown→HTML rendering (WASM). 5 + 6 + mod error; 7 + mod types; 8 + mod markdown_writer; 9 + mod preprocess; 10 + mod client; 11 + mod embed_renderer; 12 + mod writer; 13 + 14 + pub use error::{AtProtoPreprocessError, ClientRenderError}; 15 + pub use types::{BlobName, BlobInfo}; 16 + pub use preprocess::AtProtoPreprocessContext; 17 + pub use client::{ClientContext, EmbedResolver, DefaultEmbedResolver}; 18 + pub use markdown_writer::MarkdownWriter; 19 + pub use embed_renderer::{fetch_and_render_profile, fetch_and_render_post, fetch_and_render_generic}; 20 + pub use writer::{ClientWriter, EmbedContentProvider};
+498
crates/weaver-renderer/src/atproto/client.rs
··· 1 + use crate::{Frontmatter, NotebookContext}; 2 + use super::{types::BlobName, error::ClientRenderError}; 3 + use jacquard::{ 4 + client::{Agent, AgentSession}, 5 + prelude::IdentityResolver, 6 + types::string::{AtUri, Cid, Did}, 7 + }; 8 + use markdown_weaver::{Tag, CowStr as MdCowStr, WeaverAttributes}; 9 + use std::collections::HashMap; 10 + use std::sync::Arc; 11 + use weaver_api::sh_weaver::notebook::entry::Entry; 12 + 13 + /// Trait for resolving embed content on the client side 14 + /// 15 + /// Implementations can fetch from cache, make HTTP requests, or use other sources. 16 + pub trait EmbedResolver: Send + Sync { 17 + /// Resolve a profile embed by AT URI 18 + fn resolve_profile( 19 + &self, 20 + uri: &AtUri<'_>, 21 + ) -> impl std::future::Future<Output = Result<String, ClientRenderError>> + Send; 22 + 23 + /// Resolve a post/record embed by AT URI 24 + fn resolve_post( 25 + &self, 26 + uri: &AtUri<'_>, 27 + ) -> impl std::future::Future<Output = Result<String, ClientRenderError>> + Send; 28 + 29 + /// Resolve a markdown embed from URL 30 + /// 31 + /// `depth` parameter tracks recursion depth to prevent infinite loops 32 + fn resolve_markdown( 33 + &self, 34 + url: &str, 35 + depth: usize, 36 + ) -> impl std::future::Future<Output = Result<String, ClientRenderError>> + Send; 37 + } 38 + 39 + /// Default embed resolver that fetches records from PDSs 40 + /// 41 + /// This uses the same fetch/render logic as the preprocessor. 42 + pub struct DefaultEmbedResolver<A: AgentSession + IdentityResolver> { 43 + agent: Arc<Agent<A>>, 44 + } 45 + 46 + impl<A: AgentSession + IdentityResolver> DefaultEmbedResolver<A> { 47 + pub fn new(agent: Arc<Agent<A>>) -> Self { 48 + Self { agent } 49 + } 50 + } 51 + 52 + impl<A: AgentSession + IdentityResolver> EmbedResolver for DefaultEmbedResolver<A> { 53 + async fn resolve_profile(&self, uri: &AtUri<'_>) -> Result<String, ClientRenderError> { 54 + use crate::atproto::fetch_and_render_profile; 55 + use jacquard::types::ident::AtIdentifier; 56 + 57 + // Extract DID from authority 58 + let did = match uri.authority() { 59 + AtIdentifier::Did(did) => did, 60 + AtIdentifier::Handle(_) => { 61 + return Err(ClientRenderError::EntryFetch { 62 + uri: uri.as_ref().to_string(), 63 + source: "Profile URI should use DID not handle".into(), 64 + }); 65 + } 66 + }; 67 + 68 + fetch_and_render_profile(&did, &*self.agent) 69 + .await 70 + .map_err(|e| ClientRenderError::EntryFetch { 71 + uri: uri.as_ref().to_string(), 72 + source: Box::new(e), 73 + }) 74 + } 75 + 76 + async fn resolve_post(&self, uri: &AtUri<'_>) -> Result<String, ClientRenderError> { 77 + use crate::atproto::{fetch_and_render_post, fetch_and_render_generic}; 78 + 79 + // Check if it's a known type 80 + if let Some(collection) = uri.collection() { 81 + match collection.as_ref() { 82 + "app.bsky.feed.post" => { 83 + fetch_and_render_post(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 + } 90 + _ => { 91 + fetch_and_render_generic(uri, &*self.agent) 92 + .await 93 + .map_err(|e| ClientRenderError::EntryFetch { 94 + uri: uri.as_ref().to_string(), 95 + source: Box::new(e), 96 + }) 97 + } 98 + } 99 + } else { 100 + Err(ClientRenderError::EntryFetch { 101 + uri: uri.as_ref().to_string(), 102 + source: "AT URI missing collection".into(), 103 + }) 104 + } 105 + } 106 + 107 + async fn resolve_markdown( 108 + &self, 109 + url: &str, 110 + _depth: usize, 111 + ) -> Result<String, ClientRenderError> { 112 + // TODO: implement HTTP fetch + markdown rendering 113 + Err(ClientRenderError::EntryFetch { 114 + uri: url.to_string(), 115 + source: "Markdown URL embeds not yet implemented".into(), 116 + }) 117 + } 118 + } 119 + 120 + const MAX_EMBED_DEPTH: usize = 3; 121 + 122 + pub struct ClientContext<'a, R = ()> { 123 + // Entry being rendered 124 + entry: Entry<'a>, 125 + creator_did: Did<'a>, 126 + 127 + // Blob resolution 128 + blob_map: HashMap<BlobName<'static>, Cid<'static>>, 129 + 130 + // Embed resolution (optional, generic over resolver type) 131 + embed_resolver: Option<Arc<R>>, 132 + embed_depth: usize, 133 + 134 + // Shared state 135 + frontmatter: Frontmatter, 136 + title: MdCowStr<'a>, 137 + } 138 + 139 + impl<'a> ClientContext<'a, ()> { 140 + pub fn new(entry: Entry<'a>, creator_did: Did<'a>) -> Self { 141 + let blob_map = Self::build_blob_map(&entry); 142 + let title = MdCowStr::Boxed(entry.title.as_ref().into()); 143 + 144 + Self { 145 + entry, 146 + creator_did, 147 + blob_map, 148 + embed_resolver: None, 149 + embed_depth: 0, 150 + frontmatter: Frontmatter::default(), 151 + title, 152 + } 153 + } 154 + 155 + /// Add an embed resolver for fetching embed content 156 + pub fn with_embed_resolver<R: EmbedResolver>( 157 + self, 158 + resolver: Arc<R>, 159 + ) -> ClientContext<'a, R> { 160 + ClientContext { 161 + entry: self.entry, 162 + creator_did: self.creator_did, 163 + blob_map: self.blob_map, 164 + embed_resolver: Some(resolver), 165 + embed_depth: self.embed_depth, 166 + frontmatter: self.frontmatter, 167 + title: self.title, 168 + } 169 + } 170 + } 171 + 172 + impl<'a, R> ClientContext<'a, R> { 173 + /// Create a child context with incremented embed depth (for recursive embeds) 174 + fn with_depth(&self, depth: usize) -> Self 175 + where 176 + R: Clone, 177 + { 178 + Self { 179 + entry: self.entry.clone(), 180 + creator_did: self.creator_did.clone(), 181 + blob_map: self.blob_map.clone(), 182 + embed_resolver: self.embed_resolver.clone(), 183 + embed_depth: depth, 184 + frontmatter: self.frontmatter.clone(), 185 + title: self.title.clone(), 186 + } 187 + } 188 + 189 + fn build_blob_map<'b>(entry: &Entry<'b>) -> HashMap<BlobName<'static>, Cid<'static>> { 190 + use jacquard::IntoStatic; 191 + 192 + let mut map = HashMap::new(); 193 + if let Some(embeds) = &entry.embeds { 194 + if let Some(images) = &embeds.images { 195 + for img in &images.images { 196 + if let Some(name) = &img.name { 197 + let blob_name = BlobName::from_filename(name.as_ref()); 198 + map.insert(blob_name, img.image.blob().cid().clone().into_static()); 199 + } 200 + } 201 + } 202 + } 203 + map 204 + } 205 + 206 + pub fn get_blob_cid(&self, name: &str) -> Option<&Cid<'static>> { 207 + let blob_name = BlobName::from_filename(name); 208 + self.blob_map.get(&blob_name) 209 + } 210 + } 211 + 212 + /// Convert an AT URI to a web URL based on collection type 213 + /// 214 + /// Maps AT Protocol URIs to their web equivalents: 215 + /// - Profile: `at://did:plc:xyz` → `https://weaver.sh/did:plc:xyz` 216 + /// - Bluesky post: `at://{actor}/app.bsky.feed.post/{rkey}` → `https://bsky.app/profile/{actor}/post/{rkey}` 217 + /// - Bluesky list: `at://{actor}/app.bsky.graph.list/{rkey}` → `https://bsky.app/profile/{actor}/lists/{rkey}` 218 + /// - Bluesky feed: `at://{actor}/app.bsky.feed.generator/{rkey}` → `https://bsky.app/profile/{actor}/feed/{rkey}` 219 + /// - Bluesky starterpack: `at://{actor}/app.bsky.graph.starterpack/{rkey}` → `https://bsky.app/starter-pack/{actor}/{rkey}` 220 + /// - Weaver/other: `at://{actor}/{collection}/{rkey}` → `https://weaver.sh/{actor}/{collection}/{rkey}` 221 + fn at_uri_to_web_url(at_uri: &AtUri<'_>) -> String { 222 + let authority = at_uri.authority().as_ref(); 223 + 224 + // Profile-only link (no collection/rkey) 225 + if at_uri.collection().is_none() && at_uri.rkey().is_none() { 226 + return format!("https://weaver.sh/{}", authority); 227 + } 228 + 229 + // Record link 230 + if let (Some(collection), Some(rkey)) = (at_uri.collection(), at_uri.rkey()) { 231 + let collection_str = collection.as_ref(); 232 + let rkey_str = rkey.as_ref(); 233 + 234 + // Map known Bluesky collections to bsky.app URLs 235 + match collection_str { 236 + "app.bsky.feed.post" => { 237 + format!("https://bsky.app/profile/{}/post/{}", authority, rkey_str) 238 + } 239 + "app.bsky.graph.list" => { 240 + format!("https://bsky.app/profile/{}/lists/{}", authority, rkey_str) 241 + } 242 + "app.bsky.feed.generator" => { 243 + format!("https://bsky.app/profile/{}/feed/{}", authority, rkey_str) 244 + } 245 + "app.bsky.graph.starterpack" => { 246 + format!("https://bsky.app/starter-pack/{}/{}", authority, rkey_str) 247 + } 248 + // Weaver records and unknown collections go to weaver.sh 249 + _ => { 250 + format!("https://weaver.sh/{}/{}/{}", authority, collection_str, rkey_str) 251 + } 252 + } 253 + } else { 254 + // Fallback for malformed URIs 255 + format!("https://weaver.sh/{}", authority) 256 + } 257 + } 258 + 259 + // Stub NotebookContext implementation 260 + impl<'a, R> NotebookContext for ClientContext<'a, R> 261 + where 262 + R: EmbedResolver, 263 + { 264 + fn set_entry_title(&self, _title: MdCowStr<'_>) { 265 + // No-op for client context 266 + } 267 + 268 + fn entry_title(&self) -> MdCowStr<'_> { 269 + self.title.clone() 270 + } 271 + 272 + fn frontmatter(&self) -> Frontmatter { 273 + self.frontmatter.clone() 274 + } 275 + 276 + fn set_frontmatter(&self, _frontmatter: Frontmatter) { 277 + // No-op for client context 278 + } 279 + 280 + async fn handle_link<'s>(&self, link: Tag<'s>) -> Tag<'s> { 281 + match &link { 282 + Tag::Link { link_type, dest_url, title, id } => { 283 + let url = dest_url.as_ref(); 284 + 285 + // Try to parse as AT URI 286 + if let Ok(at_uri) = AtUri::new(url) { 287 + let web_url = at_uri_to_web_url(&at_uri); 288 + 289 + return Tag::Link { 290 + link_type: *link_type, 291 + dest_url: MdCowStr::Boxed(web_url.into_boxed_str()), 292 + title: title.clone(), 293 + id: id.clone(), 294 + }; 295 + } 296 + 297 + // Entry links starting with / are server-relative, pass through 298 + // External links pass through 299 + link 300 + } 301 + _ => link, 302 + } 303 + } 304 + 305 + async fn handle_image<'s>(&self, image: Tag<'s>) -> Tag<'s> { 306 + // Images already have canonical paths like /{notebook}/image/{name} 307 + // The server will handle routing these to the actual blobs 308 + image 309 + } 310 + 311 + async fn handle_embed<'s>(&self, embed: Tag<'s>) -> Tag<'s> { 312 + match &embed { 313 + Tag::Embed { 314 + embed_type, 315 + dest_url, 316 + title, 317 + id, 318 + attrs, 319 + } => { 320 + // If content already in attrs (from preprocessor), pass through 321 + if let Some(attrs) = attrs { 322 + if attrs.attrs.iter().any(|(k, _)| k.as_ref() == "content") { 323 + return embed; 324 + } 325 + } 326 + 327 + // Check if we have a resolver 328 + let Some(resolver) = &self.embed_resolver else { 329 + return embed; 330 + }; 331 + 332 + // Check recursion depth 333 + if self.embed_depth >= MAX_EMBED_DEPTH { 334 + return embed; 335 + } 336 + 337 + // Try to fetch content based on URL type 338 + let content_result = if dest_url.starts_with("at://") { 339 + // AT Protocol embed 340 + if let Ok(at_uri) = AtUri::new(dest_url.as_ref()) { 341 + if at_uri.collection().is_none() && at_uri.rkey().is_none() { 342 + // Profile embed 343 + resolver.resolve_profile(&at_uri).await 344 + } else { 345 + // Post/record embed 346 + resolver.resolve_post(&at_uri).await 347 + } 348 + } else { 349 + return embed; 350 + } 351 + } else if dest_url.starts_with("http://") || dest_url.starts_with("https://") { 352 + // Markdown embed (could be other types, but assume markdown for now) 353 + resolver 354 + .resolve_markdown(dest_url.as_ref(), self.embed_depth + 1) 355 + .await 356 + } else { 357 + // Local path or other - skip for now 358 + return embed; 359 + }; 360 + 361 + // If we got content, attach it to attrs 362 + if let Ok(content) = content_result { 363 + let mut new_attrs = attrs.clone().unwrap_or_else(|| WeaverAttributes { 364 + classes: vec![], 365 + attrs: vec![], 366 + }); 367 + 368 + new_attrs.attrs.push(("content".into(), content.into())); 369 + 370 + // Add metadata for client-side enhancement 371 + if dest_url.starts_with("at://") { 372 + new_attrs 373 + .attrs 374 + .push(("data-embed-uri".into(), dest_url.clone())); 375 + 376 + if let Ok(at_uri) = AtUri::new(dest_url.as_ref()) { 377 + if at_uri.collection().is_none() { 378 + new_attrs 379 + .attrs 380 + .push(("data-embed-type".into(), "profile".into())); 381 + } else { 382 + new_attrs 383 + .attrs 384 + .push(("data-embed-type".into(), "post".into())); 385 + } 386 + } 387 + } else { 388 + new_attrs 389 + .attrs 390 + .push(("data-embed-type".into(), "markdown".into())); 391 + } 392 + 393 + Tag::Embed { 394 + embed_type: *embed_type, 395 + dest_url: dest_url.clone(), 396 + title: title.clone(), 397 + id: id.clone(), 398 + attrs: Some(new_attrs), 399 + } 400 + } else { 401 + // Fetch failed, return original 402 + embed 403 + } 404 + } 405 + _ => embed, 406 + } 407 + } 408 + 409 + fn handle_reference(&self, reference: MdCowStr<'_>) -> MdCowStr<'_> { 410 + reference.into_static() 411 + } 412 + 413 + fn add_reference(&self, _reference: MdCowStr<'_>) { 414 + // No-op for client context 415 + } 416 + } 417 + 418 + #[cfg(test)] 419 + mod tests { 420 + use super::*; 421 + use weaver_api::sh_weaver::notebook::entry::Entry; 422 + use jacquard::types::string::{Did, Datetime}; 423 + 424 + #[test] 425 + fn test_client_context_creation() { 426 + let entry = Entry::new() 427 + .title("Test") 428 + .content("# Test") 429 + .created_at(Datetime::now()) 430 + .build(); 431 + 432 + let ctx = ClientContext::new(entry, Did::new("did:plc:test").unwrap()); 433 + assert_eq!(ctx.title.as_ref(), "Test"); 434 + } 435 + 436 + #[test] 437 + fn test_at_uri_to_web_url_profile() { 438 + let uri = AtUri::new("at://did:plc:xyz123").unwrap(); 439 + assert_eq!( 440 + at_uri_to_web_url(&uri), 441 + "https://weaver.sh/did:plc:xyz123" 442 + ); 443 + } 444 + 445 + #[test] 446 + fn test_at_uri_to_web_url_bsky_post() { 447 + let uri = AtUri::new("at://did:plc:xyz123/app.bsky.feed.post/3k7qrw5h2").unwrap(); 448 + assert_eq!( 449 + at_uri_to_web_url(&uri), 450 + "https://bsky.app/profile/did:plc:xyz123/post/3k7qrw5h2" 451 + ); 452 + } 453 + 454 + #[test] 455 + fn test_at_uri_to_web_url_bsky_list() { 456 + let uri = AtUri::new("at://alice.bsky.social/app.bsky.graph.list/abc123").unwrap(); 457 + assert_eq!( 458 + at_uri_to_web_url(&uri), 459 + "https://bsky.app/profile/alice.bsky.social/lists/abc123" 460 + ); 461 + } 462 + 463 + #[test] 464 + fn test_at_uri_to_web_url_bsky_feed() { 465 + let uri = AtUri::new("at://alice.bsky.social/app.bsky.feed.generator/my-feed").unwrap(); 466 + assert_eq!( 467 + at_uri_to_web_url(&uri), 468 + "https://bsky.app/profile/alice.bsky.social/feed/my-feed" 469 + ); 470 + } 471 + 472 + #[test] 473 + fn test_at_uri_to_web_url_bsky_starterpack() { 474 + let uri = AtUri::new("at://alice.bsky.social/app.bsky.graph.starterpack/pack123").unwrap(); 475 + assert_eq!( 476 + at_uri_to_web_url(&uri), 477 + "https://bsky.app/starter-pack/alice.bsky.social/pack123" 478 + ); 479 + } 480 + 481 + #[test] 482 + fn test_at_uri_to_web_url_weaver_entry() { 483 + let uri = AtUri::new("at://did:plc:xyz123/sh.weaver.notebook.entry/entry123").unwrap(); 484 + assert_eq!( 485 + at_uri_to_web_url(&uri), 486 + "https://weaver.sh/did:plc:xyz123/sh.weaver.notebook.entry/entry123" 487 + ); 488 + } 489 + 490 + #[test] 491 + fn test_at_uri_to_web_url_unknown_collection() { 492 + let uri = AtUri::new("at://did:plc:xyz123/com.example.unknown/rkey").unwrap(); 493 + assert_eq!( 494 + at_uri_to_web_url(&uri), 495 + "https://weaver.sh/did:plc:xyz123/com.example.unknown/rkey" 496 + ); 497 + } 498 + }
+231
crates/weaver-renderer/src/atproto/embed_renderer.rs
··· 1 + //! Fetch and render AT Protocol records as HTML embeds 2 + //! 3 + //! This module provides functions to fetch records from PDSs and render them 4 + //! as HTML strings suitable for embedding in markdown content. 5 + 6 + use jacquard::{ 7 + client::{Agent, AgentSession, AgentSessionExt, ClientError}, 8 + prelude::IdentityResolver, 9 + types::string::{AtUri, Did, Nsid}, 10 + xrpc::{self, Response, XrpcClient}, 11 + http_client::HttpClient, 12 + Data, 13 + }; 14 + use weaver_api::com_atproto::repo::get_record::{GetRecord, GetRecordResponse, GetRecordOutput}; 15 + use super::error::AtProtoPreprocessError; 16 + 17 + /// Get a record without type validation, returning untyped Data 18 + /// 19 + /// This is similar to jacquard's `get_record` but skips collection validation 20 + /// and returns the raw Data value instead of a typed response. 21 + async fn get_record_untyped<'a, A: AgentSession + IdentityResolver>( 22 + uri: &AtUri<'_>, 23 + agent: &Agent<A>, 24 + ) -> Result<Response<GetRecordResponse>, ClientError> { 25 + use jacquard::types::ident::AtIdentifier; 26 + 27 + // Extract collection and rkey from URI 28 + let collection = uri.collection().ok_or_else(|| { 29 + ClientError::invalid_request("AtUri missing collection") 30 + .with_help("ensure the URI includes a collection") 31 + })?; 32 + 33 + let rkey = uri.rkey().ok_or_else(|| { 34 + ClientError::invalid_request("AtUri missing rkey") 35 + .with_help("ensure the URI includes a record key after the collection") 36 + })?; 37 + 38 + // Resolve authority (DID or handle) to get DID and PDS 39 + let (repo_did, pds_url) = match uri.authority() { 40 + AtIdentifier::Did(did) => { 41 + let pds = agent.pds_for_did(did).await.map_err(|e| { 42 + ClientError::from(e) 43 + .with_context("DID document resolution failed during record retrieval") 44 + })?; 45 + (did.clone(), pds) 46 + } 47 + AtIdentifier::Handle(handle) => agent.pds_for_handle(handle).await.map_err(|e| { 48 + ClientError::from(e) 49 + .with_context("handle resolution failed during record retrieval") 50 + })?, 51 + }; 52 + 53 + // Make stateless XRPC call to that PDS (no auth required for public records) 54 + let request = GetRecord::new() 55 + .repo(AtIdentifier::Did(repo_did)) 56 + .collection(collection.clone()) 57 + .rkey(rkey.clone()) 58 + .build(); 59 + 60 + let http_request = xrpc::build_http_request(&pds_url, &request, &agent.opts().await)?; 61 + 62 + let http_response = agent 63 + .send_http(http_request) 64 + .await 65 + .map_err(|e| ClientError::transport(e))?; 66 + 67 + xrpc::process_response(http_response) 68 + } 69 + 70 + /// Fetch and render a profile record as HTML 71 + /// 72 + /// Constructs the profile URI `at://did/app.bsky.actor.profile/self` and fetches it. 73 + pub async fn fetch_and_render_profile<A: AgentSession + IdentityResolver>( 74 + did: &Did<'_>, 75 + agent: &Agent<A>, 76 + ) -> Result<String, AtProtoPreprocessError> { 77 + use weaver_api::app_bsky::actor::profile::Profile; 78 + 79 + // Construct profile URI: at://did/app.bsky.actor.profile/self 80 + let profile_uri = format!("at://{}/app.bsky.actor.profile/self", did.as_ref()); 81 + 82 + // Fetch using typed collection 83 + let record_uri = Profile::uri(&profile_uri) 84 + .map_err(|e| AtProtoPreprocessError::InvalidUri(e.to_string()))?; 85 + 86 + let output = agent.fetch_record(&record_uri).await 87 + .map_err(|e| AtProtoPreprocessError::FetchFailed(e.to_string()))?; 88 + 89 + // Render profile to HTML 90 + render_profile(&output.value, did) 91 + } 92 + 93 + /// Fetch and render a Bluesky post as HTML 94 + pub async fn fetch_and_render_post<A: AgentSession + IdentityResolver>( 95 + uri: &AtUri<'_>, 96 + agent: &Agent<A>, 97 + ) -> Result<String, AtProtoPreprocessError> { 98 + use weaver_api::app_bsky::feed::post::Post; 99 + 100 + // Fetch using typed collection 101 + let record_uri = Post::uri(uri.as_ref()) 102 + .map_err(|e| AtProtoPreprocessError::InvalidUri(e.to_string()))?; 103 + 104 + let output = agent.fetch_record(&record_uri).await 105 + .map_err(|e| AtProtoPreprocessError::FetchFailed(e.to_string()))?; 106 + 107 + // Render post to HTML 108 + render_post(&output.value, uri) 109 + } 110 + 111 + /// Fetch and render an unknown record type generically 112 + /// 113 + /// This fetches the record as untyped Data and probes for likely meaningful fields. 114 + pub async fn fetch_and_render_generic<A: AgentSession + IdentityResolver>( 115 + uri: &AtUri<'_>, 116 + agent: &Agent<A>, 117 + ) -> Result<String, AtProtoPreprocessError> { 118 + // Use untyped fetch 119 + let response = get_record_untyped(uri, agent).await 120 + .map_err(|e| AtProtoPreprocessError::FetchFailed(e.to_string()))?; 121 + 122 + // Parse to get GetRecordOutput with Data value 123 + let output: GetRecordOutput = response.into_output() 124 + .map_err(|e| AtProtoPreprocessError::ParseFailed(e.to_string()))?; 125 + 126 + // Probe for meaningful fields 127 + render_generic_record(&output.value, uri) 128 + } 129 + 130 + /// Render a profile record as HTML 131 + fn render_profile<'a>( 132 + profile: &weaver_api::app_bsky::actor::profile::Profile<'a>, 133 + did: &Did<'_>, 134 + ) -> Result<String, AtProtoPreprocessError> { 135 + let mut html = String::new(); 136 + 137 + html.push_str("<div class=\"atproto-embed atproto-profile\">"); 138 + 139 + if let Some(display_name) = &profile.display_name { 140 + html.push_str("<div class=\"profile-name\">"); 141 + html.push_str(&html_escape(display_name.as_ref())); 142 + html.push_str("</div>"); 143 + } 144 + 145 + html.push_str("<div class=\"profile-did\">"); 146 + html.push_str(&html_escape(did.as_ref())); 147 + html.push_str("</div>"); 148 + 149 + if let Some(description) = &profile.description { 150 + html.push_str("<div class=\"profile-description\">"); 151 + html.push_str(&html_escape(description.as_ref())); 152 + html.push_str("</div>"); 153 + } 154 + 155 + html.push_str("</div>"); 156 + 157 + Ok(html) 158 + } 159 + 160 + /// Render a Bluesky post as HTML 161 + fn render_post<'a>( 162 + post: &weaver_api::app_bsky::feed::post::Post<'a>, 163 + _uri: &AtUri<'_>, 164 + ) -> Result<String, AtProtoPreprocessError> { 165 + let mut html = String::new(); 166 + 167 + html.push_str("<div class=\"atproto-embed atproto-post\">"); 168 + 169 + html.push_str("<div class=\"post-text\">"); 170 + html.push_str(&html_escape(post.text.as_ref())); 171 + html.push_str("</div>"); 172 + 173 + html.push_str("<div class=\"post-meta\">"); 174 + html.push_str("<time>"); 175 + html.push_str(&html_escape(&post.created_at.to_string())); 176 + html.push_str("</time>"); 177 + html.push_str("</div>"); 178 + 179 + html.push_str("</div>"); 180 + 181 + Ok(html) 182 + } 183 + 184 + /// Render a generic record by probing Data for meaningful fields 185 + fn render_generic_record( 186 + data: &Data<'_>, 187 + uri: &AtUri<'_>, 188 + ) -> Result<String, AtProtoPreprocessError> { 189 + let mut html = String::new(); 190 + 191 + html.push_str("<div class=\"atproto-embed atproto-record\">"); 192 + 193 + // Try common field patterns 194 + if let Some(text) = data.query("/text").single().and_then(|d| d.as_str()) { 195 + html.push_str("<div class=\"record-text\">"); 196 + html.push_str(&html_escape(text)); 197 + html.push_str("</div>"); 198 + } 199 + 200 + if let Some(name) = data.query("/name").single().and_then(|d| d.as_str()) { 201 + html.push_str("<div class=\"record-name\">"); 202 + html.push_str(&html_escape(name)); 203 + html.push_str("</div>"); 204 + } 205 + 206 + if let Some(description) = data.query("/description").single().and_then(|d| d.as_str()) { 207 + html.push_str("<div class=\"record-description\">"); 208 + html.push_str(&html_escape(description)); 209 + html.push_str("</div>"); 210 + } 211 + 212 + // Show record type 213 + if let Some(collection) = uri.collection() { 214 + html.push_str("<div class=\"record-type\">"); 215 + html.push_str(&html_escape(collection.as_ref())); 216 + html.push_str("</div>"); 217 + } 218 + 219 + html.push_str("</div>"); 220 + 221 + Ok(html) 222 + } 223 + 224 + /// Simple HTML escaping 225 + fn html_escape(s: &str) -> String { 226 + s.replace('&', "&amp;") 227 + .replace('<', "&lt;") 228 + .replace('>', "&gt;") 229 + .replace('"', "&quot;") 230 + .replace('\'', "&#39;") 231 + }
+56
crates/weaver-renderer/src/atproto/error.rs
··· 1 + use miette::Diagnostic; 2 + use thiserror::Error; 3 + 4 + #[derive(Debug, Error, Diagnostic)] 5 + pub enum AtProtoPreprocessError { 6 + #[error("blob upload failed: {0}")] 7 + #[diagnostic(code(atproto::preprocess::blob_upload))] 8 + BlobUpload(String, #[source] Box<dyn std::error::Error + Send + Sync>), 9 + 10 + #[error("failed to resolve handle {handle} to DID")] 11 + #[diagnostic(code(atproto::preprocess::handle_resolution))] 12 + HandleResolution { 13 + handle: String, 14 + #[source] 15 + source: Box<dyn std::error::Error + Send + Sync>, 16 + }, 17 + 18 + #[error("entry not found in vault: {0}")] 19 + #[diagnostic(code(atproto::preprocess::entry_not_found))] 20 + EntryNotFound(String), 21 + 22 + #[error("invalid image path: {0}")] 23 + #[diagnostic(code(atproto::preprocess::invalid_image))] 24 + InvalidImage(String), 25 + 26 + #[error("io error: {0}")] 27 + #[diagnostic(code(atproto::preprocess::io))] 28 + Io(#[from] std::io::Error), 29 + 30 + #[error("invalid AT URI: {0}")] 31 + #[diagnostic(code(atproto::preprocess::invalid_uri))] 32 + InvalidUri(String), 33 + 34 + #[error("failed to fetch record: {0}")] 35 + #[diagnostic(code(atproto::preprocess::fetch_failed))] 36 + FetchFailed(String), 37 + 38 + #[error("failed to parse record: {0}")] 39 + #[diagnostic(code(atproto::preprocess::parse_failed))] 40 + ParseFailed(String), 41 + } 42 + 43 + #[derive(Debug, Error, Diagnostic)] 44 + pub enum ClientRenderError { 45 + #[error("failed to fetch embedded entry: {uri}")] 46 + #[diagnostic(code(atproto::client::entry_fetch))] 47 + EntryFetch { 48 + uri: String, 49 + #[source] 50 + source: Box<dyn std::error::Error + Send + Sync>, 51 + }, 52 + 53 + #[error("blob not found in entry embeds: {name}")] 54 + #[diagnostic(code(atproto::client::blob_not_found))] 55 + BlobNotFound { name: String }, 56 + }
+254
crates/weaver-renderer/src/atproto/markdown_writer.rs
··· 1 + use markdown_weaver::{Event, Tag, TagEnd, CowStr}; 2 + use markdown_weaver_escape::StrWrite; 3 + 4 + /// Writes markdown events back to markdown text 5 + pub struct MarkdownWriter<W: StrWrite> { 6 + writer: W, 7 + in_list: bool, 8 + list_depth: usize, 9 + current_link_url: Option<CowStr<'static>>, 10 + current_link_title: Option<CowStr<'static>>, 11 + } 12 + 13 + impl<W: StrWrite> MarkdownWriter<W> { 14 + pub fn new(writer: W) -> Self { 15 + Self { 16 + writer, 17 + in_list: false, 18 + list_depth: 0, 19 + current_link_url: None, 20 + current_link_title: None, 21 + } 22 + } 23 + 24 + pub fn write_event(&mut self, event: Event<'_>) -> Result<(), W::Error> { 25 + match event { 26 + Event::Start(tag) => self.start_tag(tag), 27 + Event::End(tag) => self.end_tag(tag), 28 + Event::Text(text) => write!(self.writer, "{}", text), 29 + Event::Code(code) => write!(self.writer, "`{}`", code), 30 + Event::Html(html) => write!(self.writer, "{}", html), 31 + Event::InlineHtml(html) => write!(self.writer, "{}", html), 32 + Event::SoftBreak => write!(self.writer, "\n"), 33 + Event::HardBreak => write!(self.writer, " \n"), 34 + Event::Rule => write!(self.writer, "\n---\n\n"), 35 + Event::InlineMath(math) => write!(self.writer, "${}$", math), 36 + Event::DisplayMath(math) => write!(self.writer, "\n$$\n{}\n$$\n", math), 37 + Event::FootnoteReference(name) => write!(self.writer, "[^{}]", name), 38 + Event::TaskListMarker(checked) => { 39 + if checked { 40 + write!(self.writer, "[x] ") 41 + } else { 42 + write!(self.writer, "[ ] ") 43 + } 44 + } 45 + _ => Ok(()), 46 + } 47 + } 48 + 49 + fn start_tag(&mut self, tag: Tag<'_>) -> Result<(), W::Error> { 50 + match tag { 51 + Tag::Paragraph => Ok(()), 52 + Tag::Heading { level, .. } => { 53 + write!(self.writer, "{} ", "#".repeat(level as usize)) 54 + } 55 + Tag::BlockQuote(_) => write!(self.writer, "> "), 56 + Tag::CodeBlock(kind) => { 57 + match kind { 58 + markdown_weaver::CodeBlockKind::Fenced(lang) => { 59 + write!(self.writer, "\n```{}\n", lang) 60 + } 61 + markdown_weaver::CodeBlockKind::Indented => { 62 + write!(self.writer, "\n ") 63 + } 64 + } 65 + } 66 + Tag::List(_) => { 67 + self.in_list = true; 68 + self.list_depth += 1; 69 + Ok(()) 70 + } 71 + Tag::Item => { 72 + let indent = " ".repeat(self.list_depth.saturating_sub(1)); 73 + write!(self.writer, "{}* ", indent) 74 + } 75 + Tag::Link { dest_url, title, .. } => { 76 + self.current_link_url = Some(dest_url.into_static()); 77 + self.current_link_title = if title.is_empty() { 78 + None 79 + } else { 80 + Some(title.into_static()) 81 + }; 82 + write!(self.writer, "[") 83 + } 84 + Tag::Image { dest_url, title, .. } => { 85 + self.current_link_url = Some(dest_url.into_static()); 86 + self.current_link_title = if title.is_empty() { 87 + None 88 + } else { 89 + Some(title.into_static()) 90 + }; 91 + write!(self.writer, "![") 92 + } 93 + Tag::Embed { dest_url, title, .. } => { 94 + self.current_link_url = Some(dest_url.into_static()); 95 + self.current_link_title = if title.is_empty() { 96 + None 97 + } else { 98 + Some(title.into_static()) 99 + }; 100 + write!(self.writer, "![") 101 + } 102 + Tag::Emphasis => write!(self.writer, "*"), 103 + Tag::Strong => write!(self.writer, "**"), 104 + Tag::Strikethrough => write!(self.writer, "~~"), 105 + Tag::Table(_) => write!(self.writer, "\n"), 106 + _ => Ok(()), 107 + } 108 + } 109 + 110 + fn end_tag(&mut self, tag: TagEnd) -> Result<(), W::Error> { 111 + match tag { 112 + TagEnd::Paragraph => write!(self.writer, "\n\n"), 113 + TagEnd::Heading(_) => write!(self.writer, "\n\n"), 114 + TagEnd::BlockQuote(_) => write!(self.writer, "\n\n"), 115 + TagEnd::CodeBlock => write!(self.writer, "```\n\n"), 116 + TagEnd::List(_) => { 117 + self.list_depth = self.list_depth.saturating_sub(1); 118 + if self.list_depth == 0 { 119 + self.in_list = false; 120 + write!(self.writer, "\n") 121 + } else { 122 + Ok(()) 123 + } 124 + } 125 + TagEnd::Item => write!(self.writer, "\n"), 126 + TagEnd::Link => { 127 + let url = self.current_link_url.take().unwrap_or(CowStr::Borrowed("")); 128 + if let Some(title) = self.current_link_title.take() { 129 + write!(self.writer, "]({} \"{}\")", url, title) 130 + } else { 131 + write!(self.writer, "]({})", url) 132 + } 133 + } 134 + TagEnd::Image => { 135 + let url = self.current_link_url.take().unwrap_or(CowStr::Borrowed("")); 136 + if let Some(title) = self.current_link_title.take() { 137 + write!(self.writer, "]({} \"{}\")", url, title) 138 + } else { 139 + write!(self.writer, "]({})", url) 140 + } 141 + } 142 + TagEnd::Embed => { 143 + let url = self.current_link_url.take().unwrap_or(CowStr::Borrowed("")); 144 + if let Some(title) = self.current_link_title.take() { 145 + write!(self.writer, "]({} \"{}\")", url, title) 146 + } else { 147 + write!(self.writer, "]({})", url) 148 + } 149 + } 150 + TagEnd::Emphasis => write!(self.writer, "*"), 151 + TagEnd::Strong => write!(self.writer, "**"), 152 + TagEnd::Strikethrough => write!(self.writer, "~~"), 153 + _ => Ok(()), 154 + } 155 + } 156 + } 157 + 158 + #[cfg(test)] 159 + mod tests { 160 + use super::*; 161 + use markdown_weaver::{Event, Tag, CowStr}; 162 + use markdown_weaver_escape::FmtWriter; 163 + 164 + #[test] 165 + fn test_write_paragraph() { 166 + let mut output = String::new(); 167 + let mut writer = MarkdownWriter::new(FmtWriter(&mut output)); 168 + 169 + writer.write_event(Event::Start(Tag::Paragraph)).unwrap(); 170 + writer.write_event(Event::Text(CowStr::Borrowed("Hello"))).unwrap(); 171 + writer.write_event(Event::End(markdown_weaver::TagEnd::Paragraph)).unwrap(); 172 + 173 + assert_eq!(output, "Hello\n\n"); 174 + } 175 + 176 + #[test] 177 + fn test_write_heading() { 178 + let mut output = String::new(); 179 + let mut writer = MarkdownWriter::new(FmtWriter(&mut output)); 180 + 181 + writer.write_event(Event::Start(Tag::Heading { 182 + level: markdown_weaver::HeadingLevel::H2, 183 + id: None, 184 + classes: vec![], 185 + attrs: vec![], 186 + })).unwrap(); 187 + writer.write_event(Event::Text(CowStr::Borrowed("Title"))).unwrap(); 188 + writer.write_event(Event::End(markdown_weaver::TagEnd::Heading(markdown_weaver::HeadingLevel::H2))).unwrap(); 189 + 190 + assert_eq!(output, "## Title\n\n"); 191 + } 192 + 193 + #[test] 194 + fn test_write_code() { 195 + let mut output = String::new(); 196 + let mut writer = MarkdownWriter::new(FmtWriter(&mut output)); 197 + 198 + writer.write_event(Event::Code(CowStr::Borrowed("let x = 5;"))).unwrap(); 199 + 200 + assert_eq!(output, "`let x = 5;`"); 201 + } 202 + 203 + #[test] 204 + fn test_write_link() { 205 + let mut output = String::new(); 206 + let mut writer = MarkdownWriter::new(FmtWriter(&mut output)); 207 + 208 + writer.write_event(Event::Start(Tag::Link { 209 + link_type: markdown_weaver::LinkType::Inline, 210 + dest_url: CowStr::Borrowed("/path/to/page"), 211 + title: CowStr::Borrowed(""), 212 + id: CowStr::Borrowed(""), 213 + })).unwrap(); 214 + writer.write_event(Event::Text(CowStr::Borrowed("Link text"))).unwrap(); 215 + writer.write_event(Event::End(markdown_weaver::TagEnd::Link)).unwrap(); 216 + 217 + assert_eq!(output, "[Link text](/path/to/page)"); 218 + } 219 + 220 + #[test] 221 + fn test_write_link_with_title() { 222 + let mut output = String::new(); 223 + let mut writer = MarkdownWriter::new(FmtWriter(&mut output)); 224 + 225 + writer.write_event(Event::Start(Tag::Link { 226 + link_type: markdown_weaver::LinkType::Inline, 227 + dest_url: CowStr::Borrowed("/path"), 228 + title: CowStr::Borrowed("Hover tooltip"), // The quoted "title" attribute 229 + id: CowStr::Borrowed(""), 230 + })).unwrap(); 231 + writer.write_event(Event::Text(CowStr::Borrowed("link text"))).unwrap(); 232 + writer.write_event(Event::End(markdown_weaver::TagEnd::Link)).unwrap(); 233 + 234 + assert_eq!(output, "[link text](/path \"Hover tooltip\")"); 235 + } 236 + 237 + #[test] 238 + fn test_write_image() { 239 + let mut output = String::new(); 240 + let mut writer = MarkdownWriter::new(FmtWriter(&mut output)); 241 + 242 + writer.write_event(Event::Start(Tag::Image { 243 + link_type: markdown_weaver::LinkType::Inline, 244 + dest_url: CowStr::Borrowed("/image.png"), 245 + title: CowStr::Borrowed("Hover tooltip"), // The quoted "title" attribute 246 + id: CowStr::Borrowed(""), 247 + attrs: None, 248 + })).unwrap(); 249 + writer.write_event(Event::Text(CowStr::Borrowed("Alt text in brackets"))).unwrap(); 250 + writer.write_event(Event::End(markdown_weaver::TagEnd::Image)).unwrap(); 251 + 252 + assert_eq!(output, "![Alt text in brackets](/image.png \"Hover tooltip\")"); 253 + } 254 + }
+566
crates/weaver-renderer/src/atproto/preprocess.rs
··· 1 + use crate::{Frontmatter, NotebookContext}; 2 + use super::types::{BlobName, BlobInfo}; 3 + use dashmap::DashMap; 4 + use jacquard::{ 5 + client::{Agent, AgentSession, AgentSessionExt}, 6 + prelude::IdentityResolver, 7 + types::string::{CowStr, Did, Handle}, 8 + }; 9 + use markdown_weaver::{Tag, CowStr as MdCowStr, WeaverAttributes}; 10 + use std::{ 11 + path::PathBuf, 12 + sync::Arc, 13 + }; 14 + 15 + pub struct AtProtoPreprocessContext<A: AgentSession + IdentityResolver> { 16 + // Vault information 17 + pub(crate) vault_contents: Arc<[PathBuf]>, 18 + pub(crate) current_path: PathBuf, 19 + 20 + // AT Protocol agent 21 + agent: Arc<Agent<A>>, 22 + 23 + // Notebook metadata 24 + pub(crate) notebook_title: CowStr<'static>, 25 + pub(crate) creator_did: Option<Did<'static>>, 26 + pub(crate) creator_handle: Option<Handle<'static>>, 27 + 28 + // Blob tracking 29 + blob_tracking: Arc<DashMap<BlobName<'static>, BlobInfo>>, 30 + 31 + // Shared with static site 32 + frontmatter: Arc<DashMap<PathBuf, Frontmatter>>, 33 + titles: Arc<DashMap<PathBuf, MdCowStr<'static>>>, 34 + reference_map: Arc<DashMap<MdCowStr<'static>, PathBuf>>, 35 + 36 + // Recursion tracking for markdown embeds 37 + embed_depth: usize, 38 + } 39 + 40 + impl<A: AgentSession + IdentityResolver> Clone for AtProtoPreprocessContext<A> { 41 + fn clone(&self) -> Self { 42 + Self { 43 + vault_contents: self.vault_contents.clone(), 44 + current_path: self.current_path.clone(), 45 + agent: self.agent.clone(), 46 + notebook_title: self.notebook_title.clone(), 47 + creator_did: self.creator_did.clone(), 48 + creator_handle: self.creator_handle.clone(), 49 + blob_tracking: self.blob_tracking.clone(), 50 + frontmatter: self.frontmatter.clone(), 51 + titles: self.titles.clone(), 52 + reference_map: self.reference_map.clone(), 53 + embed_depth: self.embed_depth, 54 + } 55 + } 56 + } 57 + 58 + impl<A: AgentSession + IdentityResolver> AtProtoPreprocessContext<A> { 59 + pub fn new( 60 + vault_contents: Arc<[PathBuf]>, 61 + notebook_title: impl Into<CowStr<'static>>, 62 + agent: Arc<Agent<A>>, 63 + ) -> Self { 64 + Self { 65 + vault_contents, 66 + current_path: PathBuf::new(), 67 + agent, 68 + notebook_title: notebook_title.into(), 69 + creator_did: None, 70 + creator_handle: None, 71 + blob_tracking: Arc::new(DashMap::new()), 72 + frontmatter: Arc::new(DashMap::new()), 73 + titles: Arc::new(DashMap::new()), 74 + reference_map: Arc::new(DashMap::new()), 75 + embed_depth: 0, 76 + } 77 + } 78 + 79 + pub fn with_creator(mut self, did: Did<'static>, handle: Handle<'static>) -> Self { 80 + self.creator_did = Some(did); 81 + self.creator_handle = Some(handle); 82 + self 83 + } 84 + 85 + pub fn blobs(&self) -> Vec<BlobInfo> { 86 + self.blob_tracking 87 + .iter() 88 + .map(|entry| entry.value().clone()) 89 + .collect() 90 + } 91 + 92 + pub fn set_current_path(&mut self, path: PathBuf) { 93 + self.current_path = path; 94 + } 95 + 96 + fn with_depth(&self, depth: usize) -> Self { 97 + Self { 98 + vault_contents: self.vault_contents.clone(), 99 + current_path: self.current_path.clone(), 100 + agent: self.agent.clone(), 101 + notebook_title: self.notebook_title.clone(), 102 + creator_did: self.creator_did.clone(), 103 + creator_handle: self.creator_handle.clone(), 104 + blob_tracking: self.blob_tracking.clone(), 105 + frontmatter: self.frontmatter.clone(), 106 + titles: self.titles.clone(), 107 + reference_map: self.reference_map.clone(), 108 + embed_depth: depth, 109 + } 110 + } 111 + } 112 + 113 + // Stub NotebookContext implementation 114 + impl<A: AgentSession + IdentityResolver> NotebookContext for AtProtoPreprocessContext<A> { 115 + fn set_entry_title(&self, title: MdCowStr<'_>) { 116 + self.titles.insert(self.current_path.clone(), title.into_static()); 117 + } 118 + 119 + fn entry_title(&self) -> MdCowStr<'_> { 120 + self.titles 121 + .get(&self.current_path) 122 + .map(|t| t.value().clone()) 123 + .unwrap_or(MdCowStr::Borrowed("")) 124 + } 125 + 126 + fn frontmatter(&self) -> Frontmatter { 127 + self.frontmatter 128 + .get(&self.current_path) 129 + .map(|f| f.value().clone()) 130 + .unwrap_or_default() 131 + } 132 + 133 + fn set_frontmatter(&self, frontmatter: Frontmatter) { 134 + self.frontmatter.insert(self.current_path.clone(), frontmatter); 135 + } 136 + 137 + async fn handle_link<'s>(&self, link: Tag<'s>) -> Tag<'s> { 138 + use crate::utils::lookup_filename_in_vault; 139 + use weaver_common::LinkUri; 140 + 141 + match &link { 142 + Tag::Link { 143 + link_type, 144 + dest_url, 145 + title, 146 + id, 147 + } => { 148 + // Resolve link using LinkUri helper 149 + let resolved = LinkUri::resolve(dest_url.as_ref(), &*self.agent).await; 150 + 151 + match resolved { 152 + LinkUri::Path(path) => { 153 + // Local wikilink - look up in vault 154 + if let Some(file_path) = lookup_filename_in_vault(path.as_ref(), &self.vault_contents) { 155 + let entry_title = file_path 156 + .file_stem() 157 + .and_then(|s| s.to_str()) 158 + .unwrap_or("untitled"); 159 + let normalized_title = normalize_title(entry_title); 160 + 161 + let canonical_url = if let Some(handle) = &self.creator_handle { 162 + format!( 163 + "/{}/{}/{}", 164 + handle.as_ref(), 165 + self.notebook_title.as_ref(), 166 + normalized_title 167 + ) 168 + } else { 169 + format!( 170 + "/{}/{}", 171 + self.notebook_title.as_ref(), 172 + normalized_title 173 + ) 174 + }; 175 + 176 + return Tag::Link { 177 + link_type: *link_type, 178 + dest_url: MdCowStr::Boxed(canonical_url.into_boxed_str()), 179 + title: title.clone(), 180 + id: id.clone(), 181 + }; 182 + } 183 + } 184 + LinkUri::AtIdent(did, _handle) => { 185 + // Profile link - use at://did format 186 + let at_uri = format!("at://{}", did.as_ref()); 187 + return Tag::Link { 188 + link_type: *link_type, 189 + dest_url: MdCowStr::Boxed(at_uri.into_boxed_str()), 190 + title: title.clone(), 191 + id: id.clone(), 192 + }; 193 + } 194 + LinkUri::AtRecord(uri) => { 195 + // AT URI - keep as-is or convert to HTTP 196 + // For now, keep the at:// URI 197 + return Tag::Link { 198 + link_type: *link_type, 199 + dest_url: MdCowStr::Boxed(uri.as_str().into()), 200 + title: title.clone(), 201 + id: id.clone(), 202 + }; 203 + } 204 + _ => {} 205 + } 206 + 207 + // Pass through other link types (web URLs, headings, etc.) 208 + link.clone() 209 + } 210 + _ => link, 211 + } 212 + } 213 + 214 + async fn handle_image<'s>(&self, image: Tag<'s>) -> Tag<'s> { 215 + use crate::utils::is_local_path; 216 + use tokio::fs; 217 + use jacquard::bytes::Bytes; 218 + use jacquard::types::blob::MimeType; 219 + use mime_sniffer::MimeTypeSniffer; 220 + 221 + match &image { 222 + Tag::Image { 223 + link_type, 224 + dest_url, 225 + title, 226 + id, 227 + attrs, 228 + } => { 229 + if is_local_path(dest_url) { 230 + // Read local file 231 + let file_path = if dest_url.starts_with('/') { 232 + PathBuf::from(dest_url.as_ref()) 233 + } else { 234 + self.current_path 235 + .parent() 236 + .unwrap_or(&self.current_path) 237 + .join(dest_url.as_ref()) 238 + }; 239 + 240 + if let Ok(image_data) = fs::read(&file_path).await { 241 + // Derive blob name from filename 242 + let filename = file_path 243 + .file_stem() 244 + .and_then(|s| s.to_str()) 245 + .unwrap_or("image"); 246 + let blob_name = BlobName::from_filename(filename); 247 + 248 + // Sniff mime type from data 249 + let bytes = Bytes::from(image_data.clone()); 250 + let mime = MimeType::new_owned( 251 + bytes.sniff_mime_type().unwrap_or("application/octet-stream") 252 + ); 253 + 254 + // Upload blob (dereference Arc) 255 + if let Ok(blob) = (*self.agent).upload_blob(bytes, mime.clone()).await { 256 + use jacquard::IntoStatic; 257 + 258 + // Store blob info 259 + let blob_info = BlobInfo { 260 + name: blob_name.clone(), 261 + blob: blob.into_static(), 262 + alt: if title.is_empty() { 263 + None 264 + } else { 265 + Some(CowStr::Owned(title.as_ref().into())) 266 + }, 267 + }; 268 + self.blob_tracking.insert(blob_name.clone(), blob_info); 269 + 270 + // Rewrite to canonical path 271 + let canonical_url = format!( 272 + "/{}/image/{}", 273 + self.notebook_title.as_ref(), 274 + blob_name.as_str() 275 + ); 276 + 277 + return Tag::Image { 278 + link_type: *link_type, 279 + dest_url: MdCowStr::Boxed(canonical_url.into_boxed_str()), 280 + title: title.clone(), 281 + id: id.clone(), 282 + attrs: attrs.clone(), 283 + }; 284 + } 285 + } 286 + } 287 + // If not local or upload failed, pass through 288 + image 289 + } 290 + _ => image, 291 + } 292 + } 293 + 294 + async fn handle_embed<'s>(&self, embed: Tag<'s>) -> Tag<'s> { 295 + use crate::utils::lookup_filename_in_vault; 296 + use weaver_common::LinkUri; 297 + 298 + match &embed { 299 + Tag::Embed { 300 + embed_type, 301 + dest_url, 302 + title, 303 + id, 304 + attrs, 305 + } => { 306 + // Resolve embed using LinkUri helper 307 + let resolved = LinkUri::resolve(dest_url.as_ref(), &*self.agent).await; 308 + 309 + match resolved { 310 + LinkUri::Path(path) => { 311 + // Entry embed - look up in vault 312 + if let Some(file_path) = lookup_filename_in_vault(path.as_ref(), &self.vault_contents) { 313 + let entry_title = file_path 314 + .file_stem() 315 + .and_then(|s| s.to_str()) 316 + .unwrap_or("untitled"); 317 + let normalized_title = normalize_title(entry_title); 318 + 319 + let canonical_url = if let Some(handle) = &self.creator_handle { 320 + format!( 321 + "/{}/{}/{}", 322 + handle.as_ref(), 323 + self.notebook_title.as_ref(), 324 + normalized_title 325 + ) 326 + } else { 327 + format!( 328 + "/{}/{}", 329 + self.notebook_title.as_ref(), 330 + normalized_title 331 + ) 332 + }; 333 + 334 + return Tag::Embed { 335 + embed_type: *embed_type, 336 + dest_url: MdCowStr::Boxed(canonical_url.into_boxed_str()), 337 + title: title.clone(), 338 + id: id.clone(), 339 + attrs: attrs.clone(), 340 + }; 341 + } 342 + } 343 + LinkUri::AtIdent(did, _handle) => { 344 + // Profile embed - fetch and render 345 + use crate::atproto::fetch_and_render_profile; 346 + use markdown_weaver::WeaverAttributes; 347 + 348 + let at_uri = format!("at://{}", did.as_ref()); 349 + 350 + // Fetch and render the profile 351 + let content = match fetch_and_render_profile(&did, &*self.agent).await { 352 + Ok(html) => Some(html), 353 + Err(e) => { 354 + eprintln!("Failed to fetch profile {}: {}", did.as_ref(), e); 355 + None 356 + } 357 + }; 358 + 359 + // Build or update attributes 360 + let mut new_attrs = attrs.clone().unwrap_or_else(|| WeaverAttributes { 361 + classes: vec![], 362 + attrs: vec![], 363 + }); 364 + 365 + if let Some(content_html) = content { 366 + new_attrs.attrs.push(("content".into(), content_html.into())); 367 + } 368 + 369 + return Tag::Embed { 370 + embed_type: *embed_type, 371 + dest_url: MdCowStr::Boxed(at_uri.into_boxed_str()), 372 + title: title.clone(), 373 + id: id.clone(), 374 + attrs: Some(new_attrs), 375 + }; 376 + } 377 + LinkUri::AtRecord(uri) => { 378 + // AT URI embed - fetch and render 379 + use crate::atproto::{fetch_and_render_post, fetch_and_render_generic}; 380 + use markdown_weaver::WeaverAttributes; 381 + 382 + // Determine if this is a known type 383 + let content = if let Some(collection) = uri.collection() { 384 + match collection.as_ref() { 385 + "app.bsky.feed.post" => { 386 + // Bluesky post 387 + match fetch_and_render_post(&uri, &*self.agent).await { 388 + Ok(html) => Some(html), 389 + Err(e) => { 390 + eprintln!("Failed to fetch post {}: {}", uri.as_ref(), e); 391 + None 392 + } 393 + } 394 + } 395 + _ => { 396 + // Generic record 397 + match fetch_and_render_generic(&uri, &*self.agent).await { 398 + Ok(html) => Some(html), 399 + Err(e) => { 400 + eprintln!("Failed to fetch record {}: {}", uri.as_ref(), e); 401 + None 402 + } 403 + } 404 + } 405 + } 406 + } else { 407 + None 408 + }; 409 + 410 + // Build or update attributes 411 + let mut new_attrs = attrs.clone().unwrap_or_else(|| WeaverAttributes { 412 + classes: vec![], 413 + attrs: vec![], 414 + }); 415 + 416 + if let Some(content_html) = content { 417 + new_attrs.attrs.push(("content".into(), content_html.into())); 418 + } 419 + 420 + return Tag::Embed { 421 + embed_type: *embed_type, 422 + dest_url: MdCowStr::Boxed(uri.as_str().into()), 423 + title: title.clone(), 424 + id: id.clone(), 425 + attrs: Some(new_attrs), 426 + }; 427 + } 428 + LinkUri::Path(path) => { 429 + // Markdown embed - look up in vault and render 430 + use crate::utils::lookup_filename_in_vault; 431 + use tokio::fs; 432 + 433 + // Check depth limit 434 + const MAX_DEPTH: usize = 1; 435 + if self.embed_depth >= MAX_DEPTH { 436 + eprintln!("Max embed depth reached for {}", path.as_ref()); 437 + return embed.clone(); 438 + } 439 + 440 + if let Some(file_path) = lookup_filename_in_vault(path.as_ref(), &self.vault_contents) { 441 + // Read the markdown file 442 + match fs::read_to_string(&file_path).await { 443 + Ok(markdown_content) => { 444 + // Create a child context with incremented depth 445 + let mut child_ctx = self.with_depth(self.embed_depth + 1); 446 + child_ctx.current_path = file_path.clone(); 447 + 448 + // Render the markdown through the processor 449 + // We'll use markdown_weaver to parse and render to HTML 450 + use markdown_weaver::{Parser, Options}; 451 + use markdown_weaver_escape::StrWrite; 452 + 453 + let parser = Parser::new_ext(&markdown_content, Options::all()); 454 + let mut html_output = String::new(); 455 + 456 + // Process events through context callbacks 457 + for event in parser { 458 + match event { 459 + markdown_weaver::Event::Start(tag) => { 460 + let processed = match tag { 461 + Tag::Link { .. } => child_ctx.handle_link(tag).await, 462 + Tag::Image { .. } => child_ctx.handle_image(tag).await, 463 + Tag::Embed { .. } => child_ctx.handle_embed(tag).await, 464 + _ => tag, 465 + }; 466 + // Simple HTML writing (reuse escape logic) 467 + match processed { 468 + Tag::Paragraph => html_output.write_str("<p>").ok(), 469 + _ => None, 470 + }; 471 + } 472 + markdown_weaver::Event::End(tag_end) => { 473 + match tag_end { 474 + markdown_weaver::TagEnd::Paragraph => html_output.write_str("</p>\n").ok(), 475 + _ => None, 476 + }; 477 + } 478 + markdown_weaver::Event::Text(text) => { 479 + use markdown_weaver_escape::escape_html_body_text; 480 + escape_html_body_text(&mut html_output, &text).ok(); 481 + } 482 + _ => {} 483 + } 484 + } 485 + 486 + let mut new_attrs = attrs.clone().unwrap_or_else(|| WeaverAttributes { 487 + classes: vec![], 488 + attrs: vec![], 489 + }); 490 + 491 + new_attrs.attrs.push(("content".into(), html_output.into())); 492 + 493 + return Tag::Embed { 494 + embed_type: *embed_type, 495 + dest_url: dest_url.clone(), 496 + title: title.clone(), 497 + id: id.clone(), 498 + attrs: Some(new_attrs), 499 + }; 500 + } 501 + Err(e) => { 502 + eprintln!("Failed to read file {:?}: {}", file_path, e); 503 + } 504 + } 505 + } 506 + } 507 + _ => {} 508 + } 509 + 510 + // Pass through other embed types 511 + embed.clone() 512 + } 513 + Tag::Image { 514 + link_type, 515 + dest_url, 516 + title, 517 + id, 518 + attrs, 519 + } => { 520 + // Some embeds come through as explicit Tag::Image 521 + // Delegate to handle_image for image-specific processing 522 + self.handle_image(embed).await 523 + } 524 + _ => embed, 525 + } 526 + } 527 + 528 + fn handle_reference(&self, reference: MdCowStr<'_>) -> MdCowStr<'_> { 529 + reference.into_static() 530 + } 531 + 532 + fn add_reference(&self, reference: MdCowStr<'_>) { 533 + self.reference_map.insert(reference.into_static(), self.current_path.clone()); 534 + } 535 + } 536 + 537 + /// Normalize entry title to URL-safe format 538 + fn normalize_title(title: &str) -> String { 539 + let mut normalized = String::new(); 540 + let mut last_was_space = false; 541 + 542 + for c in title.chars() { 543 + if c.is_ascii_alphanumeric() { 544 + normalized.push(c); 545 + last_was_space = false; 546 + } else if c.is_whitespace() && !last_was_space && !normalized.is_empty() { 547 + normalized.push('_'); 548 + last_was_space = true; 549 + } 550 + } 551 + 552 + // Remove trailing underscore if present 553 + if normalized.ends_with('_') { 554 + normalized.pop(); 555 + } 556 + 557 + normalized 558 + } 559 + 560 + #[cfg(test)] 561 + mod tests { 562 + use super::*; 563 + 564 + // Tests require an actual Agent instance, which needs authentication setup. 565 + // These will be tested via integration tests instead. 566 + }
+68
crates/weaver-renderer/src/atproto/types.rs
··· 1 + use jacquard::types::string::CowStr; 2 + use jacquard::types::blob::Blob; 3 + use jacquard::smol_str::{SmolStrBuilder, ToSmolStr}; 4 + 5 + /// Blob name, validated to be URL-safe snake_case 6 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 7 + #[repr(transparent)] 8 + pub struct BlobName<'a>(CowStr<'a>); 9 + 10 + impl<'a> BlobName<'a> { 11 + /// Create blob name from filename, normalizing to lowercase snake_case 12 + pub fn from_filename(filename: &str) -> BlobName<'static> { 13 + let mut builder = SmolStrBuilder::new(); 14 + for c in filename.chars() { 15 + if c.is_ascii_alphanumeric() { 16 + builder.push_str(&c.to_lowercase().to_smolstr()); 17 + } else { 18 + builder.push_str("_"); 19 + } 20 + } 21 + BlobName(CowStr::Owned(builder.finish())) 22 + } 23 + 24 + pub fn as_str(&self) -> &str { 25 + self.0.as_ref() 26 + } 27 + } 28 + 29 + impl AsRef<str> for BlobName<'_> { 30 + fn as_ref(&self) -> &str { 31 + self.as_str() 32 + } 33 + } 34 + 35 + /// Blob metadata tracked during preprocessing 36 + #[derive(Debug, Clone)] 37 + pub struct BlobInfo { 38 + pub name: BlobName<'static>, 39 + pub blob: Blob<'static>, 40 + pub alt: Option<CowStr<'static>>, 41 + } 42 + 43 + #[cfg(test)] 44 + mod tests { 45 + use super::*; 46 + 47 + #[test] 48 + fn test_blob_name_normalization() { 49 + assert_eq!(BlobName::from_filename("My Image.PNG").as_str(), "my_image_png"); 50 + assert_eq!(BlobName::from_filename("test-file!@#.jpg").as_str(), "test_file____jpg"); 51 + assert_eq!(BlobName::from_filename("already_good").as_str(), "already_good"); 52 + assert_eq!(BlobName::from_filename("CAPS").as_str(), "caps"); 53 + assert_eq!(BlobName::from_filename("with spaces.txt").as_str(), "with_spaces_txt"); 54 + } 55 + 56 + #[test] 57 + fn test_blob_name_hash_equality() { 58 + use std::collections::HashMap; 59 + 60 + let name1 = BlobName::from_filename("test.png"); 61 + let name2 = BlobName::from_filename("test.png"); 62 + 63 + let mut map = HashMap::new(); 64 + map.insert(name1.clone(), "value"); 65 + 66 + assert_eq!(map.get(&name2), Some(&"value")); 67 + } 68 + }
+521
crates/weaver-renderer/src/atproto/writer.rs
··· 1 + //! HTML writer for client-side rendering of AT Protocol entries 2 + //! 3 + //! Similar to StaticPageWriter but designed for client-side use with 4 + //! synchronous embed content injection. 5 + 6 + use markdown_weaver::{ 7 + Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType, Tag, 8 + }; 9 + use markdown_weaver_escape::{escape_href, escape_html, escape_html_body_text, StrWrite}; 10 + use std::collections::HashMap; 11 + 12 + /// Synchronous callback for injecting embed content 13 + /// 14 + /// Takes the embed tag and returns optional HTML content to inject. 15 + pub trait EmbedContentProvider { 16 + fn get_embed_content(&self, tag: &Tag<'_>) -> Option<String>; 17 + } 18 + 19 + /// Simple writer that outputs HTML from markdown events 20 + /// 21 + /// This writer is designed for client-side rendering where embeds may have 22 + /// pre-rendered content in their attributes. 23 + pub struct ClientWriter<W: StrWrite, E = ()> { 24 + writer: W, 25 + end_newline: bool, 26 + in_non_writing_block: bool, 27 + 28 + table_state: TableState, 29 + table_alignments: Vec<Alignment>, 30 + table_cell_index: usize, 31 + 32 + numbers: HashMap<String, usize>, 33 + 34 + embed_provider: Option<E>, 35 + } 36 + 37 + #[derive(Debug, Clone, Copy)] 38 + enum TableState { 39 + Head, 40 + Body, 41 + } 42 + 43 + impl<W: StrWrite> ClientWriter<W, ()> { 44 + pub fn new(writer: W) -> Self { 45 + Self { 46 + writer, 47 + end_newline: true, 48 + in_non_writing_block: false, 49 + table_state: TableState::Head, 50 + table_alignments: vec![], 51 + table_cell_index: 0, 52 + numbers: HashMap::new(), 53 + embed_provider: None, 54 + } 55 + } 56 + 57 + /// Add an embed content provider 58 + pub fn with_embed_provider<E: EmbedContentProvider>(self, provider: E) -> ClientWriter<W, E> { 59 + ClientWriter { 60 + writer: self.writer, 61 + end_newline: self.end_newline, 62 + in_non_writing_block: self.in_non_writing_block, 63 + table_state: self.table_state, 64 + table_alignments: self.table_alignments, 65 + table_cell_index: self.table_cell_index, 66 + numbers: self.numbers, 67 + embed_provider: Some(provider), 68 + } 69 + } 70 + } 71 + 72 + impl<W: StrWrite, E: EmbedContentProvider> ClientWriter<W, E> { 73 + #[inline] 74 + fn write_newline(&mut self) -> Result<(), W::Error> { 75 + self.end_newline = true; 76 + self.writer.write_str("\n") 77 + } 78 + 79 + #[inline] 80 + fn write(&mut self, s: &str) -> Result<(), W::Error> { 81 + self.writer.write_str(s)?; 82 + if !s.is_empty() { 83 + self.end_newline = s.ends_with('\n'); 84 + } 85 + Ok(()) 86 + } 87 + 88 + /// Process markdown events and write HTML 89 + pub fn run<'a>(mut self, events: impl Iterator<Item = Event<'a>>) -> Result<W, W::Error> { 90 + for event in events { 91 + self.process_event(event)?; 92 + } 93 + Ok(self.writer) 94 + } 95 + 96 + fn process_event(&mut self, event: Event<'_>) -> Result<(), W::Error> { 97 + use Event::*; 98 + match event { 99 + Start(tag) => self.start_tag(tag)?, 100 + End(tag) => self.end_tag(tag)?, 101 + Text(text) => { 102 + if !self.in_non_writing_block { 103 + escape_html_body_text(&mut self.writer, &text)?; 104 + self.end_newline = text.ends_with('\n'); 105 + } 106 + } 107 + Code(text) => { 108 + self.write("<code>")?; 109 + escape_html_body_text(&mut self.writer, &text)?; 110 + self.write("</code>")?; 111 + } 112 + InlineMath(text) => { 113 + self.write(r#"<span class="math math-inline">"#)?; 114 + escape_html(&mut self.writer, &text)?; 115 + self.write("</span>")?; 116 + } 117 + DisplayMath(text) => { 118 + self.write(r#"<span class="math math-display">"#)?; 119 + escape_html(&mut self.writer, &text)?; 120 + self.write("</span>")?; 121 + } 122 + Html(html) | InlineHtml(html) => { 123 + self.write(&html)?; 124 + } 125 + SoftBreak => self.write_newline()?, 126 + HardBreak => self.write("<br />\n")?, 127 + Rule => { 128 + if self.end_newline { 129 + self.write("<hr />\n")?; 130 + } else { 131 + self.write("\n<hr />\n")?; 132 + } 133 + } 134 + FootnoteReference(name) => { 135 + let len = self.numbers.len() + 1; 136 + self.write("<sup class=\"footnote-reference\"><a href=\"#")?; 137 + escape_html(&mut self.writer, &name)?; 138 + self.write("\">")?; 139 + let number = *self.numbers.entry(name.to_string()).or_insert(len); 140 + write!(&mut self.writer, "{}", number)?; 141 + self.write("</a></sup>")?; 142 + } 143 + TaskListMarker(checked) => { 144 + if checked { 145 + self.write("<input disabled=\"\" type=\"checkbox\" checked=\"\"/>\n")?; 146 + } else { 147 + self.write("<input disabled=\"\" type=\"checkbox\"/>\n")?; 148 + } 149 + } 150 + WeaverBlock(_) => {} 151 + } 152 + Ok(()) 153 + } 154 + 155 + fn start_tag(&mut self, tag: Tag<'_>) -> Result<(), W::Error> { 156 + match tag { 157 + Tag::HtmlBlock => Ok(()), 158 + Tag::Paragraph => { 159 + if self.end_newline { 160 + self.write("<p>") 161 + } else { 162 + self.write("\n<p>") 163 + } 164 + } 165 + Tag::Heading { level, id, classes, attrs } => { 166 + if !self.end_newline { 167 + self.write("\n")?; 168 + } 169 + self.write("<")?; 170 + write!(&mut self.writer, "{}", level)?; 171 + if let Some(id) = id { 172 + self.write(" id=\"")?; 173 + escape_html(&mut self.writer, &id)?; 174 + self.write("\"")?; 175 + } 176 + if !classes.is_empty() { 177 + self.write(" class=\"")?; 178 + for (i, class) in classes.iter().enumerate() { 179 + if i > 0 { 180 + self.write(" ")?; 181 + } 182 + escape_html(&mut self.writer, class)?; 183 + } 184 + self.write("\"")?; 185 + } 186 + for (attr, value) in attrs { 187 + self.write(" ")?; 188 + escape_html(&mut self.writer, &attr)?; 189 + if let Some(val) = value { 190 + self.write("=\"")?; 191 + escape_html(&mut self.writer, &val)?; 192 + self.write("\"")?; 193 + } else { 194 + self.write("=\"\"")?; 195 + } 196 + } 197 + self.write(">") 198 + } 199 + Tag::Table(alignments) => { 200 + self.table_alignments = alignments; 201 + self.write("<table>") 202 + } 203 + Tag::TableHead => { 204 + self.table_state = TableState::Head; 205 + self.table_cell_index = 0; 206 + self.write("<thead><tr>") 207 + } 208 + Tag::TableRow => { 209 + self.table_cell_index = 0; 210 + self.write("<tr>") 211 + } 212 + Tag::TableCell => { 213 + match self.table_state { 214 + TableState::Head => self.write("<th")?, 215 + TableState::Body => self.write("<td")?, 216 + } 217 + match self.table_alignments.get(self.table_cell_index) { 218 + Some(&Alignment::Left) => self.write(" style=\"text-align: left\">"), 219 + Some(&Alignment::Center) => self.write(" style=\"text-align: center\">"), 220 + Some(&Alignment::Right) => self.write(" style=\"text-align: right\">"), 221 + _ => self.write(">"), 222 + } 223 + } 224 + Tag::BlockQuote(kind) => { 225 + let class_str = match kind { 226 + None => "", 227 + Some(BlockQuoteKind::Note) => " class=\"markdown-alert-note\"", 228 + Some(BlockQuoteKind::Tip) => " class=\"markdown-alert-tip\"", 229 + Some(BlockQuoteKind::Important) => " class=\"markdown-alert-important\"", 230 + Some(BlockQuoteKind::Warning) => " class=\"markdown-alert-warning\"", 231 + Some(BlockQuoteKind::Caution) => " class=\"markdown-alert-caution\"", 232 + }; 233 + if self.end_newline { 234 + write!(&mut self.writer, "<blockquote{}>\n", class_str)?; 235 + } else { 236 + write!(&mut self.writer, "\n<blockquote{}>\n", class_str)?; 237 + } 238 + Ok(()) 239 + } 240 + Tag::CodeBlock(info) => { 241 + if !self.end_newline { 242 + self.write_newline()?; 243 + } 244 + match info { 245 + CodeBlockKind::Fenced(info) => { 246 + let lang = info.split(' ').next().unwrap_or(""); 247 + if !lang.is_empty() { 248 + self.write("<pre><code class=\"language-")?; 249 + escape_html(&mut self.writer, lang)?; 250 + self.write("\">")?; 251 + } else { 252 + self.write("<pre><code>")?; 253 + } 254 + } 255 + CodeBlockKind::Indented => { 256 + self.write("<pre><code>")?; 257 + } 258 + } 259 + Ok(()) 260 + } 261 + Tag::List(Some(1)) => { 262 + if self.end_newline { 263 + self.write("<ol>\n") 264 + } else { 265 + self.write("\n<ol>\n") 266 + } 267 + } 268 + Tag::List(Some(start)) => { 269 + if self.end_newline { 270 + self.write("<ol start=\"")?; 271 + } else { 272 + self.write("\n<ol start=\"")?; 273 + } 274 + write!(&mut self.writer, "{}", start)?; 275 + self.write("\">\n") 276 + } 277 + Tag::List(None) => { 278 + if self.end_newline { 279 + self.write("<ul>\n") 280 + } else { 281 + self.write("\n<ul>\n") 282 + } 283 + } 284 + Tag::Item => { 285 + if self.end_newline { 286 + self.write("<li>") 287 + } else { 288 + self.write("\n<li>") 289 + } 290 + } 291 + Tag::DefinitionList => { 292 + if self.end_newline { 293 + self.write("<dl>\n") 294 + } else { 295 + self.write("\n<dl>\n") 296 + } 297 + } 298 + Tag::DefinitionListTitle => { 299 + if self.end_newline { 300 + self.write("<dt>") 301 + } else { 302 + self.write("\n<dt>") 303 + } 304 + } 305 + Tag::DefinitionListDefinition => { 306 + if self.end_newline { 307 + self.write("<dd>") 308 + } else { 309 + self.write("\n<dd>") 310 + } 311 + } 312 + Tag::Subscript => self.write("<sub>"), 313 + Tag::Superscript => self.write("<sup>"), 314 + Tag::Emphasis => self.write("<em>"), 315 + Tag::Strong => self.write("<strong>"), 316 + Tag::Strikethrough => self.write("<del>"), 317 + Tag::Link { link_type: LinkType::Email, dest_url, title, .. } => { 318 + self.write("<a href=\"mailto:")?; 319 + escape_href(&mut self.writer, &dest_url)?; 320 + if !title.is_empty() { 321 + self.write("\" title=\"")?; 322 + escape_html(&mut self.writer, &title)?; 323 + } 324 + self.write("\">") 325 + } 326 + Tag::Link { dest_url, title, .. } => { 327 + self.write("<a href=\"")?; 328 + escape_href(&mut self.writer, &dest_url)?; 329 + if !title.is_empty() { 330 + self.write("\" title=\"")?; 331 + escape_html(&mut self.writer, &title)?; 332 + } 333 + self.write("\">") 334 + } 335 + Tag::Image { dest_url, title, attrs, .. } => { 336 + self.write("<img src=\"")?; 337 + escape_href(&mut self.writer, &dest_url)?; 338 + self.write("\" alt=\"")?; 339 + if !title.is_empty() { 340 + escape_html(&mut self.writer, &title)?; 341 + } 342 + if let Some(attrs) = attrs { 343 + if !attrs.classes.is_empty() { 344 + self.write("\" class=\"")?; 345 + for (i, class) in attrs.classes.iter().enumerate() { 346 + if i > 0 { 347 + self.write(" ")?; 348 + } 349 + escape_html(&mut self.writer, class)?; 350 + } 351 + } 352 + self.write("\"")?; 353 + for (attr, value) in &attrs.attrs { 354 + self.write(" ")?; 355 + escape_html(&mut self.writer, attr)?; 356 + self.write("=\"")?; 357 + escape_html(&mut self.writer, value)?; 358 + self.write("\"")?; 359 + } 360 + } else { 361 + self.write("\"")?; 362 + } 363 + self.write(" />") 364 + } 365 + Tag::Embed { embed_type, dest_url, title, id, attrs } => { 366 + self.write_embed(embed_type, dest_url, title, id, attrs) 367 + } 368 + Tag::WeaverBlock(_, _) => { 369 + self.in_non_writing_block = true; 370 + Ok(()) 371 + } 372 + Tag::FootnoteDefinition(name) => { 373 + if self.end_newline { 374 + self.write("<div class=\"footnote-definition\" id=\"")?; 375 + } else { 376 + self.write("\n<div class=\"footnote-definition\" id=\"")?; 377 + } 378 + escape_html(&mut self.writer, &name)?; 379 + self.write("\"><sup class=\"footnote-definition-label\">")?; 380 + let len = self.numbers.len() + 1; 381 + let number = *self.numbers.entry(name.to_string()).or_insert(len); 382 + write!(&mut self.writer, "{}", number)?; 383 + self.write("</sup>") 384 + } 385 + Tag::MetadataBlock(_) => { 386 + self.in_non_writing_block = true; 387 + Ok(()) 388 + } 389 + } 390 + } 391 + 392 + fn end_tag(&mut self, tag: markdown_weaver::TagEnd) -> Result<(), W::Error> { 393 + use markdown_weaver::TagEnd; 394 + match tag { 395 + TagEnd::HtmlBlock => Ok(()), 396 + TagEnd::Paragraph => self.write("</p>\n"), 397 + TagEnd::Heading(level) => { 398 + self.write("</")?; 399 + write!(&mut self.writer, "{}", level)?; 400 + self.write(">\n") 401 + } 402 + TagEnd::Table => self.write("</tbody></table>\n"), 403 + TagEnd::TableHead => { 404 + self.write("</tr></thead><tbody>\n")?; 405 + self.table_state = TableState::Body; 406 + Ok(()) 407 + } 408 + TagEnd::TableRow => self.write("</tr>\n"), 409 + TagEnd::TableCell => { 410 + match self.table_state { 411 + TableState::Head => self.write("</th>")?, 412 + TableState::Body => self.write("</td>")?, 413 + } 414 + self.table_cell_index += 1; 415 + Ok(()) 416 + } 417 + TagEnd::BlockQuote(_) => self.write("</blockquote>\n"), 418 + TagEnd::CodeBlock => self.write("</code></pre>\n"), 419 + TagEnd::List(true) => self.write("</ol>\n"), 420 + TagEnd::List(false) => self.write("</ul>\n"), 421 + TagEnd::Item => self.write("</li>\n"), 422 + TagEnd::DefinitionList => self.write("</dl>\n"), 423 + TagEnd::DefinitionListTitle => self.write("</dt>\n"), 424 + TagEnd::DefinitionListDefinition => self.write("</dd>\n"), 425 + TagEnd::Emphasis => self.write("</em>"), 426 + TagEnd::Superscript => self.write("</sup>"), 427 + TagEnd::Subscript => self.write("</sub>"), 428 + TagEnd::Strong => self.write("</strong>"), 429 + TagEnd::Strikethrough => self.write("</del>"), 430 + TagEnd::Link => self.write("</a>"), 431 + TagEnd::Image => Ok(()), 432 + TagEnd::Embed => Ok(()), 433 + TagEnd::WeaverBlock(_) => { 434 + self.in_non_writing_block = false; 435 + Ok(()) 436 + } 437 + TagEnd::FootnoteDefinition => self.write("</div>\n"), 438 + TagEnd::MetadataBlock(_) => { 439 + self.in_non_writing_block = false; 440 + Ok(()) 441 + } 442 + } 443 + } 444 + 445 + fn write_embed( 446 + &mut self, 447 + embed_type: EmbedType, 448 + dest_url: CowStr<'_>, 449 + title: CowStr<'_>, 450 + id: CowStr<'_>, 451 + attrs: Option<markdown_weaver::WeaverAttributes<'_>>, 452 + ) -> Result<(), W::Error> { 453 + // Try to get content from attributes first 454 + let content_from_attrs = if let Some(ref attrs) = attrs { 455 + attrs.attrs.iter() 456 + .find(|(k, _)| k.as_ref() == "content") 457 + .map(|(_, v)| v.as_ref().to_string()) 458 + } else { 459 + None 460 + }; 461 + 462 + // If no content in attrs, try provider 463 + let content = if let Some(content) = content_from_attrs { 464 + Some(content) 465 + } else if let Some(ref provider) = self.embed_provider { 466 + let tag = Tag::Embed { 467 + embed_type, 468 + dest_url: dest_url.clone(), 469 + title: title.clone(), 470 + id: id.clone(), 471 + attrs: attrs.clone(), 472 + }; 473 + provider.get_embed_content(&tag) 474 + } else { 475 + None 476 + }; 477 + 478 + if let Some(html_content) = content { 479 + // Write the pre-rendered content directly 480 + self.write(&html_content)?; 481 + self.write_newline()?; 482 + } else { 483 + // Fallback: render as iframe 484 + self.write("<iframe src=\"")?; 485 + escape_href(&mut self.writer, &dest_url)?; 486 + self.write("\" title=\"")?; 487 + escape_html(&mut self.writer, &title)?; 488 + if !id.is_empty() { 489 + self.write("\" id=\"")?; 490 + escape_html(&mut self.writer, &id)?; 491 + } 492 + self.write("\"")?; 493 + 494 + if let Some(attrs) = attrs { 495 + if !attrs.classes.is_empty() { 496 + self.write(" class=\"")?; 497 + for (i, class) in attrs.classes.iter().enumerate() { 498 + if i > 0 { 499 + self.write(" ")?; 500 + } 501 + escape_html(&mut self.writer, class)?; 502 + } 503 + self.write("\"")?; 504 + } 505 + for (attr, value) in &attrs.attrs { 506 + // Skip the content attr in HTML output 507 + if attr.as_ref() != "content" { 508 + self.write(" ")?; 509 + escape_html(&mut self.writer, attr)?; 510 + self.write("=\"")?; 511 + escape_html(&mut self.writer, value)?; 512 + self.write("\"")?; 513 + } 514 + } 515 + } 516 + self.write("></iframe>")?; 517 + } 518 + 519 + Ok(()) 520 + } 521 + }
+13 -1
crates/weaver-renderer/src/lib.rs
··· 6 6 use markdown_weaver::CowStr; 7 7 use markdown_weaver::Event; 8 8 use markdown_weaver::Tag; 9 - use n0_future::Stream; 10 9 use n0_future::pin; 11 10 use n0_future::stream::once_future; 11 + use n0_future::Stream; 12 12 use yaml_rust2::Yaml; 13 13 use yaml_rust2::YamlLoader; 14 14 ··· 327 327 } 328 328 } 329 329 } 330 + 331 + pub fn default_md_options() -> markdown_weaver::Options { 332 + markdown_weaver::Options::ENABLE_WIKILINKS 333 + | markdown_weaver::Options::ENABLE_FOOTNOTES 334 + | markdown_weaver::Options::ENABLE_TABLES 335 + | markdown_weaver::Options::ENABLE_GFM 336 + | markdown_weaver::Options::ENABLE_STRIKETHROUGH 337 + | markdown_weaver::Options::ENABLE_YAML_STYLE_METADATA_BLOCKS 338 + | markdown_weaver::Options::ENABLE_OBSIDIAN_EMBEDS 339 + | markdown_weaver::Options::ENABLE_MATH 340 + | markdown_weaver::Options::ENABLE_HEADING_ATTRIBUTES 341 + }
+1 -79
crates/weaver-renderer/src/static_site.rs
··· 30 30 path::{Path, PathBuf}, 31 31 sync::Arc, 32 32 }; 33 + use crate::utils::VaultBrokenLinkCallback; 33 34 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 34 35 use tokio::io::AsyncWriteExt; 35 36 use unicode_normalization::UnicodeNormalization; ··· 66 67 } 67 68 } 68 69 69 - pub fn default_md_options() -> markdown_weaver::Options { 70 - markdown_weaver::Options::ENABLE_WIKILINKS 71 - | markdown_weaver::Options::ENABLE_FOOTNOTES 72 - | markdown_weaver::Options::ENABLE_TABLES 73 - | markdown_weaver::Options::ENABLE_GFM 74 - | markdown_weaver::Options::ENABLE_STRIKETHROUGH 75 - | markdown_weaver::Options::ENABLE_YAML_STYLE_METADATA_BLOCKS 76 - | markdown_weaver::Options::ENABLE_OBSIDIAN_EMBEDS 77 - | markdown_weaver::Options::ENABLE_MATH 78 - | markdown_weaver::Options::ENABLE_HEADING_ATTRIBUTES 79 - } 80 70 81 71 pub struct StaticSiteWriter<A> 82 72 where ··· 419 409 Ok(()) 420 410 } 421 411 422 - /// Path lookup in an Obsidian vault 423 - /// 424 - /// Credit to https://github.com/zoni 425 - /// 426 - /// Taken from https://github.com/zoni/obsidian-export/blob/main/src/lib.rs.rs on 2025-05-21 427 - /// 428 - pub fn lookup_filename_in_vault<'a>( 429 - filename: &str, 430 - vault_contents: &'a [PathBuf], 431 - ) -> Option<&'a PathBuf> { 432 - let filename = PathBuf::from(filename); 433 - let filename_normalized: String = filename.to_string_lossy().nfc().collect(); 434 412 435 - vault_contents.iter().find(|path| { 436 - let path_normalized_str: String = path.to_string_lossy().nfc().collect(); 437 - let path_normalized = PathBuf::from(&path_normalized_str); 438 - let path_normalized_lowered = PathBuf::from(&path_normalized_str.to_lowercase()); 439 - 440 - // It would be convenient if we could just do `filename.set_extension("md")` at the start 441 - // of this funtion so we don't need multiple separate + ".md" match cases here, however 442 - // that would break with a reference of `[[Note.1]]` linking to `[[Note.1.md]]`. 443 - 444 - path_normalized.ends_with(&filename_normalized) 445 - || path_normalized.ends_with(filename_normalized.clone() + ".md") 446 - || path_normalized_lowered.ends_with(filename_normalized.to_lowercase()) 447 - || path_normalized_lowered.ends_with(filename_normalized.to_lowercase() + ".md") 448 - }) 449 - } 450 - 451 - pub struct VaultBrokenLinkCallback { 452 - vault_contents: Arc<[PathBuf]>, 453 - } 454 - 455 - impl<'input> markdown_weaver::BrokenLinkCallback<'input> for VaultBrokenLinkCallback { 456 - fn handle_broken_link( 457 - &mut self, 458 - link: BrokenLink<'input>, 459 - ) -> Option<(CowStr<'input>, CowStr<'input>)> { 460 - let text = link.reference; 461 - let captures = crate::OBSIDIAN_NOTE_LINK_RE 462 - .captures(&text) 463 - .expect("note link regex didn't match - bad input?"); 464 - let file = captures.name("file").map(|v| v.as_str().trim()); 465 - let label = captures.name("label").map(|v| v.as_str()); 466 - let section = captures.name("section").map(|v| v.as_str().trim()); 467 - 468 - if let Some(file) = file { 469 - if let Some(path) = lookup_filename_in_vault(file, self.vault_contents.as_ref()) { 470 - let mut link_text = String::from(path.to_string_lossy()); 471 - if let Some(section) = section { 472 - link_text.push('#'); 473 - link_text.push_str(section); 474 - if let Some(label) = label { 475 - let label = label.to_string(); 476 - Some((CowStr::from(link_text), CowStr::from(label))) 477 - } else { 478 - Some((link_text.into(), format!("{} > {}", file, section).into())) 479 - } 480 - } else { 481 - Some((link_text.into(), format!("{}", file).into())) 482 - } 483 - } else { 484 - None 485 - } 486 - } else { 487 - None 488 - } 489 - } 490 - } 491 413 492 414 #[cfg(test)] 493 415 mod tests;
+2 -2
crates/weaver-renderer/src/static_site/context.rs
··· 1 - use crate::static_site::{StaticSiteOptions, default_md_options}; 1 + use crate::static_site::{StaticSiteOptions}; 2 2 use crate::theme::Theme; 3 - use crate::{Frontmatter, NotebookContext}; 3 + use crate::{Frontmatter, NotebookContext,default_md_options}; 4 4 use dashmap::DashMap; 5 5 use markdown_weaver::{CowStr, EmbedType, Tag, WeaverAttributes}; 6 6 use std::{
+75 -1
crates/weaver-renderer/src/utils.rs
··· 1 1 use std::{path::Path, sync::OnceLock}; 2 - 3 2 use markdown_weaver::{CodeBlockKind, CowStr, Event, Tag}; 4 3 use miette::IntoDiagnostic; 5 4 use n0_future::TryFutureExt; 6 5 use regex::Regex; 6 + use markdown_weaver::BrokenLink; 7 + use std::path::PathBuf; 8 + use std::sync::Arc; 9 + use unicode_normalization::UnicodeNormalization; 7 10 8 11 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 9 12 pub async fn inline_file(path: impl AsRef<Path>) -> Option<String> { ··· 240 243 .into_diagnostic()?; 241 244 Ok(file) 242 245 } 246 + 247 + 248 + /// Path lookup in an Obsidian vault 249 + /// 250 + /// Credit to https://github.com/zoni 251 + /// 252 + /// Taken from https://github.com/zoni/obsidian-export/blob/main/src/lib.rs.rs on 2025-05-21 253 + /// 254 + pub fn lookup_filename_in_vault<'a>( 255 + filename: &str, 256 + vault_contents: &'a [PathBuf], 257 + ) -> Option<&'a PathBuf> { 258 + let filename = PathBuf::from(filename); 259 + let filename_normalized: String = filename.to_string_lossy().nfc().collect(); 260 + 261 + vault_contents.iter().find(|path| { 262 + let path_normalized_str: String = path.to_string_lossy().nfc().collect(); 263 + let path_normalized = PathBuf::from(&path_normalized_str); 264 + let path_normalized_lowered = PathBuf::from(&path_normalized_str.to_lowercase()); 265 + 266 + // It would be convenient if we could just do `filename.set_extension("md")` at the start 267 + // of this funtion so we don't need multiple separate + ".md" match cases here, however 268 + // that would break with a reference of `[[Note.1]]` linking to `[[Note.1.md]]`. 269 + 270 + path_normalized.ends_with(&filename_normalized) 271 + || path_normalized.ends_with(filename_normalized.clone() + ".md") 272 + || path_normalized_lowered.ends_with(filename_normalized.to_lowercase()) 273 + || path_normalized_lowered.ends_with(filename_normalized.to_lowercase() + ".md") 274 + }) 275 + } 276 + 277 + pub struct VaultBrokenLinkCallback { 278 + pub vault_contents: Arc<[PathBuf]>, 279 + } 280 + 281 + impl<'input> markdown_weaver::BrokenLinkCallback<'input> for VaultBrokenLinkCallback { 282 + fn handle_broken_link( 283 + &mut self, 284 + link: BrokenLink<'input>, 285 + ) -> Option<(CowStr<'input>, CowStr<'input>)> { 286 + let text = link.reference; 287 + let captures = crate::OBSIDIAN_NOTE_LINK_RE 288 + .captures(&text) 289 + .expect("note link regex didn't match - bad input?"); 290 + let file = captures.name("file").map(|v| v.as_str().trim()); 291 + let label = captures.name("label").map(|v| v.as_str()); 292 + let section = captures.name("section").map(|v| v.as_str().trim()); 293 + 294 + if let Some(file) = file { 295 + if let Some(path) = lookup_filename_in_vault(file, self.vault_contents.as_ref()) { 296 + let mut link_text = String::from(path.to_string_lossy()); 297 + if let Some(section) = section { 298 + link_text.push('#'); 299 + link_text.push_str(section); 300 + if let Some(label) = label { 301 + let label = label.to_string(); 302 + Some((CowStr::from(link_text), CowStr::from(label))) 303 + } else { 304 + Some((link_text.into(), format!("{} > {}", file, section).into())) 305 + } 306 + } else { 307 + Some((link_text.into(), format!("{}", file).into())) 308 + } 309 + } else { 310 + None 311 + } 312 + } else { 313 + None 314 + } 315 + } 316 + }
+46
crates/weaver-renderer/tests/atproto_integration.rs
··· 1 + // Integration tests for AT Protocol rendering pipeline 2 + // 3 + // These tests verify the full markdown→markdown transformation pipeline: 4 + // 1. Parse input markdown 5 + // 2. Process through AtProtoPreprocessContext 6 + // 3. Upload images to PDS 7 + // 4. Canonicalize wikilinks and profile links 8 + // 5. Write transformed markdown 9 + 10 + // NOTE: Full implementation pending processor streaming support 11 + // For now, these are placeholders that will be completed when: 12 + // - NotebookProcessor can stream events through contexts 13 + // - MarkdownWriter can consume event streams 14 + 15 + #[cfg(test)] 16 + mod tests { 17 + #[test] 18 + #[ignore] 19 + fn test_markdown_to_markdown_pipeline() { 20 + // TODO: Implement once processor streaming is available 21 + // This test should: 22 + // 1. Create mock vault with test markdown files 23 + // 2. Set up AtProtoPreprocessContext with test agent 24 + // 3. Process markdown through the pipeline 25 + // 4. Verify output contains canonical links 26 + // 5. Verify blob tracking captured image metadata 27 + } 28 + 29 + #[test] 30 + #[ignore] 31 + fn test_wikilink_canonicalization() { 32 + // TODO: Test that [[Entry Name]] becomes /{handle}/{notebook}/Entry_Name 33 + } 34 + 35 + #[test] 36 + #[ignore] 37 + fn test_image_upload_and_rewrite() { 38 + // TODO: Test that ![alt](./image.png) uploads blob and rewrites to /{notebook}/image/{name} 39 + } 40 + 41 + #[test] 42 + #[ignore] 43 + fn test_profile_link_resolution() { 44 + // TODO: Test that [[@handle]] resolves to /{handle} 45 + } 46 + }