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