further embed display work

Orual f83a2ef7 0d35d8db

+1557 -1106
+128
crates/weaver-app/src/blobcache.rs
··· 1 1 use crate::cache_impl; 2 2 use crate::fetch::Fetcher; 3 + #[cfg(all(feature = "fullstack-server", feature = "server"))] 4 + use axum::Extension; 5 + use dioxus::prelude::*; 3 6 use dioxus::{CapturedError, Result}; 4 7 use jacquard::{ 5 8 IntoStatic, ··· 338 341 self.map.get(name).and_then(|cid| self.cache.get(&cid)) 339 342 } 340 343 } 344 + 345 + /// Build an image response with appropriate headers for immutable blobs. 346 + #[cfg(all(feature = "fullstack-server", feature = "server"))] 347 + fn build_image_response(bytes: jacquard::bytes::Bytes) -> axum::response::Response { 348 + use axum::{ 349 + http::header::{CACHE_CONTROL, CONTENT_TYPE}, 350 + response::IntoResponse, 351 + }; 352 + use mime_sniffer::MimeTypeSniffer; 353 + 354 + let mime = bytes.sniff_mime_type().unwrap_or("image/jpg").to_string(); 355 + ( 356 + [ 357 + (CONTENT_TYPE, mime), 358 + ( 359 + CACHE_CONTROL, 360 + "public, max-age=31536000, immutable".to_string(), 361 + ), 362 + ], 363 + bytes, 364 + ) 365 + .into_response() 366 + } 367 + 368 + /// Return a 404 response for missing images. 369 + #[cfg(all(feature = "fullstack-server", feature = "server"))] 370 + fn image_not_found() -> axum::response::Response { 371 + use axum::{http::StatusCode, response::IntoResponse}; 372 + (StatusCode::NOT_FOUND, "Image not found").into_response() 373 + } 374 + 375 + #[cfg(all(feature = "fullstack-server", feature = "server"))] 376 + #[get("/{notebook}/image/{name}", blob_cache: Extension<Arc<crate::blobcache::BlobCache>>)] 377 + pub async fn image_named(notebook: SmolStr, name: SmolStr) -> Result<axum::response::Response> { 378 + if let Some(bytes) = blob_cache.get_named(&name) { 379 + return Ok(build_image_response(bytes)); 380 + } 381 + 382 + // Try to resolve from notebook 383 + match blob_cache.resolve_from_notebook(&notebook, &name).await { 384 + Ok(bytes) => Ok(build_image_response(bytes)), 385 + Err(_) => Ok(image_not_found()), 386 + } 387 + } 388 + 389 + #[cfg(all(feature = "fullstack-server", feature = "server"))] 390 + #[get("/{_notebook}/blob/{cid}", blob_cache: Extension<Arc<crate::blobcache::BlobCache>>)] 391 + pub async fn blob(_notebook: SmolStr, cid: SmolStr) -> Result<axum::response::Response> { 392 + match Cid::new_owned(cid.as_bytes()) { 393 + Ok(cid) => { 394 + if let Some(bytes) = blob_cache.get_cid(&cid) { 395 + Ok(build_image_response(bytes)) 396 + } else { 397 + Ok(image_not_found()) 398 + } 399 + } 400 + Err(_) => Ok(image_not_found()), 401 + } 402 + } 403 + 404 + // Route: /image/{notebook}/{name} - notebook entry image by name 405 + #[cfg(all(feature = "fullstack-server", feature = "server"))] 406 + #[get("/image/{notebook}/{name}", blob_cache: Extension<Arc<crate::blobcache::BlobCache>>)] 407 + pub async fn image_notebook(notebook: SmolStr, name: SmolStr) -> Result<axum::response::Response> { 408 + // Try name-based lookup first (backwards compat with cached entries) 409 + if let Some(bytes) = blob_cache.get_named(&name) { 410 + return Ok(build_image_response(bytes)); 411 + } 412 + 413 + // Try to resolve from notebook 414 + match blob_cache.resolve_from_notebook(&notebook, &name).await { 415 + Ok(bytes) => Ok(build_image_response(bytes)), 416 + Err(_) => Ok(image_not_found()), 417 + } 418 + } 419 + 420 + // Route: /image/{ident}/draft/{blob_rkey} - draft image (unpublished) 421 + #[cfg(all(feature = "fullstack-server", feature = "server"))] 422 + #[get("/image/{ident}/draft/{blob_rkey}", blob_cache: Extension<Arc<crate::blobcache::BlobCache>>)] 423 + pub async fn image_draft(ident: SmolStr, blob_rkey: SmolStr) -> Result<axum::response::Response> { 424 + let Ok(at_ident) = AtIdentifier::new_owned(ident.clone()) else { 425 + return Ok(image_not_found()); 426 + }; 427 + 428 + match blob_cache.resolve_from_draft(&at_ident, &blob_rkey).await { 429 + Ok(bytes) => Ok(build_image_response(bytes)), 430 + Err(_) => Ok(image_not_found()), 431 + } 432 + } 433 + 434 + // Route: /image/{ident}/draft/{blob_rkey}/{name} - draft image with name (name is decorative) 435 + #[cfg(all(feature = "fullstack-server", feature = "server"))] 436 + #[get("/image/{ident}/draft/{blob_rkey}/{_name}", blob_cache: Extension<Arc<crate::blobcache::BlobCache>>)] 437 + pub async fn image_draft_named( 438 + ident: SmolStr, 439 + blob_rkey: SmolStr, 440 + _name: SmolStr, 441 + ) -> Result<axum::response::Response> { 442 + let Ok(at_ident) = AtIdentifier::new_owned(ident.clone()) else { 443 + return Ok(image_not_found()); 444 + }; 445 + 446 + match blob_cache.resolve_from_draft(&at_ident, &blob_rkey).await { 447 + Ok(bytes) => Ok(build_image_response(bytes)), 448 + Err(_) => Ok(image_not_found()), 449 + } 450 + } 451 + 452 + // Route: /image/{ident}/{rkey}/{name} - published entry image 453 + #[cfg(all(feature = "fullstack-server", feature = "server"))] 454 + #[get("/image/{ident}/{rkey}/{name}", blob_cache: Extension<Arc<crate::blobcache::BlobCache>>)] 455 + pub async fn image_entry( 456 + ident: SmolStr, 457 + rkey: SmolStr, 458 + name: SmolStr, 459 + ) -> Result<axum::response::Response> { 460 + let Ok(at_ident) = AtIdentifier::new_owned(ident.clone()) else { 461 + return Ok(image_not_found()); 462 + }; 463 + 464 + match blob_cache.resolve_from_entry(&at_ident, &rkey, &name).await { 465 + Ok(bytes) => Ok(build_image_response(bytes)), 466 + Err(_) => Ok(image_not_found()), 467 + } 468 + }
+83 -5
crates/weaver-app/src/components/editor/component.rs
··· 141 141 tags.push(tag_str).ok(); 142 142 } 143 143 } 144 + 145 + // Restore existing embeds from the entry 146 + if let Some(ref embeds) = loaded.entry.embeds { 147 + let embeds_map = doc.get_map("embeds"); 148 + 149 + // Restore images 150 + if let Some(ref images) = embeds.images { 151 + let images_list = embeds_map 152 + .get_or_create_container("images", loro::LoroList::new()) 153 + .expect("images list"); 154 + for image in &images.images { 155 + // Serialize image to JSON and add to list 156 + // No publishedBlobUri since these are already published 157 + let json = serde_json::to_value(image) 158 + .expect("Image serializes"); 159 + images_list.push(json).ok(); 160 + } 161 + } 162 + 163 + // Restore record embeds 164 + if let Some(ref records) = embeds.records { 165 + let records_list = embeds_map 166 + .get_or_create_container("records", loro::LoroList::new()) 167 + .expect("records list"); 168 + for record in &records.records { 169 + let json = serde_json::to_value(record) 170 + .expect("RecordEmbed serializes"); 171 + records_list.push(json).ok(); 172 + } 173 + } 174 + } 175 + 144 176 doc.commit(); 145 177 146 178 return LoadResult::Loaded(LoadedDocState { ··· 233 265 let auth_state = use_context::<Signal<AuthState>>(); 234 266 235 267 let mut document = use_hook(|| { 236 - let doc = EditorDocument::from_loaded_state(loaded_state.clone()); 268 + let mut doc = EditorDocument::from_loaded_state(loaded_state.clone()); 269 + 270 + // Seed collected_refs with existing record embeds so they get fetched/rendered 271 + let record_embeds = doc.record_embeds(); 272 + if !record_embeds.is_empty() { 273 + let refs: Vec<weaver_common::ExtractedRef> = record_embeds 274 + .into_iter() 275 + .filter_map(|embed| { 276 + embed.name.map(|name| weaver_common::ExtractedRef::AtEmbed { 277 + uri: name.to_string(), 278 + alt_text: None, 279 + }) 280 + }) 281 + .collect(); 282 + doc.set_collected_refs(refs); 283 + } 284 + 237 285 storage::save_to_storage(&doc, &draft_key).ok(); 238 286 doc 239 287 }); 240 288 let editor_id = "markdown-editor"; 241 289 let mut render_cache = use_signal(|| render::RenderCache::default()); 242 - let mut image_resolver = use_signal(EditorImageResolver::default); 290 + 291 + // Populate resolver from existing images if editing a published entry 292 + let mut image_resolver: Signal<EditorImageResolver> = use_signal(|| { 293 + let images = document.images(); 294 + if let (false, Some(ref r)) = (images.is_empty(), document.entry_ref()) { 295 + let ident = r.uri.authority().clone().into_static(); 296 + let entry_rkey = r.uri.rkey().map(|rk| rk.0.clone().into_static()); 297 + EditorImageResolver::from_images(&images, ident, entry_rkey) 298 + } else { 299 + EditorImageResolver::default() 300 + } 301 + }); 243 302 let resolved_content = use_signal(weaver_common::ResolvedContent::default); 244 303 245 304 let doc_for_memo = document.clone(); ··· 1095 1154 } else { 1096 1155 uploaded.alt.clone() 1097 1156 }; 1098 - let markdown = format!("![{}](/image/{})", alt_text, name); 1157 + 1158 + // Check if authenticated and get DID for draft path 1159 + let auth = auth_state.read(); 1160 + let did_for_path = auth.did.clone(); 1161 + let is_authenticated = auth.is_authenticated(); 1162 + drop(auth); 1163 + 1164 + // Pre-generate TID for the blob rkey (used in draft path and upload) 1165 + let blob_tid = jacquard::types::tid::Ticker::new().next(None); 1166 + 1167 + // Build markdown with proper draft path if authenticated 1168 + let markdown = if let Some(ref did) = did_for_path { 1169 + format!("![{}](/image/{}/draft/{}/{})", alt_text, did, blob_tid.as_str(), name) 1170 + } else { 1171 + // Fallback for unauthenticated - simple path (won't be publishable anyway) 1172 + format!("![{}](/image/{})", alt_text, name) 1173 + }; 1099 1174 1100 1175 let pos = doc.cursor.read().offset; 1101 1176 let _ = doc.insert_tracked(pos, &markdown); 1102 1177 doc.cursor.write().offset = pos + markdown.chars().count(); 1103 1178 1104 1179 // Upload to PDS in background if authenticated 1105 - let is_authenticated = auth_state.read().is_authenticated(); 1106 1180 if is_authenticated { 1107 1181 let fetcher = fetcher.clone(); 1108 1182 let name_for_upload = name.clone(); ··· 1116 1190 // Clone data for cache pre-warming 1117 1191 let data_for_cache = data.clone(); 1118 1192 1193 + // Use pre-generated TID as rkey for the blob record 1194 + let rkey = jacquard::types::recordkey::RecordKey::any(blob_tid.as_str()) 1195 + .expect("TID is valid record key"); 1196 + 1119 1197 // Upload blob and create temporary PublishedBlob record 1120 - match client.publish_blob(data, &name_for_upload, None).await { 1198 + match client.publish_blob(data, &name_for_upload, Some(rkey)).await { 1121 1199 Ok((strong_ref, published_blob)) => { 1122 1200 // Get DID from fetcher 1123 1201 let did = match fetcher.current_did().await {
+32
crates/weaver-app/src/components/editor/document.rs
··· 26 26 use jacquard::types::string::AtUri; 27 27 use weaver_api::com_atproto::repo::strong_ref::StrongRef; 28 28 use weaver_api::sh_weaver::embed::images::Image; 29 + use weaver_api::sh_weaver::embed::records::RecordEmbed; 29 30 use weaver_api::sh_weaver::notebook::entry::Entry; 30 31 31 32 /// Helper for working with editor images. ··· 612 613 } 613 614 } 614 615 } 616 + } 617 + 618 + // --- Record embed methods --- 619 + 620 + /// Get the records LoroList from embeds, creating it if needed. 621 + fn get_records_list(&self) -> LoroList { 622 + self.embeds 623 + .get_or_create_container("records", LoroList::new()) 624 + .unwrap() 625 + } 626 + 627 + /// Get all record embeds as a Vec. 628 + pub fn record_embeds(&self) -> Vec<RecordEmbed<'static>> { 629 + let records_list = self.get_records_list(); 630 + let mut result = Vec::new(); 631 + 632 + for i in 0..records_list.len() { 633 + if let Some(record_embed) = self.loro_value_to_record_embed(&records_list, i) { 634 + result.push(record_embed); 635 + } 636 + } 637 + 638 + result 639 + } 640 + 641 + /// Convert a LoroValue at the given index to a RecordEmbed. 642 + fn loro_value_to_record_embed(&self, list: &LoroList, index: usize) -> Option<RecordEmbed<'static>> { 643 + let value = list.get(index)?; 644 + let loro_value = value.as_value()?; 645 + let json = loro_value.to_json_value(); 646 + from_json_value::<RecordEmbed>(json).ok().map(|r| r.into_static()) 615 647 } 616 648 617 649 /// Insert text into content and record edit info for incremental rendering.
+17 -2
crates/weaver-app/src/components/editor/publish.rs
··· 3 3 //! Handles creating/updating/loading AT Protocol notebook entries. 4 4 5 5 use dioxus::prelude::*; 6 + use jacquard::cowstr::ToCowStr; 6 7 use jacquard::types::collection::Collection; 7 8 use jacquard::types::ident::AtIdentifier; 8 9 use jacquard::types::recordkey::RecordKey; ··· 171 172 for uri in at_embed_uris { 172 173 match fetcher.confirm_record_ref(&uri).await { 173 174 Ok(strong_ref) => { 174 - record_embeds.push(RecordEmbed::new().record(strong_ref).build()); 175 + // Store original URI in name field for lookup when authority differs (handle vs DID) 176 + record_embeds.push( 177 + RecordEmbed::new() 178 + .name(uri.to_cowstr().into_static()) 179 + .record(strong_ref) 180 + .build(), 181 + ); 175 182 } 176 183 Err(e) => { 177 184 tracing::warn!("Failed to resolve embed {}: {}", uri, e); ··· 180 187 } 181 188 182 189 // Build embeds if we have images or records 190 + tracing::debug!( 191 + "[publish_entry] Building embeds: {} images, {} record embeds", 192 + editor_images.len(), 193 + record_embeds.len() 194 + ); 183 195 let entry_embeds = if editor_images.is_empty() && record_embeds.is_empty() { 184 196 None 185 197 } else { ··· 428 440 429 441 let mut show_dialog = use_signal(|| false); 430 442 let mut notebook_title = use_signal(|| { 431 - props.target_notebook.clone().unwrap_or_else(|| String::from("Default")) 443 + props 444 + .target_notebook 445 + .clone() 446 + .unwrap_or_else(|| String::from("Default")) 432 447 }); 433 448 let mut use_notebook = use_signal(|| props.target_notebook.is_some()); 434 449 let mut is_publishing = use_signal(|| false);
+15 -7
crates/weaver-app/src/components/editor/render.rs
··· 40 40 pub offset_map: Vec<OffsetMapping>, 41 41 /// Syntax spans for conditional visibility 42 42 pub syntax_spans: Vec<SyntaxSpanInfo>, 43 + /// Collected refs (wikilinks, AT embeds) from this paragraph 44 + pub collected_refs: Vec<weaver_common::ExtractedRef>, 43 45 } 44 46 45 47 /// Check if an edit affects paragraph boundaries. ··· 148 150 html: empty_html, 149 151 offset_map: vec![], 150 152 syntax_spans: vec![], 153 + collected_refs: vec![], 151 154 }], 152 155 next_node_id: 1, 153 156 next_syn_id: 0, ··· 294 297 let cached_match = 295 298 cache.and_then(|c| c.paragraphs.iter().find(|p| p.source_hash == source_hash)); 296 299 297 - let (html, offset_map, syntax_spans) = if let Some(cached) = cached_match { 300 + let (html, offset_map, syntax_spans, para_refs) = if let Some(cached) = cached_match { 298 301 // Reuse cached HTML, offset map, and syntax spans (adjusted for position) 299 302 let char_delta = char_range.start as isize - cached.char_range.start as isize; 300 303 let byte_delta = byte_range.start as isize - cached.byte_range.start as isize; ··· 314 317 span.adjust_positions(char_delta); 315 318 } 316 319 317 - (cached.html.clone(), adjusted_map, adjusted_syntax) 320 + // Include cached refs in all_refs 321 + all_refs.extend(cached.collected_refs.clone()); 322 + 323 + (cached.html.clone(), adjusted_map, adjusted_syntax, cached.collected_refs.clone()) 318 324 } else { 319 325 // Fresh render needed - create detached LoroDoc for this paragraph 320 326 let para_doc = loro::LoroDoc::new(); ··· 348 354 writer = writer.with_entry_index(idx); 349 355 } 350 356 351 - let (mut offset_map, mut syntax_spans) = match writer.run() { 357 + let (mut offset_map, mut syntax_spans, para_refs) = match writer.run() { 352 358 Ok(result) => { 353 359 // Update node ID offset 354 360 let max_node_id = result ··· 377 383 syn_id_offset = max_syn_id + 1; 378 384 379 385 // Collect refs from this paragraph 380 - all_refs.extend(result.collected_refs); 386 + let para_refs = result.collected_refs; 387 + all_refs.extend(para_refs.clone()); 381 388 382 - (result.offset_maps, result.syntax_spans) 389 + (result.offset_maps, result.syntax_spans, para_refs) 383 390 } 384 - Err(_) => (Vec::new(), Vec::new()), 391 + Err(_) => (Vec::new(), Vec::new(), Vec::new()), 385 392 }; 386 393 387 394 // Offsets are already document-absolute since we pass char_range.start/byte_range.start 388 395 // to the writer constructor 389 - (output, offset_map, syntax_spans) 396 + (output, offset_map, syntax_spans, para_refs) 390 397 }; 391 398 392 399 // Store in cache ··· 397 404 html: html.clone(), 398 405 offset_map: offset_map.clone(), 399 406 syntax_spans: syntax_spans.clone(), 407 + collected_refs: para_refs, 400 408 }); 401 409 402 410 paragraphs.push(ParagraphRender {
+14 -14
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__blockquote.snap
··· 11 11 html: "<blockquote>\n<p id=\"n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"1\">&gt;</span> This is a quote\n</p>\n</blockquote>\n" 12 12 offset_map: 13 13 - byte_range: 14 - - 43 15 - - 43 14 + - 2 15 + - 2 16 16 char_range: 17 17 - 0 18 18 - 0 ··· 21 21 child_index: 0 22 22 utf16_len: 0 23 23 - byte_range: 24 - - 41 25 - - 42 24 + - 0 25 + - 1 26 26 char_range: 27 27 - 0 28 28 - 1 ··· 31 31 child_index: ~ 32 32 utf16_len: 1 33 33 - byte_range: 34 - - 42 35 - - 43 34 + - 1 35 + - 2 36 36 char_range: 37 37 - 1 38 38 - 2 ··· 41 41 child_index: ~ 42 42 utf16_len: 1 43 43 - byte_range: 44 - - 43 45 - - 58 44 + - 2 45 + - 17 46 46 char_range: 47 47 - 2 48 48 - 17 ··· 51 51 child_index: ~ 52 52 utf16_len: 15 53 53 - byte_range: 54 - - 58 55 - - 59 54 + - 17 55 + - 18 56 56 char_range: 57 57 - 17 58 58 - 18 ··· 89 89 html: "<p id=\"n1\">With multiple lines</p>\n" 90 90 offset_map: 91 91 - byte_range: 92 - - 22 93 - - 22 92 + - 0 93 + - 0 94 94 char_range: 95 95 - 22 96 96 - 22 ··· 99 99 child_index: 0 100 100 utf16_len: 0 101 101 - byte_range: 102 - - 22 103 - - 41 102 + - 0 103 + - 19 104 104 char_range: 105 105 - 22 106 106 - 41
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__code_block_fenced.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 24 11 - html: "<span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"8\">```rust</span>\n<pre><code class=\"wvc-code language-Rust\"><span class=\"wvc-source wvc-rust\"><span class=\"wvc-meta wvc-function wvc-rust\"><span class=\"wvc-meta wvc-function wvc-rust\"><span class=\"wvc-storage wvc-type wvc-function wvc-rust\">fn</span> </span><span class=\"wvc-entity wvc-name wvc-function wvc-rust\">main</span></span><span class=\"wvc-meta wvc-function wvc-rust\"><span class=\"wvc-meta wvc-function wvc-parameters wvc-rust\"><span class=\"wvc-punctuation wvc-section wvc-parameters wvc-begin wvc-rust\">(</span></span><span class=\"wvc-meta wvc-function wvc-rust\"><span class=\"wvc-meta wvc-function wvc-parameters wvc-rust\"><span class=\"wvc-punctuation wvc-section wvc-parameters wvc-end wvc-rust\">)</span></span></span></span><span class=\"wvc-meta wvc-function wvc-rust\"> </span><span class=\"wvc-meta wvc-function wvc-rust\"><span class=\"wvc-meta wvc-block wvc-rust\"><span class=\"wvc-punctuation wvc-section wvc-block wvc-begin wvc-rust\">{</span></span><span class=\"wvc-meta wvc-block wvc-rust\"><span class=\"wvc-punctuation wvc-section wvc-block wvc-end wvc-rust\">}</span></span></span>\n</span></code></pre><span class=\"md-syntax-block\" data-syn-id=\"s1\" data-char-start=\"21\" data-char-end=\"24\">```</span>" 11 + html: "<span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"8\" spellcheck=\"false\">```rust</span>\n<pre data-node-id=\"n0\"><code class=\"wvc-code language-Rust\"><span class=\"wvc-source wvc-rust\"><span class=\"wvc-meta wvc-function wvc-rust\"><span class=\"wvc-meta wvc-function wvc-rust\"><span class=\"wvc-storage wvc-type wvc-function wvc-rust\">fn</span> </span><span class=\"wvc-entity wvc-name wvc-function wvc-rust\">main</span></span><span class=\"wvc-meta wvc-function wvc-rust\"><span class=\"wvc-meta wvc-function wvc-parameters wvc-rust\"><span class=\"wvc-punctuation wvc-section wvc-parameters wvc-begin wvc-rust\">(</span></span><span class=\"wvc-meta wvc-function wvc-rust\"><span class=\"wvc-meta wvc-function wvc-parameters wvc-rust\"><span class=\"wvc-punctuation wvc-section wvc-parameters wvc-end wvc-rust\">)</span></span></span></span><span class=\"wvc-meta wvc-function wvc-rust\"> </span><span class=\"wvc-meta wvc-function wvc-rust\"><span class=\"wvc-meta wvc-block wvc-rust\"><span class=\"wvc-punctuation wvc-section wvc-block wvc-begin wvc-rust\">{</span></span><span class=\"wvc-meta wvc-block wvc-rust\"><span class=\"wvc-punctuation wvc-section wvc-block wvc-end wvc-rust\">}</span></span></span>\n</span></code></pre><span class=\"md-syntax-block\" data-syn-id=\"s1\" data-char-start=\"21\" data-char-end=\"24\" spellcheck=\"false\">```</span>" 12 12 offset_map: 13 13 - byte_range: 14 14 - 8
+4 -4
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__gap_between_blocks.snap
··· 60 60 html: "<p id=\"n1\">Paragraph below</p>\n" 61 61 offset_map: 62 62 - byte_range: 63 - - 11 64 - - 11 63 + - 0 64 + - 0 65 65 char_range: 66 66 - 11 67 67 - 11 ··· 70 70 child_index: 0 71 71 utf16_len: 0 72 72 - byte_range: 73 - - 11 74 - - 26 73 + - 0 74 + - 15 75 75 char_range: 76 76 - 11 77 77 - 26
+25 -25
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_levels.snap
··· 57 57 char_range: 58 58 - 6 59 59 - 12 60 - html: "<h2 data-node-id=\"n1\"><span class=\"md-syntax-block\" data-syn-id=\"s1\" data-char-start=\"0\" data-char-end=\"3\">## </span>H2\n</h2>\n" 60 + html: "<h2 data-node-id=\"n1\"><span class=\"md-syntax-block\" data-syn-id=\"s1\" data-char-start=\"6\" data-char-end=\"9\">## </span>H2\n</h2>\n" 61 61 offset_map: 62 62 - byte_range: 63 - - 6 64 - - 6 63 + - 0 64 + - 0 65 65 char_range: 66 66 - 6 67 67 - 6 ··· 70 70 child_index: 0 71 71 utf16_len: 0 72 72 - byte_range: 73 - - 6 74 - - 9 73 + - 0 74 + - 3 75 75 char_range: 76 76 - 6 77 77 - 9 ··· 80 80 child_index: ~ 81 81 utf16_len: 3 82 82 - byte_range: 83 - - 9 84 - - 11 83 + - 3 84 + - 5 85 85 char_range: 86 86 - 9 87 87 - 11 ··· 90 90 child_index: ~ 91 91 utf16_len: 2 92 92 - byte_range: 93 - - 11 94 - - 12 93 + - 5 94 + - 6 95 95 char_range: 96 96 - 11 97 97 - 12 ··· 106 106 char_range: 107 107 - 13 108 108 - 20 109 - html: "<h3 data-node-id=\"n2\"><span class=\"md-syntax-block\" data-syn-id=\"s2\" data-char-start=\"0\" data-char-end=\"4\">### </span>H3\n</h3>\n" 109 + html: "<h3 data-node-id=\"n2\"><span class=\"md-syntax-block\" data-syn-id=\"s2\" data-char-start=\"13\" data-char-end=\"17\">### </span>H3\n</h3>\n" 110 110 offset_map: 111 111 - byte_range: 112 - - 13 113 - - 13 112 + - 0 113 + - 0 114 114 char_range: 115 115 - 13 116 116 - 13 ··· 119 119 child_index: 0 120 120 utf16_len: 0 121 121 - byte_range: 122 - - 13 123 - - 17 122 + - 0 123 + - 4 124 124 char_range: 125 125 - 13 126 126 - 17 ··· 129 129 child_index: ~ 130 130 utf16_len: 4 131 131 - byte_range: 132 - - 17 133 - - 19 132 + - 4 133 + - 6 134 134 char_range: 135 135 - 17 136 136 - 19 ··· 139 139 child_index: ~ 140 140 utf16_len: 2 141 141 - byte_range: 142 - - 19 143 - - 20 142 + - 6 143 + - 7 144 144 char_range: 145 145 - 19 146 146 - 20 ··· 155 155 char_range: 156 156 - 21 157 157 - 28 158 - html: "<h4 data-node-id=\"n3\"><span class=\"md-syntax-block\" data-syn-id=\"s3\" data-char-start=\"0\" data-char-end=\"5\">#### </span>H4</h4>\n" 158 + html: "<h4 data-node-id=\"n3\"><span class=\"md-syntax-block\" data-syn-id=\"s3\" data-char-start=\"21\" data-char-end=\"26\">#### </span>H4</h4>\n" 159 159 offset_map: 160 160 - byte_range: 161 - - 21 162 - - 21 161 + - 0 162 + - 0 163 163 char_range: 164 164 - 21 165 165 - 21 ··· 168 168 child_index: 0 169 169 utf16_len: 0 170 170 - byte_range: 171 - - 21 172 - - 26 171 + - 0 172 + - 5 173 173 char_range: 174 174 - 21 175 175 - 26 ··· 178 178 child_index: ~ 179 179 utf16_len: 5 180 180 - byte_range: 181 - - 26 182 - - 28 181 + - 5 182 + - 7 183 183 char_range: 184 184 - 26 185 185 - 28
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__inline_code.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 16 11 - html: "<p id=\"n0\">Some <span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"5\" data-char-end=\"6\">`</span><code>code</code><span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"10\" data-char-end=\"11\">`</span> here</p>\n" 11 + html: "<p id=\"n0\">Some <span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"5\" data-char-end=\"6\" spellcheck=\"false\">`</span><code>code</code><span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"10\" data-char-end=\"11\" spellcheck=\"false\">`</span> here</p>\n" 12 12 offset_map: 13 13 - byte_range: 14 14 - 0
+4 -4
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_blank_lines.snap
··· 69 69 html: "<p id=\"n1\">Second</p>\n" 70 70 offset_map: 71 71 - byte_range: 72 - - 9 73 - - 9 72 + - 0 73 + - 0 74 74 char_range: 75 75 - 9 76 76 - 9 ··· 79 79 child_index: 0 80 80 utf16_len: 0 81 81 - byte_range: 82 - - 9 83 - - 15 82 + - 0 83 + - 6 84 84 char_range: 85 85 - 9 86 86 - 15
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_inline_formats.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 32 11 - html: "<p id=\"n0\"><span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\">**</span><strong>Bold</strong><span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"6\" data-char-end=\"8\">**</span> and <span class=\"md-syntax-inline\" data-syn-id=\"s2\" data-char-start=\"13\" data-char-end=\"14\">*</span><em>italic</em><span class=\"md-syntax-inline\" data-syn-id=\"s3\" data-char-start=\"20\" data-char-end=\"21\">*</span> and <span class=\"md-syntax-inline\" data-syn-id=\"s4\" data-char-start=\"26\" data-char-end=\"27\">`</span><code>code</code><span class=\"md-syntax-inline\" data-syn-id=\"s5\" data-char-start=\"31\" data-char-end=\"32\">`</span></p>\n" 11 + html: "<p id=\"n0\"><span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\">**</span><strong>Bold</strong><span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"6\" data-char-end=\"8\">**</span> and <span class=\"md-syntax-inline\" data-syn-id=\"s2\" data-char-start=\"13\" data-char-end=\"14\">*</span><em>italic</em><span class=\"md-syntax-inline\" data-syn-id=\"s3\" data-char-start=\"20\" data-char-end=\"21\">*</span> and <span class=\"md-syntax-inline\" data-syn-id=\"s4\" data-char-start=\"26\" data-char-end=\"27\" spellcheck=\"false\">`</span><code>code</code><span class=\"md-syntax-inline\" data-syn-id=\"s5\" data-char-start=\"31\" data-char-end=\"32\" spellcheck=\"false\">`</span></p>\n" 12 12 offset_map: 13 13 - byte_range: 14 14 - 0
+13 -13
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__nested_list.snap
··· 27 27 char_range: 28 28 - 11 29 29 - 33 30 - html: "<ul>\n<li data-node-id=\"n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\">- </span>Child 1\n \n<ul>\n<li data-node-id=\"n1\"><span class=\"md-syntax-block\" data-syn-id=\"s1\" data-char-start=\"12\" data-char-end=\"14\">- </span>Child 2\n</li>\n</ul>\n</li>\n</ul>\n" 30 + html: "<ul>\n<li data-node-id=\"n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"11\" data-char-end=\"13\" spellcheck=\"false\">- </span>Child 1\n \n<ul>\n<li data-node-id=\"n1\"><span class=\"md-syntax-block\" data-syn-id=\"s1\" data-char-start=\"23\" data-char-end=\"25\" spellcheck=\"false\">- </span>Child 2\n</li>\n</ul>\n</li>\n</ul>\n" 31 31 offset_map: 32 32 - byte_range: 33 - - 11 34 - - 13 33 + - 0 34 + - 2 35 35 char_range: 36 36 - 11 37 37 - 13 ··· 40 40 child_index: ~ 41 41 utf16_len: 2 42 42 - byte_range: 43 - - 13 44 - - 20 43 + - 2 44 + - 9 45 45 char_range: 46 46 - 13 47 47 - 20 ··· 50 50 child_index: ~ 51 51 utf16_len: 7 52 52 - byte_range: 53 - - 20 54 - - 23 53 + - 9 54 + - 12 55 55 char_range: 56 56 - 20 57 57 - 23 ··· 60 60 child_index: ~ 61 61 utf16_len: 3 62 62 - byte_range: 63 - - 23 64 - - 25 63 + - 12 64 + - 14 65 65 char_range: 66 66 - 23 67 67 - 25 ··· 70 70 child_index: ~ 71 71 utf16_len: 2 72 72 - byte_range: 73 - - 25 74 - - 32 73 + - 14 74 + - 21 75 75 char_range: 76 76 - 25 77 77 - 32 ··· 80 80 child_index: ~ 81 81 utf16_len: 7 82 82 - byte_range: 83 - - 32 84 - - 33 83 + - 21 84 + - 22 85 85 char_range: 86 86 - 32 87 87 - 33
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__ordered_list.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 27 11 - html: "<ol>\n<li data-node-id=\"n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"3\">1. </span>First\n</li>\n<li data-node-id=\"n1\"><span class=\"md-syntax-block\" data-syn-id=\"s1\" data-char-start=\"9\" data-char-end=\"12\">2. </span>Second\n</li>\n<li data-node-id=\"n2\"><span class=\"md-syntax-block\" data-syn-id=\"s2\" data-char-start=\"19\" data-char-end=\"22\">3. </span>Third</li>\n</ol>\n" 11 + html: "<ol>\n<li data-node-id=\"n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"3\" spellcheck=\"false\">1. </span>First\n</li>\n<li data-node-id=\"n1\"><span class=\"md-syntax-block\" data-syn-id=\"s1\" data-char-start=\"9\" data-char-end=\"12\" spellcheck=\"false\">2. </span>Second\n</li>\n<li data-node-id=\"n2\"><span class=\"md-syntax-block\" data-syn-id=\"s2\" data-char-start=\"19\" data-char-end=\"22\" spellcheck=\"false\">3. </span>Third</li>\n</ol>\n" 12 12 offset_map: 13 13 - byte_range: 14 14 - 0
+10 -10
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__three_paragraphs.snap
··· 50 50 html: "<p id=\"n1\">Two.\n</p>\n" 51 51 offset_map: 52 52 - byte_range: 53 - - 6 54 - - 6 53 + - 0 54 + - 0 55 55 char_range: 56 56 - 6 57 57 - 6 ··· 60 60 child_index: 0 61 61 utf16_len: 0 62 62 - byte_range: 63 - - 6 64 - - 10 63 + - 0 64 + - 4 65 65 char_range: 66 66 - 6 67 67 - 10 ··· 70 70 child_index: ~ 71 71 utf16_len: 4 72 72 - byte_range: 73 - - 10 74 - - 11 73 + - 4 74 + - 5 75 75 char_range: 76 76 - 10 77 77 - 11 ··· 89 89 html: "<p id=\"n2\">Three.</p>\n" 90 90 offset_map: 91 91 - byte_range: 92 - - 12 93 - - 12 92 + - 0 93 + - 0 94 94 char_range: 95 95 - 12 96 96 - 12 ··· 99 99 child_index: 0 100 100 utf16_len: 0 101 101 - byte_range: 102 - - 12 103 - - 18 102 + - 0 103 + - 6 104 104 char_range: 105 105 - 12 106 106 - 18
+4 -4
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__two_paragraphs.snap
··· 50 50 html: "<p id=\"n1\">Second paragraph.</p>\n" 51 51 offset_map: 52 52 - byte_range: 53 - - 18 54 - - 18 53 + - 0 54 + - 0 55 55 char_range: 56 56 - 18 57 57 - 18 ··· 60 60 child_index: 0 61 61 utf16_len: 0 62 62 - byte_range: 63 - - 18 64 - - 35 63 + - 0 64 + - 17 65 65 char_range: 66 66 - 18 67 67 - 35
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unordered_list.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 26 11 - html: "<ul>\n<li data-node-id=\"n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\">- </span>Item 1\n</li>\n<li data-node-id=\"n1\"><span class=\"md-syntax-block\" data-syn-id=\"s1\" data-char-start=\"9\" data-char-end=\"11\">- </span>Item 2\n</li>\n<li data-node-id=\"n2\"><span class=\"md-syntax-block\" data-syn-id=\"s2\" data-char-start=\"18\" data-char-end=\"20\">- </span>Item 3</li>\n</ul>\n" 11 + html: "<ul>\n<li data-node-id=\"n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\" spellcheck=\"false\">- </span>Item 1\n</li>\n<li data-node-id=\"n1\"><span class=\"md-syntax-block\" data-syn-id=\"s1\" data-char-start=\"9\" data-char-end=\"11\" spellcheck=\"false\">- </span>Item 2\n</li>\n<li data-node-id=\"n2\"><span class=\"md-syntax-block\" data-syn-id=\"s2\" data-char-start=\"18\" data-char-end=\"20\" spellcheck=\"false\">- </span>Item 3</li>\n</ul>\n" 12 12 offset_map: 13 13 - byte_range: 14 14 - 0
+6 -6
crates/weaver-app/src/components/editor/tests.rs
··· 728 728 html: "<blockquote>\n<p id=\"n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"1\">&gt;</span>quote</p>\n</blockquote>\n" 729 729 offset_map: 730 730 - byte_range: 731 - - 7 732 - - 7 731 + - 1 732 + - 1 733 733 char_range: 734 734 - 0 735 735 - 0 ··· 738 738 child_index: 0 739 739 utf16_len: 0 740 740 - byte_range: 741 - - 6 742 - - 7 741 + - 0 742 + - 1 743 743 char_range: 744 744 - 0 745 745 - 1 ··· 748 748 child_index: ~ 749 749 utf16_len: 1 750 750 - byte_range: 751 - - 7 752 - - 12 751 + - 1 752 + - 6 753 753 char_range: 754 754 - 1 755 755 - 6
+41 -31
crates/weaver-app/src/components/editor/writer.rs
··· 206 206 blob_rkey: Rkey<'static>, 207 207 ident: AtIdentifier<'static>, 208 208 ) { 209 - self.images.insert( 210 - name.to_string(), 211 - ResolvedImage::Draft { blob_rkey, ident }, 212 - ); 209 + self.images 210 + .insert(name.to_string(), ResolvedImage::Draft { blob_rkey, ident }); 213 211 } 214 212 215 213 /// Add an already-uploaded draft image. ··· 314 312 /// 315 313 /// This writer processes offset-iter events to detect gaps (consumed formatting) 316 314 /// and emits them as styled spans for visibility in the editor. 317 - pub struct EditorWriter<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, W: StrWrite, E = (), R = ()> { 315 + pub struct EditorWriter< 316 + 'a, 317 + I: Iterator<Item = (Event<'a>, Range<usize>)>, 318 + W: StrWrite, 319 + E = (), 320 + R = (), 321 + > { 318 322 source: &'a str, 319 323 source_text: &'a LoroText, 320 324 events: I, ··· 387 391 } 388 392 389 393 impl< 390 - 'a, 391 - I: Iterator<Item = (Event<'a>, Range<usize>)>, 392 - W: StrWrite, 393 - E: EmbedContentProvider, 394 - R: ImageResolver, 395 - > EditorWriter<'a, I, W, E, R> 394 + 'a, 395 + I: Iterator<Item = (Event<'a>, Range<usize>)>, 396 + W: StrWrite, 397 + E: EmbedContentProvider, 398 + R: ImageResolver, 399 + > EditorWriter<'a, I, W, E, R> 396 400 { 397 401 pub fn new(source: &'a str, source_text: &'a LoroText, events: I, writer: W) -> Self { 398 402 Self::new_with_node_offset(source, source_text, events, writer, 0) ··· 1329 1333 write!( 1330 1334 &mut self.writer, 1331 1335 "<span class=\"math math-inline math-rendered math-clickable\" contenteditable=\"false\" data-char-target=\"{}\">{}</span>", 1332 - content_char_start, 1333 - mathml 1336 + content_char_start, mathml 1334 1337 )?; 1335 1338 } 1336 1339 weaver_renderer::math::MathResult::Error { html, .. } => { ··· 1423 1426 write!( 1424 1427 &mut self.writer, 1425 1428 "<span class=\"math math-display math-rendered math-clickable\" contenteditable=\"false\" data-char-target=\"{}\">{}</span>", 1426 - content_char_start, 1427 - mathml 1429 + content_char_start, mathml 1428 1430 )?; 1429 1431 } 1430 1432 weaver_renderer::math::MathResult::Error { html, .. } => { ··· 1740 1742 } 1741 1743 1742 1744 // Emit the opening tag 1743 - tracing::debug!(?tag, "start_tag"); 1744 1745 match tag { 1745 1746 Tag::HtmlBlock => Ok(()), 1746 1747 Tag::Paragraph => { ··· 2612 2613 Err(_) => { 2613 2614 // Fallback to plain code block 2614 2615 if let Some(ref nid) = node_id { 2615 - write!(&mut self.writer, "<pre data-node-id=\"{}\"><code class=\"language-", nid)?; 2616 + write!( 2617 + &mut self.writer, 2618 + "<pre data-node-id=\"{}\"><code class=\"language-", 2619 + nid 2620 + )?; 2616 2621 } else { 2617 2622 self.write("<pre><code class=\"language-")?; 2618 2623 } ··· 2666 2671 self.last_byte_offset += fence.len(); 2667 2672 2668 2673 // Compute formatted_range for entire code block (opening fence to closing fence) 2669 - let formatted_range = code_block_start 2670 - .map(|start| start..self.last_char_offset); 2674 + let formatted_range = 2675 + code_block_start.map(|start| start..self.last_char_offset); 2671 2676 2672 2677 // Update opening fence span with formatted_range 2673 - if let (Some(idx), Some(fr)) = (opening_span_idx, formatted_range.as_ref()) 2678 + if let (Some(idx), Some(fr)) = 2679 + (opening_span_idx, formatted_range.as_ref()) 2674 2680 { 2675 2681 if let Some(span) = self.syntax_spans.get_mut(idx) { 2676 2682 span.formatted_range = Some(fr.clone()); ··· 2809 2815 } 2810 2816 2811 2817 impl< 2812 - 'a, 2813 - I: Iterator<Item = (Event<'a>, Range<usize>)>, 2814 - W: StrWrite, 2815 - E: EmbedContentProvider, 2816 - R: ImageResolver, 2817 - > EditorWriter<'a, I, W, E, R> 2818 + 'a, 2819 + I: Iterator<Item = (Event<'a>, Range<usize>)>, 2820 + W: StrWrite, 2821 + E: EmbedContentProvider, 2822 + R: ImageResolver, 2823 + > EditorWriter<'a, I, W, E, R> 2818 2824 { 2819 2825 fn write_embed( 2820 2826 &mut self, ··· 2891 2897 formatted_range: Some(formatted_range.clone()), 2892 2898 }); 2893 2899 2894 - self.record_mapping( 2895 - range.start + 3..range.end - 2, 2896 - url_char_start..url_char_end, 2897 - ); 2900 + self.record_mapping(range.start + 3..range.end - 2, url_char_start..url_char_end); 2898 2901 2899 2902 // 3. Emit closing ]] syntax span 2900 2903 if raw_text.ends_with("]]") { ··· 2919 2922 2920 2923 // Collect AT URI for later resolution 2921 2924 if url.starts_with("at://") || url.starts_with("did:") { 2922 - self.ref_collector.add_at_embed(url, if title.is_empty() { None } else { Some(title.as_ref()) }); 2925 + self.ref_collector.add_at_embed( 2926 + url, 2927 + if title.is_empty() { 2928 + None 2929 + } else { 2930 + Some(title.as_ref()) 2931 + }, 2932 + ); 2923 2933 } 2924 2934 2925 2935 // 4. Emit the actual embed content
+6 -2
crates/weaver-app/src/components/entry.rs
··· 835 835 836 836 /// Render some text as markdown. 837 837 pub fn EntryMarkdown(props: EntryMarkdownProps) -> Element { 838 - let processed = crate::data::use_rendered_markdown(props.content, props.ident); 838 + let (_res, processed) = crate::data::use_rendered_markdown(props.content, props.ident); 839 + #[cfg(feature = "fullstack-server")] 840 + _res?; 839 841 840 842 match &*processed.read() { 841 843 Some(html_buf) => rsx! { ··· 866 868 // Use feature-gated hook for SSR support 867 869 let content = use_signal(|| content); 868 870 let ident = use_signal(|| ident); 869 - let processed = crate::data::use_rendered_markdown(content.into(), ident.into()); 871 + let (_res, processed) = crate::data::use_rendered_markdown(content.into(), ident.into()); 872 + #[cfg(feature = "fullstack-server")] 873 + _res?; 870 874 871 875 match &*processed.read() { 872 876 Some(html_buf) => rsx! {
+20 -19
crates/weaver-app/src/components/identity.rs
··· 46 46 47 47 #[component] 48 48 pub fn Repository(ident: ReadSignal<AtIdentifier<'static>>) -> Element { 49 - tracing::debug!("Repository component rendering for ident: {:?}", ident()); 50 - // Fetch notebooks for this specific DID with SSR support; 51 - tracing::debug!("Repository component context set up"); 52 - 53 49 rsx! { 54 50 div { 55 51 Outlet::<Route> {} ··· 59 55 60 56 #[component] 61 57 pub fn RepositoryIndex(ident: ReadSignal<AtIdentifier<'static>>) -> Element { 62 - tracing::debug!( 63 - "RepositoryIndex component rendering for ident: {:?}", 64 - ident() 65 - ); 66 58 use crate::components::ProfileDisplay; 67 59 let (notebooks_result, notebooks) = data::use_notebooks_for_did(ident); 68 60 let (profile_result, profile) = crate::data::use_profile_data(ident); 69 - tracing::debug!("RepositoryIndex got profile and notebooks"); 70 61 71 62 #[cfg(feature = "fullstack-server")] 72 63 notebooks_result?; ··· 81 72 82 73 let (display_name, handle, bio) = match &profile_view.inner { 83 74 ProfileDataViewInner::ProfileView(p) => ( 84 - p.display_name.as_ref().map(|n| n.as_ref().to_string()).unwrap_or_default(), 75 + p.display_name 76 + .as_ref() 77 + .map(|n| n.as_ref().to_string()) 78 + .unwrap_or_default(), 85 79 p.handle.as_ref().to_string(), 86 - p.description.as_ref().map(|d| d.as_ref().to_string()).unwrap_or_default(), 80 + p.description 81 + .as_ref() 82 + .map(|d| d.as_ref().to_string()) 83 + .unwrap_or_default(), 87 84 ), 88 85 ProfileDataViewInner::ProfileViewDetailed(p) => ( 89 - p.display_name.as_ref().map(|n| n.as_ref().to_string()).unwrap_or_default(), 90 - p.handle.as_ref().to_string(), 91 - p.description.as_ref().map(|d| d.as_ref().to_string()).unwrap_or_default(), 92 - ), 93 - ProfileDataViewInner::TangledProfileView(p) => ( 94 - String::new(), 86 + p.display_name 87 + .as_ref() 88 + .map(|n| n.as_ref().to_string()) 89 + .unwrap_or_default(), 95 90 p.handle.as_ref().to_string(), 96 - String::new(), 91 + p.description 92 + .as_ref() 93 + .map(|d| d.as_ref().to_string()) 94 + .unwrap_or_default(), 97 95 ), 96 + ProfileDataViewInner::TangledProfileView(p) => { 97 + (String::new(), p.handle.as_ref().to_string(), String::new()) 98 + } 98 99 _ => (String::new(), "unknown".to_string(), String::new()), 99 100 }; 100 101 ··· 174 175 notebook: NotebookView<'static>, 175 176 entry_refs: Vec<StrongRef<'static>>, 176 177 ) -> Element { 177 - use jacquard::{from_data, IntoStatic}; 178 + use jacquard::{IntoStatic, from_data}; 178 179 use weaver_api::sh_weaver::notebook::book::Book; 179 180 180 181 let fetcher = use_context::<fetch::Fetcher>();
+157 -49
crates/weaver-app/src/data.rs
··· 13 13 use jacquard::{ 14 14 IntoStatic, 15 15 identity::resolver::IdentityError, 16 - types::{did::Did, string::Handle}, 16 + types::{aturi::AtUri, did::Did, string::Handle}, 17 17 }; 18 18 #[allow(unused_imports)] 19 19 use jacquard::{ ··· 26 26 use weaver_api::com_atproto::repo::strong_ref::StrongRef; 27 27 use weaver_api::sh_weaver::actor::ProfileDataView; 28 28 use weaver_api::sh_weaver::notebook::{BookEntryView, EntryView, NotebookView, entry::Entry}; 29 + use weaver_common::ResolvedContent; 29 30 // ============================================================================ 30 31 // Wrapper Hooks (feature-gated) 31 32 // ============================================================================ ··· 45 46 let res = use_server_future(use_reactive!(|(ident, book_title, title)| { 46 47 let fetcher = fetcher.clone(); 47 48 async move { 48 - let fetch_result = fetcher 49 - .get_entry(ident(), book_title(), title()) 50 - .await; 49 + let fetch_result = fetcher.get_entry(ident(), book_title(), title()).await; 51 50 52 51 match fetch_result { 53 52 Ok(Some(entry)) => { ··· 387 386 pub fn use_rendered_markdown( 388 387 content: ReadSignal<Entry<'static>>, 389 388 ident: ReadSignal<AtIdentifier<'static>>, 390 - ) -> Memo<Option<String>> { 389 + ) -> ( 390 + Result<Resource<Option<String>>, RenderError>, 391 + Memo<Option<String>>, 392 + ) { 391 393 let fetcher = use_context::<crate::fetch::Fetcher>(); 394 + let fetcher = fetcher.clone(); 392 395 let res = use_server_future(use_reactive!(|(content, ident)| { 393 - let client = fetcher.get_client(); 396 + let fetcher = fetcher.clone(); 394 397 async move { 398 + let entry = content(); 395 399 let did = match ident.read().clone() { 396 400 AtIdentifier::Did(d) => d, 397 - AtIdentifier::Handle(h) => client.resolve_handle(&h).await.ok()?, 401 + AtIdentifier::Handle(h) => fetcher.get_client().resolve_handle(&h).await.ok()?, 398 402 }; 399 - Some(render_markdown_impl(content(), did).await) 403 + 404 + let resolved_content = prefetch_embeds(&entry, &fetcher).await; 405 + 406 + Some(render_markdown_impl(entry, did, resolved_content).await) 400 407 } 401 408 })); 402 - use_memo(use_reactive!(|res| { 403 - let res = res.ok()?; 409 + let memo = use_memo(use_reactive!(|res| { 410 + let res = res.as_ref().ok()?; 404 411 if let Some(Some(value)) = &*res.read() { 405 412 Some(value.clone()) 406 413 } else { 407 414 None 408 415 } 409 - })) 416 + })); 417 + (res, memo) 410 418 } 411 419 412 420 /// Hook to render markdown client-side only (no SSR). ··· 414 422 pub fn use_rendered_markdown( 415 423 content: ReadSignal<Entry<'static>>, 416 424 ident: ReadSignal<AtIdentifier<'static>>, 417 - ) -> Memo<Option<String>> { 425 + ) -> (Resource<Option<String>>, Memo<Option<String>>) { 418 426 let fetcher = use_context::<crate::fetch::Fetcher>(); 427 + let fetcher = fetcher.clone(); 419 428 let res = use_resource(move || { 420 - let client = fetcher.get_client(); 429 + let fetcher = fetcher.clone(); 421 430 async move { 431 + let entry = content(); 422 432 let did = match ident() { 423 433 AtIdentifier::Did(d) => d, 424 - AtIdentifier::Handle(h) => client.resolve_handle(&h).await.ok()?, 434 + AtIdentifier::Handle(h) => fetcher.get_client().resolve_handle(&h).await.ok()?, 425 435 }; 426 - Some(render_markdown_impl(content(), did).await) 436 + 437 + let resolved_content = prefetch_embeds(&entry, &fetcher).await; 438 + 439 + Some(render_markdown_impl(entry, did, resolved_content).await) 427 440 } 428 441 }); 429 - use_memo(move || { 442 + let memo = use_memo(move || { 430 443 if let Some(Some(value)) = &*res.read() { 431 444 Some(value.clone()) 432 445 } else { 433 446 None 434 447 } 435 - }) 448 + }); 449 + (res, memo) 450 + } 451 + 452 + /// Extract AT URIs for embeds from stored records or by parsing markdown. 453 + /// 454 + /// Tries stored `embeds.records` first, falls back to parsing markdown content. 455 + fn extract_embed_uris(entry: &Entry<'_>) -> Vec<AtUri<'static>> { 456 + use jacquard::IntoStatic; 457 + 458 + // Try stored records first 459 + if let Some(ref embeds) = entry.embeds { 460 + if let Some(ref records) = embeds.records { 461 + let stored_uris: Vec<_> = records 462 + .records 463 + .iter() 464 + .map(|r| r.record.uri.clone().into_static()) 465 + .collect(); 466 + if !stored_uris.is_empty() { 467 + return stored_uris; 468 + } 469 + } 470 + } 471 + 472 + // Fall back to parsing markdown for at:// URIs 473 + use regex_lite::Regex; 474 + use std::sync::LazyLock; 475 + 476 + static AT_URI_REGEX: LazyLock<Regex> = 477 + LazyLock::new(|| Regex::new(r"at://[^\s\)\]]+").unwrap()); 478 + 479 + let uris: Vec<_> = AT_URI_REGEX 480 + .find_iter(&entry.content) 481 + .filter_map(|m| AtUri::new(m.as_str()).ok().map(|u| u.into_static())) 482 + .collect(); 483 + uris 484 + } 485 + 486 + /// Pre-fetch embed content for all AT URIs in an entry. 487 + async fn prefetch_embeds( 488 + entry: &Entry<'static>, 489 + fetcher: &crate::fetch::Fetcher, 490 + ) -> weaver_common::ResolvedContent { 491 + use weaver_renderer::atproto::fetch_and_render; 492 + 493 + let mut resolved = weaver_common::ResolvedContent::new(); 494 + let uris = extract_embed_uris(entry); 495 + 496 + for uri in uris { 497 + match fetch_and_render(&uri, fetcher).await { 498 + Ok(html) => { 499 + resolved.add_embed(uri, html, None); 500 + } 501 + Err(e) => { 502 + tracing::warn!("[prefetch_embeds] Failed to fetch {}: {}", uri, e); 503 + } 504 + } 505 + } 506 + 507 + resolved 436 508 } 437 509 438 510 /// Internal implementation of markdown rendering. 439 - async fn render_markdown_impl(content: Entry<'static>, did: Did<'static>) -> String { 511 + async fn render_markdown_impl( 512 + content: Entry<'static>, 513 + did: Did<'static>, 514 + resolved_content: weaver_common::ResolvedContent, 515 + ) -> String { 440 516 use n0_future::stream::StreamExt; 441 517 use weaver_renderer::{ 442 518 ContextIterator, NotebookProcessor, ··· 452 528 let events: Vec<_> = StreamExt::collect(processor).await; 453 529 454 530 let mut html_buf = String::new(); 455 - let _ = ClientWriter::<_, _, ()>::new(events.into_iter(), &mut html_buf).run(); 531 + let writer = ClientWriter::<_, _, ()>::new(events.into_iter(), &mut html_buf) 532 + .with_embed_provider(resolved_content); 533 + writer.run().ok(); 456 534 html_buf 457 535 } 458 536 ··· 688 766 let entry_json = serde_json::to_value(entry).ok()?; 689 767 Some((view_json, entry_json, *time)) 690 768 }) 691 - .collect::<Vec<_>>() 769 + .collect::<Vec<_>>(), 692 770 ) 693 771 } 694 772 Err(e) => { ··· 727 805 let res = use_resource(move || { 728 806 let fetcher = fetcher.clone(); 729 807 async move { 730 - fetcher 731 - .fetch_entries_from_ufos() 732 - .await 733 - .ok() 734 - .map(|entries| { 735 - entries 736 - .iter() 737 - .map(|arc| arc.as_ref().clone()) 738 - .collect::<Vec<_>>() 739 - }) 808 + fetcher.fetch_entries_from_ufos().await.ok().map(|entries| { 809 + entries 810 + .iter() 811 + .map(|arc| arc.as_ref().clone()) 812 + .collect::<Vec<_>>() 813 + }) 740 814 } 741 815 }); 742 816 let memo = use_memo(move || res.read().clone().flatten()); ··· 960 1034 ident: ReadSignal<AtIdentifier<'static>>, 961 1035 rkey: ReadSignal<SmolStr>, 962 1036 ) -> ( 963 - Result<Resource<Option<(serde_json::Value, serde_json::Value, Option<(serde_json::Value, serde_json::Value)>)>>, RenderError>, 1037 + Result< 1038 + Resource< 1039 + Option<( 1040 + serde_json::Value, 1041 + serde_json::Value, 1042 + Option<(serde_json::Value, serde_json::Value)>, 1043 + )>, 1044 + >, 1045 + RenderError, 1046 + >, 964 1047 Memo<Option<crate::fetch::StandaloneEntryData>>, 965 1048 ) { 966 1049 let fetcher = use_context::<crate::fetch::Fetcher>(); ··· 991 1074 } 992 1075 let entry_json = serde_json::to_value(&data.entry).ok()?; 993 1076 let entry_view_json = serde_json::to_value(&data.entry_view).ok()?; 994 - let notebook_ctx_json = data.notebook_context.as_ref().map(|ctx| { 995 - let notebook_json = serde_json::to_value(&ctx.notebook).ok()?; 996 - let book_entry_json = serde_json::to_value(&ctx.book_entry_view).ok()?; 997 - Some((notebook_json, book_entry_json)) 998 - }).flatten(); 1077 + let notebook_ctx_json = data 1078 + .notebook_context 1079 + .as_ref() 1080 + .map(|ctx| { 1081 + let notebook_json = serde_json::to_value(&ctx.notebook).ok()?; 1082 + let book_entry_json = 1083 + serde_json::to_value(&ctx.book_entry_view).ok()?; 1084 + Some((notebook_json, book_entry_json)) 1085 + }) 1086 + .flatten(); 999 1087 Some((entry_json, entry_view_json, notebook_ctx_json)) 1000 1088 } 1001 1089 Ok(None) => None, ··· 1008 1096 })); 1009 1097 1010 1098 let memo = use_memo(use_reactive!(|res| { 1011 - use crate::fetch::{StandaloneEntryData, NotebookContext}; 1012 - use weaver_api::sh_weaver::notebook::{EntryView, entry::Entry, BookEntryView, NotebookView}; 1099 + use crate::fetch::{NotebookContext, StandaloneEntryData}; 1100 + use weaver_api::sh_weaver::notebook::{ 1101 + BookEntryView, EntryView, NotebookView, entry::Entry, 1102 + }; 1013 1103 1014 1104 let res = res.as_ref().ok()?; 1015 - let Some(Some((entry_json, entry_view_json, notebook_ctx_json))) = res.read().clone() else { 1105 + let Some(Some((entry_json, entry_view_json, notebook_ctx_json))) = res.read().clone() 1106 + else { 1016 1107 return None; 1017 1108 }; 1018 1109 1019 1110 let entry: Entry<'static> = jacquard::from_json_value::<Entry>(entry_json).ok()?; 1020 - let entry_view: EntryView<'static> = jacquard::from_json_value::<EntryView>(entry_view_json).ok()?; 1021 - let notebook_context = notebook_ctx_json.map(|(notebook_json, book_entry_json)| { 1022 - let notebook: NotebookView<'static> = jacquard::from_json_value::<NotebookView>(notebook_json).ok()?; 1023 - let book_entry_view: BookEntryView<'static> = jacquard::from_json_value::<BookEntryView>(book_entry_json).ok()?; 1024 - Some(NotebookContext { notebook, book_entry_view }) 1025 - }).flatten(); 1111 + let entry_view: EntryView<'static> = 1112 + jacquard::from_json_value::<EntryView>(entry_view_json).ok()?; 1113 + let notebook_context = notebook_ctx_json 1114 + .map(|(notebook_json, book_entry_json)| { 1115 + let notebook: NotebookView<'static> = 1116 + jacquard::from_json_value::<NotebookView>(notebook_json).ok()?; 1117 + let book_entry_view: BookEntryView<'static> = 1118 + jacquard::from_json_value::<BookEntryView>(book_entry_json).ok()?; 1119 + Some(NotebookContext { 1120 + notebook, 1121 + book_entry_view, 1122 + }) 1123 + }) 1124 + .flatten(); 1026 1125 1027 - Some(StandaloneEntryData { entry, entry_view, notebook_context }) 1126 + Some(StandaloneEntryData { 1127 + entry, 1128 + entry_view, 1129 + notebook_context, 1130 + }) 1028 1131 })); 1029 1132 1030 1133 (res, memo) ··· 1069 1172 let res = use_server_future(use_reactive!(|(ident, book_title, rkey)| { 1070 1173 let fetcher = fetcher.clone(); 1071 1174 async move { 1072 - match fetcher.get_notebook_entry_by_rkey(ident(), book_title(), rkey()).await { 1175 + match fetcher 1176 + .get_notebook_entry_by_rkey(ident(), book_title(), rkey()) 1177 + .await 1178 + { 1073 1179 Ok(Some(data)) => { 1074 1180 let book_entry_json = serde_json::to_value(&data.0).ok()?; 1075 1181 let entry_json = serde_json::to_value(&data.1).ok()?; ··· 1087 1193 let memo = use_memo(use_reactive!(|res| { 1088 1194 let res = res.as_ref().ok()?; 1089 1195 if let Some(Some((book_entry_json, entry_json))) = &*res.read() { 1090 - let book_entry: BookEntryView<'static> = jacquard::from_json_value::<BookEntryView>(book_entry_json.clone()).ok()?; 1091 - let entry: Entry<'static> = jacquard::from_json_value::<Entry>(entry_json.clone()).ok()?; 1196 + let book_entry: BookEntryView<'static> = 1197 + jacquard::from_json_value::<BookEntryView>(book_entry_json.clone()).ok()?; 1198 + let entry: Entry<'static> = 1199 + jacquard::from_json_value::<Entry>(entry_json.clone()).ok()?; 1092 1200 Some((book_entry, entry)) 1093 1201 } else { 1094 1202 None
+6 -669
crates/weaver-app/src/main.rs
··· 129 129 use tracing::Level; 130 130 use tracing::subscriber::set_global_default; 131 131 use tracing_subscriber::Registry; 132 + use tracing_subscriber::filter::EnvFilter; 132 133 use tracing_subscriber::layer::SubscriberExt; 133 134 134 135 let console_level = if cfg!(debug_assertions) { 135 136 Level::DEBUG 136 137 } else { 137 - Level::DEBUG 138 + Level::INFO 138 139 }; 139 140 140 141 let wasm_layer = tracing_wasm::WASMLayer::new( ··· 143 144 .build(), 144 145 ); 145 146 147 + // Filter out noisy crates 148 + let filter = EnvFilter::new("debug,loro_internal=warn"); 149 + 146 150 let reg = Registry::default() 151 + .with(filter) 147 152 .with(wasm_layer) 148 153 .with(components::editor::LogCaptureLayer); 149 154 ··· 317 322 use axum::{http::header::CONTENT_TYPE, response::IntoResponse}; 318 323 let bytes = include_bytes!("../assets/weaver_photo_sm.jpg"); 319 324 ([(CONTENT_TYPE, "image/jpg")], bytes).into_response() 320 - } 321 - 322 - /// Build an image response with appropriate headers for immutable blobs. 323 - #[cfg(all(feature = "fullstack-server", feature = "server"))] 324 - fn build_image_response(bytes: jacquard::bytes::Bytes) -> axum::response::Response { 325 - use axum::{ 326 - http::header::{CACHE_CONTROL, CONTENT_TYPE}, 327 - response::IntoResponse, 328 - }; 329 - use mime_sniffer::MimeTypeSniffer; 330 - 331 - let mime = bytes.sniff_mime_type().unwrap_or("image/jpg").to_string(); 332 - ( 333 - [ 334 - (CONTENT_TYPE, mime), 335 - ( 336 - CACHE_CONTROL, 337 - "public, max-age=31536000, immutable".to_string(), 338 - ), 339 - ], 340 - bytes, 341 - ) 342 - .into_response() 343 - } 344 - 345 - /// Return a 404 response for missing images. 346 - #[cfg(all(feature = "fullstack-server", feature = "server"))] 347 - fn image_not_found() -> axum::response::Response { 348 - use axum::{http::StatusCode, response::IntoResponse}; 349 - (StatusCode::NOT_FOUND, "Image not found").into_response() 350 - } 351 - 352 - #[cfg(all(feature = "fullstack-server", feature = "server"))] 353 - #[get("/{notebook}/image/{name}", blob_cache: Extension<Arc<crate::blobcache::BlobCache>>)] 354 - pub async fn image_named(notebook: SmolStr, name: SmolStr) -> Result<axum::response::Response> { 355 - if let Some(bytes) = blob_cache.get_named(&name) { 356 - return Ok(build_image_response(bytes)); 357 - } 358 - 359 - // Try to resolve from notebook 360 - match blob_cache.resolve_from_notebook(&notebook, &name).await { 361 - Ok(bytes) => Ok(build_image_response(bytes)), 362 - Err(_) => Ok(image_not_found()), 363 - } 364 - } 365 - 366 - #[cfg(all(feature = "fullstack-server", feature = "server"))] 367 - #[get("/{_notebook}/blob/{cid}", blob_cache: Extension<Arc<crate::blobcache::BlobCache>>)] 368 - pub async fn blob(_notebook: SmolStr, cid: SmolStr) -> Result<axum::response::Response> { 369 - match Cid::new_owned(cid.as_bytes()) { 370 - Ok(cid) => { 371 - if let Some(bytes) = blob_cache.get_cid(&cid) { 372 - Ok(build_image_response(bytes)) 373 - } else { 374 - Ok(image_not_found()) 375 - } 376 - } 377 - Err(_) => Ok(image_not_found()), 378 - } 379 - } 380 - 381 - // Route: /image/{notebook}/{name} - notebook entry image by name 382 - #[cfg(all(feature = "fullstack-server", feature = "server"))] 383 - #[get("/image/{notebook}/{name}", blob_cache: Extension<Arc<crate::blobcache::BlobCache>>)] 384 - pub async fn image_notebook(notebook: SmolStr, name: SmolStr) -> Result<axum::response::Response> { 385 - // Try name-based lookup first (backwards compat with cached entries) 386 - if let Some(bytes) = blob_cache.get_named(&name) { 387 - return Ok(build_image_response(bytes)); 388 - } 389 - 390 - // Try to resolve from notebook 391 - match blob_cache.resolve_from_notebook(&notebook, &name).await { 392 - Ok(bytes) => Ok(build_image_response(bytes)), 393 - Err(_) => Ok(image_not_found()), 394 - } 395 - } 396 - 397 - // Route: /image/{ident}/draft/{blob_rkey} - draft image (unpublished) 398 - #[cfg(all(feature = "fullstack-server", feature = "server"))] 399 - #[get("/image/{ident}/draft/{blob_rkey}", blob_cache: Extension<Arc<crate::blobcache::BlobCache>>)] 400 - pub async fn image_draft(ident: SmolStr, blob_rkey: SmolStr) -> Result<axum::response::Response> { 401 - let Ok(at_ident) = AtIdentifier::new_owned(ident.clone()) else { 402 - return Ok(image_not_found()); 403 - }; 404 - 405 - match blob_cache.resolve_from_draft(&at_ident, &blob_rkey).await { 406 - Ok(bytes) => Ok(build_image_response(bytes)), 407 - Err(_) => Ok(image_not_found()), 408 - } 409 - } 410 - 411 - // Route: /image/{ident}/draft/{blob_rkey}/{name} - draft image with name (name is decorative) 412 - #[cfg(all(feature = "fullstack-server", feature = "server"))] 413 - #[get("/image/{ident}/draft/{blob_rkey}/{_name}", blob_cache: Extension<Arc<crate::blobcache::BlobCache>>)] 414 - pub async fn image_draft_named( 415 - ident: SmolStr, 416 - blob_rkey: SmolStr, 417 - _name: SmolStr, 418 - ) -> Result<axum::response::Response> { 419 - let Ok(at_ident) = AtIdentifier::new_owned(ident.clone()) else { 420 - return Ok(image_not_found()); 421 - }; 422 - 423 - match blob_cache.resolve_from_draft(&at_ident, &blob_rkey).await { 424 - Ok(bytes) => Ok(build_image_response(bytes)), 425 - Err(_) => Ok(image_not_found()), 426 - } 427 - } 428 - 429 - // Route: /image/{ident}/{rkey}/{name} - published entry image 430 - #[cfg(all(feature = "fullstack-server", feature = "server"))] 431 - #[get("/image/{ident}/{rkey}/{name}", blob_cache: Extension<Arc<crate::blobcache::BlobCache>>)] 432 - pub async fn image_entry( 433 - ident: SmolStr, 434 - rkey: SmolStr, 435 - name: SmolStr, 436 - ) -> Result<axum::response::Response> { 437 - let Ok(at_ident) = AtIdentifier::new_owned(ident.clone()) else { 438 - return Ok(image_not_found()); 439 - }; 440 - 441 - match blob_cache.resolve_from_entry(&at_ident, &rkey, &name).await { 442 - Ok(bytes) => Ok(build_image_response(bytes)), 443 - Err(_) => Ok(image_not_found()), 444 - } 445 - } 446 - 447 - // Route: /og/{ident}/{book_title}/{entry_title} - OpenGraph image for entry 448 - #[cfg(all(feature = "fullstack-server", feature = "server"))] 449 - #[get("/og/{ident}/{book_title}/{entry_title}", fetcher: Extension<Arc<fetch::Fetcher>>)] 450 - pub async fn og_image( 451 - ident: SmolStr, 452 - book_title: SmolStr, 453 - entry_title: SmolStr, 454 - ) -> Result<axum::response::Response> { 455 - use axum::{ 456 - http::{ 457 - StatusCode, 458 - header::{CACHE_CONTROL, CONTENT_TYPE}, 459 - }, 460 - response::IntoResponse, 461 - }; 462 - use weaver_api::sh_weaver::actor::ProfileDataViewInner; 463 - use weaver_api::sh_weaver::notebook::Title; 464 - 465 - // Strip .png extension if present 466 - let entry_title = entry_title.strip_suffix(".png").unwrap_or(&entry_title); 467 - 468 - let Ok(at_ident) = AtIdentifier::new_owned(ident.clone()) else { 469 - return Ok((StatusCode::BAD_REQUEST, "Invalid identifier").into_response()); 470 - }; 471 - 472 - // Fetch entry data 473 - let entry_result = fetcher 474 - .get_entry(at_ident.clone(), book_title.clone(), entry_title.into()) 475 - .await; 476 - 477 - let arc_data = match entry_result { 478 - Ok(Some(data)) => data, 479 - Ok(None) => return Ok((StatusCode::NOT_FOUND, "Entry not found").into_response()), 480 - Err(e) => { 481 - tracing::error!("Failed to fetch entry for OG image: {:?}", e); 482 - return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch entry").into_response()); 483 - } 484 - }; 485 - let (book_entry, entry) = arc_data.as_ref(); 486 - 487 - // Build cache key using entry CID 488 - let entry_cid = book_entry.entry.cid.as_ref(); 489 - let cache_key = og::cache_key(&ident, &book_title, entry_title, entry_cid); 490 - 491 - // Check cache first 492 - if let Some(cached) = og::get_cached(&cache_key) { 493 - return Ok(( 494 - [ 495 - (CONTENT_TYPE, "image/png"), 496 - (CACHE_CONTROL, "public, max-age=3600"), 497 - ], 498 - cached, 499 - ) 500 - .into_response()); 501 - } 502 - 503 - // Extract metadata 504 - let title: &str = entry.title.as_ref(); 505 - 506 - // Use book_title from URL - it's the notebook slug/title 507 - // TODO: Could fetch actual notebook record to get display title 508 - let notebook_title_str: String = book_title.to_string(); 509 - 510 - let author_handle = book_entry 511 - .entry 512 - .authors 513 - .first() 514 - .map(|a| match &a.record.inner { 515 - ProfileDataViewInner::ProfileView(p) => p.handle.as_ref().to_string(), 516 - ProfileDataViewInner::ProfileViewDetailed(p) => p.handle.as_ref().to_string(), 517 - ProfileDataViewInner::TangledProfileView(p) => p.handle.as_ref().to_string(), 518 - _ => "unknown".to_string(), 519 - }) 520 - .unwrap_or_else(|| "unknown".to_string()); 521 - 522 - // Check for hero image in embeds 523 - let hero_image_data = if let Some(ref embeds) = entry.embeds { 524 - if let Some(ref images) = embeds.images { 525 - if let Some(first_image) = images.images.first() { 526 - // Get DID from the entry URI 527 - let did = book_entry.entry.uri.authority(); 528 - 529 - let blob = first_image.image.blob(); 530 - let cid = blob.cid(); 531 - let mime = blob.mime_type.as_ref(); 532 - let format = mime.strip_prefix("image/").unwrap_or("jpeg"); 533 - 534 - // Build CDN URL 535 - let cdn_url = format!( 536 - "https://cdn.bsky.app/img/feed_fullsize/plain/{}/{}@{}", 537 - did.as_str(), 538 - cid.as_ref(), 539 - format 540 - ); 541 - 542 - // Fetch the image 543 - match reqwest::get(&cdn_url).await { 544 - Ok(response) if response.status().is_success() => { 545 - match response.bytes().await { 546 - Ok(bytes) => { 547 - use base64::Engine; 548 - let base64_str = 549 - base64::engine::general_purpose::STANDARD.encode(&bytes); 550 - Some(format!("data:{};base64,{}", mime, base64_str)) 551 - } 552 - Err(_) => None, 553 - } 554 - } 555 - _ => None, 556 - } 557 - } else { 558 - None 559 - } 560 - } else { 561 - None 562 - } 563 - } else { 564 - None 565 - }; 566 - 567 - // Extract content snippet - render markdown to HTML then strip tags 568 - let content_snippet: String = { 569 - let parser = markdown_weaver::Parser::new(entry.content.as_ref()); 570 - let mut html = String::new(); 571 - markdown_weaver::html::push_html(&mut html, parser); 572 - // Strip HTML tags 573 - regex_lite::Regex::new(r"<[^>]+>") 574 - .unwrap() 575 - .replace_all(&html, "") 576 - .replace("&amp;", "&") 577 - .replace("&lt;", "<") 578 - .replace("&gt;", ">") 579 - .replace("&quot;", "\"") 580 - .replace("&#39;", "'") 581 - .replace('\n', " ") 582 - .split_whitespace() 583 - .collect::<Vec<_>>() 584 - .join(" ") 585 - }; 586 - 587 - // Generate image - hero or text-only based on available data 588 - let png_bytes = if let Some(ref hero_data) = hero_image_data { 589 - match og::generate_hero_image(hero_data, title, &notebook_title_str, &author_handle) { 590 - Ok(bytes) => bytes, 591 - Err(e) => { 592 - tracing::error!( 593 - "Failed to generate hero OG image: {:?}, falling back to text", 594 - e 595 - ); 596 - og::generate_text_only(title, &content_snippet, &notebook_title_str, &author_handle) 597 - .map_err(|e| { 598 - tracing::error!("Failed to generate text OG image: {:?}", e); 599 - }) 600 - .ok() 601 - .unwrap_or_default() 602 - } 603 - } 604 - } else { 605 - match og::generate_text_only(title, &content_snippet, &notebook_title_str, &author_handle) { 606 - Ok(bytes) => bytes, 607 - Err(e) => { 608 - tracing::error!("Failed to generate OG image: {:?}", e); 609 - return Ok(( 610 - StatusCode::INTERNAL_SERVER_ERROR, 611 - "Failed to generate image", 612 - ) 613 - .into_response()); 614 - } 615 - } 616 - }; 617 - 618 - // Cache the generated image 619 - og::cache_image(cache_key, png_bytes.clone()); 620 - 621 - Ok(( 622 - [ 623 - (CONTENT_TYPE, "image/png"), 624 - (CACHE_CONTROL, "public, max-age=3600"), 625 - ], 626 - png_bytes, 627 - ) 628 - .into_response()) 629 - } 630 - 631 - // Route: /og/notebook/{ident}/{book_title}.png - OpenGraph image for notebook index 632 - #[cfg(all(feature = "fullstack-server", feature = "server"))] 633 - #[get("/og/notebook/{ident}/{book_title}", fetcher: Extension<Arc<fetch::Fetcher>>)] 634 - pub async fn og_notebook_image( 635 - ident: SmolStr, 636 - book_title: SmolStr, 637 - ) -> Result<axum::response::Response> { 638 - use axum::{ 639 - http::{ 640 - StatusCode, 641 - header::{CACHE_CONTROL, CONTENT_TYPE}, 642 - }, 643 - response::IntoResponse, 644 - }; 645 - use weaver_api::sh_weaver::actor::ProfileDataViewInner; 646 - 647 - // Strip .png extension if present 648 - let book_title = book_title.strip_suffix(".png").unwrap_or(&book_title); 649 - 650 - let Ok(at_ident) = AtIdentifier::new_owned(ident.clone()) else { 651 - return Ok((StatusCode::BAD_REQUEST, "Invalid identifier").into_response()); 652 - }; 653 - 654 - // Fetch notebook data 655 - let notebook_result = fetcher 656 - .get_notebook(at_ident.clone(), book_title.into()) 657 - .await; 658 - 659 - let arc_data = match notebook_result { 660 - Ok(Some(data)) => data, 661 - Ok(None) => return Ok((StatusCode::NOT_FOUND, "Notebook not found").into_response()), 662 - Err(e) => { 663 - tracing::error!("Failed to fetch notebook for OG image: {:?}", e); 664 - return Ok(( 665 - StatusCode::INTERNAL_SERVER_ERROR, 666 - "Failed to fetch notebook", 667 - ) 668 - .into_response()); 669 - } 670 - }; 671 - let (notebook_view, _entries) = arc_data.as_ref(); 672 - 673 - // Build cache key using notebook CID 674 - let notebook_cid = notebook_view.cid.as_ref(); 675 - let cache_key = og::notebook_cache_key(&ident, book_title, notebook_cid); 676 - 677 - // Check cache first 678 - if let Some(cached) = og::get_cached(&cache_key) { 679 - return Ok(( 680 - [ 681 - (CONTENT_TYPE, "image/png"), 682 - (CACHE_CONTROL, "public, max-age=3600"), 683 - ], 684 - cached, 685 - ) 686 - .into_response()); 687 - } 688 - 689 - // Extract metadata 690 - let title = notebook_view 691 - .title 692 - .as_ref() 693 - .map(|t| t.as_ref()) 694 - .unwrap_or("Untitled Notebook"); 695 - 696 - let author_handle = notebook_view 697 - .authors 698 - .first() 699 - .map(|a| match &a.record.inner { 700 - ProfileDataViewInner::ProfileView(p) => p.handle.as_ref().to_string(), 701 - ProfileDataViewInner::ProfileViewDetailed(p) => p.handle.as_ref().to_string(), 702 - ProfileDataViewInner::TangledProfileView(p) => p.handle.as_ref().to_string(), 703 - _ => "unknown".to_string(), 704 - }) 705 - .unwrap_or_else(|| "unknown".to_string()); 706 - 707 - // Fetch entries to get entry titles and count 708 - let entries_result = fetcher 709 - .list_notebook_entries(at_ident.clone(), book_title.into()) 710 - .await; 711 - let (entry_count, entry_titles) = match entries_result { 712 - Ok(Some(entries)) => { 713 - let count = entries.len(); 714 - let titles: Vec<String> = entries 715 - .iter() 716 - .take(4) 717 - .map(|e| { 718 - e.entry 719 - .title 720 - .as_ref() 721 - .map(|t| t.as_ref().to_string()) 722 - .unwrap_or_else(|| "Untitled".to_string()) 723 - }) 724 - .collect(); 725 - (count, titles) 726 - } 727 - _ => (0, vec![]), 728 - }; 729 - 730 - // Generate image 731 - let png_bytes = match og::generate_notebook_og(title, &author_handle, entry_count, entry_titles) 732 - { 733 - Ok(bytes) => bytes, 734 - Err(e) => { 735 - tracing::error!("Failed to generate notebook OG image: {:?}", e); 736 - return Ok(( 737 - StatusCode::INTERNAL_SERVER_ERROR, 738 - "Failed to generate image", 739 - ) 740 - .into_response()); 741 - } 742 - }; 743 - 744 - // Cache the generated image 745 - og::cache_image(cache_key, png_bytes.clone()); 746 - 747 - Ok(( 748 - [ 749 - (CONTENT_TYPE, "image/png"), 750 - (CACHE_CONTROL, "public, max-age=3600"), 751 - ], 752 - png_bytes, 753 - ) 754 - .into_response()) 755 - } 756 - 757 - // Route: /og/profile/{ident}.png - OpenGraph image for profile/repository 758 - #[cfg(all(feature = "fullstack-server", feature = "server"))] 759 - #[get("/og/profile/{ident}", fetcher: Extension<Arc<fetch::Fetcher>>)] 760 - pub async fn og_profile_image(ident: SmolStr) -> Result<axum::response::Response> { 761 - use axum::{ 762 - http::{ 763 - StatusCode, 764 - header::{CACHE_CONTROL, CONTENT_TYPE}, 765 - }, 766 - response::IntoResponse, 767 - }; 768 - use weaver_api::sh_weaver::actor::ProfileDataViewInner; 769 - 770 - // Strip .png extension if present 771 - let ident = ident.strip_suffix(".png").unwrap_or(&ident); 772 - 773 - let Ok(at_ident) = AtIdentifier::new_owned(ident.to_string()) else { 774 - return Ok((StatusCode::BAD_REQUEST, "Invalid identifier").into_response()); 775 - }; 776 - 777 - // Fetch profile data 778 - let profile_result = fetcher.fetch_profile(&at_ident).await; 779 - 780 - let profile_view = match profile_result { 781 - Ok(data) => data, 782 - Err(e) => { 783 - tracing::error!("Failed to fetch profile for OG image: {:?}", e); 784 - return Ok( 785 - (StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch profile").into_response(), 786 - ); 787 - } 788 - }; 789 - 790 - // Extract profile fields based on type 791 - // Use DID as cache key since profiles don't have a CID field 792 - let (display_name, handle, bio, avatar_url, banner_url, cache_id) = match &profile_view.inner { 793 - ProfileDataViewInner::ProfileView(p) => ( 794 - p.display_name 795 - .as_ref() 796 - .map(|n| n.as_ref().to_string()) 797 - .unwrap_or_default(), 798 - p.handle.as_ref().to_string(), 799 - p.description 800 - .as_ref() 801 - .map(|d| d.as_ref().to_string()) 802 - .unwrap_or_default(), 803 - p.avatar.as_ref().map(|u| u.as_ref().to_string()), 804 - None::<String>, 805 - p.did.as_ref().to_string(), 806 - ), 807 - ProfileDataViewInner::ProfileViewDetailed(p) => ( 808 - p.display_name 809 - .as_ref() 810 - .map(|n| n.as_ref().to_string()) 811 - .unwrap_or_default(), 812 - p.handle.as_ref().to_string(), 813 - p.description 814 - .as_ref() 815 - .map(|d| d.as_ref().to_string()) 816 - .unwrap_or_default(), 817 - p.avatar.as_ref().map(|u| u.as_ref().to_string()), 818 - p.banner.as_ref().map(|u| u.as_ref().to_string()), 819 - p.did.as_ref().to_string(), 820 - ), 821 - ProfileDataViewInner::TangledProfileView(p) => ( 822 - String::new(), 823 - p.handle.as_ref().to_string(), 824 - String::new(), 825 - None, 826 - None, 827 - p.did.as_ref().to_string(), 828 - ), 829 - _ => return Ok((StatusCode::NOT_FOUND, "Profile type not supported").into_response()), 830 - }; 831 - 832 - // Build cache key 833 - let cache_key = og::profile_cache_key(ident, &cache_id); 834 - 835 - // Check cache first 836 - if let Some(cached) = og::get_cached(&cache_key) { 837 - return Ok(( 838 - [ 839 - (CONTENT_TYPE, "image/png"), 840 - (CACHE_CONTROL, "public, max-age=3600"), 841 - ], 842 - cached, 843 - ) 844 - .into_response()); 845 - } 846 - 847 - // Fetch notebook count 848 - let notebooks_result = fetcher.fetch_notebooks_for_did(&at_ident).await; 849 - let notebook_count = notebooks_result.map(|n| n.len()).unwrap_or(0); 850 - 851 - // Fetch avatar as base64 if available 852 - let avatar_data = if let Some(ref url) = avatar_url { 853 - match reqwest::get(url).await { 854 - Ok(response) if response.status().is_success() => { 855 - let content_type = response 856 - .headers() 857 - .get("content-type") 858 - .and_then(|v| v.to_str().ok()) 859 - .unwrap_or("image/jpeg") 860 - .to_string(); 861 - match response.bytes().await { 862 - Ok(bytes) => { 863 - use base64::Engine; 864 - let base64_str = base64::engine::general_purpose::STANDARD.encode(&bytes); 865 - Some(format!("data:{};base64,{}", content_type, base64_str)) 866 - } 867 - Err(_) => None, 868 - } 869 - } 870 - _ => None, 871 - } 872 - } else { 873 - None 874 - }; 875 - 876 - // Check for banner and generate appropriate template 877 - let png_bytes = if let Some(ref banner_url) = banner_url { 878 - // Fetch banner image 879 - let banner_data = match reqwest::get(banner_url).await { 880 - Ok(response) if response.status().is_success() => { 881 - let content_type = response 882 - .headers() 883 - .get("content-type") 884 - .and_then(|v| v.to_str().ok()) 885 - .unwrap_or("image/jpeg") 886 - .to_string(); 887 - match response.bytes().await { 888 - Ok(bytes) => { 889 - use base64::Engine; 890 - let base64_str = base64::engine::general_purpose::STANDARD.encode(&bytes); 891 - Some(format!("data:{};base64,{}", content_type, base64_str)) 892 - } 893 - Err(_) => None, 894 - } 895 - } 896 - _ => None, 897 - }; 898 - 899 - if let Some(banner_data) = banner_data { 900 - match og::generate_profile_banner_og( 901 - &display_name, 902 - &handle, 903 - &bio, 904 - banner_data, 905 - avatar_data.clone(), 906 - notebook_count, 907 - ) { 908 - Ok(bytes) => bytes, 909 - Err(e) => { 910 - tracing::error!( 911 - "Failed to generate profile banner OG image: {:?}, falling back", 912 - e 913 - ); 914 - og::generate_profile_og( 915 - &display_name, 916 - &handle, 917 - &bio, 918 - avatar_data, 919 - notebook_count, 920 - ) 921 - .unwrap_or_default() 922 - } 923 - } 924 - } else { 925 - og::generate_profile_og(&display_name, &handle, &bio, avatar_data, notebook_count) 926 - .unwrap_or_default() 927 - } 928 - } else { 929 - match og::generate_profile_og(&display_name, &handle, &bio, avatar_data, notebook_count) { 930 - Ok(bytes) => bytes, 931 - Err(e) => { 932 - tracing::error!("Failed to generate profile OG image: {:?}", e); 933 - return Ok(( 934 - StatusCode::INTERNAL_SERVER_ERROR, 935 - "Failed to generate image", 936 - ) 937 - .into_response()); 938 - } 939 - } 940 - }; 941 - 942 - // Cache the generated image 943 - og::cache_image(cache_key, png_bytes.clone()); 944 - 945 - Ok(( 946 - [ 947 - (CONTENT_TYPE, "image/png"), 948 - (CACHE_CONTROL, "public, max-age=3600"), 949 - ], 950 - png_bytes, 951 - ) 952 - .into_response()) 953 - } 954 - 955 - // Route: /og/site.png - OpenGraph image for homepage 956 - #[cfg(all(feature = "fullstack-server", feature = "server"))] 957 - #[get("/og/site.png")] 958 - pub async fn og_site_image() -> Result<axum::response::Response> { 959 - use axum::{ 960 - http::{ 961 - StatusCode, 962 - header::{CACHE_CONTROL, CONTENT_TYPE}, 963 - }, 964 - response::IntoResponse, 965 - }; 966 - 967 - // Site OG is static, cache aggressively 968 - static SITE_OG_CACHE: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new(); 969 - 970 - let png_bytes = SITE_OG_CACHE.get_or_init(|| og::generate_site_og().unwrap_or_default()); 971 - 972 - if png_bytes.is_empty() { 973 - return Ok(( 974 - StatusCode::INTERNAL_SERVER_ERROR, 975 - "Failed to generate image", 976 - ) 977 - .into_response()); 978 - } 979 - 980 - Ok(( 981 - [ 982 - (CONTENT_TYPE, "image/png"), 983 - (CACHE_CONTROL, "public, max-age=86400"), 984 - ], 985 - png_bytes.clone(), 986 - ) 987 - .into_response()) 988 325 } 989 326 990 327 // #[server(endpoint = "static_routes", output = server_fn::codec::Json)]
+3 -4
crates/weaver-app/src/og/mod.rs
··· 1 1 //! OpenGraph image generation module 2 2 //! 3 3 //! Generates social card images for entry pages using SVG templates rendered to PNG. 4 + pub mod server; 4 5 6 + use crate::cache_impl::{Cache, new_cache}; 5 7 use askama::Template; 6 8 use std::sync::OnceLock; 7 9 use std::time::Duration; 8 - 9 - use crate::cache_impl::{Cache, new_cache}; 10 10 11 11 /// Cache for generated OG images 12 12 /// Key: "{ident}/{book}/{entry}/{cid}" - includes CID for invalidation ··· 139 139 // Load IBM Plex Sans Bold (static weight for proper bold rendering) 140 140 let font_data = include_bytes!("../../assets/fonts/IBMPlexSans-Bold.ttf"); 141 141 db.load_font_data(font_data.to_vec()); 142 - let font_data = 143 - include_bytes!("../../assets/fonts/ioskeley-mono/IoskeleyMono-Regular.ttf"); 142 + let font_data = include_bytes!("../../assets/fonts/ioskeley-mono/IoskeleyMono-Regular.ttf"); 144 143 db.load_font_data(font_data.to_vec()); 145 144 db 146 145 })
+557
crates/weaver-app/src/og/server.rs
··· 1 + #[cfg(all(feature = "fullstack-server", feature = "server"))] 2 + use crate::fetch; 3 + #[cfg(all(feature = "fullstack-server", feature = "server"))] 4 + use crate::og; 5 + #[cfg(all(feature = "fullstack-server", feature = "server"))] 6 + use axum::Extension; 7 + #[cfg(all(feature = "fullstack-server", feature = "server"))] 8 + use dioxus::prelude::*; 9 + #[cfg(all(feature = "fullstack-server", feature = "server"))] 10 + use jacquard::smol_str::SmolStr; 11 + #[cfg(all(feature = "fullstack-server", feature = "server"))] 12 + use jacquard::types::string::AtIdentifier; 13 + #[cfg(all(feature = "fullstack-server", feature = "server"))] 14 + use std::sync::Arc; 15 + 16 + // Route: /og/{ident}/{book_title}/{entry_title} - OpenGraph image for entry 17 + #[cfg(all(feature = "fullstack-server", feature = "server"))] 18 + #[get("/og/{ident}/{book_title}/{entry_title}", fetcher: Extension<Arc<fetch::Fetcher>>)] 19 + pub async fn og_image( 20 + ident: SmolStr, 21 + book_title: SmolStr, 22 + entry_title: SmolStr, 23 + ) -> Result<axum::response::Response> { 24 + use axum::{ 25 + http::{ 26 + StatusCode, 27 + header::{CACHE_CONTROL, CONTENT_TYPE}, 28 + }, 29 + response::IntoResponse, 30 + }; 31 + use weaver_api::sh_weaver::actor::ProfileDataViewInner; 32 + use weaver_api::sh_weaver::notebook::Title; 33 + 34 + // Strip .png extension if present 35 + let entry_title = entry_title.strip_suffix(".png").unwrap_or(&entry_title); 36 + 37 + let Ok(at_ident) = AtIdentifier::new_owned(ident.clone()) else { 38 + return Ok((StatusCode::BAD_REQUEST, "Invalid identifier").into_response()); 39 + }; 40 + 41 + // Fetch entry data 42 + let entry_result = fetcher 43 + .get_entry(at_ident.clone(), book_title.clone(), entry_title.into()) 44 + .await; 45 + 46 + let arc_data = match entry_result { 47 + Ok(Some(data)) => data, 48 + Ok(None) => return Ok((StatusCode::NOT_FOUND, "Entry not found").into_response()), 49 + Err(e) => { 50 + tracing::error!("Failed to fetch entry for OG image: {:?}", e); 51 + return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch entry").into_response()); 52 + } 53 + }; 54 + let (book_entry, entry) = arc_data.as_ref(); 55 + 56 + // Build cache key using entry CID 57 + let entry_cid = book_entry.entry.cid.as_ref(); 58 + let cache_key = og::cache_key(&ident, &book_title, entry_title, entry_cid); 59 + 60 + // Check cache first 61 + if let Some(cached) = og::get_cached(&cache_key) { 62 + return Ok(( 63 + [ 64 + (CONTENT_TYPE, "image/png"), 65 + (CACHE_CONTROL, "public, max-age=3600"), 66 + ], 67 + cached, 68 + ) 69 + .into_response()); 70 + } 71 + 72 + // Extract metadata 73 + let title: &str = entry.title.as_ref(); 74 + 75 + // Use book_title from URL - it's the notebook slug/title 76 + // TODO: Could fetch actual notebook record to get display title 77 + let notebook_title_str: String = book_title.to_string(); 78 + 79 + let author_handle = book_entry 80 + .entry 81 + .authors 82 + .first() 83 + .map(|a| match &a.record.inner { 84 + ProfileDataViewInner::ProfileView(p) => p.handle.as_ref().to_string(), 85 + ProfileDataViewInner::ProfileViewDetailed(p) => p.handle.as_ref().to_string(), 86 + ProfileDataViewInner::TangledProfileView(p) => p.handle.as_ref().to_string(), 87 + _ => "unknown".to_string(), 88 + }) 89 + .unwrap_or_else(|| "unknown".to_string()); 90 + 91 + // Check for hero image in embeds 92 + let hero_image_data = if let Some(ref embeds) = entry.embeds { 93 + if let Some(ref images) = embeds.images { 94 + if let Some(first_image) = images.images.first() { 95 + // Get DID from the entry URI 96 + let did = book_entry.entry.uri.authority(); 97 + 98 + let blob = first_image.image.blob(); 99 + let cid = blob.cid(); 100 + let mime = blob.mime_type.as_ref(); 101 + let format = mime.strip_prefix("image/").unwrap_or("jpeg"); 102 + 103 + // Build CDN URL 104 + let cdn_url = format!( 105 + "https://cdn.bsky.app/img/feed_fullsize/plain/{}/{}@{}", 106 + did.as_str(), 107 + cid.as_ref(), 108 + format 109 + ); 110 + 111 + // Fetch the image 112 + match reqwest::get(&cdn_url).await { 113 + Ok(response) if response.status().is_success() => { 114 + match response.bytes().await { 115 + Ok(bytes) => { 116 + use base64::Engine; 117 + let base64_str = 118 + base64::engine::general_purpose::STANDARD.encode(&bytes); 119 + Some(format!("data:{};base64,{}", mime, base64_str)) 120 + } 121 + Err(_) => None, 122 + } 123 + } 124 + _ => None, 125 + } 126 + } else { 127 + None 128 + } 129 + } else { 130 + None 131 + } 132 + } else { 133 + None 134 + }; 135 + 136 + // Extract content snippet - render markdown to HTML then strip tags 137 + let content_snippet: String = { 138 + let parser = markdown_weaver::Parser::new(entry.content.as_ref()); 139 + let mut html = String::new(); 140 + markdown_weaver::html::push_html(&mut html, parser); 141 + // Strip HTML tags 142 + regex_lite::Regex::new(r"<[^>]+>") 143 + .unwrap() 144 + .replace_all(&html, "") 145 + .replace("&amp;", "&") 146 + .replace("&lt;", "<") 147 + .replace("&gt;", ">") 148 + .replace("&quot;", "\"") 149 + .replace("&#39;", "'") 150 + .replace('\n', " ") 151 + .split_whitespace() 152 + .collect::<Vec<_>>() 153 + .join(" ") 154 + }; 155 + 156 + // Generate image - hero or text-only based on available data 157 + let png_bytes = if let Some(ref hero_data) = hero_image_data { 158 + match og::generate_hero_image(hero_data, title, &notebook_title_str, &author_handle) { 159 + Ok(bytes) => bytes, 160 + Err(e) => { 161 + tracing::error!( 162 + "Failed to generate hero OG image: {:?}, falling back to text", 163 + e 164 + ); 165 + og::generate_text_only(title, &content_snippet, &notebook_title_str, &author_handle) 166 + .map_err(|e| { 167 + tracing::error!("Failed to generate text OG image: {:?}", e); 168 + }) 169 + .ok() 170 + .unwrap_or_default() 171 + } 172 + } 173 + } else { 174 + match og::generate_text_only(title, &content_snippet, &notebook_title_str, &author_handle) { 175 + Ok(bytes) => bytes, 176 + Err(e) => { 177 + tracing::error!("Failed to generate OG image: {:?}", e); 178 + return Ok(( 179 + StatusCode::INTERNAL_SERVER_ERROR, 180 + "Failed to generate image", 181 + ) 182 + .into_response()); 183 + } 184 + } 185 + }; 186 + 187 + // Cache the generated image 188 + og::cache_image(cache_key, png_bytes.clone()); 189 + 190 + Ok(( 191 + [ 192 + (CONTENT_TYPE, "image/png"), 193 + (CACHE_CONTROL, "public, max-age=3600"), 194 + ], 195 + png_bytes, 196 + ) 197 + .into_response()) 198 + } 199 + 200 + // Route: /og/notebook/{ident}/{book_title}.png - OpenGraph image for notebook index 201 + #[cfg(all(feature = "fullstack-server", feature = "server"))] 202 + #[get("/og/notebook/{ident}/{book_title}", fetcher: Extension<Arc<fetch::Fetcher>>)] 203 + pub async fn og_notebook_image( 204 + ident: SmolStr, 205 + book_title: SmolStr, 206 + ) -> Result<axum::response::Response> { 207 + use axum::{ 208 + http::{ 209 + StatusCode, 210 + header::{CACHE_CONTROL, CONTENT_TYPE}, 211 + }, 212 + response::IntoResponse, 213 + }; 214 + use weaver_api::sh_weaver::actor::ProfileDataViewInner; 215 + 216 + // Strip .png extension if present 217 + let book_title = book_title.strip_suffix(".png").unwrap_or(&book_title); 218 + 219 + let Ok(at_ident) = AtIdentifier::new_owned(ident.clone()) else { 220 + return Ok((StatusCode::BAD_REQUEST, "Invalid identifier").into_response()); 221 + }; 222 + 223 + // Fetch notebook data 224 + let notebook_result = fetcher 225 + .get_notebook(at_ident.clone(), book_title.into()) 226 + .await; 227 + 228 + let arc_data = match notebook_result { 229 + Ok(Some(data)) => data, 230 + Ok(None) => return Ok((StatusCode::NOT_FOUND, "Notebook not found").into_response()), 231 + Err(e) => { 232 + tracing::error!("Failed to fetch notebook for OG image: {:?}", e); 233 + return Ok(( 234 + StatusCode::INTERNAL_SERVER_ERROR, 235 + "Failed to fetch notebook", 236 + ) 237 + .into_response()); 238 + } 239 + }; 240 + let (notebook_view, _entries) = arc_data.as_ref(); 241 + 242 + // Build cache key using notebook CID 243 + let notebook_cid = notebook_view.cid.as_ref(); 244 + let cache_key = og::notebook_cache_key(&ident, book_title, notebook_cid); 245 + 246 + // Check cache first 247 + if let Some(cached) = og::get_cached(&cache_key) { 248 + return Ok(( 249 + [ 250 + (CONTENT_TYPE, "image/png"), 251 + (CACHE_CONTROL, "public, max-age=3600"), 252 + ], 253 + cached, 254 + ) 255 + .into_response()); 256 + } 257 + 258 + // Extract metadata 259 + let title = notebook_view 260 + .title 261 + .as_ref() 262 + .map(|t| t.as_ref()) 263 + .unwrap_or("Untitled Notebook"); 264 + 265 + let author_handle = notebook_view 266 + .authors 267 + .first() 268 + .map(|a| match &a.record.inner { 269 + ProfileDataViewInner::ProfileView(p) => p.handle.as_ref().to_string(), 270 + ProfileDataViewInner::ProfileViewDetailed(p) => p.handle.as_ref().to_string(), 271 + ProfileDataViewInner::TangledProfileView(p) => p.handle.as_ref().to_string(), 272 + _ => "unknown".to_string(), 273 + }) 274 + .unwrap_or_else(|| "unknown".to_string()); 275 + 276 + // Fetch entries to get entry titles and count 277 + let entries_result = fetcher 278 + .list_notebook_entries(at_ident.clone(), book_title.into()) 279 + .await; 280 + let (entry_count, entry_titles) = match entries_result { 281 + Ok(Some(entries)) => { 282 + let count = entries.len(); 283 + let titles: Vec<String> = entries 284 + .iter() 285 + .take(4) 286 + .map(|e| { 287 + e.entry 288 + .title 289 + .as_ref() 290 + .map(|t| t.as_ref().to_string()) 291 + .unwrap_or_else(|| "Untitled".to_string()) 292 + }) 293 + .collect(); 294 + (count, titles) 295 + } 296 + _ => (0, vec![]), 297 + }; 298 + 299 + // Generate image 300 + let png_bytes = match og::generate_notebook_og(title, &author_handle, entry_count, entry_titles) 301 + { 302 + Ok(bytes) => bytes, 303 + Err(e) => { 304 + tracing::error!("Failed to generate notebook OG image: {:?}", e); 305 + return Ok(( 306 + StatusCode::INTERNAL_SERVER_ERROR, 307 + "Failed to generate image", 308 + ) 309 + .into_response()); 310 + } 311 + }; 312 + 313 + // Cache the generated image 314 + og::cache_image(cache_key, png_bytes.clone()); 315 + 316 + Ok(( 317 + [ 318 + (CONTENT_TYPE, "image/png"), 319 + (CACHE_CONTROL, "public, max-age=3600"), 320 + ], 321 + png_bytes, 322 + ) 323 + .into_response()) 324 + } 325 + 326 + // Route: /og/profile/{ident}.png - OpenGraph image for profile/repository 327 + #[cfg(all(feature = "fullstack-server", feature = "server"))] 328 + #[get("/og/profile/{ident}", fetcher: Extension<Arc<fetch::Fetcher>>)] 329 + pub async fn og_profile_image(ident: SmolStr) -> Result<axum::response::Response> { 330 + use axum::{ 331 + http::{ 332 + StatusCode, 333 + header::{CACHE_CONTROL, CONTENT_TYPE}, 334 + }, 335 + response::IntoResponse, 336 + }; 337 + use weaver_api::sh_weaver::actor::ProfileDataViewInner; 338 + 339 + // Strip .png extension if present 340 + let ident = ident.strip_suffix(".png").unwrap_or(&ident); 341 + 342 + let Ok(at_ident) = AtIdentifier::new_owned(ident.to_string()) else { 343 + return Ok((StatusCode::BAD_REQUEST, "Invalid identifier").into_response()); 344 + }; 345 + 346 + // Fetch profile data 347 + let profile_result = fetcher.fetch_profile(&at_ident).await; 348 + 349 + let profile_view = match profile_result { 350 + Ok(data) => data, 351 + Err(e) => { 352 + tracing::error!("Failed to fetch profile for OG image: {:?}", e); 353 + return Ok( 354 + (StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch profile").into_response(), 355 + ); 356 + } 357 + }; 358 + 359 + // Extract profile fields based on type 360 + // Use DID as cache key since profiles don't have a CID field 361 + let (display_name, handle, bio, avatar_url, banner_url, cache_id) = match &profile_view.inner { 362 + ProfileDataViewInner::ProfileView(p) => ( 363 + p.display_name 364 + .as_ref() 365 + .map(|n| n.as_ref().to_string()) 366 + .unwrap_or_default(), 367 + p.handle.as_ref().to_string(), 368 + p.description 369 + .as_ref() 370 + .map(|d| d.as_ref().to_string()) 371 + .unwrap_or_default(), 372 + p.avatar.as_ref().map(|u| u.as_ref().to_string()), 373 + None::<String>, 374 + p.did.as_ref().to_string(), 375 + ), 376 + ProfileDataViewInner::ProfileViewDetailed(p) => ( 377 + p.display_name 378 + .as_ref() 379 + .map(|n| n.as_ref().to_string()) 380 + .unwrap_or_default(), 381 + p.handle.as_ref().to_string(), 382 + p.description 383 + .as_ref() 384 + .map(|d| d.as_ref().to_string()) 385 + .unwrap_or_default(), 386 + p.avatar.as_ref().map(|u| u.as_ref().to_string()), 387 + p.banner.as_ref().map(|u| u.as_ref().to_string()), 388 + p.did.as_ref().to_string(), 389 + ), 390 + ProfileDataViewInner::TangledProfileView(p) => ( 391 + String::new(), 392 + p.handle.as_ref().to_string(), 393 + String::new(), 394 + None, 395 + None, 396 + p.did.as_ref().to_string(), 397 + ), 398 + _ => return Ok((StatusCode::NOT_FOUND, "Profile type not supported").into_response()), 399 + }; 400 + 401 + // Build cache key 402 + let cache_key = og::profile_cache_key(ident, &cache_id); 403 + 404 + // Check cache first 405 + if let Some(cached) = og::get_cached(&cache_key) { 406 + return Ok(( 407 + [ 408 + (CONTENT_TYPE, "image/png"), 409 + (CACHE_CONTROL, "public, max-age=3600"), 410 + ], 411 + cached, 412 + ) 413 + .into_response()); 414 + } 415 + 416 + // Fetch notebook count 417 + let notebooks_result = fetcher.fetch_notebooks_for_did(&at_ident).await; 418 + let notebook_count = notebooks_result.map(|n| n.len()).unwrap_or(0); 419 + 420 + // Fetch avatar as base64 if available 421 + let avatar_data = if let Some(ref url) = avatar_url { 422 + match reqwest::get(url).await { 423 + Ok(response) if response.status().is_success() => { 424 + let content_type = response 425 + .headers() 426 + .get("content-type") 427 + .and_then(|v| v.to_str().ok()) 428 + .unwrap_or("image/jpeg") 429 + .to_string(); 430 + match response.bytes().await { 431 + Ok(bytes) => { 432 + use base64::Engine; 433 + let base64_str = base64::engine::general_purpose::STANDARD.encode(&bytes); 434 + Some(format!("data:{};base64,{}", content_type, base64_str)) 435 + } 436 + Err(_) => None, 437 + } 438 + } 439 + _ => None, 440 + } 441 + } else { 442 + None 443 + }; 444 + 445 + // Check for banner and generate appropriate template 446 + let png_bytes = if let Some(ref banner_url) = banner_url { 447 + // Fetch banner image 448 + let banner_data = match reqwest::get(banner_url).await { 449 + Ok(response) if response.status().is_success() => { 450 + let content_type = response 451 + .headers() 452 + .get("content-type") 453 + .and_then(|v| v.to_str().ok()) 454 + .unwrap_or("image/jpeg") 455 + .to_string(); 456 + match response.bytes().await { 457 + Ok(bytes) => { 458 + use base64::Engine; 459 + let base64_str = base64::engine::general_purpose::STANDARD.encode(&bytes); 460 + Some(format!("data:{};base64,{}", content_type, base64_str)) 461 + } 462 + Err(_) => None, 463 + } 464 + } 465 + _ => None, 466 + }; 467 + 468 + if let Some(banner_data) = banner_data { 469 + match og::generate_profile_banner_og( 470 + &display_name, 471 + &handle, 472 + &bio, 473 + banner_data, 474 + avatar_data.clone(), 475 + notebook_count, 476 + ) { 477 + Ok(bytes) => bytes, 478 + Err(e) => { 479 + tracing::error!( 480 + "Failed to generate profile banner OG image: {:?}, falling back", 481 + e 482 + ); 483 + og::generate_profile_og( 484 + &display_name, 485 + &handle, 486 + &bio, 487 + avatar_data, 488 + notebook_count, 489 + ) 490 + .unwrap_or_default() 491 + } 492 + } 493 + } else { 494 + og::generate_profile_og(&display_name, &handle, &bio, avatar_data, notebook_count) 495 + .unwrap_or_default() 496 + } 497 + } else { 498 + match og::generate_profile_og(&display_name, &handle, &bio, avatar_data, notebook_count) { 499 + Ok(bytes) => bytes, 500 + Err(e) => { 501 + tracing::error!("Failed to generate profile OG image: {:?}", e); 502 + return Ok(( 503 + StatusCode::INTERNAL_SERVER_ERROR, 504 + "Failed to generate image", 505 + ) 506 + .into_response()); 507 + } 508 + } 509 + }; 510 + 511 + // Cache the generated image 512 + og::cache_image(cache_key, png_bytes.clone()); 513 + 514 + Ok(( 515 + [ 516 + (CONTENT_TYPE, "image/png"), 517 + (CACHE_CONTROL, "public, max-age=3600"), 518 + ], 519 + png_bytes, 520 + ) 521 + .into_response()) 522 + } 523 + 524 + // Route: /og/site.png - OpenGraph image for homepage 525 + #[cfg(all(feature = "fullstack-server", feature = "server"))] 526 + #[get("/og/site.png")] 527 + pub async fn og_site_image() -> Result<axum::response::Response> { 528 + use axum::{ 529 + http::{ 530 + StatusCode, 531 + header::{CACHE_CONTROL, CONTENT_TYPE}, 532 + }, 533 + response::IntoResponse, 534 + }; 535 + 536 + // Site OG is static, cache aggressively 537 + static SITE_OG_CACHE: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new(); 538 + 539 + let png_bytes = SITE_OG_CACHE.get_or_init(|| og::generate_site_og().unwrap_or_default()); 540 + 541 + if png_bytes.is_empty() { 542 + return Ok(( 543 + StatusCode::INTERNAL_SERVER_ERROR, 544 + "Failed to generate image", 545 + ) 546 + .into_response()); 547 + } 548 + 549 + Ok(( 550 + [ 551 + (CONTENT_TYPE, "image/png"), 552 + (CACHE_CONTROL, "public, max-age=86400"), 553 + ], 554 + png_bytes.clone(), 555 + ) 556 + .into_response()) 557 + }
+10 -4
crates/weaver-common/src/agent.rs
··· 13 13 use jacquard::error::ClientError; 14 14 use jacquard::prelude::*; 15 15 use jacquard::types::blob::{BlobRef, MimeType}; 16 - use jacquard::types::string::{AtUri, Did, RecordKey}; 16 + use jacquard::types::string::{AtUri, Did, RecordKey, Rkey}; 17 17 use jacquard::types::tid::Tid; 18 18 use jacquard::types::uri::Uri; 19 19 use jacquard::url::Url; ··· 79 79 &'a self, 80 80 blob: Bytes, 81 81 url_path: &'a str, 82 - prev: Option<Tid>, 82 + rkey: Option<RecordKey<Rkey<'a>>>, 83 83 ) -> impl Future<Output = Result<(StrongRef<'a>, PublishedBlob<'a>), WeaverError>> + 'a { 84 84 async move { 85 85 let mime_type = ··· 90 90 .path(url_path) 91 91 .upload(BlobRef::Blob(blob)) 92 92 .build(); 93 - let tid = W_TICKER.lock().await.next(prev); 93 + let record_key = match rkey { 94 + Some(key) => key, 95 + None => { 96 + let tid = W_TICKER.lock().await.next(None); 97 + RecordKey(Rkey::new_owned(tid.as_str())?) 98 + } 99 + }; 94 100 let record = self 95 - .create_record(publish_record.clone(), Some(RecordKey::any(tid.as_str())?)) 101 + .create_record(publish_record.clone(), Some(record_key)) 96 102 .await?; 97 103 let strong_ref = StrongRef::new().uri(record.uri).cid(record.cid).build(); 98 104
+50 -16
crates/weaver-renderer/src/atproto/client.rs
··· 104 104 } 105 105 106 106 impl EmbedResolver for () { 107 - async fn resolve_profile(&self, uri: &AtUri<'_>) -> Result<String, ClientRenderError> { 107 + async fn resolve_profile(&self, _uri: &AtUri<'_>) -> Result<String, ClientRenderError> { 108 108 Ok("".to_string()) 109 109 } 110 110 111 - async fn resolve_post(&self, uri: &AtUri<'_>) -> Result<String, ClientRenderError> { 111 + async fn resolve_post(&self, _uri: &AtUri<'_>) -> Result<String, ClientRenderError> { 112 112 Ok("".to_string()) 113 113 } 114 114 115 - async fn resolve_markdown(&self, url: &str, depth: usize) -> Result<String, ClientRenderError> { 115 + async fn resolve_markdown( 116 + &self, 117 + _url: &str, 118 + _depth: usize, 119 + ) -> Result<String, ClientRenderError> { 120 + Ok("".to_string()) 121 + } 122 + } 123 + 124 + impl EmbedResolver for ResolvedContent { 125 + async fn resolve_profile(&self, uri: &AtUri<'_>) -> Result<String, ClientRenderError> { 126 + self.get_embed_content(uri) 127 + .map(|s| s.to_string()) 128 + .ok_or_else(|| ClientRenderError::EntryFetch { 129 + uri: uri.to_string(), 130 + source: "Not in pre-resolved content".into(), 131 + }) 132 + } 133 + 134 + async fn resolve_post(&self, uri: &AtUri<'_>) -> Result<String, ClientRenderError> { 135 + self.get_embed_content(uri) 136 + .map(|s| s.to_string()) 137 + .ok_or_else(|| ClientRenderError::EntryFetch { 138 + uri: uri.to_string(), 139 + source: "Not in pre-resolved content".into(), 140 + }) 141 + } 142 + 143 + async fn resolve_markdown( 144 + &self, 145 + _url: &str, 146 + _depth: usize, 147 + ) -> Result<String, ClientRenderError> { 116 148 Ok("".to_string()) 117 149 } 118 150 } ··· 159 191 } 160 192 } 161 193 194 + /// Add an entry index for wikilink resolution 195 + pub fn with_entry_index(mut self, index: EntryIndex) -> Self { 196 + self.entry_index = Some(index); 197 + self 198 + } 199 + 200 + /// Add pre-resolved content for sync rendering 201 + pub fn with_resolved_content(mut self, content: ResolvedContent) -> Self { 202 + self.resolved_content = Some(content); 203 + self 204 + } 205 + } 206 + 207 + impl<'a> ClientContext<'a> { 162 208 /// Add an embed resolver for fetching embed content 163 - pub fn with_embed_resolver(self, resolver: Arc<R>) -> ClientContext<'a, R> { 209 + pub fn with_embed_resolver<R: EmbedResolver>(self, resolver: Arc<R>) -> ClientContext<'a, R> { 164 210 ClientContext { 165 211 entry: self.entry, 166 212 creator_did: self.creator_did, ··· 172 218 frontmatter: self.frontmatter, 173 219 title: self.title, 174 220 } 175 - } 176 - 177 - /// Add an entry index for wikilink resolution 178 - pub fn with_entry_index(mut self, index: EntryIndex) -> Self { 179 - self.entry_index = Some(index); 180 - self 181 - } 182 - 183 - /// Add pre-resolved content for sync rendering 184 - pub fn with_resolved_content(mut self, content: ResolvedContent) -> Self { 185 - self.resolved_content = Some(content); 186 - self 187 221 } 188 222 } 189 223
+209 -52
crates/weaver-renderer/src/atproto/embed_renderer.rs
··· 75 75 A: AgentSessionExt, 76 76 { 77 77 // Use GetPosts for richer data (author info, engagement counts) 78 - let response = agent 79 - .send(GetPosts::new().uris(vec![uri.clone()]).build()) 80 - .await 81 - .map_err(|e| AtProtoPreprocessError::FetchFailed(e.to_string()))?; 78 + let request = GetPosts::new().uris(vec![uri.clone()]).build(); 79 + let response = agent.send(request).await; 80 + let response = response.map_err(|e| AtProtoPreprocessError::FetchFailed(e.to_string()))?; 82 81 83 82 let output = response 84 83 .into_output() ··· 123 122 where 124 123 A: AgentSessionExt, 125 124 { 126 - use weaver_common::agent::WeaverExt; 127 - use markdown_weaver::Parser; 128 125 use crate::atproto::writer::ClientWriter; 129 126 use crate::default_md_options; 127 + use markdown_weaver::Parser; 128 + use weaver_common::agent::WeaverExt; 130 129 131 130 // Get rkey from URI 132 131 let rkey = uri ··· 144 143 let mut content_html = String::new(); 145 144 ClientWriter::<_, _, ()>::new(parser, &mut content_html) 146 145 .run() 147 - .map_err(|e| AtProtoPreprocessError::FetchFailed(format!("Markdown render failed: {}", e)))?; 146 + .map_err(|e| { 147 + AtProtoPreprocessError::FetchFailed(format!("Markdown render failed: {}", e)) 148 + })?; 148 149 149 150 // Generate unique ID for the toggle checkbox 150 151 let toggle_id = format!("entry-toggle-{}", rkey.as_ref()); ··· 212 213 let collection = uri.collection().map(|c| c.as_ref()); 213 214 214 215 match collection { 215 - Some("app.bsky.feed.post") => fetch_and_render_post(uri, agent).await, 216 + Some("app.bsky.feed.post") => { 217 + let result = fetch_and_render_post(uri, agent).await; 218 + result 219 + } 216 220 Some("app.bsky.actor.profile") => { 217 221 // Extract DID from URI authority 218 222 fetch_and_render_profile(uri.authority(), agent).await ··· 224 228 } 225 229 226 230 /// Render a profile from ProfileDataViewInner (weaver, bsky, or tangled) 227 - fn render_profile_data_view(inner: &ProfileDataViewInner<'_>) -> Result<String, AtProtoPreprocessError> { 231 + fn render_profile_data_view( 232 + inner: &ProfileDataViewInner<'_>, 233 + ) -> Result<String, AtProtoPreprocessError> { 228 234 let mut html = String::new(); 229 235 230 236 match inner { 231 237 ProfileDataViewInner::ProfileView(profile) => { 232 238 // Weaver profile - link to bsky for now 233 239 let profile_url = format!("https://bsky.app/profile/{}", profile.handle.as_ref()); 234 - html.push_str("<span class=\"atproto-embed atproto-profile\" contenteditable=\"false\">"); 240 + html.push_str( 241 + "<span class=\"atproto-embed atproto-profile\" contenteditable=\"false\">", 242 + ); 235 243 236 244 // Background link covers whole card 237 245 html.push_str("<a class=\"embed-card-link\" href=\""); ··· 267 275 ProfileDataViewInner::ProfileViewDetailed(profile) => { 268 276 // Bsky profile 269 277 let profile_url = format!("https://bsky.app/profile/{}", profile.handle.as_ref()); 270 - html.push_str("<span class=\"atproto-embed atproto-profile\" contenteditable=\"false\">"); 278 + html.push_str( 279 + "<span class=\"atproto-embed atproto-profile\" contenteditable=\"false\">", 280 + ); 271 281 272 282 // Background link covers whole card 273 283 html.push_str("<a class=\"embed-card-link\" href=\""); ··· 321 331 ProfileDataViewInner::TangledProfileView(profile) => { 322 332 // Tangled profile - link to tangled 323 333 let profile_url = format!("https://tangled.sh/@{}", profile.handle.as_ref()); 324 - html.push_str("<span class=\"atproto-embed atproto-profile\" contenteditable=\"false\">"); 334 + html.push_str( 335 + "<span class=\"atproto-embed atproto-profile\" contenteditable=\"false\">", 336 + ); 325 337 326 338 // Background link covers whole card 327 339 html.push_str("<a class=\"embed-card-link\" href=\""); ··· 346 358 } 347 359 ProfileDataViewInner::Unknown(data) => { 348 360 // Unknown - no link, just render 349 - html.push_str("<span class=\"atproto-embed atproto-profile\" contenteditable=\"false\">"); 361 + html.push_str( 362 + "<span class=\"atproto-embed atproto-profile\" contenteditable=\"false\">", 363 + ); 350 364 html.push_str(&render_generic_data(data)); 351 365 html.push_str("</span>"); 352 366 } ··· 435 449 436 450 html.push_str("<span class=\"atproto-embed atproto-record\" contenteditable=\"false\">"); 437 451 438 - // Show record type as header 452 + // Show record type as header (full NSID) 439 453 if let Some(collection) = uri.collection() { 440 - // Extract just the record name (e.g., "entry" from "sh.weaver.notebook.entry") 441 - let type_name = collection.as_ref().rsplit('.').next().unwrap_or(collection.as_ref()); 442 454 html.push_str("<span class=\"embed-author-handle\">"); 443 - html.push_str(&html_escape(type_name)); 455 + html.push_str(&html_escape(collection.as_ref())); 444 456 html.push_str("</span>"); 445 457 } 446 458 447 459 // Priority fields to show first (in order) 448 - let priority_fields = ["name", "displayName", "title", "text", "description", "content"]; 460 + let priority_fields = [ 461 + "name", 462 + "displayName", 463 + "title", 464 + "text", 465 + "description", 466 + "content", 467 + ]; 449 468 let mut shown_fields = Vec::new(); 450 469 451 470 if let Some(obj) = data.as_object() { ··· 524 543 } 525 544 526 545 /// Render author block from ProfileView (has same fields as ProfileViewBasic) 527 - pub fn render_author_block_full(author: &weaver_api::app_bsky::actor::ProfileView<'_>, link_to_profile: bool) -> String { 546 + pub fn render_author_block_full( 547 + author: &weaver_api::app_bsky::actor::ProfileView<'_>, 548 + link_to_profile: bool, 549 + ) -> String { 528 550 render_author_block_inner( 529 551 author.avatar.as_ref().map(|u| u.as_ref()), 530 552 author.display_name.as_ref().map(|s| s.as_ref()), ··· 704 726 } 705 727 Some("app.bsky.feed.generator") => { 706 728 // Custom feed - show feed info with type label 707 - if let Ok(generator) = 708 - jacquard::from_data::<weaver_api::app_bsky::feed::generator::Generator>( 709 - &record.value, 710 - ) 729 + if let Ok(generator) = jacquard::from_data::< 730 + weaver_api::app_bsky::feed::generator::Generator, 731 + >(&record.value) 711 732 { 712 733 html.push_str("<span class=\"embed-type\">Custom Feed</span>"); 713 734 html.push_str("<span class=\"embed-author-name\">"); ··· 740 761 } 741 762 Some("app.bsky.graph.starterpack") => { 742 763 // Starter pack 743 - if let Ok(sp) = jacquard::from_data::<weaver_api::app_bsky::graph::starterpack::Starterpack>(&record.value) { 764 + if let Ok(sp) = jacquard::from_data::< 765 + weaver_api::app_bsky::graph::starterpack::Starterpack, 766 + >(&record.value) 767 + { 744 768 html.push_str("<span class=\"embed-type\">Starter Pack</span>"); 745 769 html.push_str("<span class=\"embed-author-name\">"); 746 770 html.push_str(&html_escape(sp.name.as_ref())); ··· 773 797 } 774 798 775 799 /// Render an embed item from a ViewRecord (nested embeds in quotes) 776 - fn render_view_record_embed(embed: &weaver_api::app_bsky::embed::record::ViewRecordEmbedsItem<'_>) -> String { 800 + fn render_view_record_embed( 801 + embed: &weaver_api::app_bsky::embed::record::ViewRecordEmbedsItem<'_>, 802 + ) -> String { 777 803 use weaver_api::app_bsky::embed::record::ViewRecordEmbedsItem; 778 804 779 805 match embed { ··· 995 1021 html.push_str("</span>"); // end author 996 1022 997 1023 // Description 998 - if let Some(desc) = sp.record.query("description").single().and_then(|d| d.as_str()) { 1024 + if let Some(desc) = sp 1025 + .record 1026 + .query("description") 1027 + .single() 1028 + .and_then(|d| d.as_str()) 1029 + { 999 1030 html.push_str("<span class=\"embed-description\">"); 1000 1031 html.push_str(&html_escape(desc)); 1001 1032 html.push_str("</span>"); ··· 1029 1060 /// 1030 1061 /// Used as fallback for Unknown variants of open unions. 1031 1062 fn render_generic_data(data: &Data<'_>) -> String { 1063 + render_generic_data_with_depth(data, 0) 1064 + } 1065 + 1066 + /// Render generic data with depth tracking for nested objects 1067 + fn render_generic_data_with_depth(data: &Data<'_>, depth: u8) -> String { 1032 1068 let mut html = String::new(); 1033 1069 1034 - html.push_str("<span class=\"embed-record-card\">"); 1070 + // Only wrap in card at top level 1071 + let is_nested = depth > 0; 1072 + if is_nested { 1073 + html.push_str("<span class=\"embed-fields\">"); 1074 + } else { 1075 + html.push_str("<span class=\"embed-record-card\">"); 1076 + } 1035 1077 1036 1078 // Show record type as header if present 1037 1079 if let Some(record_type) = data.type_discriminator() { 1038 - // Extract just the record name from full NSID (e.g., "app.bsky.feed.post" -> "post") 1039 - let type_name = record_type.rsplit('.').next().unwrap_or(record_type); 1040 1080 html.push_str("<span class=\"embed-author-handle\">"); 1041 - html.push_str(&html_escape(type_name)); 1081 + html.push_str(&html_escape(record_type)); 1042 1082 html.push_str("</span>"); 1043 1083 } 1044 1084 ··· 1067 1107 } 1068 1108 1069 1109 // Show remaining fields as a simple list 1070 - html.push_str("<span class=\"embed-fields\">"); 1110 + if !is_nested { 1111 + html.push_str("<span class=\"embed-fields\">"); 1112 + } 1071 1113 for (key, value) in obj.iter() { 1072 1114 let key_str: &str = key.as_ref(); 1073 1115 1074 - // Skip already shown, internal fields, and complex nested objects 1116 + // Skip already shown, internal fields 1075 1117 if shown_fields.contains(&key_str) 1076 1118 || key_str.starts_with('$') 1077 1119 || key_str == "facets" ··· 1080 1122 continue; 1081 1123 } 1082 1124 1083 - if let Some(formatted) = format_field_value(key_str, value) { 1125 + if let Some(formatted) = format_field_value_with_depth(key_str, value, depth) { 1084 1126 html.push_str("<span class=\"embed-field\">"); 1085 1127 html.push_str("<span class=\"embed-field-name\">"); 1086 1128 html.push_str(&html_escape(&format_field_name(key_str))); ··· 1089 1131 html.push_str("</span>"); 1090 1132 } 1091 1133 } 1092 - html.push_str("</span>"); 1134 + if !is_nested { 1135 + html.push_str("</span>"); 1136 + } 1093 1137 } 1094 1138 1095 1139 html.push_str("</span>"); ··· 1114 1158 1115 1159 /// Format a field value for display, returning None for complex/unrenderable values 1116 1160 fn format_field_value(key: &str, value: &Data<'_>) -> Option<String> { 1161 + format_field_value_with_depth(key, value, 0) 1162 + } 1163 + 1164 + /// Maximum nesting depth for rendering nested objects 1165 + const MAX_NESTED_DEPTH: u8 = 2; 1166 + 1167 + /// Format a field value for display with depth tracking 1168 + fn format_field_value_with_depth(key: &str, value: &Data<'_>, depth: u8) -> Option<String> { 1117 1169 // String values - detect AT Protocol types 1118 1170 if let Some(s) = value.as_str() { 1119 1171 return Some(format_string_value(key, s)); ··· 1126 1178 1127 1179 // Booleans 1128 1180 if let Some(b) = value.as_boolean() { 1129 - let class = if b { "embed-field-bool-true" } else { "embed-field-bool-false" }; 1130 - return Some(format!("<span class=\"{}\">{}</span>", class, if b { "yes" } else { "no" })); 1181 + let class = if b { 1182 + "embed-field-bool-true" 1183 + } else { 1184 + "embed-field-bool-false" 1185 + }; 1186 + return Some(format!( 1187 + "<span class=\"{}\">{}</span>", 1188 + class, 1189 + if b { "yes" } else { "no" } 1190 + )); 1131 1191 } 1132 1192 1133 - // Arrays - show count 1193 + // Arrays - show count or render items if simple 1134 1194 if let Some(arr) = value.as_array() { 1135 - let len = arr.len(); 1136 - return Some(format!( 1137 - "<span class=\"embed-field-count\">{} item{}</span>", 1138 - len, 1139 - if len == 1 { "" } else { "s" } 1140 - )); 1195 + return Some(format_array_value(arr, depth)); 1196 + } 1197 + 1198 + // Nested objects - render if within depth limit 1199 + if value.as_object().is_some() { 1200 + if depth < MAX_NESTED_DEPTH { 1201 + return Some(render_generic_data_with_depth(value, depth + 1)); 1202 + } else { 1203 + // At max depth, just show field count 1204 + let count = value.as_object().map(|o| o.len()).unwrap_or(0); 1205 + return Some(format!( 1206 + "<span class=\"embed-field-count\">{} field{}</span>", 1207 + count, 1208 + if count == 1 { "" } else { "s" } 1209 + )); 1210 + } 1141 1211 } 1142 1212 1143 - // Skip nested objects - too complex for inline display 1213 + None 1214 + } 1215 + 1216 + /// Format an array value, rendering items if simple enough 1217 + fn format_array_value(arr: &jacquard::Array<'_>, depth: u8) -> String { 1218 + let len = arr.len(); 1219 + 1220 + // Empty array 1221 + if len == 0 { 1222 + return "<span class=\"embed-field-count\">empty</span>".to_string(); 1223 + } 1224 + 1225 + // For small arrays of simple values, show them inline 1226 + if len <= 3 && depth < MAX_NESTED_DEPTH { 1227 + let mut items = Vec::new(); 1228 + let mut all_simple = true; 1229 + 1230 + for item in arr.iter() { 1231 + if let Some(formatted) = format_simple_value(item) { 1232 + items.push(formatted); 1233 + } else { 1234 + all_simple = false; 1235 + break; 1236 + } 1237 + } 1238 + 1239 + if all_simple { 1240 + return format!( 1241 + "<span class=\"embed-field-value\">[{}]</span>", 1242 + items.join(", ") 1243 + ); 1244 + } 1245 + } 1246 + 1247 + // Otherwise just show count 1248 + format!( 1249 + "<span class=\"embed-field-count\">{} item{}</span>", 1250 + len, 1251 + if len == 1 { "" } else { "s" } 1252 + ) 1253 + } 1254 + 1255 + /// Format a simple value (string, number, bool) without field name context 1256 + fn format_simple_value(value: &Data<'_>) -> Option<String> { 1257 + if let Some(s) = value.as_str() { 1258 + // Keep it short for array display 1259 + let display = if s.len() > 50 { 1260 + format!("{}…", &s[..50]) 1261 + } else { 1262 + s.to_string() 1263 + }; 1264 + return Some(format!("\"{}\"", html_escape(&display))); 1265 + } 1266 + 1267 + if let Some(n) = value.as_integer() { 1268 + return Some(n.to_string()); 1269 + } 1270 + 1271 + if let Some(b) = value.as_boolean() { 1272 + return Some(if b { "true" } else { "false" }.to_string()); 1273 + } 1274 + 1144 1275 None 1145 1276 } 1146 1277 ··· 1173 1304 // Datetime fields - show just the date 1174 1305 if key.ends_with("At") || key == "createdAt" || key == "indexedAt" { 1175 1306 let date_part = s.split('T').next().unwrap_or(s); 1176 - return format!("<span class=\"embed-field-date\">{}</span>", html_escape(date_part)); 1307 + return format!( 1308 + "<span class=\"embed-field-date\">{}</span>", 1309 + html_escape(date_part) 1310 + ); 1177 1311 } 1178 1312 1179 1313 // NSID (e.g., app.bsky.feed.post) 1180 - if s.contains('.') && s.chars().all(|c| c.is_alphanumeric() || c == '.') && s.matches('.').count() >= 2 { 1314 + if s.contains('.') 1315 + && s.chars().all(|c| c.is_alphanumeric() || c == '.') 1316 + && s.matches('.').count() >= 2 1317 + { 1181 1318 return format!("<span class=\"embed-field-nsid\">{}</span>", html_escape(s)); 1182 1319 } 1183 1320 1184 1321 // Handle (contains dots, no colons or slashes) 1185 - if s.contains('.') && !s.contains(':') && !s.contains('/') && s.chars().all(|c| c.is_alphanumeric() || c == '.' || c == '-' || c == '_') { 1186 - return format!("<span class=\"embed-field-handle\">@{}</span>", html_escape(s)); 1322 + if s.contains('.') 1323 + && !s.contains(':') 1324 + && !s.contains('/') 1325 + && s.chars() 1326 + .all(|c| c.is_alphanumeric() || c == '.' || c == '-' || c == '_') 1327 + { 1328 + return format!( 1329 + "<span class=\"embed-field-handle\">@{}</span>", 1330 + html_escape(s) 1331 + ); 1187 1332 } 1188 1333 1189 1334 // Plain string ··· 1197 1342 let mut result = String::from("<span class=\"aturi-scheme\">at://</span>"); 1198 1343 1199 1344 if !parts.is_empty() { 1200 - result.push_str(&format!("<span class=\"aturi-authority\">{}</span>", html_escape(parts[0]))); 1345 + result.push_str(&format!( 1346 + "<span class=\"aturi-authority\">{}</span>", 1347 + html_escape(parts[0]) 1348 + )); 1201 1349 } 1202 1350 if parts.len() > 1 { 1203 1351 result.push_str("<span class=\"aturi-slash\">/</span>"); 1204 - result.push_str(&format!("<span class=\"aturi-collection\">{}</span>", html_escape(parts[1]))); 1352 + result.push_str(&format!( 1353 + "<span class=\"aturi-collection\">{}</span>", 1354 + html_escape(parts[1]) 1355 + )); 1205 1356 } 1206 1357 if parts.len() > 2 { 1207 1358 result.push_str("<span class=\"aturi-slash\">/</span>"); 1208 - result.push_str(&format!("<span class=\"aturi-rkey\">{}</span>", html_escape(parts[2]))); 1359 + result.push_str(&format!( 1360 + "<span class=\"aturi-rkey\">{}</span>", 1361 + html_escape(parts[2]) 1362 + )); 1209 1363 } 1210 1364 result 1211 1365 } else { ··· 1229 1383 ); 1230 1384 } 1231 1385 } 1232 - format!("<span class=\"embed-field-did\">{}</span>", html_escape(did)) 1386 + format!( 1387 + "<span class=\"embed-field-did\">{}</span>", 1388 + html_escape(did) 1389 + ) 1233 1390 } 1234 1391 1235 1392 // =============================================================================
+121 -42
crates/weaver-renderer/src/atproto/writer.rs
··· 3 3 //! Similar to StaticPageWriter but designed for client-side use with 4 4 //! synchronous embed content injection. 5 5 6 + use jacquard::types::string::AtUri; 6 7 use markdown_weaver::{ 7 8 Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType, Tag, 8 9 }; 9 10 use markdown_weaver_escape::{StrWrite, escape_href, escape_html, escape_html_body_text}; 10 11 use std::collections::HashMap; 12 + use weaver_common::ResolvedContent; 11 13 12 14 /// Synchronous callback for injecting embed content 13 15 /// ··· 22 24 } 23 25 } 24 26 27 + impl EmbedContentProvider for ResolvedContent { 28 + fn get_embed_content(&self, tag: &Tag<'_>) -> Option<String> { 29 + let url = match tag { 30 + Tag::Embed { dest_url, .. } => Some(dest_url.as_ref()), 31 + // WikiLink images with at:// URLs are embeds in disguise 32 + Tag::Image { 33 + link_type: LinkType::WikiLink { .. }, 34 + dest_url, 35 + .. 36 + } if dest_url.starts_with("at://") || dest_url.starts_with("did:") => { 37 + Some(dest_url.as_ref()) 38 + } 39 + _ => None, 40 + }; 41 + 42 + if let Some(url) = url { 43 + if url.starts_with("at://") { 44 + if let Ok(at_uri) = AtUri::new(url) { 45 + return self.get_embed_content(&at_uri).map(|s| s.to_string()); 46 + } 47 + } 48 + } 49 + None 50 + } 51 + } 52 + 25 53 /// Simple writer that outputs HTML from markdown events 26 54 /// 27 55 /// This writer is designed for client-side rendering where embeds may have ··· 50 78 Body, 51 79 } 52 80 81 + impl<'a, I: Iterator<Item = Event<'a>>, W: StrWrite> ClientWriter<'a, I, W> { 82 + /// Add an embed content provider 83 + pub fn with_embed_provider<E: EmbedContentProvider>( 84 + self, 85 + provider: E, 86 + ) -> ClientWriter<'a, I, W, E> { 87 + ClientWriter { 88 + events: self.events, 89 + writer: self.writer, 90 + end_newline: self.end_newline, 91 + in_non_writing_block: self.in_non_writing_block, 92 + table_state: self.table_state, 93 + table_alignments: self.table_alignments, 94 + table_cell_index: self.table_cell_index, 95 + numbers: self.numbers, 96 + embed_provider: Some(provider), 97 + code_buffer: self.code_buffer, 98 + _phantom: std::marker::PhantomData, 99 + } 100 + } 101 + } 102 + 53 103 impl<'a, I: Iterator<Item = Event<'a>>, W: StrWrite, E: EmbedContentProvider> 54 104 ClientWriter<'a, I, W, E> 55 105 { ··· 69 119 } 70 120 } 71 121 72 - /// Add an embed content provider 73 - pub fn with_embed_provider(self, provider: E) -> ClientWriter<'a, I, W, E> { 74 - ClientWriter { 75 - events: self.events, 76 - writer: self.writer, 77 - end_newline: self.end_newline, 78 - in_non_writing_block: self.in_non_writing_block, 79 - table_state: self.table_state, 80 - table_alignments: self.table_alignments, 81 - table_cell_index: self.table_cell_index, 82 - numbers: self.numbers, 83 - embed_provider: Some(provider), 84 - code_buffer: self.code_buffer, 85 - _phantom: std::marker::PhantomData, 86 - } 87 - } 88 122 #[inline] 89 123 fn write_newline(&mut self) -> Result<(), W::Error> { 90 124 self.end_newline = true; ··· 108 142 Ok(self.writer) 109 143 } 110 144 145 + /// Consume events until End tag without writing anything. 146 + /// Used when we've already rendered content and just need to advance the iterator. 147 + fn consume_until_end(&mut self) { 148 + use Event::*; 149 + let mut nest = 0; 150 + while let Some(event) = self.events.next() { 151 + match event { 152 + Start(_) => nest += 1, 153 + End(_) => { 154 + if nest == 0 { 155 + break; 156 + } 157 + nest -= 1; 158 + } 159 + _ => {} 160 + } 161 + } 162 + } 163 + 111 164 // Consume raw text events until end tag, for alt attributes 112 165 fn raw_text(&mut self) -> Result<(), W::Error> { 113 166 use Event::*; ··· 173 226 escape_html_body_text(&mut self.writer, &text)?; 174 227 self.write("</code>")?; 175 228 } 176 - InlineMath(text) => { 177 - match crate::math::render_math(&text, false) { 178 - crate::math::MathResult::Success(mathml) => { 179 - self.write(r#"<span class="math math-inline">"#)?; 180 - self.write(&mathml)?; 181 - self.write("</span>")?; 182 - } 183 - crate::math::MathResult::Error { html, .. } => { 184 - self.write(&html)?; 185 - } 229 + InlineMath(text) => match crate::math::render_math(&text, false) { 230 + crate::math::MathResult::Success(mathml) => { 231 + self.write(r#"<span class="math math-inline">"#)?; 232 + self.write(&mathml)?; 233 + self.write("</span>")?; 186 234 } 187 - } 188 - DisplayMath(text) => { 189 - match crate::math::render_math(&text, true) { 190 - crate::math::MathResult::Success(mathml) => { 191 - self.write(r#"<span class="math math-display">"#)?; 192 - self.write(&mathml)?; 193 - self.write("</span>")?; 194 - } 195 - crate::math::MathResult::Error { html, .. } => { 196 - self.write(&html)?; 197 - } 235 + crate::math::MathResult::Error { html, .. } => { 236 + self.write(&html)?; 198 237 } 199 - } 238 + }, 239 + DisplayMath(text) => match crate::math::render_math(&text, true) { 240 + crate::math::MathResult::Success(mathml) => { 241 + self.write(r#"<span class="math math-display">"#)?; 242 + self.write(&mathml)?; 243 + self.write("</span>")?; 244 + } 245 + crate::math::MathResult::Error { html, .. } => { 246 + self.write(&html)?; 247 + } 248 + }, 200 249 Html(html) | InlineHtml(html) => { 201 250 self.write(&html)?; 202 251 } ··· 424 473 } 425 474 self.write("\">") 426 475 } 427 - Tag::Image { 428 - dest_url, 429 - title, 430 - attrs, 476 + ref tag @ Tag::Image { 477 + ref dest_url, 478 + ref title, 479 + ref attrs, 480 + ref link_type, 431 481 .. 432 482 } => { 483 + // Check if this is an AT embed disguised as a WikiLink image 484 + // (markdown-weaver parses ![[at://...]] as Image with WikiLink link_type) 485 + if matches!(link_type, LinkType::WikiLink { .. }) 486 + && (dest_url.starts_with("at://") || dest_url.starts_with("did:")) 487 + { 488 + tracing::debug!("[ClientWriter] AT embed image detected: {}", dest_url); 489 + if let Some(embed_provider) = &self.embed_provider { 490 + if let Some(html) = embed_provider.get_embed_content(&tag) { 491 + tracing::debug!("[ClientWriter] Got embed content for {}", dest_url); 492 + // Consume events without writing - we're replacing with embed HTML 493 + self.consume_until_end(); 494 + return self.write(&html); 495 + } else { 496 + tracing::debug!("[ClientWriter] No embed content from provider for {}", dest_url); 497 + } 498 + } else { 499 + tracing::debug!("[ClientWriter] No embed provider available"); 500 + } 501 + // Fallback: render as link if no embed content available 502 + tracing::debug!("[ClientWriter] Using fallback link for {}", dest_url); 503 + self.consume_until_end(); 504 + self.write("<a class=\"embed-fallback\" href=\"")?; 505 + escape_href(&mut self.writer, &dest_url)?; 506 + self.write("\">")?; 507 + escape_html(&mut self.writer, &dest_url)?; 508 + return self.write("</a>"); 509 + } 510 + 511 + // Regular image handling 433 512 self.write("<img src=\"")?; 434 513 escape_href(&mut self.writer, &dest_url)?; 435 514 self.write("\" alt=\"")?;
+16 -2
crates/weaver-renderer/src/css.rs
··· 652 652 .embed-fields {{ 653 653 display: block; 654 654 margin-top: 0.5rem; 655 - font-size: 0.85em; 655 + font-size: 0.85rem; 656 656 color: var(--color-muted); 657 657 }} 658 658 659 659 .embed-field {{ 660 660 display: block; 661 661 margin-top: 0.25rem; 662 + }} 663 + 664 + /* Nested fields get indentation */ 665 + .embed-fields .embed-fields {{ 666 + display: block; 667 + margin-top: 0.5rem; 668 + margin-left: 1rem; 669 + padding-left: 0.5rem; 670 + border-left: 1px solid var(--color-border); 671 + }} 672 + 673 + /* Type label inside fields should be block with spacing */ 674 + .embed-fields > .embed-author-handle {{ 675 + display: block; 676 + margin-bottom: 0.25rem; 662 677 }} 663 678 664 679 .embed-field-name {{ ··· 748 763 .atproto-record > .embed-author-handle {{ 749 764 display: block; 750 765 margin-bottom: 0.25rem; 751 - text-transform: capitalize; 752 766 }} 753 767 754 768 .atproto-record > .embed-author-name {{
+1 -117
crates/weaver-renderer/src/utils.rs
··· 1 - use std::{path::Path, sync::OnceLock}; 2 1 use markdown_weaver::{CodeBlockKind, CowStr, Event, Tag}; 3 2 use miette::IntoDiagnostic; 4 3 use n0_future::TryFutureExt; 4 + use std::{path::Path, sync::OnceLock}; 5 5 6 6 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 7 7 use regex::Regex; ··· 117 117 ("", path) 118 118 } 119 119 120 - fn event_to_owned<'a>(event: Event<'a>) -> Event<'a> { 121 - match event { 122 - Event::Start(tag) => Event::Start(tag_to_owned(tag)), 123 - Event::End(tag) => Event::End(tag), 124 - Event::Text(cowstr) => Event::Text(CowStr::from(cowstr.into_string())), 125 - Event::Code(cowstr) => Event::Code(CowStr::from(cowstr.into_string())), 126 - Event::Html(cowstr) => Event::Html(CowStr::from(cowstr.into_string())), 127 - Event::InlineHtml(cowstr) => Event::InlineHtml(CowStr::from(cowstr.into_string())), 128 - Event::FootnoteReference(cowstr) => { 129 - Event::FootnoteReference(CowStr::from(cowstr.into_string())) 130 - } 131 - Event::SoftBreak => Event::SoftBreak, 132 - Event::HardBreak => Event::HardBreak, 133 - Event::Rule => Event::Rule, 134 - Event::TaskListMarker(checked) => Event::TaskListMarker(checked), 135 - Event::InlineMath(cowstr) => Event::InlineMath(CowStr::from(cowstr.into_string())), 136 - Event::DisplayMath(cowstr) => Event::DisplayMath(CowStr::from(cowstr.into_string())), 137 - Event::WeaverBlock(cow_str) => todo!(), 138 - } 139 - } 140 - 141 - fn tag_to_owned<'a>(tag: Tag<'a>) -> Tag<'a> { 142 - match tag { 143 - Tag::Paragraph => Tag::Paragraph, 144 - Tag::Heading { 145 - level: heading_level, 146 - id, 147 - classes, 148 - attrs, 149 - } => Tag::Heading { 150 - level: heading_level, 151 - id: id.map(|cowstr| CowStr::from(cowstr.into_string())), 152 - classes: classes 153 - .into_iter() 154 - .map(|cowstr| CowStr::from(cowstr.into_string())) 155 - .collect(), 156 - attrs: attrs 157 - .into_iter() 158 - .map(|(attr, value)| { 159 - ( 160 - CowStr::from(attr.into_string()), 161 - value.map(|cowstr| CowStr::from(cowstr.into_string())), 162 - ) 163 - }) 164 - .collect(), 165 - }, 166 - Tag::BlockQuote(blockquote_kind) => Tag::BlockQuote(blockquote_kind), 167 - Tag::CodeBlock(codeblock_kind) => Tag::CodeBlock(codeblock_kind_to_owned(codeblock_kind)), 168 - Tag::List(optional) => Tag::List(optional), 169 - Tag::Item => Tag::Item, 170 - Tag::FootnoteDefinition(cowstr) => { 171 - Tag::FootnoteDefinition(CowStr::from(cowstr.into_string())) 172 - } 173 - Tag::Table(alignment_vector) => Tag::Table(alignment_vector), 174 - Tag::TableHead => Tag::TableHead, 175 - Tag::TableRow => Tag::TableRow, 176 - Tag::TableCell => Tag::TableCell, 177 - Tag::Emphasis => Tag::Emphasis, 178 - Tag::Strong => Tag::Strong, 179 - Tag::Strikethrough => Tag::Strikethrough, 180 - Tag::Link { 181 - link_type, 182 - dest_url, 183 - title, 184 - id, 185 - } => Tag::Link { 186 - link_type, 187 - dest_url: CowStr::from(dest_url.into_string()), 188 - title: CowStr::from(title.into_string()), 189 - id: CowStr::from(id.into_string()), 190 - }, 191 - Tag::Embed { 192 - embed_type, 193 - dest_url, 194 - title, 195 - id, 196 - attrs, 197 - } => Tag::Embed { 198 - embed_type, 199 - dest_url: CowStr::from(dest_url.into_string()), 200 - title: CowStr::from(title.into_string()), 201 - id: CowStr::from(id.into_string()), 202 - attrs, 203 - }, 204 - Tag::Image { 205 - link_type, 206 - dest_url, 207 - title, 208 - id, 209 - attrs, 210 - } => Tag::Image { 211 - link_type, 212 - dest_url: CowStr::from(dest_url.into_string()), 213 - title: CowStr::from(title.into_string()), 214 - id: CowStr::from(id.into_string()), 215 - attrs, 216 - }, 217 - Tag::HtmlBlock => Tag::HtmlBlock, 218 - Tag::MetadataBlock(metadata_block_kind) => Tag::MetadataBlock(metadata_block_kind), 219 - Tag::DefinitionList => Tag::DefinitionList, 220 - Tag::DefinitionListTitle => Tag::DefinitionListTitle, 221 - Tag::DefinitionListDefinition => Tag::DefinitionListDefinition, 222 - Tag::Superscript => todo!(), 223 - Tag::Subscript => todo!(), 224 - Tag::WeaverBlock(weaver_block_kind, weaver_attributes) => todo!(), 225 - } 226 - } 227 - 228 - fn codeblock_kind_to_owned<'a>(codeblock_kind: CodeBlockKind<'_>) -> CodeBlockKind<'a> { 229 - match codeblock_kind { 230 - CodeBlockKind::Indented => CodeBlockKind::Indented, 231 - CodeBlockKind::Fenced(cowstr) => CodeBlockKind::Fenced(CowStr::from(cowstr.into_string())), 232 - } 233 - } 234 - 235 120 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 236 121 use tokio::fs::{self, File}; 237 122 ··· 252 137 .into_diagnostic()?; 253 138 Ok(file) 254 139 } 255 - 256 140 257 141 /// Path lookup in an Obsidian vault 258 142 ///