at main 423 lines 14 kB view raw
1use jacquard::IntoStatic; 2use jacquard::client::{Agent, FileAuthStore}; 3use jacquard::identity::JacquardResolver; 4use jacquard::oauth::client::{OAuthClient, OAuthSession}; 5use jacquard::oauth::loopback::LoopbackConfig; 6use jacquard::prelude::*; 7use jacquard::types::string::Handle; 8use miette::{IntoDiagnostic, Result}; 9use std::io::BufRead; 10use std::path::PathBuf; 11use std::sync::Arc; 12use weaver_common::normalize_title_path; 13use weaver_renderer::atproto::AtProtoPreprocessContext; 14use weaver_renderer::static_site::StaticSiteWriter; 15use weaver_renderer::utils::VaultBrokenLinkCallback; 16use weaver_renderer::walker::{WalkOptions, vault_contents}; 17 18use clap::{Parser, Subcommand}; 19 20#[derive(Parser)] 21#[command(version, about = "Weaver - Static site generator for AT Protocol notebooks", long_about = None)] 22#[command(propagate_version = true)] 23struct Cli { 24 /// Path to notebook directory 25 source: Option<PathBuf>, 26 27 /// Output directory for static site 28 dest: Option<PathBuf>, 29 30 /// Path to auth store file 31 #[arg(long)] 32 store: Option<PathBuf>, 33 34 #[command(subcommand)] 35 command: Option<Commands>, 36} 37 38#[derive(Subcommand)] 39enum Commands { 40 /// Authenticate with your atproto PDS using OAuth 41 Auth { 42 /// Handle (e.g., alice.bsky.social), DID, or PDS URL 43 handle: String, 44 45 /// Path to auth store file (will be created if missing) 46 #[arg(long)] 47 store: Option<PathBuf>, 48 }, 49 /// Publish notebook to AT Protocol 50 Publish { 51 /// Path to notebook directory 52 source: PathBuf, 53 54 /// Notebook title 55 //#[arg(long)] 56 title: String, 57 58 /// Path to auth store file 59 #[arg(long)] 60 store: Option<PathBuf>, 61 }, 62} 63 64#[tokio::main] 65async fn main() -> Result<()> { 66 init_miette(); 67 68 let cli = Cli::parse(); 69 70 match cli.command { 71 Some(Commands::Auth { handle, store }) => { 72 let store_path = store.unwrap_or_else(default_auth_store_path); 73 authenticate(handle, store_path).await?; 74 } 75 Some(Commands::Publish { 76 source, 77 title, 78 store, 79 }) => { 80 let store_path = store.unwrap_or_else(default_auth_store_path); 81 publish_notebook(source, title, store_path).await?; 82 } 83 None => { 84 // Render command (default) 85 let source = cli.source.ok_or_else(|| { 86 miette::miette!("Source directory required. Usage: weaver <source> <dest>") 87 })?; 88 let dest = cli.dest.ok_or_else(|| { 89 miette::miette!("Destination directory required. Usage: weaver <source> <dest>") 90 })?; 91 let store_path = cli.store.unwrap_or_else(default_auth_store_path); 92 93 render_notebook(source, dest, store_path).await?; 94 } 95 } 96 97 Ok(()) 98} 99 100async fn authenticate(handle: String, store_path: PathBuf) -> Result<()> { 101 println!("Authenticating as @{handle} ..."); 102 103 let oauth = OAuthClient::with_default_config(FileAuthStore::new(&store_path)); 104 105 let session = oauth 106 .login_with_local_server(handle, Default::default(), LoopbackConfig::default()) 107 .await 108 .into_diagnostic()?; 109 110 let (did, session_id) = session.session_info().await; 111 112 // Save DID and session_id for later use 113 let config_path = store_path.with_extension("kdl"); 114 let config_content = format!("did \"{}\"\nsession-id \"{}\"\n", did, session_id); 115 std::fs::write(&config_path, config_content).into_diagnostic()?; 116 117 println!("Successfully authenticated!"); 118 println!("Session saved to: {}", store_path.display()); 119 120 Ok(()) 121} 122 123async fn try_load_session( 124 store_path: &PathBuf, 125) -> Option<OAuthSession<JacquardResolver, FileAuthStore>> { 126 use kdl::KdlDocument; 127 128 // Check if auth store exists 129 if !store_path.exists() { 130 return None; 131 } 132 133 // Read KDL config 134 let config_path = store_path.with_extension("kdl"); 135 let config_content = std::fs::read_to_string(&config_path).ok()?; 136 137 // Parse KDL 138 let doc: KdlDocument = config_content.parse().ok()?; 139 140 // Extract did and session-id 141 let did_node = doc.get("did")?; 142 let session_id_node = doc.get("session-id")?; 143 144 let did_str = did_node.entries().first()?.value().as_string()?; 145 let session_id = session_id_node.entries().first()?.value().as_string()?; 146 147 // Parse DID 148 let did = jacquard::types::string::Did::new(did_str).ok()?; 149 150 // Restore OAuth session 151 let oauth = OAuthClient::with_default_config(FileAuthStore::new(store_path)); 152 oauth.restore(&did, session_id).await.ok() 153} 154 155async fn render_notebook(source: PathBuf, dest: PathBuf, store_path: PathBuf) -> Result<()> { 156 // Validate source exists 157 if !source.exists() { 158 return Err(miette::miette!( 159 "Source directory not found: {}", 160 source.display() 161 )); 162 } 163 164 // Try to load session 165 let session = try_load_session(&store_path).await; 166 167 // Log auth status 168 if session.is_some() { 169 println!("✓ Found authentication"); 170 } else { 171 println!("⚠ No authentication found"); 172 println!(" Run 'weaver auth <handle>' to enable network features"); 173 } 174 175 // Create dest parent directories if needed 176 if let Some(parent) = dest.parent() { 177 if !parent.exists() { 178 std::fs::create_dir_all(parent).into_diagnostic()?; 179 } 180 } 181 182 // Create renderer 183 let writer = StaticSiteWriter::new(source, dest.clone(), session); 184 185 // Render 186 println!("→ Rendering notebook..."); 187 let start = std::time::Instant::now(); 188 writer.run().await?; 189 let elapsed = start.elapsed(); 190 191 // Report success 192 println!("✓ Rendered in {:.2}s", elapsed.as_secs_f64()); 193 println!("✓ Output: {}", dest.display()); 194 195 Ok(()) 196} 197 198fn default_auth_store_path() -> PathBuf { 199 dirs::config_dir() 200 .expect("Could not determine config directory") 201 .join("weaver") 202 .join("auth.json") 203} 204 205async fn publish_notebook(source: PathBuf, title: String, store_path: PathBuf) -> Result<()> { 206 // Initialize tracing for debugging 207 tracing_subscriber::fmt() 208 .with_env_filter( 209 tracing_subscriber::EnvFilter::try_from_default_env() 210 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("debug")), 211 ) 212 .init(); 213 214 println!("Publishing notebook from: {}", source.display()); 215 println!("Title: {}", title); 216 217 // Validate source exists 218 if !source.exists() { 219 return Err(miette::miette!( 220 "Source directory not found: {}", 221 source.display() 222 )); 223 } 224 225 // Try to load session, trigger auth if needed 226 let session = match try_load_session(&store_path).await { 227 Some(session) => { 228 println!("✓ Authenticated"); 229 session 230 } 231 None => { 232 println!("⚠ No authentication found"); 233 println!("Please enter your handle to authenticate:"); 234 235 let mut handle = String::new(); 236 let stdin = std::io::stdin(); 237 stdin.lock().read_line(&mut handle).into_diagnostic()?; 238 let handle = handle.trim().to_string(); 239 240 authenticate(handle, store_path.clone()).await?; 241 242 // Load the session we just created 243 try_load_session(&store_path) 244 .await 245 .ok_or_else(|| miette::miette!("Failed to load session after authentication"))? 246 } 247 }; 248 249 // Get user DID and handle from session 250 251 // Create agent and resolve DID document to get handle 252 let agent = Agent::new(session); 253 let (did, _session_id) = agent 254 .info() 255 .await 256 .ok_or_else(|| miette::miette!("No session info available"))?; 257 let did_doc_response = agent.resolve_did_doc(&did).await?; 258 let did_doc = did_doc_response.parse()?; 259 260 // Extract handle from alsoKnownAs 261 let aka_vec = did_doc 262 .also_known_as 263 .ok_or_else(|| miette::miette!("No alsoKnownAs in DID document"))?; 264 let handle_str = aka_vec 265 .get(0) 266 .and_then(|aka| aka.as_ref().strip_prefix("at://")) 267 .ok_or_else(|| miette::miette!("No handle found in DID document"))?; 268 let handle = Handle::new(handle_str)?; 269 270 println!("Publishing as @{}", handle.as_ref()); 271 272 // Walk vault directory 273 println!("→ Scanning vault..."); 274 tracing::debug!("Scanning directory: {}", source.display()); 275 let contents = vault_contents(&source, WalkOptions::new())?; 276 277 // Convert to Arc first 278 let agent = Arc::new(agent); 279 let vault_arc: Arc<[PathBuf]> = contents.into(); 280 281 // Filter markdown files after converting to Arc 282 let md_files: Vec<PathBuf> = vault_arc 283 .iter() 284 .filter(|path| { 285 path.extension() 286 .and_then(|ext| ext.to_str()) 287 .map(|ext| ext == "md" || ext == "markdown") 288 .unwrap_or(false) 289 }) 290 .cloned() 291 .collect(); 292 293 println!("Found {} markdown files", md_files.len()); 294 295 // Create preprocessing context 296 let context = AtProtoPreprocessContext::new(vault_arc.clone(), title.clone(), agent.clone()) 297 .with_creator(did.clone().into_static(), handle.clone().into_static()); 298 299 // Process each file 300 for file_path in &md_files { 301 let _span = tracing::info_span!("process_file", path = %file_path.display()).entered(); 302 println!("Processing: {}", file_path.display()); 303 304 // Read file content 305 let contents = tokio::fs::read_to_string(&file_path) 306 .await 307 .into_diagnostic()?; 308 309 // Clone context for this file 310 let mut file_context = context.clone(); 311 file_context.set_current_path(file_path.clone()); 312 let callback = Some(VaultBrokenLinkCallback { 313 vault_contents: vault_arc.clone(), 314 }); 315 316 // Parse markdown 317 use markdown_weaver::Parser; 318 use weaver_renderer::default_md_options; 319 let parser = 320 Parser::new_with_broken_link_callback(&contents, default_md_options(), callback) 321 .into_offset_iter(); 322 let iterator = weaver_renderer::ContextIterator::default(parser); 323 324 // Process through NotebookProcessor 325 use n0_future::StreamExt; 326 use weaver_renderer::{NotebookContext, NotebookProcessor}; 327 let mut processor = NotebookProcessor::new(file_context.clone(), iterator); 328 329 // Write canonical markdown with MarkdownWriter 330 use markdown_weaver_escape::FmtWriter; 331 use weaver_renderer::atproto::MarkdownWriter; 332 let mut output = String::new(); 333 let mut md_writer = MarkdownWriter::new(FmtWriter(&mut output)); 334 335 // Process all events 336 while let Some((event, _)) = processor.next().await { 337 md_writer 338 .write_event(event) 339 .map_err(|e| miette::miette!("Failed to write markdown: {:?}", e))?; 340 } 341 342 // Extract blobs and entry metadata 343 let blobs = file_context.blobs(); 344 let entry_title = file_context.entry_title(); 345 346 if !blobs.is_empty() { 347 tracing::debug!("Uploaded {} image(s)", blobs.len()); 348 } 349 350 // Build Entry record with blobs 351 use jacquard::types::blob::BlobRef; 352 use jacquard::types::string::Datetime; 353 use weaver_api::sh_weaver::embed::images::{Image, Images}; 354 use weaver_api::sh_weaver::notebook::entry::{Entry, EntryEmbeds}; 355 356 let embeds = if !blobs.is_empty() { 357 // Build images from blobs 358 let images: Vec<Image> = blobs 359 .iter() 360 .map(|blob_info| { 361 Image::new() 362 .image(BlobRef::Blob(blob_info.blob.clone())) 363 .alt(blob_info.alt.as_ref().map(|a| a.as_ref()).unwrap_or("")) 364 .maybe_name(Some(blob_info.name.as_str().into())) 365 .build() 366 }) 367 .collect(); 368 369 Some(EntryEmbeds { 370 images: Some(Images::new().images(images).build()), 371 externals: None, 372 records: None, 373 records_with_media: None, 374 videos: None, 375 extra_data: None, 376 }) 377 } else { 378 None 379 }; 380 381 let entry = Entry::new() 382 .content(output.as_str()) 383 .title(entry_title.as_ref()) 384 .path(normalize_title_path(entry_title.as_ref())) 385 .created_at(Datetime::now()) 386 .maybe_embeds(embeds) 387 .build(); 388 389 // Use WeaverExt to upsert entry (handles notebook + entry creation/updates) 390 use jacquard::http_client::HttpClient; 391 use weaver_common::WeaverExt; 392 let (entry_ref, _, was_created) = agent 393 .upsert_entry(&title, entry_title.as_ref(), entry, None) 394 .await?; 395 396 if was_created { 397 println!(" ✓ Created new entry: {}", entry_ref.uri.as_ref()); 398 } else { 399 println!(" ✓ Updated existing entry: {}", entry_ref.uri.as_ref()); 400 } 401 } 402 403 println!("✓ Published {} entries", md_files.len()); 404 405 Ok(()) 406} 407 408fn init_miette() { 409 miette::set_hook(Box::new(|_| { 410 Box::new( 411 miette::MietteHandlerOpts::new() 412 .terminal_links(true) 413 .with_cause_chain() 414 .color(true) 415 .context_lines(5) 416 .tab_width(2) 417 .break_words(true) 418 .build(), 419 ) 420 })) 421 .expect("couldn't set the miette hook"); 422 miette::set_panic_hook(); 423}