bunch of updates to embed rendering, etc.

Orual 6ecfcf49 e2b0d156

+3855 -684
+1
.gitignore
··· 20 20 **/.trash 21 21 **/bug_notes.md 22 22 /opencodetmp 23 + deploy.sh
+10
Cargo.lock
··· 7136 7136 ] 7137 7137 7138 7138 [[package]] 7139 + name = "pulldown-latex" 7140 + version = "0.6.3" 7141 + source = "registry+https://github.com/rust-lang/crates.io-index" 7142 + checksum = "5abae0e37a730c3e0c6805cfc30d80754fa03091bf5bad54ae47d033aae6ba8b" 7143 + dependencies = [ 7144 + "bumpalo", 7145 + ] 7146 + 7147 + [[package]] 7139 7148 name = "quick-error" 7140 7149 version = "1.2.3" 7141 7150 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 10455 10464 "n0-future", 10456 10465 "pin-project", 10457 10466 "pin-utils", 10467 + "pulldown-latex", 10458 10468 "regex", 10459 10469 "regex-lite", 10460 10470 "reqwest",
+59 -7
crates/weaver-app/assets/styling/editor.css
··· 156 156 min-height: 700px; 157 157 line-height: var(--spacing-line-height); 158 158 font-size: 16px; 159 - background: var(--color-surface); 160 - border: 1px solid var(--color-border); 159 + background: var(--color-base); 160 + border: 1px solid var(--color-overlay); 161 161 color: var(--color-text); 162 162 } 163 163 164 164 .editor-content:focus { 165 - background: var(--color-surface); 165 + border: 1px solid var(--color-border); 166 166 } 167 167 168 168 .editor-toolbar { ··· 228 228 /* Hidden syntax spans - collapsed when cursor is not near */ 229 229 .md-syntax-inline.hidden, 230 230 .md-syntax-block.hidden, 231 - .image-alt.hidden { 231 + .image-alt.hidden, 232 + .math-source.hidden { 232 233 display: none; 234 + } 235 + 236 + /* Math source is hidden by default, shown when syntax is visible */ 237 + .math-source { 238 + display: none; 239 + color: var(--color-text); 240 + font-family: var(--font-mono); 241 + white-space: pre-wrap; 242 + background: color-mix(in srgb, var(--color-primary) 10%, transparent); 243 + padding: 0 2px; 244 + border-radius: 2px; 245 + } 246 + 247 + /* When syntax is visible (cursor nearby), show source too */ 248 + .math-source:not(.hidden) { 249 + display: inline; 250 + } 251 + 252 + /* Rendered math is always visible */ 253 + .math-rendered { 254 + display: inline; 255 + } 256 + 257 + .math-display.math-rendered { 258 + display: block; 259 + text-align: center; 260 + margin: 0.5rem 0; 261 + } 262 + 263 + /* Clickable math - click to edit */ 264 + .math-clickable { 265 + cursor: pointer; 266 + } 267 + 268 + /* Math error styling */ 269 + .math-error { 270 + border: 1px dashed var(--color-error, #eb6f92); 271 + border-radius: 4px; 272 + padding: 2px 6px; 273 + background: color-mix(in srgb, var(--color-error, #eb6f92) 10%, transparent); 274 + } 275 + 276 + .math-error code { 277 + font-family: var(--font-mono); 278 + font-size: 0.9em; 233 279 } 234 280 235 281 /* Hide HTML list markers when markdown syntax is visible (not hidden) */ ··· 630 676 border-radius: 12px; 631 677 font-size: 12px; 632 678 cursor: pointer; 633 - transition: background 0.2s ease, opacity 0.2s ease; 679 + transition: 680 + background 0.2s ease, 681 + opacity 0.2s ease; 634 682 user-select: none; 635 683 } 636 684 ··· 665 713 } 666 714 667 715 @keyframes spin { 668 - from { transform: rotate(0deg); } 669 - to { transform: rotate(360deg); } 716 + from { 717 + transform: rotate(0deg); 718 + } 719 + to { 720 + transform: rotate(360deg); 721 + } 670 722 } 671 723 672 724 /* Unsynced state - has pending changes */
+145 -55
crates/weaver-app/src/blobcache.rs
··· 1 1 use crate::cache_impl; 2 + use crate::fetch::Fetcher; 2 3 use dioxus::{CapturedError, Result}; 3 4 use jacquard::{ 5 + IntoStatic, 4 6 bytes::Bytes, 5 - client::UnauthenticatedSession, 6 - identity::JacquardResolver, 7 7 prelude::*, 8 8 smol_str::SmolStr, 9 9 types::{cid::Cid, collection::Collection, ident::AtIdentifier, nsid::Nsid, string::Rkey}, 10 10 xrpc::XrpcExt, 11 - IntoStatic, 12 11 }; 13 - use std::{ 14 - sync::Arc, 15 - time::Duration, 16 - }; 12 + use std::{sync::Arc, time::Duration}; 17 13 use weaver_api::com_atproto::repo::get_record::GetRecord; 18 14 use weaver_api::com_atproto::sync::get_blob::GetBlob; 19 15 use weaver_api::sh_weaver::notebook::entry::Entry; 20 16 use weaver_api::sh_weaver::publish::blob::Blob as PublishedBlob; 17 + use weaver_common::WeaverExt; 21 18 22 19 #[derive(Clone)] 23 20 pub struct BlobCache { 24 - client: Arc<UnauthenticatedSession<JacquardResolver>>, 21 + fetcher: Arc<Fetcher>, 25 22 cache: cache_impl::Cache<Cid<'static>, Bytes>, 26 23 map: cache_impl::Cache<SmolStr, Cid<'static>>, 27 24 } 28 25 29 26 impl BlobCache { 30 - pub fn new(client: Arc<UnauthenticatedSession<JacquardResolver>>) -> Self { 31 - let cache = cache_impl::new_cache(100, Duration::from_secs(1200)); 32 - let map = cache_impl::new_cache(500, Duration::from_secs(1200)); 27 + pub fn new(fetcher: Arc<Fetcher>) -> Self { 28 + let cache = cache_impl::new_cache(100, Duration::from_secs(12000)); 29 + let map = cache_impl::new_cache(500, Duration::from_secs(12000)); 33 30 34 - Self { client, cache, map } 31 + Self { 32 + fetcher, 33 + cache, 34 + map, 35 + } 35 36 } 36 37 37 38 /// Resolve DID and PDS URL from an identifier ··· 41 42 ) -> Result<(jacquard::types::string::Did<'static>, jacquard::url::Url)> { 42 43 match ident { 43 44 AtIdentifier::Did(did) => { 44 - let pds = self.client.pds_for_did(did).await?; 45 + let pds = self.fetcher.pds_for_did(did).await?; 45 46 Ok((did.clone().into_static(), pds)) 46 47 } 47 48 AtIdentifier::Handle(handle) => { 48 - let (did, pds) = self.client.pds_for_handle(handle).await?; 49 + let (did, pds) = self.fetcher.pds_for_handle(handle).await?; 49 50 Ok((did, pds)) 50 51 } 51 52 } ··· 59 60 cid: &Cid<'_>, 60 61 ) -> Result<Bytes> { 61 62 match self 62 - .client 63 + .fetcher 63 64 .xrpc(pds_url.clone()) 64 - .send( 65 - &GetBlob::new() 66 - .cid(cid.clone()) 67 - .did(did.clone()) 68 - .build(), 69 - ) 65 + .send(&GetBlob::new().cid(cid.clone()).did(did.clone()).build()) 70 66 .await 71 67 { 72 68 Ok(blob_stream) => Ok(blob_stream.buffer().clone()), ··· 98 94 name: Option<SmolStr>, 99 95 ) -> Result<()> { 100 96 let (repo_did, pds_url) = self.resolve_ident(&ident).await?; 101 - 97 + 102 98 if self.get_cid(&cid).is_some() { 103 99 return Ok(()); 104 100 } 105 - 101 + 106 102 let blob = self.fetch_blob(&repo_did, pds_url, &cid).await?; 107 103 108 104 self.cache.insert(cid.clone(), blob); ··· 114 110 } 115 111 116 112 /// Resolve an image from a published entry by name. 117 - /// 113 + /// 118 114 /// Looks up the entry record at `{ident}/sh.weaver.notebook.entry/{rkey}`, 119 115 /// finds the image by name in the embeds, and returns the blob bytes. 120 116 pub async fn resolve_from_entry( ··· 124 120 name: &str, 125 121 ) -> Result<Bytes> { 126 122 let (repo_did, pds_url) = self.resolve_ident(ident).await?; 127 - 123 + 128 124 // Fetch the entry record 129 125 let resp = self 130 - .client 126 + .fetcher 131 127 .xrpc(pds_url.clone()) 132 128 .send( 133 129 &GetRecord::new() ··· 144 140 .map_err(|e| CapturedError::from_display(format!("Failed to parse entry: {}", e)))?; 145 141 146 142 // Parse the entry 147 - let entry: Entry = jacquard::from_data(&record.value) 148 - .map_err(|e| CapturedError::from_display(format!("Failed to deserialize entry: {}", e)))?; 143 + let entry: Entry = jacquard::from_data(&record.value).map_err(|e| { 144 + CapturedError::from_display(format!("Failed to deserialize entry: {}", e)) 145 + })?; 149 146 150 147 // Find the image by name 151 148 let cid = entry ··· 153 150 .as_ref() 154 151 .and_then(|e| e.images.as_ref()) 155 152 .and_then(|imgs| { 156 - imgs.images.iter().find(|img| { 157 - img.name.as_ref().map(|n| n.as_ref()) == Some(name) 158 - }) 153 + imgs.images 154 + .iter() 155 + .find(|img| img.name.as_ref().map(|n| n.as_ref()) == Some(name)) 159 156 }) 160 157 .map(|img| img.image.blob().cid().clone().into_static()) 161 - .ok_or_else(|| CapturedError::from_display(format!("Image '{}' not found in entry", name)))?; 158 + .ok_or_else(|| { 159 + CapturedError::from_display(format!("Image '{}' not found in entry", name)) 160 + })?; 162 161 163 162 // Check cache first 164 163 if let Some(bytes) = self.get_cid(&cid) { ··· 169 168 let blob = self.fetch_blob(&repo_did, pds_url, &cid).await?; 170 169 self.cache.insert(cid.clone(), blob.clone()); 171 170 self.map.insert(name.into(), cid); 172 - 171 + 173 172 Ok(blob) 174 173 } 175 174 176 175 /// Resolve an image from a draft (unpublished) entry via PublishedBlob record. 177 - /// 176 + /// 178 177 /// Looks up the PublishedBlob record at `{ident}/sh.weaver.publish.blob/{blob_rkey}`, 179 178 /// gets the CID from it, and returns the blob bytes. 180 179 pub async fn resolve_from_draft( ··· 183 182 blob_rkey: &str, 184 183 ) -> Result<Bytes> { 185 184 let (repo_did, pds_url) = self.resolve_ident(ident).await?; 186 - 185 + 187 186 // Fetch the PublishedBlob record 188 187 let resp = self 189 - .client 188 + .fetcher 190 189 .xrpc(pds_url.clone()) 191 190 .send( 192 191 &GetRecord::new() ··· 196 195 .build(), 197 196 ) 198 197 .await 199 - .map_err(|e| CapturedError::from_display(format!("Failed to fetch PublishedBlob: {}", e)))?; 198 + .map_err(|e| { 199 + CapturedError::from_display(format!("Failed to fetch PublishedBlob: {}", e)) 200 + })?; 200 201 201 - let record = resp 202 - .into_output() 203 - .map_err(|e| CapturedError::from_display(format!("Failed to parse PublishedBlob: {}", e)))?; 202 + let record = resp.into_output().map_err(|e| { 203 + CapturedError::from_display(format!("Failed to parse PublishedBlob: {}", e)) 204 + })?; 204 205 205 206 // Parse the PublishedBlob 206 - let published: PublishedBlob = jacquard::from_data(&record.value) 207 - .map_err(|e| CapturedError::from_display(format!("Failed to deserialize PublishedBlob: {}", e)))?; 207 + let published: PublishedBlob = jacquard::from_data(&record.value).map_err(|e| { 208 + CapturedError::from_display(format!("Failed to deserialize PublishedBlob: {}", e)) 209 + })?; 208 210 209 211 // Get CID from the upload blob ref 210 212 let cid = published.upload.blob().cid().clone().into_static(); ··· 217 219 // Fetch and cache the blob 218 220 let blob = self.fetch_blob(&repo_did, pds_url, &cid).await?; 219 221 self.cache.insert(cid, blob.clone()); 220 - 222 + 221 223 Ok(blob) 222 224 } 223 225 224 226 /// Resolve an image from a notebook entry by name. 225 - /// 226 - /// This is a convenience method that looks up the notebook, finds the entry, 227 - /// and resolves the image. Used for `/image/{notebook}/{name}` paths. 227 + /// 228 + /// Looks up the notebook by title or path, iterates through entries to find 229 + /// the image by name, and returns the blob bytes. Used for `/image/{notebook}/{name}` paths. 230 + /// Cache key uses `{notebook_key}_{image_name}` to avoid collisions across notebooks. 228 231 pub async fn resolve_from_notebook( 229 232 &self, 230 - notebook_title: &str, 233 + notebook_key: &str, 231 234 image_name: &str, 232 235 ) -> Result<Bytes> { 233 - // For now, just try to get from the name cache 234 - // Full notebook resolution would require fetching the notebook record 235 - // and iterating through entries to find the image 236 - self.get_named(&image_name.into()) 237 - .ok_or_else(|| CapturedError::from_display(format!( 238 - "Image '{}' not found in notebook '{}'", 239 - image_name, notebook_title 240 - ))) 236 + // Try scoped cache key first: {notebook_key}_{image_name} 237 + let cache_key: SmolStr = format!("{}_{}", notebook_key, image_name).into(); 238 + if let Some(bytes) = self.get_named(&cache_key) { 239 + return Ok(bytes); 240 + } 241 + 242 + // Use Fetcher's notebook lookup (works with title or path) 243 + let notebook = self 244 + .fetcher 245 + .get_notebook_by_key(notebook_key) 246 + .await? 247 + .ok_or_else(|| { 248 + CapturedError::from_display(format!("Notebook '{}' not found", notebook_key)) 249 + })?; 250 + 251 + let (view, entry_refs) = notebook.as_ref(); 252 + 253 + // Get the DID from the notebook URI for blob fetching 254 + let notebook_did = jacquard::types::aturi::AtUri::new(view.uri.as_ref()) 255 + .map_err(|e| CapturedError::from_display(format!("Invalid notebook URI: {}", e)))? 256 + .authority() 257 + .clone() 258 + .into_static(); 259 + let repo_did = match &notebook_did { 260 + AtIdentifier::Did(d) => d.clone(), 261 + AtIdentifier::Handle(h) => self 262 + .fetcher 263 + .resolve_handle(h) 264 + .await 265 + .map_err(|e| CapturedError::from_display(e))?, 266 + }; 267 + let pds_url = self 268 + .fetcher 269 + .pds_for_did(&repo_did) 270 + .await 271 + .map_err(|e| CapturedError::from_display(e))?; 272 + 273 + // Iterate through entries to find the image 274 + let client = self.fetcher.get_client(); 275 + for entry_ref in entry_refs { 276 + // Parse the entry URI to get rkey 277 + let entry_uri = jacquard::types::aturi::AtUri::new(entry_ref.uri.as_ref()) 278 + .map_err(|e| CapturedError::from_display(format!("Invalid entry URI: {}", e)))?; 279 + let rkey = entry_uri 280 + .rkey() 281 + .ok_or_else(|| CapturedError::from_display("Entry URI missing rkey"))?; 282 + 283 + // Fetch entry using client's cached method 284 + let (_entry_view, entry) = match client 285 + .fetch_entry_by_rkey(&notebook_did, rkey.0.as_str()) 286 + .await 287 + { 288 + Ok(result) => result, 289 + Err(_) => continue, 290 + }; 291 + 292 + // Check if this entry has the image we're looking for 293 + if let Some(embeds) = &entry.embeds { 294 + if let Some(images) = &embeds.images { 295 + if let Some(img) = images 296 + .images 297 + .iter() 298 + .find(|i| i.name.as_deref() == Some(image_name)) 299 + { 300 + let cid = img.image.blob().cid().clone().into_static(); 301 + 302 + // Check blob cache 303 + if let Some(bytes) = self.get_cid(&cid) { 304 + // Also cache with scoped key for next time 305 + self.map.insert(cache_key, cid); 306 + return Ok(bytes); 307 + } 308 + 309 + // Fetch and cache the blob 310 + let blob = self.fetch_blob(&repo_did, pds_url, &cid).await?; 311 + self.cache.insert(cid.clone(), blob.clone()); 312 + self.map.insert(cache_key, cid); 313 + return Ok(blob); 314 + } 315 + } 316 + } 317 + } 318 + 319 + Err(CapturedError::from_display(format!( 320 + "Image '{}' not found in notebook '{}'", 321 + image_name, notebook_key 322 + ))) 323 + } 324 + 325 + /// Insert bytes directly into cache (for pre-warming after upload) 326 + pub fn insert_bytes(&self, cid: Cid<'static>, bytes: Bytes, name: Option<SmolStr>) { 327 + self.cache.insert(cid.clone(), bytes); 328 + if let Some(name) = name { 329 + self.map.insert(name, cid); 330 + } 241 331 } 242 332 243 333 pub fn get_cid(&self, cid: &Cid<'static>) -> Option<Bytes> {
+135 -46
crates/weaver-app/src/components/editor/component.rs
··· 59 59 /// - `initial_content`: Optional initial markdown content (for new entries) 60 60 /// - `entry_uri`: Optional AT-URI of an existing entry to edit 61 61 /// - `target_notebook`: Optional notebook title to add the entry to when publishing 62 + /// - `entry_index`: Optional index of entries for wikilink validation 62 63 #[component] 63 64 pub fn MarkdownEditor( 64 65 initial_content: Option<String>, 65 66 entry_uri: Option<String>, 66 67 target_notebook: Option<SmolStr>, 68 + entry_index: Option<weaver_common::EntryIndex>, 67 69 ) -> Element { 68 70 let fetcher = use_context::<Fetcher>(); 69 71 70 - // Determine draft key - use entry URI if editing existing, otherwise generate TID 71 72 let draft_key = use_hook(|| { 72 73 entry_uri.clone().unwrap_or_else(|| { 73 74 format!( ··· 77 78 }) 78 79 }); 79 80 80 - // Parse entry URI once 81 81 let parsed_uri = entry_uri.as_ref().and_then(|s| { 82 82 jacquard::types::string::AtUri::new(s) 83 83 .ok() 84 84 .map(|u| u.into_static()) 85 85 }); 86 - 87 - // Clone draft_key for render (resource closure moves it) 88 86 let draft_key_for_render = draft_key.clone(); 87 + let target_notebook_for_render = target_notebook.clone(); 89 88 90 - // Resource loads and merges document state 91 89 let load_resource = use_resource(move || { 92 90 let fetcher = fetcher.clone(); 93 91 let draft_key = draft_key.clone(); ··· 95 93 let initial_content = initial_content.clone(); 96 94 97 95 async move { 98 - // Try to load merged state from PDS + localStorage 99 96 match load_and_merge_document(&fetcher, &draft_key, entry_uri.as_ref()).await { 100 97 Ok(Some(state)) => { 101 98 tracing::debug!("Loaded merged document state"); ··· 110 107 let is_own_entry = match entry_authority { 111 108 AtIdentifier::Did(did) => did == &current_did, 112 109 AtIdentifier::Handle(handle) => { 113 - // Resolve handle to DID and compare 114 110 match fetcher.client.resolve_handle(handle).await { 115 111 Ok(resolved_did) => resolved_did == current_did, 116 112 Err(_) => false, ··· 127 123 ); 128 124 } 129 125 } 130 - 131 - // Try to load the entry content from PDS 132 126 match load_entry_for_editing(&fetcher, uri).await { 133 127 Ok(loaded) => { 134 128 // Create LoadedDocState from entry ··· 188 182 } 189 183 }); 190 184 191 - // Render based on load state 192 185 match &*load_resource.read() { 193 186 Some(LoadResult::Loaded(state)) => { 194 187 rsx! { ··· 196 189 key: "{draft_key_for_render}", 197 190 draft_key: draft_key_for_render.clone(), 198 191 loaded_state: state.clone(), 192 + target_notebook: target_notebook_for_render.clone(), 193 + entry_index: entry_index.clone(), 199 194 } 200 195 } 201 196 } ··· 226 221 /// - PDS sync with auto-save 227 222 /// - Keyboard shortcuts (Ctrl+B for bold, Ctrl+I for italic) 228 223 #[component] 229 - fn MarkdownEditorInner(draft_key: String, loaded_state: LoadedDocState) -> Element { 224 + fn MarkdownEditorInner( 225 + draft_key: String, 226 + loaded_state: LoadedDocState, 227 + target_notebook: Option<SmolStr>, 228 + /// Optional entry index for wikilink validation in the editor 229 + entry_index: Option<weaver_common::EntryIndex>, 230 + ) -> Element { 230 231 // Context for authenticated API calls 231 232 let fetcher = use_context::<Fetcher>(); 232 233 let auth_state = use_context::<Signal<AuthState>>(); 233 234 234 - // Create EditorDocument from loaded state (must be in use_hook for Signals) 235 235 let mut document = use_hook(|| { 236 236 let doc = EditorDocument::from_loaded_state(loaded_state.clone()); 237 - // Save to localStorage so we have a local backup 238 237 storage::save_to_storage(&doc, &draft_key).ok(); 239 238 doc 240 239 }); 241 240 let editor_id = "markdown-editor"; 242 - 243 - // Cache for incremental paragraph rendering 244 241 let mut render_cache = use_signal(|| render::RenderCache::default()); 245 - 246 - // Image resolver for mapping /image/{name} to data URLs or CDN URLs 247 242 let mut image_resolver = use_signal(EditorImageResolver::default); 243 + let resolved_content = use_signal(weaver_common::ResolvedContent::default); 248 244 249 - // Render paragraphs with incremental caching 250 - // Reads document.last_edit signal - creates dependency on content changes only 251 245 let doc_for_memo = document.clone(); 246 + let doc_for_refs = document.clone(); 252 247 let paragraphs = use_memo(move || { 253 - let edit = doc_for_memo.last_edit(); // Signal read - reactive dependency 248 + let edit = doc_for_memo.last_edit(); 254 249 let cache = render_cache.peek(); 255 250 let resolver = image_resolver(); 251 + let resolved = resolved_content(); 256 252 257 - let (paras, new_cache) = render::render_paragraphs_incremental( 253 + let (paras, new_cache, refs) = render::render_paragraphs_incremental( 258 254 doc_for_memo.loro_text(), 259 255 Some(&cache), 260 256 edit.as_ref(), 261 257 Some(&resolver), 258 + entry_index.as_ref(), 259 + &resolved, 262 260 ); 263 - 264 - // Update cache for next render (write-only via spawn to avoid reactive loop) 261 + let mut doc_for_spawn = doc_for_refs.clone(); 265 262 dioxus::prelude::spawn(async move { 266 263 render_cache.set(new_cache); 264 + doc_for_spawn.set_collected_refs(refs); 267 265 }); 268 266 269 267 paras 270 268 }); 271 269 272 - // Flatten offset maps from all paragraphs 270 + // Background fetch for AT embeds 271 + let mut resolved_content_for_fetch = resolved_content.clone(); 272 + let doc_for_embeds = document.clone(); 273 + let fetcher_for_embeds = fetcher.clone(); 274 + use_effect(move || { 275 + let refs = doc_for_embeds.collected_refs.read(); 276 + let current_resolved = resolved_content_for_fetch.peek(); 277 + let fetcher = fetcher_for_embeds.clone(); 278 + 279 + // Find AT embeds that need fetching 280 + let to_fetch: Vec<String> = refs 281 + .iter() 282 + .filter_map(|r| match r { 283 + weaver_common::ExtractedRef::AtEmbed { uri, .. } => { 284 + // Skip if already resolved 285 + if let Ok(at_uri) = jacquard::types::string::AtUri::new(uri) { 286 + if current_resolved.get_embed_content(&at_uri).is_none() { 287 + return Some(uri.clone()); 288 + } 289 + } 290 + None 291 + } 292 + _ => None, 293 + }) 294 + .collect(); 295 + 296 + if to_fetch.is_empty() { 297 + return; 298 + } 299 + 300 + // Spawn background fetches 301 + dioxus::prelude::spawn(async move { 302 + for uri_str in to_fetch { 303 + let Ok(at_uri) = jacquard::types::string::AtUri::new(&uri_str) else { 304 + continue; 305 + }; 306 + 307 + match weaver_renderer::atproto::fetch_and_render(&at_uri, &fetcher) 308 + .await 309 + { 310 + Ok(html) => { 311 + resolved_content_for_fetch.with_mut(|rc| { 312 + rc.add_embed(at_uri.into_static(), html, None); 313 + }); 314 + } 315 + Err(e) => { 316 + tracing::warn!("failed to fetch embed {}: {}", uri_str, e); 317 + } 318 + } 319 + } 320 + }); 321 + }); 322 + 323 + let mut new_tag = use_signal(String::new); 324 + 273 325 let offset_map = use_memo(move || { 274 326 paragraphs() 275 327 .iter() 276 328 .flat_map(|p| p.offset_map.iter().cloned()) 277 329 .collect::<Vec<_>>() 278 330 }); 279 - 280 - // Flatten syntax spans from all paragraphs 281 331 let syntax_spans = use_memo(move || { 282 332 paragraphs() 283 333 .iter() 284 334 .flat_map(|p| p.syntax_spans.iter().cloned()) 285 335 .collect::<Vec<_>>() 286 336 }); 287 - 288 - // Cache paragraphs for change detection AND for event handlers to access 289 337 let mut cached_paragraphs = use_signal(|| Vec::<ParagraphRender>::new()); 290 338 291 - // Update DOM when paragraphs change (incremental rendering) 292 339 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 293 340 let mut doc_for_dom = document.clone(); 294 341 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] ··· 365 412 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 366 413 let mut interval_holder: Signal<Option<gloo_timers::callback::Interval>> = use_signal(|| None); 367 414 368 - // Auto-save with periodic check (no reactive dependency to avoid loops) 369 415 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 370 416 let doc_for_autosave = document.clone(); 371 417 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] ··· 385 431 None => true, 386 432 Some(last) => &current_frontiers != last, 387 433 } 388 - }; // drop last_frontiers borrow here 434 + }; 389 435 390 436 if needs_save { 391 437 doc.sync_loro_cursor(); ··· 436 482 // Get target range from the event if available 437 483 let paras = cached_paras.peek().clone(); 438 484 let target_range = get_target_range_from_event(&evt, editor_id, &paras); 439 - 440 - // Get data from the event 441 485 let data = get_data_from_event(&evt); 442 - 443 - // Build context and handle 444 486 let ctx = BeforeInputContext { 445 487 input_type: input_type.clone(), 446 488 data, ··· 530 572 closure.forget(); 531 573 }); 532 574 533 - // Local state for adding new tags 534 - let mut new_tag = use_signal(String::new); 535 - 536 575 rsx! { 537 576 Stylesheet { href: asset!("/assets/styling/editor.css") } 538 577 div { class: "markdown-editor-container", ··· 621 660 PublishButton { 622 661 document: document.clone(), 623 662 draft_key: draft_key.to_string(), 663 + target_notebook: target_notebook.as_ref().map(|s| s.to_string()), 624 664 } 625 665 } 626 666 } ··· 804 844 805 845 onclick: { 806 846 let mut doc = document.clone(); 807 - move |_evt| { 847 + move |evt| { 808 848 tracing::debug!("onclick fired"); 809 849 let paras = cached_paragraphs(); 850 + 851 + // Check if click target is a math-clickable element 852 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 853 + { 854 + use dioxus::web::WebEventExt; 855 + use wasm_bindgen::JsCast; 856 + 857 + let web_evt = evt.as_web_event(); 858 + if let Some(target) = web_evt.target() { 859 + if let Some(element) = target.dyn_ref::<web_sys::Element>() { 860 + // Check element or ancestors for math-clickable 861 + if let Ok(Some(math_el)) = element.closest(".math-clickable") { 862 + if let Some(char_target) = math_el.get_attribute("data-char-target") { 863 + if let Ok(offset) = char_target.parse::<usize>() { 864 + tracing::debug!("math-clickable clicked, moving cursor to {}", offset); 865 + doc.cursor.write().offset = offset; 866 + *doc.selection.write() = None; 867 + // Update visibility FIRST so math-source is visible 868 + let spans = syntax_spans(); 869 + update_syntax_visibility(offset, None, &spans, &paras); 870 + // Then set DOM selection 871 + let map = offset_map(); 872 + let _ = crate::components::editor::cursor::restore_cursor_position( 873 + offset, 874 + &map, 875 + editor_id, 876 + None, 877 + ); 878 + return; 879 + } 880 + } 881 + } 882 + } 883 + } 884 + } 885 + 810 886 sync_cursor_from_dom(&mut doc, editor_id, &paras); 811 887 let spans = syntax_spans(); 812 888 let cursor_offset = doc.cursor.read().offset; ··· 980 1056 } 981 1057 }, 982 1058 } 983 - 984 - // Debug panel snug below editor 985 1059 div { class: "editor-debug", 986 1060 div { "Cursor: {document.cursor.read().offset}, Chars: {document.len_chars()}" }, 987 1061 ReportButton { ··· 991 1065 } 992 1066 } 993 1067 994 - // Toolbar in grid column 2, row 3 995 1068 EditorToolbar { 996 1069 on_format: { 997 1070 let mut doc = document.clone(); ··· 1040 1113 spawn(async move { 1041 1114 let client = fetcher.get_client(); 1042 1115 1116 + // Clone data for cache pre-warming 1117 + let data_for_cache = data.clone(); 1118 + 1043 1119 // Upload blob and create temporary PublishedBlob record 1044 1120 match client.publish_blob(data, &name_for_upload, None).await { 1045 1121 Ok((strong_ref, published_blob)) => { ··· 1061 1137 } 1062 1138 }; 1063 1139 1064 - // Build Image using the builder API 1140 + let cid = published_blob.upload.blob().cid().clone().into_static(); 1141 + 1065 1142 let name_for_resolver = name_for_upload.clone(); 1066 1143 let image = Image::new() 1067 1144 .alt(alt_for_upload.to_cowstr()) 1068 1145 .image(published_blob.upload) 1069 1146 .name(name_for_upload.to_cowstr()) 1070 1147 .build(); 1071 - 1072 - // Add to document 1073 1148 doc_for_spawn.add_image(&image, Some(&strong_ref.uri)); 1074 1149 1075 1150 // Promote from pending to uploaded in resolver ··· 1083 1158 }); 1084 1159 1085 1160 tracing::info!(name = %name_for_resolver, "Image uploaded to PDS"); 1161 + 1162 + // Pre-warm server cache with blob bytes 1163 + #[cfg(feature = "fullstack-server")] 1164 + { 1165 + use jacquard::smol_str::ToSmolStr; 1166 + if let Err(e) = crate::data::cache_blob_bytes( 1167 + cid.to_smolstr(), 1168 + Some(name_for_resolver.into()), 1169 + None, 1170 + data_for_cache.into(), 1171 + ).await { 1172 + tracing::warn!(error = %e, "Failed to pre-warm blob cache"); 1173 + } 1174 + } 1086 1175 } 1087 1176 Err(e) => { 1088 1177 tracing::error!(error = %e, "Failed to upload image"); ··· 1091 1180 } 1092 1181 }); 1093 1182 } else { 1094 - tracing::info!(name = %name, "Image added with data URL (not authenticated)"); 1183 + tracing::debug!(name = %name, "Image added with data URL (not authenticated)"); 1095 1184 } 1096 1185 } 1097 1186 },
+31 -3
crates/weaver-app/src/components/editor/cursor.rs
··· 115 115 /// 116 116 /// Walks all text nodes in the container, accumulating their UTF-16 lengths 117 117 /// until we find the node containing the target offset. 118 + /// Skips text nodes inside contenteditable="false" elements (like embeds). 118 119 /// 119 120 /// Returns (text_node, offset_within_node). 120 121 #[cfg(all(target_family = "wasm", target_os = "unknown"))] ··· 127 128 .document() 128 129 .ok_or("no document")?; 129 130 130 - // Create tree walker to find text nodes 131 - // SHOW_TEXT = 4 (from DOM spec) 132 - let walker = document.create_tree_walker_with_what_to_show(container, 4)?; 131 + // Use SHOW_ALL to see element boundaries for tracking non-editable regions 132 + let walker = document.create_tree_walker_with_what_to_show(container, 0xFFFFFFFF)?; 133 133 134 134 let mut accumulated_utf16 = 0; 135 135 let mut last_node: Option<web_sys::Node> = None; 136 + let mut skip_until_exit: Option<web_sys::Element> = None; 136 137 137 138 while let Some(node) = walker.next_node()? { 139 + // Check if we've exited the non-editable subtree 140 + if let Some(ref skip_elem) = skip_until_exit { 141 + if !skip_elem.contains(Some(&node)) { 142 + skip_until_exit = None; 143 + } 144 + } 145 + 146 + // Check if entering a non-editable element 147 + if skip_until_exit.is_none() { 148 + if let Some(element) = node.dyn_ref::<web_sys::Element>() { 149 + if element.get_attribute("contenteditable").as_deref() == Some("false") { 150 + skip_until_exit = Some(element.clone()); 151 + continue; 152 + } 153 + } 154 + } 155 + 156 + // Skip everything inside non-editable regions 157 + if skip_until_exit.is_some() { 158 + continue; 159 + } 160 + 161 + // Only process text nodes 162 + if node.node_type() != web_sys::Node::TEXT_NODE { 163 + continue; 164 + } 165 + 138 166 last_node = Some(node.clone()); 139 167 140 168 if let Some(text) = node.text_content() {
+30
crates/weaver-app/src/components/editor/document.rs
··· 134 134 /// Pending snap direction for cursor restoration after edits. 135 135 /// Set by input handlers, consumed by cursor restoration. 136 136 pub pending_snap: Signal<Option<super::offset_map::SnapDirection>>, 137 + 138 + /// Collected refs (wikilinks, AT embeds) from the most recent render. 139 + /// Updated by the render pipeline, read by publish for populating records. 140 + pub collected_refs: Signal<Vec<weaver_common::ExtractedRef>>, 137 141 } 138 142 139 143 /// Cursor state including position and affinity. ··· 312 316 composition_ended_at: Signal::new(None), 313 317 last_edit: Signal::new(None), 314 318 pending_snap: Signal::new(None), 319 + collected_refs: Signal::new(Vec::new()), 315 320 } 316 321 } 317 322 ··· 789 794 self.last_edit.read().clone() 790 795 } 791 796 797 + // --- Collected refs accessors --- 798 + 799 + /// Update collected refs from the render pipeline. 800 + pub fn set_collected_refs(&mut self, refs: Vec<weaver_common::ExtractedRef>) { 801 + self.collected_refs.set(refs); 802 + } 803 + 804 + /// Get AT URIs from collected embeds for populating entry.embeds.records. 805 + /// 806 + /// Filters for AtEmbed refs and parses to AtUri. Invalid URIs are skipped. 807 + pub fn at_embed_uris(&self) -> Vec<AtUri<'static>> { 808 + self.collected_refs 809 + .read() 810 + .iter() 811 + .filter_map(|r| match r { 812 + weaver_common::ExtractedRef::AtEmbed { uri, .. } => { 813 + AtUri::new(uri).ok().map(|u| u.into_static()) 814 + } 815 + _ => None, 816 + }) 817 + .collect() 818 + } 819 + 792 820 // --- Edit sync methods --- 793 821 794 822 /// Get the edit root StrongRef if set. ··· 951 979 composition_ended_at: Signal::new(None), 952 980 last_edit: Signal::new(None), 953 981 pending_snap: Signal::new(None), 982 + collected_refs: Signal::new(Vec::new()), 954 983 } 955 984 } 956 985 ··· 1009 1038 composition_ended_at: Signal::new(None), 1010 1039 last_edit: Signal::new(None), 1011 1040 pending_snap: Signal::new(None), 1041 + collected_refs: Signal::new(Vec::new()), 1012 1042 } 1013 1043 } 1014 1044 }
+38 -7
crates/weaver-app/src/components/editor/dom_sync.rs
··· 161 161 })?; 162 162 163 163 // Calculate UTF-16 offset from start of container to the position 164 + // Skip text nodes inside contenteditable="false" elements (like embeds) 164 165 let mut utf16_offset_in_container = 0; 165 166 166 - if let Ok(walker) = dom_document.create_tree_walker_with_what_to_show(&container, 4) { 167 - while let Ok(Some(text_node)) = walker.next_node() { 168 - if &text_node == node { 169 - utf16_offset_in_container += offset_in_text_node; 170 - break; 167 + // Use SHOW_ALL (0xFFFFFFFF) to see element boundaries for tracking non-editable regions 168 + if let Ok(walker) = dom_document.create_tree_walker_with_what_to_show(&container, 0xFFFFFFFF) { 169 + // Track the non-editable element we're inside (if any) 170 + let mut skip_until_exit: Option<web_sys::Element> = None; 171 + 172 + while let Ok(Some(dom_node)) = walker.next_node() { 173 + // Check if we've exited the non-editable subtree 174 + if let Some(ref skip_elem) = skip_until_exit { 175 + if !skip_elem.contains(Some(&dom_node)) { 176 + skip_until_exit = None; 177 + } 171 178 } 172 179 173 - if let Some(text) = text_node.text_content() { 174 - utf16_offset_in_container += text.encode_utf16().count(); 180 + // Check if entering a non-editable element 181 + if skip_until_exit.is_none() { 182 + if let Some(element) = dom_node.dyn_ref::<web_sys::Element>() { 183 + if element.get_attribute("contenteditable").as_deref() == Some("false") { 184 + skip_until_exit = Some(element.clone()); 185 + continue; 186 + } 187 + } 188 + } 189 + 190 + // Skip everything inside non-editable regions 191 + if skip_until_exit.is_some() { 192 + continue; 193 + } 194 + 195 + // Only process text nodes 196 + if dom_node.node_type() == web_sys::Node::TEXT_NODE { 197 + if &dom_node == node { 198 + utf16_offset_in_container += offset_in_text_node; 199 + break; 200 + } 201 + 202 + if let Some(text) = dom_node.text_content() { 203 + utf16_offset_in_container += text.encode_utf16().count(); 204 + } 175 205 } 176 206 } 177 207 } ··· 331 361 ) -> bool { 332 362 false 333 363 } 364 +
+107 -39
crates/weaver-app/src/components/editor/publish.rs
··· 15 15 use weaver_api::com_atproto::repo::strong_ref::StrongRef; 16 16 use weaver_api::com_atproto::repo::{create_record::CreateRecord, put_record::PutRecord}; 17 17 use weaver_api::sh_weaver::embed::images::Images; 18 + use weaver_api::sh_weaver::embed::records::{RecordEmbed, Records}; 18 19 use weaver_api::sh_weaver::notebook::entry::{Entry, EntryEmbeds}; 19 20 use weaver_common::{WeaverError, WeaverExt}; 20 21 ··· 34 35 let did = &caps[1]; 35 36 let name = &caps[3]; 36 37 format!("/image/{}/{}/{}", did, entry_rkey, name) 38 + }) 39 + .into_owned() 40 + } 41 + 42 + /// Rewrite draft paths for notebook entries. 43 + /// 44 + /// Converts `/image/{did}/draft/{blob_rkey}/{name}` to `/image/{notebook}/{name}` 45 + fn rewrite_draft_paths_for_notebook(content: &str, notebook_key: &str) -> String { 46 + DRAFT_IMAGE_PATH_REGEX 47 + .replace_all(content, |caps: &regex_lite::Captures| { 48 + let name = &caps[3]; 49 + format!("/image/{}/{}", notebook_key, name) 37 50 }) 38 51 .into_owned() 39 52 } ··· 82 95 // Resolve DID and PDS 83 96 let (did, pds_url) = match ident { 84 97 AtIdentifier::Did(d) => { 85 - let pds = fetcher 86 - .client 87 - .pds_for_did(d) 88 - .await 89 - .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to resolve DID: {}", e)))?; 98 + let pds = fetcher.client.pds_for_did(d).await.map_err(|e| { 99 + WeaverError::InvalidNotebook(format!("Failed to resolve DID: {}", e)) 100 + })?; 90 101 (d.clone(), pds) 91 102 } 92 103 AtIdentifier::Handle(h) => { 93 - let (did, pds) = fetcher 94 - .client 95 - .pds_for_handle(h) 96 - .await 97 - .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to resolve handle: {}", e)))?; 104 + let (did, pds) = fetcher.client.pds_for_handle(h).await.map_err(|e| { 105 + WeaverError::InvalidNotebook(format!("Failed to resolve handle: {}", e)) 106 + })?; 98 107 (did, pds) 99 108 } 100 109 }; ··· 124 133 // Build StrongRef from URI and CID 125 134 let entry_ref = StrongRef::new() 126 135 .uri(uri.clone().into_static()) 127 - .cid(record.cid.ok_or_else(|| { 128 - WeaverError::InvalidNotebook("Entry response missing CID".into()) 129 - })?.into_static()) 136 + .cid( 137 + record 138 + .cid 139 + .ok_or_else(|| WeaverError::InvalidNotebook("Entry response missing CID".into()))? 140 + .into_static(), 141 + ) 130 142 .build(); 131 143 132 144 Ok(LoadedEntry { ··· 153 165 // Get images from the document 154 166 let editor_images = doc.images(); 155 167 156 - // Build embeds if we have images 157 - let entry_embeds = if editor_images.is_empty() { 168 + // Resolve AT embed URIs to StrongRefs 169 + let at_embed_uris = doc.at_embed_uris(); 170 + let mut record_embeds: Vec<RecordEmbed<'static>> = Vec::new(); 171 + for uri in at_embed_uris { 172 + match fetcher.confirm_record_ref(&uri).await { 173 + Ok(strong_ref) => { 174 + record_embeds.push(RecordEmbed::new().record(strong_ref).build()); 175 + } 176 + Err(e) => { 177 + tracing::warn!("Failed to resolve embed {}: {}", uri, e); 178 + } 179 + } 180 + } 181 + 182 + // Build embeds if we have images or records 183 + let entry_embeds = if editor_images.is_empty() && record_embeds.is_empty() { 158 184 None 159 185 } else { 160 - // Extract Image types from EditorImage wrappers 161 - let images: Vec<_> = editor_images.iter().map(|ei| ei.image.clone()).collect(); 186 + let images = if editor_images.is_empty() { 187 + None 188 + } else { 189 + Some(Images { 190 + images: editor_images.iter().map(|ei| ei.image.clone()).collect(), 191 + extra_data: None, 192 + }) 193 + }; 194 + 195 + let records = if record_embeds.is_empty() { 196 + None 197 + } else { 198 + Some(Records::new().records(record_embeds).build()) 199 + }; 162 200 163 201 Some(EntryEmbeds { 164 - images: Some(Images { 165 - images, 166 - extra_data: None, 167 - }), 202 + images, 203 + records, 168 204 ..Default::default() 169 205 }) 170 206 }; ··· 192 228 let client = fetcher.get_client(); 193 229 let result = if let Some(notebook) = notebook_title { 194 230 // Publish to a notebook via upsert_entry 195 - // TODO: Need to handle path rewriting for notebook case 196 - // For now, use content as-is (notebook entries use different path scheme anyway) 231 + // Rewrite draft image paths to notebook paths: /image/{notebook}/{name} 232 + let content = rewrite_draft_paths_for_notebook(&doc.content(), notebook); 233 + 197 234 let entry = Entry::new() 198 - .content(doc.content()) 235 + .content(content) 199 236 .title(doc.title()) 200 237 .path(path) 201 238 .created_at(Datetime::now()) ··· 203 240 .maybe_embeds(entry_embeds) 204 241 .build(); 205 242 206 - let (entry_ref, was_created) = client.upsert_entry(notebook, &doc.title(), entry).await?; 243 + // Pass existing rkey if re-publishing (to allow title changes without creating new entry) 244 + let doc_entry_ref = doc.entry_ref(); 245 + let existing_rkey = doc_entry_ref.as_ref().and_then(|r| r.uri.rkey()); 246 + let (entry_ref, was_created) = client 247 + .upsert_entry( 248 + notebook, 249 + &doc.title(), 250 + entry, 251 + existing_rkey.map(|r| r.0.as_str()), 252 + ) 253 + .await?; 207 254 let uri = entry_ref.uri.clone(); 208 255 209 256 // Set entry_ref so subsequent publishes update this record ··· 368 415 pub document: EditorDocument, 369 416 /// Storage key for the draft 370 417 pub draft_key: String, 418 + /// Pre-selected notebook (from URL param) 419 + #[props(optional)] 420 + pub target_notebook: Option<String>, 371 421 } 372 422 373 423 /// Publish button component with notebook selection. ··· 377 427 let auth_state = use_context::<Signal<AuthState>>(); 378 428 379 429 let mut show_dialog = use_signal(|| false); 380 - let mut notebook_title = use_signal(|| String::from("Default")); 381 - let mut use_notebook = use_signal(|| true); 430 + let mut notebook_title = use_signal(|| { 431 + props.target_notebook.clone().unwrap_or_else(|| String::from("Default")) 432 + }); 433 + let mut use_notebook = use_signal(|| props.target_notebook.is_some()); 382 434 let mut is_publishing = use_signal(|| false); 383 435 let mut error_message: Signal<Option<String>> = use_signal(|| None); 384 436 let mut success_uri: Signal<Option<AtUri<'static>>> = use_signal(|| None); ··· 420 472 error_message.set(None); 421 473 422 474 let mut doc_snapshot = doc_snapshot; 423 - match publish_entry(&fetcher, &mut doc_snapshot, notebook.as_deref(), &draft_key).await { 475 + match publish_entry(&fetcher, &mut doc_snapshot, notebook.as_deref(), &draft_key).await 476 + { 424 477 Ok(result) => { 425 478 success_uri.set(Some(result.uri().clone())); 426 479 } ··· 460 513 h2 { "Publish Entry" } 461 514 462 515 if let Some(uri) = success_uri() { 463 - div { class: "publish-success", 464 - p { "Entry published successfully!" } 465 - a { 466 - href: "{uri}", 467 - target: "_blank", 468 - "View entry →" 469 - } 470 - button { 471 - class: "publish-done", 472 - onclick: close_dialog, 473 - "Done" 516 + { 517 + // Construct web URL from AT-URI 518 + let did = uri.authority(); 519 + let web_url = if use_notebook() { 520 + // Notebook entry: /{did}/{notebook}/{entry_path} 521 + format!("/{}/{}/{}", did, notebook_title(), doc.path()) 522 + } else { 523 + // Standalone entry: /{did}/e/{rkey} 524 + let rkey = uri.rkey().map(|r| r.0.as_str()).unwrap_or(""); 525 + format!("/{}/e/{}", did, rkey) 526 + }; 527 + 528 + rsx! { 529 + div { class: "publish-success", 530 + p { "Entry published successfully!" } 531 + a { 532 + href: "{web_url}", 533 + target: "_blank", 534 + "View entry → " 535 + } 536 + button { 537 + class: "publish-done", 538 + onclick: close_dialog, 539 + "Done" 540 + } 541 + } 474 542 } 475 543 } 476 544 } else {
+65 -49
crates/weaver-app/src/components/editor/render.rs
··· 11 11 use loro::LoroText; 12 12 use markdown_weaver::Parser; 13 13 use std::ops::Range; 14 + use weaver_common::{EntryIndex, ResolvedContent}; 14 15 15 16 /// Cache for incremental paragraph rendering. 16 17 /// Stores previously rendered paragraphs to avoid re-rendering unchanged content. ··· 104 105 /// 105 106 /// Uses cached paragraph renders when possible, only re-rendering changed paragraphs. 106 107 /// For "safe" edits (no boundary changes), skips boundary rediscovery entirely. 108 + /// 109 + /// # Parameters 110 + /// - `entry_index`: Optional index for wikilink validation (adds link-valid/link-broken classes) 111 + /// - `resolved_content`: Pre-resolved embed content for sync rendering 112 + /// 113 + /// # Returns 114 + /// (paragraphs, cache, collected_refs) - collected_refs contains wikilinks and AT embeds found during render 107 115 pub fn render_paragraphs_incremental( 108 116 text: &LoroText, 109 117 cache: Option<&RenderCache>, 110 118 edit: Option<&EditInfo>, 111 119 image_resolver: Option<&EditorImageResolver>, 112 - ) -> (Vec<ParagraphRender>, RenderCache) { 120 + entry_index: Option<&EntryIndex>, 121 + resolved_content: &ResolvedContent, 122 + ) -> ( 123 + Vec<ParagraphRender>, 124 + RenderCache, 125 + Vec<weaver_common::ExtractedRef>, 126 + ) { 113 127 let source = text.to_string(); 114 128 115 129 // Handle empty document ··· 139 153 next_syn_id: 0, 140 154 }; 141 155 142 - return (vec![para], new_cache); 156 + return (vec![para], new_cache, vec![]); 143 157 } 144 158 145 159 // Determine if we can use fast path (skip boundary discovery) ··· 218 232 .run() 219 233 { 220 234 Ok(result) => result.paragraph_ranges, 221 - Err(_) => return (Vec::new(), RenderCache::default()), 235 + Err(_) => return (Vec::new(), RenderCache::default(), vec![]), 222 236 } 223 237 }; 224 238 ··· 241 255 // Render paragraphs, reusing cache where possible 242 256 let mut paragraphs = Vec::with_capacity(paragraph_ranges.len()); 243 257 let mut new_cached = Vec::with_capacity(paragraph_ranges.len()); 258 + let mut all_refs: Vec<weaver_common::ExtractedRef> = Vec::new(); 244 259 let mut node_id_offset = cache.map(|c| c.next_node_id).unwrap_or(0); 245 260 let mut syn_id_offset = cache.map(|c| c.next_syn_id).unwrap_or(0); 246 261 ··· 286 301 // Use provided resolver or empty default 287 302 let resolver = image_resolver.cloned().unwrap_or_default(); 288 303 289 - let (mut offset_map, mut syntax_spans) = 290 - match EditorWriter::<_, _, ()>::new_with_offsets( 304 + // Build writer with optional entry index for wikilink validation 305 + // Pass paragraph's document-level offsets so all embedded char/byte positions are absolute 306 + let mut writer = 307 + EditorWriter::<_, _, &ResolvedContent, &EditorImageResolver>::new_with_all_offsets( 291 308 &para_source, 292 309 &para_text, 293 310 parser, 294 311 &mut output, 295 312 node_id_offset, 296 313 syn_id_offset, 314 + char_range.start, 315 + byte_range.start, 297 316 ) 298 317 .with_image_resolver(&resolver) 299 - .run() 300 - { 301 - Ok(result) => { 302 - // Update node ID offset 303 - let max_node_id = result 304 - .offset_maps 305 - .iter() 306 - .filter_map(|m| { 307 - m.node_id 308 - .strip_prefix("n") 309 - .and_then(|s| s.parse::<usize>().ok()) 310 - }) 311 - .max() 312 - .unwrap_or(node_id_offset); 313 - node_id_offset = max_node_id + 1; 318 + .with_embed_provider(resolved_content); 314 319 315 - // Update syn ID offset 316 - let max_syn_id = result 317 - .syntax_spans 318 - .iter() 319 - .filter_map(|s| { 320 - s.syn_id 321 - .strip_prefix("s") 322 - .and_then(|id| id.parse::<usize>().ok()) 323 - }) 324 - .max() 325 - .unwrap_or(syn_id_offset.saturating_sub(1)); 326 - syn_id_offset = max_syn_id + 1; 320 + if let Some(idx) = entry_index { 321 + writer = writer.with_entry_index(idx); 322 + } 327 323 328 - (result.offset_maps, result.syntax_spans) 329 - } 330 - Err(_) => (Vec::new(), Vec::new()), 331 - }; 324 + let (mut offset_map, mut syntax_spans) = match writer.run() { 325 + Ok(result) => { 326 + // Update node ID offset 327 + let max_node_id = result 328 + .offset_maps 329 + .iter() 330 + .filter_map(|m| { 331 + m.node_id 332 + .strip_prefix("n") 333 + .and_then(|s| s.parse::<usize>().ok()) 334 + }) 335 + .max() 336 + .unwrap_or(node_id_offset); 337 + node_id_offset = max_node_id + 1; 338 + 339 + // Update syn ID offset 340 + let max_syn_id = result 341 + .syntax_spans 342 + .iter() 343 + .filter_map(|s| { 344 + s.syn_id 345 + .strip_prefix("s") 346 + .and_then(|id| id.parse::<usize>().ok()) 347 + }) 348 + .max() 349 + .unwrap_or(syn_id_offset.saturating_sub(1)); 350 + syn_id_offset = max_syn_id + 1; 332 351 333 - // Adjust offsets to document coordinates 334 - let para_char_start = char_range.start; 335 - let para_byte_start = byte_range.start; 336 - for mapping in &mut offset_map { 337 - mapping.byte_range.start += para_byte_start; 338 - mapping.byte_range.end += para_byte_start; 339 - mapping.char_range.start += para_char_start; 340 - mapping.char_range.end += para_char_start; 341 - } 342 - for span in &mut syntax_spans { 343 - span.adjust_positions(para_char_start as isize); 344 - } 352 + // Collect refs from this paragraph 353 + all_refs.extend(result.collected_refs); 354 + 355 + (result.offset_maps, result.syntax_spans) 356 + } 357 + Err(_) => (Vec::new(), Vec::new()), 358 + }; 345 359 360 + // Offsets are already document-absolute since we pass char_range.start/byte_range.start 361 + // to the writer constructor 346 362 (output, offset_map, syntax_spans) 347 363 }; 348 364 ··· 448 464 next_syn_id: syn_id_offset, 449 465 }; 450 466 451 - (paragraphs_with_gaps, new_cache) 467 + (paragraphs_with_gaps, new_cache, all_refs) 452 468 }
+27 -7
crates/weaver-app/src/components/editor/tests.rs
··· 5 5 use super::render::render_paragraphs_incremental; 6 6 use loro::LoroDoc; 7 7 use serde::Serialize; 8 + use weaver_common::ResolvedContent; 8 9 9 10 /// Serializable version of ParagraphRender for snapshot testing. 10 11 #[derive(Debug, Serialize)] ··· 57 58 let doc = LoroDoc::new(); 58 59 let text = doc.get_text("content"); 59 60 text.insert(0, input).unwrap(); 60 - let (paragraphs, _cache) = render_paragraphs_incremental(&text, None, None, None); 61 + let (paragraphs, _cache, _refs) = 62 + render_paragraphs_incremental(&text, None, None, None, None, &ResolvedContent::default()); 61 63 paragraphs.iter().map(TestParagraph::from).collect() 62 64 } 63 65 ··· 645 647 646 648 // Initial state: "#" is a valid empty heading 647 649 text.insert(0, "#").unwrap(); 648 - let (paras1, cache1) = render_paragraphs_incremental(&text, None, None, None); 650 + let (paras1, cache1, _refs1) = 651 + render_paragraphs_incremental(&text, None, None, None, None, &ResolvedContent::default()); 649 652 650 653 eprintln!("State 1 ('#'): {}", paras1[0].html); 651 654 assert!(paras1[0].html.contains("<h1"), "# alone should be heading"); ··· 656 659 657 660 // Transition: add "t" to make "#t" - no longer a heading 658 661 text.insert(1, "t").unwrap(); 659 - let (paras2, _cache2) = render_paragraphs_incremental(&text, Some(&cache1), None, None); 662 + let (paras2, _cache2, _refs2) = render_paragraphs_incremental( 663 + &text, 664 + Some(&cache1), 665 + None, 666 + None, 667 + None, 668 + &ResolvedContent::default(), 669 + ); 660 670 661 671 eprintln!("State 2 ('#t'): {}", paras2[0].html); 662 672 assert!( ··· 765 775 let doc = LoroDoc::new(); 766 776 let text = doc.get_text("content"); 767 777 text.insert(0, input).unwrap(); 768 - let (paragraphs, _cache) = render_paragraphs_incremental(&text, None, None, None); 778 + let (paragraphs, _cache, _refs) = 779 + render_paragraphs_incremental(&text, None, None, None, None, &ResolvedContent::default()); 769 780 770 781 // With standard \n\n break, we expect 2 paragraphs (no gap element) 771 782 // Paragraph ranges include some trailing whitespace from markdown parsing ··· 794 805 let doc = LoroDoc::new(); 795 806 let text = doc.get_text("content"); 796 807 text.insert(0, input).unwrap(); 797 - let (paragraphs, _cache) = render_paragraphs_incremental(&text, None, None, None); 808 + let (paragraphs, _cache, _refs) = 809 + render_paragraphs_incremental(&text, None, None, None, None, &ResolvedContent::default()); 798 810 799 811 // With extra newlines, we expect 3 elements: para, gap, para 800 812 assert_eq!( ··· 894 906 let text = doc.get_text("content"); 895 907 text.insert(0, input).unwrap(); 896 908 897 - let (paras1, cache1) = render_paragraphs_incremental(&text, None, None, None); 909 + let (paras1, cache1, _refs1) = 910 + render_paragraphs_incremental(&text, None, None, None, None, &ResolvedContent::default()); 898 911 assert!(!cache1.paragraphs.is_empty(), "Cache should be populated"); 899 912 900 913 // Second render with same content should reuse cache 901 - let (paras2, _cache2) = render_paragraphs_incremental(&text, Some(&cache1), None, None); 914 + let (paras2, _cache2, _refs2) = render_paragraphs_incremental( 915 + &text, 916 + Some(&cache1), 917 + None, 918 + None, 919 + None, 920 + &ResolvedContent::default(), 921 + ); 902 922 903 923 // Should produce identical output 904 924 assert_eq!(paras1.len(), paras2.len());
+365 -176
crates/weaver-app/src/components/editor/writer.rs
··· 18 18 }; 19 19 use std::collections::HashMap; 20 20 use std::ops::Range; 21 + use weaver_common::{EntryIndex, ResolvedContent}; 21 22 22 23 /// Result of rendering with the EditorWriter. 23 24 #[derive(Debug, Clone)] ··· 31 32 32 33 /// Syntax spans that can be conditionally hidden 33 34 pub syntax_spans: Vec<SyntaxSpanInfo>, 35 + 36 + /// Refs (wikilinks, AT embeds) collected during this render pass 37 + pub collected_refs: Vec<weaver_common::ExtractedRef>, 34 38 } 35 39 36 40 /// Classification of markdown syntax characters ··· 120 124 } 121 125 } 122 126 127 + impl EmbedContentProvider for &ResolvedContent { 128 + fn get_embed_content(&self, tag: &Tag<'_>) -> Option<String> { 129 + if let Tag::Embed { dest_url, .. } = tag { 130 + let url = dest_url.as_ref(); 131 + if url.starts_with("at://") { 132 + if let Ok(at_uri) = jacquard::types::string::AtUri::new(url) { 133 + return ResolvedContent::get_embed_content(self, &at_uri) 134 + .map(|s| s.to_string()); 135 + } 136 + } 137 + } 138 + None 139 + } 140 + } 141 + 123 142 /// Resolves image URLs to CDN URLs based on stored images. 124 143 /// 125 144 /// The markdown may reference images by name (e.g., "photo.jpg" or "/notebook/image.png"). ··· 314 333 315 334 embed_provider: Option<E>, 316 335 image_resolver: Option<R>, 336 + entry_index: Option<&'a EntryIndex>, 317 337 318 338 code_buffer: Option<(Option<String>, String)>, // (lang, content) 319 339 code_buffer_byte_range: Option<Range<usize>>, // byte range of buffered code content ··· 351 371 /// Stack of pending inline formats: (syn_id of opening span, char start of region) 352 372 /// Used to set formatted_range when closing paired inline markers 353 373 pending_inline_formats: Vec<(String, usize)>, 374 + 375 + /// Collected refs (wikilinks, AT embeds, AT links) during this render pass 376 + ref_collector: weaver_common::RefCollector, 354 377 355 378 _phantom: std::marker::PhantomData<&'a ()>, 356 379 } ··· 391 414 node_id_offset: usize, 392 415 syn_id_offset: usize, 393 416 ) -> Self { 417 + Self::new_with_all_offsets( 418 + source, 419 + source_text, 420 + events, 421 + writer, 422 + node_id_offset, 423 + syn_id_offset, 424 + 0, 425 + 0, 426 + ) 427 + } 428 + 429 + pub fn new_with_all_offsets( 430 + source: &'a str, 431 + source_text: &'a LoroText, 432 + events: I, 433 + writer: W, 434 + node_id_offset: usize, 435 + syn_id_offset: usize, 436 + char_offset_base: usize, 437 + byte_offset_base: usize, 438 + ) -> Self { 394 439 Self { 395 440 source, 396 441 source_text, 397 442 events, 398 443 writer, 399 - last_byte_offset: 0, 400 - last_char_offset: 0, 444 + last_byte_offset: byte_offset_base, 445 + last_char_offset: char_offset_base, 401 446 end_newline: true, 402 447 in_non_writing_block: false, 403 448 table_state: TableState::Head, ··· 406 451 numbers: HashMap::new(), 407 452 embed_provider: None, 408 453 image_resolver: None, 454 + entry_index: None, 409 455 code_buffer: None, 410 456 code_buffer_byte_range: None, 411 457 code_buffer_char_range: None, ··· 425 471 syntax_spans: Vec::new(), 426 472 next_syn_id: syn_id_offset, 427 473 pending_inline_formats: Vec::new(), 474 + ref_collector: weaver_common::RefCollector::new(), 428 475 _phantom: std::marker::PhantomData, 429 476 } 430 477 } ··· 452 499 numbers: HashMap::new(), 453 500 embed_provider: None, 454 501 image_resolver: None, 502 + entry_index: None, 455 503 code_buffer: None, 456 504 code_buffer_byte_range: None, 457 505 code_buffer_char_range: None, ··· 467 515 syntax_spans: Vec::new(), 468 516 next_syn_id: 0, 469 517 pending_inline_formats: Vec::new(), 518 + ref_collector: weaver_common::RefCollector::new(), 470 519 paragraph_ranges: Vec::new(), 471 520 current_paragraph_start: None, 472 521 list_depth: 0, ··· 492 541 numbers: self.numbers, 493 542 embed_provider: Some(provider), 494 543 image_resolver: self.image_resolver, 544 + entry_index: self.entry_index, 495 545 code_buffer: self.code_buffer, 496 546 code_buffer_byte_range: self.code_buffer_byte_range, 497 547 code_buffer_char_range: self.code_buffer_char_range, ··· 511 561 syntax_spans: self.syntax_spans, 512 562 next_syn_id: self.next_syn_id, 513 563 pending_inline_formats: self.pending_inline_formats, 564 + ref_collector: self.ref_collector, 514 565 _phantom: std::marker::PhantomData, 515 566 } 516 567 } ··· 535 586 numbers: self.numbers, 536 587 embed_provider: self.embed_provider, 537 588 image_resolver: Some(resolver), 589 + entry_index: self.entry_index, 538 590 code_buffer: self.code_buffer, 539 591 code_buffer_byte_range: self.code_buffer_byte_range, 540 592 code_buffer_char_range: self.code_buffer_char_range, ··· 554 606 syntax_spans: self.syntax_spans, 555 607 next_syn_id: self.next_syn_id, 556 608 pending_inline_formats: self.pending_inline_formats, 609 + ref_collector: self.ref_collector, 557 610 _phantom: std::marker::PhantomData, 558 611 } 559 612 } 613 + 614 + /// Add an entry index for wikilink resolution feedback 615 + pub fn with_entry_index(mut self, index: &'a EntryIndex) -> Self { 616 + self.entry_index = Some(index); 617 + self 618 + } 619 + 560 620 #[inline] 561 621 fn write_newline(&mut self) -> Result<(), W::Error> { 562 622 self.end_newline = true; ··· 992 1052 offset_maps: self.offset_maps, 993 1053 paragraph_ranges: self.paragraph_ranges, 994 1054 syntax_spans: self.syntax_spans, 1055 + collected_refs: self.ref_collector.take(), 995 1056 }) 996 1057 } 997 1058 ··· 1120 1181 let backtick_char_end = char_start + 1; 1121 1182 write!( 1122 1183 &mut self.writer, 1123 - "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">`</span>", 1184 + "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">`</span>", 1124 1185 syn_id, char_start, backtick_char_end 1125 1186 )?; 1126 1187 self.syntax_spans.push(SyntaxSpanInfo { ··· 1155 1216 let backtick_char_end = backtick_char_start + 1; 1156 1217 write!( 1157 1218 &mut self.writer, 1158 - "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">`</span>", 1219 + "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">`</span>", 1159 1220 syn_id, backtick_char_start, backtick_char_end 1160 1221 )?; 1161 1222 ··· 1178 1239 } 1179 1240 } 1180 1241 InlineMath(text) => { 1181 - let format_start = self.last_char_offset; 1242 + // Math rendering follows embed pattern: syntax spans hide when cursor outside, 1243 + // rendered content always visible 1182 1244 let raw_text = &self.source[range.clone()]; 1245 + let syn_id = self.gen_syn_id(); 1246 + let opening_char_start = self.last_char_offset; 1183 1247 1184 - // Track opening span index so we can set formatted_range later 1185 - let opening_span_idx = if raw_text.starts_with('$') { 1186 - let syn_id = self.gen_syn_id(); 1187 - let char_start = self.last_char_offset; 1188 - let char_end = char_start + 1; 1248 + // Calculate char positions 1249 + let text_char_len = text.chars().count(); 1250 + let opening_char_end = opening_char_start + 1; // "$" 1251 + let content_char_start = opening_char_end; 1252 + let content_char_end = content_char_start + text_char_len; 1253 + let closing_char_start = content_char_end; 1254 + let closing_char_end = closing_char_start + 1; // "$" 1255 + let formatted_range = opening_char_start..closing_char_end; 1256 + 1257 + // 1. Emit opening $ syntax span 1258 + if raw_text.starts_with('$') { 1189 1259 write!( 1190 1260 &mut self.writer, 1191 - "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">$</span>", 1192 - syn_id, char_start, char_end 1261 + "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">$</span>", 1262 + syn_id, opening_char_start, opening_char_end 1193 1263 )?; 1194 1264 self.syntax_spans.push(SyntaxSpanInfo { 1195 - syn_id, 1196 - char_range: char_start..char_end, 1265 + syn_id: syn_id.clone(), 1266 + char_range: opening_char_start..opening_char_end, 1197 1267 syntax_type: SyntaxType::Inline, 1198 - formatted_range: None, // Set after we know the full range 1268 + formatted_range: Some(formatted_range.clone()), 1199 1269 }); 1200 - self.last_char_offset += 1; 1201 - Some(self.syntax_spans.len() - 1) 1202 - } else { 1203 - None 1204 - }; 1270 + self.record_mapping( 1271 + range.start..range.start + 1, 1272 + opening_char_start..opening_char_end, 1273 + ); 1274 + } 1205 1275 1206 - self.write(r#"<span class="math math-inline">"#)?; 1207 - let text_char_len = text.chars().count(); 1276 + // 2. Emit raw LaTeX content (hidden with syntax when cursor outside) 1277 + write!( 1278 + &mut self.writer, 1279 + "<span class=\"math-source\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">", 1280 + syn_id, content_char_start, content_char_end 1281 + )?; 1208 1282 escape_html(&mut self.writer, &text)?; 1209 - self.last_char_offset += text_char_len; 1210 1283 self.write("</span>")?; 1284 + self.syntax_spans.push(SyntaxSpanInfo { 1285 + syn_id: syn_id.clone(), 1286 + char_range: content_char_start..content_char_end, 1287 + syntax_type: SyntaxType::Inline, 1288 + formatted_range: Some(formatted_range.clone()), 1289 + }); 1290 + self.record_mapping( 1291 + range.start + 1..range.end - 1, 1292 + content_char_start..content_char_end, 1293 + ); 1211 1294 1212 - // Emit closing $ and track it 1295 + // 3. Emit closing $ syntax span 1213 1296 if raw_text.ends_with('$') { 1214 - let syn_id = self.gen_syn_id(); 1215 - let char_start = self.last_char_offset; 1216 - let char_end = char_start + 1; 1217 1297 write!( 1218 1298 &mut self.writer, 1219 - "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">$</span>", 1220 - syn_id, char_start, char_end 1299 + "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">$</span>", 1300 + syn_id, closing_char_start, closing_char_end 1221 1301 )?; 1222 - 1223 - // Now we know the full formatted range 1224 - let formatted_range = format_start..char_end; 1225 - 1226 1302 self.syntax_spans.push(SyntaxSpanInfo { 1227 - syn_id, 1228 - char_range: char_start..char_end, 1303 + syn_id: syn_id.clone(), 1304 + char_range: closing_char_start..closing_char_end, 1229 1305 syntax_type: SyntaxType::Inline, 1230 1306 formatted_range: Some(formatted_range.clone()), 1231 1307 }); 1308 + self.record_mapping( 1309 + range.end - 1..range.end, 1310 + closing_char_start..closing_char_end, 1311 + ); 1312 + } 1232 1313 1233 - // Update opening span with formatted_range 1234 - if let Some(idx) = opening_span_idx { 1235 - self.syntax_spans[idx].formatted_range = Some(formatted_range); 1314 + // 4. Emit rendered MathML (always visible, not tied to syn_id) 1315 + // Include data-char-target so clicking moves cursor into the math region 1316 + // contenteditable="false" so DOM walker skips this for offset counting 1317 + match weaver_renderer::math::render_math(&text, false) { 1318 + weaver_renderer::math::MathResult::Success(mathml) => { 1319 + write!( 1320 + &mut self.writer, 1321 + "<span class=\"math math-inline math-rendered math-clickable\" contenteditable=\"false\" data-char-target=\"{}\">{}</span>", 1322 + content_char_start, 1323 + mathml 1324 + )?; 1236 1325 } 1326 + weaver_renderer::math::MathResult::Error { html, .. } => { 1327 + // Show error indicator (also always visible) 1328 + self.write(&html)?; 1329 + } 1330 + } 1237 1331 1238 - self.last_char_offset += 1; 1239 - } 1332 + self.last_char_offset = closing_char_end; 1240 1333 } 1241 1334 DisplayMath(text) => { 1335 + // Math rendering follows embed pattern: syntax spans hide when cursor outside, 1336 + // rendered content always visible 1242 1337 let raw_text = &self.source[range.clone()]; 1338 + let syn_id = self.gen_syn_id(); 1339 + let opening_char_start = self.last_char_offset; 1243 1340 1244 - // Emit opening $$ and track it 1341 + // Calculate char positions 1342 + let text_char_len = text.chars().count(); 1343 + let opening_char_end = opening_char_start + 2; // "$$" 1344 + let content_char_start = opening_char_end; 1345 + let content_char_end = content_char_start + text_char_len; 1346 + let closing_char_start = content_char_end; 1347 + let closing_char_end = closing_char_start + 2; // "$$" 1348 + let formatted_range = opening_char_start..closing_char_end; 1349 + 1350 + // 1. Emit opening $$ syntax span 1351 + // Use Block syntax type so visibility is based on "cursor in same paragraph" 1245 1352 if raw_text.starts_with("$$") { 1246 - let syn_id = self.gen_syn_id(); 1247 - let char_start = self.last_char_offset; 1248 - let char_end = char_start + 2; 1249 1353 write!( 1250 1354 &mut self.writer, 1251 - "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">$$</span>", 1252 - syn_id, char_start, char_end 1355 + "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">$$</span>", 1356 + syn_id, opening_char_start, opening_char_end 1253 1357 )?; 1254 1358 self.syntax_spans.push(SyntaxSpanInfo { 1255 - syn_id, 1256 - char_range: char_start..char_end, 1257 - syntax_type: SyntaxType::Inline, 1258 - formatted_range: None, 1359 + syn_id: syn_id.clone(), 1360 + char_range: opening_char_start..opening_char_end, 1361 + syntax_type: SyntaxType::Block, 1362 + formatted_range: Some(formatted_range.clone()), 1259 1363 }); 1260 - self.last_char_offset += 2; 1364 + self.record_mapping( 1365 + range.start..range.start + 2, 1366 + opening_char_start..opening_char_end, 1367 + ); 1261 1368 } 1262 1369 1263 - self.write(r#"<span class="math math-display">"#)?; 1264 - let text_char_len = text.chars().count(); 1370 + // 2. Emit raw LaTeX content (hidden with syntax when cursor outside) 1371 + write!( 1372 + &mut self.writer, 1373 + "<span class=\"math-source\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">", 1374 + syn_id, content_char_start, content_char_end 1375 + )?; 1265 1376 escape_html(&mut self.writer, &text)?; 1266 - self.last_char_offset += text_char_len; 1267 1377 self.write("</span>")?; 1378 + self.syntax_spans.push(SyntaxSpanInfo { 1379 + syn_id: syn_id.clone(), 1380 + char_range: content_char_start..content_char_end, 1381 + syntax_type: SyntaxType::Block, 1382 + formatted_range: Some(formatted_range.clone()), 1383 + }); 1384 + self.record_mapping( 1385 + range.start + 2..range.end - 2, 1386 + content_char_start..content_char_end, 1387 + ); 1268 1388 1269 - // Emit closing $$ and track it 1389 + // 3. Emit closing $$ syntax span 1270 1390 if raw_text.ends_with("$$") { 1271 - let syn_id = self.gen_syn_id(); 1272 - let char_start = self.last_char_offset; 1273 - let char_end = char_start + 2; 1274 1391 write!( 1275 1392 &mut self.writer, 1276 - "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">$$</span>", 1277 - syn_id, char_start, char_end 1393 + "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">$$</span>", 1394 + syn_id, closing_char_start, closing_char_end 1278 1395 )?; 1279 1396 self.syntax_spans.push(SyntaxSpanInfo { 1280 - syn_id, 1281 - char_range: char_start..char_end, 1282 - syntax_type: SyntaxType::Inline, 1283 - formatted_range: None, 1397 + syn_id: syn_id.clone(), 1398 + char_range: closing_char_start..closing_char_end, 1399 + syntax_type: SyntaxType::Block, 1400 + formatted_range: Some(formatted_range.clone()), 1284 1401 }); 1285 - self.last_char_offset += 2; 1402 + self.record_mapping( 1403 + range.end - 2..range.end, 1404 + closing_char_start..closing_char_end, 1405 + ); 1406 + } 1407 + 1408 + // 4. Emit rendered MathML (always visible, not tied to syn_id) 1409 + // Include data-char-target so clicking moves cursor into the math region 1410 + // contenteditable="false" so DOM walker skips this for offset counting 1411 + match weaver_renderer::math::render_math(&text, true) { 1412 + weaver_renderer::math::MathResult::Success(mathml) => { 1413 + write!( 1414 + &mut self.writer, 1415 + "<span class=\"math math-display math-rendered math-clickable\" contenteditable=\"false\" data-char-target=\"{}\">{}</span>", 1416 + content_char_start, 1417 + mathml 1418 + )?; 1419 + } 1420 + weaver_renderer::math::MathResult::Error { html, .. } => { 1421 + // Show error indicator (also always visible) 1422 + self.write(&html)?; 1423 + } 1286 1424 } 1425 + 1426 + self.last_char_offset = closing_char_end; 1287 1427 } 1288 1428 Html(html) | InlineHtml(html) => { 1289 1429 // Track offset mapping for raw HTML ··· 1418 1558 1419 1559 write!( 1420 1560 &mut self.writer, 1421 - "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 1561 + "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">", 1422 1562 syn_id, char_start, char_end 1423 1563 )?; 1424 1564 escape_html(&mut self.writer, trimmed)?; ··· 1460 1600 1461 1601 write!( 1462 1602 &mut self.writer, 1463 - "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 1603 + "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">", 1464 1604 syn_id, char_start, char_end 1465 1605 )?; 1466 1606 escape_html(&mut self.writer, syntax)?; ··· 1590 1730 } 1591 1731 1592 1732 // Emit the opening tag 1733 + tracing::debug!(?tag, "start_tag"); 1593 1734 match tag { 1594 1735 Tag::HtmlBlock => Ok(()), 1595 1736 Tag::Paragraph => { ··· 1826 1967 1827 1968 write!( 1828 1969 &mut self.writer, 1829 - "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 1970 + "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">", 1830 1971 syn_id, char_start, char_end 1831 1972 )?; 1832 1973 escape_html(&mut self.writer, syntax)?; ··· 1937 2078 let syn_id = self.gen_syn_id(); 1938 2079 write!( 1939 2080 &mut self.writer, 1940 - "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 2081 + "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">", 1941 2082 syn_id, char_start, char_end 1942 2083 )?; 1943 2084 escape_html(&mut self.writer, syntax)?; ··· 1970 2111 let syn_id = self.gen_syn_id(); 1971 2112 write!( 1972 2113 &mut self.writer, 1973 - "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 2114 + "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">", 1974 2115 syn_id, char_start, char_end 1975 2116 )?; 1976 2117 escape_html(&mut self.writer, syntax)?; ··· 2047 2188 self.write("\">") 2048 2189 } 2049 2190 Tag::Link { 2050 - dest_url, title, .. 2191 + link_type, 2192 + dest_url, 2193 + title, 2194 + .. 2051 2195 } => { 2052 - self.write("<a href=\"")?; 2196 + // Collect refs for later resolution 2197 + let url = dest_url.as_ref(); 2198 + if matches!(link_type, LinkType::WikiLink { .. }) { 2199 + let (target, fragment) = weaver_common::EntryIndex::parse_wikilink(url); 2200 + self.ref_collector.add_wikilink(target, fragment, None); 2201 + } else if url.starts_with("at://") { 2202 + self.ref_collector.add_at_link(url); 2203 + } 2204 + 2205 + // Determine link validity class for wikilinks 2206 + let validity_class = if matches!(link_type, LinkType::WikiLink { .. }) { 2207 + if let Some(index) = &self.entry_index { 2208 + if index.resolve(dest_url.as_ref()).is_some() { 2209 + " link-valid" 2210 + } else { 2211 + " link-broken" 2212 + } 2213 + } else { 2214 + "" 2215 + } 2216 + } else { 2217 + "" 2218 + }; 2219 + 2220 + self.write("<a class=\"link")?; 2221 + self.write(validity_class)?; 2222 + self.write("\" href=\"")?; 2053 2223 escape_href(&mut self.writer, &dest_url)?; 2054 2224 if !title.is_empty() { 2055 2225 self.write("\" title=\"")?; ··· 2058 2228 self.write("\">") 2059 2229 } 2060 2230 Tag::Image { 2231 + link_type, 2061 2232 dest_url, 2062 2233 title, 2234 + id, 2063 2235 attrs, 2064 - .. 2065 2236 } => { 2237 + // Check if this is actually an AT embed disguised as a wikilink image 2238 + // (markdown-weaver parses ![[at://...]] as Image with WikiLink link_type) 2239 + let url = dest_url.as_ref(); 2240 + if matches!(link_type, LinkType::WikiLink { .. }) 2241 + && (url.starts_with("at://") || url.starts_with("did:")) 2242 + { 2243 + return self.write_embed( 2244 + range, 2245 + EmbedType::Other, // AT embeds - disambiguated via NSID later 2246 + dest_url, 2247 + title, 2248 + id, 2249 + attrs, 2250 + ); 2251 + } 2252 + 2066 2253 // Image rendering: all syntax elements share one syn_id for visibility toggling 2067 2254 // Structure: ![ alt text ](url) <img> cursor-landing 2068 2255 let raw_text = &self.source[range.clone()]; ··· 2096 2283 if raw_text.starts_with("![") { 2097 2284 write!( 2098 2285 &mut self.writer, 2099 - "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">![</span>", 2286 + "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">![</span>", 2100 2287 syn_id, opening_char_start, opening_char_end 2101 2288 )?; 2102 2289 ··· 2142 2329 if !closing_syntax.is_empty() { 2143 2330 write!( 2144 2331 &mut self.writer, 2145 - "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 2332 + "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">", 2146 2333 syn_id, closing_char_start, closing_char_end 2147 2334 )?; 2148 2335 escape_html(&mut self.writer, closing_syntax)?; ··· 2430 2617 2431 2618 write!( 2432 2619 &mut self.writer, 2433 - "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 2620 + "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">", 2434 2621 syn_id, char_start, char_end 2435 2622 )?; 2436 2623 escape_html(&mut self.writer, fence)?; ··· 2526 2713 2527 2714 write!( 2528 2715 &mut self.writer, 2529 - "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">]]</span>", 2716 + "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">]]</span>", 2530 2717 syn_id, char_start, char_end 2531 2718 )?; 2532 2719 ··· 2586 2773 id: CowStr<'_>, 2587 2774 attrs: Option<markdown_weaver::WeaverAttributes<'_>>, 2588 2775 ) -> Result<(), W::Error> { 2589 - // Track opening span index for formatted_range 2590 - let opening_span_idx: Option<usize>; 2591 - let opening_char_start: usize; 2776 + // Embed rendering: all syntax elements share one syn_id for visibility toggling 2777 + // Structure: ![[ url-as-link ]] <embed-content> 2778 + let raw_text = &self.source[range.clone()]; 2779 + let syn_id = self.gen_syn_id(); 2780 + let opening_char_start = self.last_char_offset; 2781 + 2782 + // Extract the URL from raw text (between ![[ and ]]) 2783 + let url_text = if raw_text.starts_with("![[") && raw_text.ends_with("]]") { 2784 + &raw_text[3..raw_text.len() - 2] 2785 + } else { 2786 + dest_url.as_ref() 2787 + }; 2592 2788 2593 - // Emit opening ![[ 2594 - if range.start < range.end { 2595 - let raw_text = &self.source[range.clone()]; 2596 - if raw_text.starts_with("![[") { 2597 - let syn_id = self.gen_syn_id(); 2598 - let char_start = self.last_char_offset; 2599 - let char_end = char_start + 3; // "![[" 2789 + // Calculate char positions 2790 + let url_char_len = url_text.chars().count(); 2791 + let opening_char_end = opening_char_start + 3; // "![[" 2792 + let url_char_start = opening_char_end; 2793 + let url_char_end = url_char_start + url_char_len; 2794 + let closing_char_start = url_char_end; 2795 + let closing_char_end = closing_char_start + 2; // "]]" 2796 + let formatted_range = opening_char_start..closing_char_end; 2600 2797 2601 - write!( 2602 - &mut self.writer, 2603 - "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">![[</span>", 2604 - syn_id, char_start, char_end 2605 - )?; 2798 + // 1. Emit opening ![[ syntax span 2799 + if raw_text.starts_with("![[") { 2800 + write!( 2801 + &mut self.writer, 2802 + "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">![[</span>", 2803 + syn_id, opening_char_start, opening_char_end 2804 + )?; 2606 2805 2607 - opening_span_idx = Some(self.syntax_spans.len()); 2608 - opening_char_start = char_start; 2609 - self.syntax_spans.push(SyntaxSpanInfo { 2610 - syn_id, 2611 - char_range: char_start..char_end, 2612 - syntax_type: SyntaxType::Inline, 2613 - formatted_range: None, 2614 - }); 2806 + self.syntax_spans.push(SyntaxSpanInfo { 2807 + syn_id: syn_id.clone(), 2808 + char_range: opening_char_start..opening_char_end, 2809 + syntax_type: SyntaxType::Inline, 2810 + formatted_range: Some(formatted_range.clone()), 2811 + }); 2615 2812 2616 - self.last_char_offset = char_end; 2617 - self.last_byte_offset = range.start + 3; 2618 - } else { 2619 - opening_span_idx = None; 2620 - opening_char_start = self.last_char_offset; 2621 - } 2813 + self.record_mapping( 2814 + range.start..range.start + 3, 2815 + opening_char_start..opening_char_end, 2816 + ); 2817 + } 2818 + 2819 + // 2. Emit URL as a clickable link (same syn_id, shown/hidden with syntax) 2820 + let url = dest_url.as_ref(); 2821 + let link_href = if url.starts_with("at://") { 2822 + format!("https://alpha.weaver.sh/record/{}", url) 2622 2823 } else { 2623 - opening_span_idx = None; 2624 - opening_char_start = self.last_char_offset; 2824 + url.to_string() 2825 + }; 2826 + 2827 + write!( 2828 + &mut self.writer, 2829 + "<a class=\"image-alt embed-url\" href=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" target=\"_blank\">", 2830 + link_href, syn_id, url_char_start, url_char_end 2831 + )?; 2832 + escape_html(&mut self.writer, url_text)?; 2833 + self.write("</a>")?; 2834 + 2835 + self.syntax_spans.push(SyntaxSpanInfo { 2836 + syn_id: syn_id.clone(), 2837 + char_range: url_char_start..url_char_end, 2838 + syntax_type: SyntaxType::Inline, 2839 + formatted_range: Some(formatted_range.clone()), 2840 + }); 2841 + 2842 + self.record_mapping( 2843 + range.start + 3..range.end - 2, 2844 + url_char_start..url_char_end, 2845 + ); 2846 + 2847 + // 3. Emit closing ]] syntax span 2848 + if raw_text.ends_with("]]") { 2849 + write!( 2850 + &mut self.writer, 2851 + "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">]]</span>", 2852 + syn_id, closing_char_start, closing_char_end 2853 + )?; 2854 + 2855 + self.syntax_spans.push(SyntaxSpanInfo { 2856 + syn_id: syn_id.clone(), 2857 + char_range: closing_char_start..closing_char_end, 2858 + syntax_type: SyntaxType::Inline, 2859 + formatted_range: Some(formatted_range.clone()), 2860 + }); 2861 + 2862 + self.record_mapping( 2863 + range.end - 2..range.end, 2864 + closing_char_start..closing_char_end, 2865 + ); 2625 2866 } 2626 2867 2868 + // Collect AT URI for later resolution 2869 + if url.starts_with("at://") || url.starts_with("did:") { 2870 + self.ref_collector.add_at_embed(url, if title.is_empty() { None } else { Some(title.as_ref()) }); 2871 + } 2872 + 2873 + // 4. Emit the actual embed content 2627 2874 // Try to get content from attributes first 2628 2875 let content_from_attrs = if let Some(ref attrs) = attrs { 2629 2876 attrs ··· 2654 2901 if let Some(html_content) = content { 2655 2902 // Write the pre-rendered content directly 2656 2903 self.write(&html_content)?; 2657 - self.write_newline()?; 2658 2904 } else { 2659 - // Fallback: render as iframe 2660 - self.write("<iframe src=\"")?; 2661 - escape_href(&mut self.writer, &dest_url)?; 2662 - self.write("\" title=\"")?; 2663 - escape_html(&mut self.writer, &title)?; 2664 - if !id.is_empty() { 2665 - self.write("\" id=\"")?; 2666 - escape_html(&mut self.writer, &id)?; 2667 - } 2668 - self.write("\"")?; 2669 - 2670 - if let Some(attrs) = attrs { 2671 - if !attrs.classes.is_empty() { 2672 - self.write(" class=\"")?; 2673 - for (i, class) in attrs.classes.iter().enumerate() { 2674 - if i > 0 { 2675 - self.write(" ")?; 2676 - } 2677 - escape_html(&mut self.writer, class)?; 2678 - } 2679 - self.write("\"")?; 2680 - } 2681 - for (attr, value) in &attrs.attrs { 2682 - // Skip the content attr in HTML output 2683 - if attr.as_ref() != "content" { 2684 - self.write(" ")?; 2685 - escape_html(&mut self.writer, attr)?; 2686 - self.write("=\"")?; 2687 - escape_html(&mut self.writer, value)?; 2688 - self.write("\"")?; 2689 - } 2690 - } 2691 - } 2692 - self.write("></iframe>")?; 2905 + // Fallback: render as placeholder div (iframe doesn't make sense for at:// URIs) 2906 + self.write("<div class=\"atproto-embed atproto-embed-placeholder\">")?; 2907 + self.write("<span class=\"embed-loading\">Loading embed...</span>")?; 2908 + self.write("</div>")?; 2693 2909 } 2694 2910 2695 - // Emit closing ]] 2696 - if range.start < range.end { 2697 - let raw_text = &self.source[range.clone()]; 2698 - if raw_text.ends_with("]]") { 2699 - let syn_id = self.gen_syn_id(); 2700 - let char_start = self.last_char_offset; 2701 - let char_end = char_start + 2; // "]]" 2911 + // Consume the text events for the URL (they're still in the iterator) 2912 + // Use consume_until_end() since we already wrote the URL from source 2913 + self.consume_until_end(); 2702 2914 2703 - write!( 2704 - &mut self.writer, 2705 - "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">]]</span>", 2706 - syn_id, char_start, char_end 2707 - )?; 2708 - 2709 - // Set formatted_range on both opening and closing spans 2710 - let formatted_range = opening_char_start..char_end; 2711 - self.syntax_spans.push(SyntaxSpanInfo { 2712 - syn_id, 2713 - char_range: char_start..char_end, 2714 - syntax_type: SyntaxType::Inline, 2715 - formatted_range: Some(formatted_range.clone()), 2716 - }); 2717 - 2718 - // Update opening span's formatted_range 2719 - if let Some(idx) = opening_span_idx { 2720 - if let Some(span) = self.syntax_spans.get_mut(idx) { 2721 - span.formatted_range = Some(formatted_range); 2722 - } 2723 - } 2724 - 2725 - self.last_char_offset = char_end; 2726 - self.last_byte_offset = range.end; 2727 - } 2728 - } 2915 + // Update offsets 2916 + self.last_char_offset = closing_char_end; 2917 + self.last_byte_offset = range.end; 2729 2918 2730 2919 Ok(()) 2731 2920 }
+20
crates/weaver-app/src/data.rs
··· 1131 1131 let cid = Cid::new_owned(cid.as_bytes())?; 1132 1132 cache.cache(ident, cid, name).await 1133 1133 } 1134 + 1135 + /// Cache blob bytes directly (for pre-warming after upload). 1136 + /// If `notebook` is provided, uses scoped cache key `{notebook}_{name}`. 1137 + #[cfg(feature = "fullstack-server")] 1138 + #[put("/cache-bytes/{cid}?name&notebook", cache: Extension<Arc<BlobCache>>)] 1139 + pub async fn cache_blob_bytes( 1140 + cid: SmolStr, 1141 + name: Option<SmolStr>, 1142 + notebook: Option<SmolStr>, 1143 + body: jacquard::bytes::Bytes, 1144 + ) -> Result<()> { 1145 + let cid = Cid::new_owned(cid.as_bytes())?; 1146 + let cache_key = match (&notebook, &name) { 1147 + (Some(nb), Some(n)) => Some(SmolStr::new(format!("{}_{}", nb, n))), 1148 + (None, Some(n)) => Some(n.clone()), 1149 + _ => None, 1150 + }; 1151 + cache.insert_bytes(cid, body, cache_key); 1152 + Ok(()) 1153 + }
+81 -5
crates/weaver-app/src/fetch.rs
··· 348 348 } 349 349 } 350 350 351 - //#[cfg(not(feature = "server"))] 352 351 #[derive(Clone)] 353 352 pub struct Fetcher { 354 353 pub client: Arc<Client>, ··· 357 356 (AtIdentifier<'static>, SmolStr), 358 357 Arc<(NotebookView<'static>, Vec<StrongRef<'static>>)>, 359 358 >, 359 + /// Maps notebook title OR path to ident (book_cache accepts either as key) 360 + #[cfg(feature = "server")] 361 + notebook_key_cache: cache_impl::Cache<SmolStr, AtIdentifier<'static>>, 360 362 #[cfg(feature = "server")] 361 363 entry_cache: cache_impl::Cache< 362 364 (AtIdentifier<'static>, SmolStr), ··· 369 371 cache_impl::Cache<(AtIdentifier<'static>, SmolStr), Arc<StandaloneEntryData>>, 370 372 } 371 373 372 - //#[cfg(not(feature = "server"))] 373 374 impl Fetcher { 374 375 pub fn new(client: OAuthClient<JacquardResolver, AuthStore>) -> Self { 375 376 Self { 376 377 client: Arc::new(Client::new(client)), 377 378 #[cfg(feature = "server")] 378 379 book_cache: cache_impl::new_cache(100, std::time::Duration::from_secs(30)), 380 + #[cfg(feature = "server")] 381 + notebook_key_cache: cache_impl::new_cache(500, std::time::Duration::from_secs(30)), 379 382 #[cfg(feature = "server")] 380 383 entry_cache: cache_impl::new_cache(100, std::time::Duration::from_secs(30)), 381 384 #[cfg(feature = "server")] ··· 432 435 { 433 436 let stored = Arc::new((notebook, entries)); 434 437 #[cfg(feature = "server")] 435 - cache_impl::insert(&self.book_cache, (ident, title), stored.clone()); 438 + { 439 + // Cache by title 440 + cache_impl::insert(&self.notebook_key_cache, title.clone(), ident.clone()); 441 + cache_impl::insert(&self.book_cache, (ident.clone(), title), stored.clone()); 442 + // Also cache by path if available 443 + if let Some(path) = stored.0.path.as_ref() { 444 + let path: SmolStr = path.as_ref().into(); 445 + cache_impl::insert(&self.notebook_key_cache, path.clone(), ident.clone()); 446 + cache_impl::insert(&self.book_cache, (ident, path), stored.clone()); 447 + } 448 + } 436 449 Ok(Some(stored)) 437 450 } else { 438 451 Err(dioxus::CapturedError::from_display("Notebook not found")) 439 452 } 440 453 } 441 454 455 + /// Get notebook by title or path (for image resolution without knowing owner). 456 + /// Checks notebook_key_cache first, falls back to UFOS discovery. 457 + #[cfg(feature = "server")] 458 + pub async fn get_notebook_by_key( 459 + &self, 460 + key: &str, 461 + ) -> Result<Option<Arc<(NotebookView<'static>, Vec<StrongRef<'static>>)>>> { 462 + let key: SmolStr = key.into(); 463 + 464 + // Check cache first (key could be title or path) 465 + if let Some(ident) = cache_impl::get(&self.notebook_key_cache, &key) { 466 + return self.get_notebook(ident, key).await; 467 + } 468 + 469 + // Fallback: query UFOS and populate caches 470 + let notebooks = self.fetch_notebooks_from_ufos().await?; 471 + Ok(notebooks.into_iter().find(|arc| { 472 + let (view, _) = arc.as_ref(); 473 + view.title.as_deref() == Some(key.as_str()) 474 + || view.path.as_deref() == Some(key.as_str()) 475 + })) 476 + } 477 + 442 478 pub async fn get_entry( 443 479 &self, 444 480 ident: AtIdentifier<'static>, ··· 509 545 510 546 let result = Arc::new((notebook, entries)); 511 547 #[cfg(feature = "server")] 512 - cache_impl::insert(&self.book_cache, (ident, title), result.clone()); 548 + { 549 + // Cache by title 550 + cache_impl::insert(&self.notebook_key_cache, title.clone(), ident.clone()); 551 + cache_impl::insert( 552 + &self.book_cache, 553 + (ident.clone(), title), 554 + result.clone(), 555 + ); 556 + // Also cache by path if available 557 + if let Some(path) = result.0.path.as_ref() { 558 + let path: SmolStr = path.as_ref().into(); 559 + cache_impl::insert( 560 + &self.notebook_key_cache, 561 + path.clone(), 562 + ident.clone(), 563 + ); 564 + cache_impl::insert(&self.book_cache, (ident, path), result.clone()); 565 + } 566 + } 513 567 notebooks.push(result); 514 568 } 515 569 Err(_) => continue, // Skip notebooks that fail to load ··· 635 689 636 690 let result = Arc::new((notebook, entries)); 637 691 #[cfg(feature = "server")] 638 - cache_impl::insert(&self.book_cache, (ident, title), result.clone()); 692 + { 693 + // Cache by title 694 + cache_impl::insert( 695 + &self.notebook_key_cache, 696 + title.clone(), 697 + ident.clone(), 698 + ); 699 + cache_impl::insert( 700 + &self.book_cache, 701 + (ident.clone(), title), 702 + result.clone(), 703 + ); 704 + // Also cache by path if available 705 + if let Some(path) = result.0.path.as_ref() { 706 + let path: SmolStr = path.as_ref().into(); 707 + cache_impl::insert( 708 + &self.notebook_key_cache, 709 + path.clone(), 710 + ident.clone(), 711 + ); 712 + cache_impl::insert(&self.book_cache, (ident, path), result.clone()); 713 + } 714 + } 639 715 notebooks.push(result); 640 716 } 641 717 Err(_) => continue, // Skip notebooks that fail to load
+10 -9
crates/weaver-app/src/main.rs
··· 173 173 174 174 #[cfg(feature = "fullstack-server")] 175 175 let router = { 176 - use jacquard::client::UnauthenticatedSession; 177 176 let fetcher = Arc::new(fetch::Fetcher::new(OAuthClient::new( 178 177 AuthStore::new(), 179 178 ClientData::new_public(CONFIG.oauth.clone()), 180 179 ))); 181 - let blob_cache = Arc::new(BlobCache::new(Arc::new( 182 - UnauthenticatedSession::new_public(), 183 - ))); 180 + let blob_cache = Arc::new(BlobCache::new(fetcher.clone())); 184 181 axum::Router::new() 185 182 .route("/favicon.ico", get(favicon)) 186 183 // Server side render the application, serve static assets, and register server functions ··· 353 350 } 354 351 355 352 #[cfg(all(feature = "fullstack-server", feature = "server"))] 356 - #[get("/{_notebook}/image/{name}", blob_cache: Extension<Arc<crate::blobcache::BlobCache>>)] 357 - pub async fn image_named(_notebook: SmolStr, name: SmolStr) -> Result<axum::response::Response> { 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> { 358 355 if let Some(bytes) = blob_cache.get_named(&name) { 359 - Ok(build_image_response(bytes)) 360 - } else { 361 - Ok(image_not_found()) 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()), 362 363 } 363 364 } 364 365
+30 -1
crates/weaver-app/src/views/drafts.rs
··· 208 208 rkey: ReadSignal<SmolStr>, 209 209 ) -> Element { 210 210 use crate::components::editor::MarkdownEditor; 211 + use crate::data::use_notebook_entries; 211 212 use crate::views::editor::EditorCss; 213 + use weaver_common::EntryIndex; 212 214 213 215 // Construct AT-URI for the entry 214 216 let entry_uri = 215 217 use_memo(move || format!("at://{}/sh.weaver.notebook.entry/{}", ident(), rkey())); 216 218 219 + // Fetch notebook entries for wikilink validation 220 + let (_entries_resource, entries_memo) = use_notebook_entries(ident, book_title); 221 + 222 + // Build entry index from notebook entries 223 + let entry_index = use_memo(move || { 224 + entries_memo().map(|entries| { 225 + let mut index = EntryIndex::new(); 226 + let ident_str = ident().to_string(); 227 + let book = book_title(); 228 + for book_entry in &entries { 229 + // EntryView has optional title/path 230 + let title = book_entry.entry.title.as_ref().map(|t| t.as_str()).unwrap_or(""); 231 + let path = book_entry.entry.path.as_ref().map(|p| p.as_str()).unwrap_or(""); 232 + if !title.is_empty() || !path.is_empty() { 233 + // Build canonical URL: /{ident}/{book}/{path} 234 + let canonical_url = format!("/{}/{}/{}", ident_str, book, path); 235 + index.add_entry(title, path, canonical_url); 236 + } 237 + } 238 + index 239 + }) 240 + }); 241 + 217 242 rsx! { 218 243 EditorCss {} 219 244 div { class: "editor-page", 220 - MarkdownEditor { entry_uri: Some(entry_uri()), target_notebook: Some(book_title()) } 245 + MarkdownEditor { 246 + entry_uri: Some(entry_uri()), 247 + target_notebook: Some(book_title()), 248 + entry_index: entry_index(), 249 + } 221 250 } 222 251 } 223 252 }
+1 -1
crates/weaver-cli/src/main.rs
··· 389 389 use jacquard::http_client::HttpClient; 390 390 use weaver_common::WeaverExt; 391 391 let (entry_ref, was_created) = agent 392 - .upsert_entry(&title, entry_title.as_ref(), entry) 392 + .upsert_entry(&title, entry_title.as_ref(), entry, None) 393 393 .await?; 394 394 395 395 if was_created {
+55 -2
crates/weaver-common/src/agent.rs
··· 250 250 } 251 251 } 252 252 253 - /// Find or create an entry within a notebook by title 253 + /// Find or create an entry within a notebook 254 254 /// 255 255 /// Multi-step workflow: 256 256 /// 1. Find the notebook by title 257 - /// 2. Check notebook's entry_list for entry with matching title 257 + /// 2. If existing_rkey is provided, match by rkey; otherwise match by title 258 258 /// 3. If found: update the entry with new content 259 259 /// 4. If not found: create new entry and append to notebook's entry_list 260 260 /// 261 + /// The `existing_rkey` parameter allows updating an entry even if its title changed, 262 + /// and enables pre-generating rkeys for path rewriting before publish. 263 + /// 261 264 /// Returns (entry_ref, was_created) 262 265 fn upsert_entry( 263 266 &self, 264 267 notebook_title: &str, 265 268 entry_title: &str, 266 269 entry: entry::Entry<'_>, 270 + existing_rkey: Option<&str>, 267 271 ) -> impl Future<Output = Result<(StrongRef<'static>, bool), WeaverError>> 268 272 where 269 273 Self: Sized, ··· 276 280 277 281 // Find or create notebook 278 282 let (notebook_uri, entry_refs) = self.upsert_notebook(notebook_title, &did).await?; 283 + 284 + // If we have an existing rkey, try to find and update that specific entry 285 + if let Some(rkey) = existing_rkey { 286 + // Check if this entry exists in the notebook by comparing rkeys 287 + for entry_ref in &entry_refs { 288 + let ref_rkey = entry_ref.uri.rkey().map(|r| r.0.as_str()); 289 + if ref_rkey == Some(rkey) { 290 + // Found it - update 291 + let output = self 292 + .update_record::<entry::Entry>(&entry_ref.uri, |e| { 293 + e.content = entry.content.clone(); 294 + e.title = entry.title.clone(); 295 + e.path = entry.path.clone(); 296 + e.embeds = entry.embeds.clone(); 297 + e.tags = entry.tags.clone(); 298 + }) 299 + .await?; 300 + let updated_ref = StrongRef::new() 301 + .uri(output.uri.into_static()) 302 + .cid(output.cid.into_static()) 303 + .build(); 304 + return Ok((updated_ref, false)); 305 + } 306 + } 307 + 308 + // Entry with this rkey not in notebook - create with specific rkey 309 + let response = self 310 + .create_record(entry, Some(RecordKey::any(rkey)?)) 311 + .await?; 312 + let new_ref = StrongRef::new() 313 + .uri(response.uri.clone().into_static()) 314 + .cid(response.cid.clone().into_static()) 315 + .build(); 316 + 317 + use weaver_api::sh_weaver::notebook::book::Book; 318 + let notebook_entry_ref = StrongRef::new() 319 + .uri(response.uri.into_static()) 320 + .cid(response.cid.into_static()) 321 + .build(); 322 + 323 + self.update_record::<Book>(&notebook_uri, |book| { 324 + book.entry_list.push(notebook_entry_ref); 325 + }) 326 + .await?; 327 + 328 + return Ok((new_ref, true)); 329 + } 330 + 331 + // No existing rkey - use title-based matching (original behavior) 279 332 280 333 // Fast path: if notebook is empty, skip search and create directly 281 334 if entry_refs.is_empty() {
+4
crates/weaver-common/src/lib.rs
··· 3 3 pub mod agent; 4 4 pub mod constellation; 5 5 pub mod error; 6 + pub mod resolve; 6 7 pub mod worker_rt; 7 8 8 9 // Re-export jacquard for convenience 9 10 pub use agent::WeaverExt; 10 11 pub use error::WeaverError; 12 + pub use resolve::{EntryIndex, ExtractedRef, RefCollector, ResolvedContent, ResolvedEntry}; 13 + #[cfg(any(test, feature = "standalone-collection"))] 14 + pub use resolve::collect_refs_from_markdown; 11 15 pub use jacquard; 12 16 use jacquard::CowStr; 13 17 use jacquard::client::{Agent, AgentSession};
+444
crates/weaver-common/src/resolve.rs
··· 1 + //! Wikilink and embed resolution types for rendering without network calls 2 + //! 3 + //! This module provides pre-resolution infrastructure so that markdown rendering 4 + //! can happen synchronously without network calls in the hot path. 5 + 6 + use std::collections::HashMap; 7 + 8 + use jacquard::CowStr; 9 + use jacquard::smol_str::SmolStr; 10 + use jacquard::types::string::AtUri; 11 + use weaver_api::com_atproto::repo::strong_ref::StrongRef; 12 + 13 + /// Pre-resolved data for rendering without network calls. 14 + /// 15 + /// Populated during an async collection phase, then passed to the sync render phase. 16 + #[derive(Debug, Clone, Default)] 17 + pub struct ResolvedContent { 18 + /// Wikilink target (lowercase) → resolved entry info 19 + pub entry_links: HashMap<SmolStr, ResolvedEntry>, 20 + /// AT URI → rendered HTML content 21 + pub embed_content: HashMap<AtUri<'static>, CowStr<'static>>, 22 + /// AT URI → StrongRef for populating records array 23 + pub embed_refs: Vec<StrongRef<'static>>, 24 + } 25 + 26 + /// A resolved entry reference from a wikilink 27 + #[derive(Debug, Clone)] 28 + pub struct ResolvedEntry { 29 + /// The canonical URL path (e.g., "/handle/notebook/entry_path") 30 + pub canonical_path: CowStr<'static>, 31 + /// The original entry title for display 32 + pub display_title: CowStr<'static>, 33 + } 34 + 35 + impl ResolvedContent { 36 + pub fn new() -> Self { 37 + Self::default() 38 + } 39 + 40 + /// Look up a wikilink target, returns the resolved entry if found 41 + pub fn resolve_wikilink(&self, target: &str) -> Option<&ResolvedEntry> { 42 + // Strip fragment if present 43 + let (target, _fragment) = target.split_once('#').unwrap_or((target, "")); 44 + let key = SmolStr::new(target.to_lowercase()); 45 + self.entry_links.get(&key) 46 + } 47 + 48 + /// Get pre-rendered embed content for an AT URI 49 + pub fn get_embed_content(&self, uri: &AtUri<'_>) -> Option<&str> { 50 + // Need to look up by equivalent URI, not exact reference 51 + self.embed_content 52 + .iter() 53 + .find(|(k, _)| k.as_str() == uri.as_str()) 54 + .map(|(_, v)| v.as_ref()) 55 + } 56 + 57 + /// Add a resolved entry link 58 + pub fn add_entry( 59 + &mut self, 60 + target: &str, 61 + canonical_path: impl Into<CowStr<'static>>, 62 + display_title: impl Into<CowStr<'static>>, 63 + ) { 64 + self.entry_links.insert( 65 + SmolStr::new(target.to_lowercase()), 66 + ResolvedEntry { 67 + canonical_path: canonical_path.into(), 68 + display_title: display_title.into(), 69 + }, 70 + ); 71 + } 72 + 73 + /// Add resolved embed content 74 + pub fn add_embed( 75 + &mut self, 76 + uri: AtUri<'static>, 77 + html: impl Into<CowStr<'static>>, 78 + strong_ref: Option<StrongRef<'static>>, 79 + ) { 80 + self.embed_content.insert(uri, html.into()); 81 + if let Some(sr) = strong_ref { 82 + self.embed_refs.push(sr); 83 + } 84 + } 85 + } 86 + 87 + /// Index of entries within a notebook for wikilink resolution. 88 + /// 89 + /// Supports case-insensitive matching against entry title OR path slug. 90 + #[derive(Debug, Clone, Default, PartialEq)] 91 + pub struct EntryIndex { 92 + /// lowercase title → (canonical_path, original_title) 93 + by_title: HashMap<SmolStr, (CowStr<'static>, CowStr<'static>)>, 94 + /// lowercase path slug → (canonical_path, original_title) 95 + by_path: HashMap<SmolStr, (CowStr<'static>, CowStr<'static>)>, 96 + } 97 + 98 + impl EntryIndex { 99 + pub fn new() -> Self { 100 + Self::default() 101 + } 102 + 103 + /// Add an entry to the index 104 + pub fn add_entry( 105 + &mut self, 106 + title: &str, 107 + path: &str, 108 + canonical_url: impl Into<CowStr<'static>>, 109 + ) { 110 + let canonical: CowStr<'static> = canonical_url.into(); 111 + let title_cow: CowStr<'static> = CowStr::from(title.to_string()); 112 + 113 + self.by_title.insert( 114 + SmolStr::new(title.to_lowercase()), 115 + (canonical.clone(), title_cow.clone()), 116 + ); 117 + self.by_path 118 + .insert(SmolStr::new(path.to_lowercase()), (canonical, title_cow)); 119 + } 120 + 121 + /// Resolve a wikilink target to (canonical_path, display_title, fragment) 122 + /// 123 + /// Matches case-insensitively against title first, then path slug. 124 + /// Fragment (if present) is returned with the input's lifetime. 125 + pub fn resolve<'a, 'b>( 126 + &'a self, 127 + wikilink: &'b str, 128 + ) -> Option<(&'a str, &'a str, Option<&'b str>)> { 129 + let (target, fragment) = match wikilink.split_once('#') { 130 + Some((t, f)) => (t, Some(f)), 131 + None => (wikilink, None), 132 + }; 133 + let key = SmolStr::new(target.to_lowercase()); 134 + 135 + // Try title match first 136 + if let Some((path, title)) = self.by_title.get(&key) { 137 + return Some((path.as_ref(), title.as_ref(), fragment)); 138 + } 139 + 140 + // Try path match 141 + if let Some((path, title)) = self.by_path.get(&key) { 142 + return Some((path.as_ref(), title.as_ref(), fragment)); 143 + } 144 + 145 + None 146 + } 147 + 148 + /// Parse a wikilink into (target, fragment) 149 + pub fn parse_wikilink(wikilink: &str) -> (&str, Option<&str>) { 150 + match wikilink.split_once('#') { 151 + Some((t, f)) => (t, Some(f)), 152 + None => (wikilink, None), 153 + } 154 + } 155 + 156 + /// Check if the index contains any entries 157 + pub fn is_empty(&self) -> bool { 158 + self.by_title.is_empty() 159 + } 160 + 161 + /// Get the number of entries 162 + pub fn len(&self) -> usize { 163 + self.by_title.len() 164 + } 165 + } 166 + 167 + /// Reference extracted from markdown that needs resolution 168 + #[derive(Debug, Clone, PartialEq)] 169 + pub enum ExtractedRef { 170 + /// Wikilink like [[Entry Name]] or [[Entry Name#header]] 171 + Wikilink { 172 + target: String, 173 + fragment: Option<String>, 174 + display_text: Option<String>, 175 + }, 176 + /// AT Protocol embed like ![[at://did/collection/rkey]] or ![alt](at://...) 177 + AtEmbed { 178 + uri: String, 179 + alt_text: Option<String>, 180 + }, 181 + /// AT Protocol link like [text](at://...) 182 + AtLink { uri: String }, 183 + } 184 + 185 + /// Collector for refs encountered during rendering. 186 + /// 187 + /// Pass this to renderers to collect refs as a side effect of the render pass. 188 + /// This avoids a separate parsing pass just for collection. 189 + #[derive(Debug, Clone, Default)] 190 + pub struct RefCollector { 191 + pub refs: Vec<ExtractedRef>, 192 + } 193 + 194 + impl RefCollector { 195 + pub fn new() -> Self { 196 + Self::default() 197 + } 198 + 199 + /// Record a wikilink reference 200 + pub fn add_wikilink( 201 + &mut self, 202 + target: &str, 203 + fragment: Option<&str>, 204 + display_text: Option<&str>, 205 + ) { 206 + self.refs.push(ExtractedRef::Wikilink { 207 + target: target.to_string(), 208 + fragment: fragment.map(|s| s.to_string()), 209 + display_text: display_text.map(|s| s.to_string()), 210 + }); 211 + } 212 + 213 + /// Record an AT Protocol embed reference 214 + pub fn add_at_embed(&mut self, uri: &str, alt_text: Option<&str>) { 215 + self.refs.push(ExtractedRef::AtEmbed { 216 + uri: uri.to_string(), 217 + alt_text: alt_text.map(|s| s.to_string()), 218 + }); 219 + } 220 + 221 + /// Record an AT Protocol link reference 222 + pub fn add_at_link(&mut self, uri: &str) { 223 + self.refs.push(ExtractedRef::AtLink { 224 + uri: uri.to_string(), 225 + }); 226 + } 227 + 228 + /// Get wikilinks that need resolution 229 + pub fn wikilinks(&self) -> impl Iterator<Item = &str> { 230 + self.refs.iter().filter_map(|r| match r { 231 + ExtractedRef::Wikilink { target, .. } => Some(target.as_str()), 232 + _ => None, 233 + }) 234 + } 235 + 236 + /// Get AT URIs that need fetching 237 + pub fn at_uris(&self) -> impl Iterator<Item = &str> { 238 + self.refs.iter().filter_map(|r| match r { 239 + ExtractedRef::AtEmbed { uri, .. } | ExtractedRef::AtLink { uri } => Some(uri.as_str()), 240 + _ => None, 241 + }) 242 + } 243 + 244 + /// Take ownership of collected refs 245 + pub fn take(self) -> Vec<ExtractedRef> { 246 + self.refs 247 + } 248 + } 249 + 250 + /// Extract all references from markdown that need resolution. 251 + /// 252 + /// **Note:** This does a separate parsing pass. For production use, prefer 253 + /// passing a `RefCollector` to the renderer to collect during the render pass. 254 + /// This function is primarily useful for testing or quick analysis. 255 + #[cfg(any(test, feature = "standalone-collection"))] 256 + pub fn collect_refs_from_markdown(markdown: &str) -> Vec<ExtractedRef> { 257 + use markdown_weaver::{Event, LinkType, Options, Parser, Tag}; 258 + 259 + let mut collector = RefCollector::new(); 260 + let options = Options::all(); 261 + let parser = Parser::new_ext(markdown, options); 262 + 263 + for event in parser { 264 + match event { 265 + Event::Start(Tag::Link { 266 + link_type, 267 + dest_url, 268 + .. 269 + }) => { 270 + let url = dest_url.as_ref(); 271 + 272 + if matches!(link_type, LinkType::WikiLink { .. }) { 273 + let (target, fragment) = match url.split_once('#') { 274 + Some((t, f)) => (t, Some(f)), 275 + None => (url, None), 276 + }; 277 + collector.add_wikilink(target, fragment, None); 278 + } else if url.starts_with("at://") { 279 + collector.add_at_link(url); 280 + } 281 + } 282 + Event::Start(Tag::Embed { 283 + dest_url, title, .. 284 + }) => { 285 + let url = dest_url.as_ref(); 286 + 287 + if url.starts_with("at://") || url.starts_with("did:") { 288 + let alt = if title.is_empty() { 289 + None 290 + } else { 291 + Some(title.as_ref()) 292 + }; 293 + collector.add_at_embed(url, alt); 294 + } else if !url.starts_with("http://") && !url.starts_with("https://") { 295 + let (target, fragment) = match url.split_once('#') { 296 + Some((t, f)) => (t, Some(f)), 297 + None => (url, None), 298 + }; 299 + collector.add_wikilink(target, fragment, None); 300 + } 301 + } 302 + Event::Start(Tag::Image { 303 + dest_url, title, .. 304 + }) => { 305 + let url = dest_url.as_ref(); 306 + 307 + if url.starts_with("at://") { 308 + let alt = if title.is_empty() { 309 + None 310 + } else { 311 + Some(title.as_ref()) 312 + }; 313 + collector.add_at_embed(url, alt); 314 + } 315 + } 316 + _ => {} 317 + } 318 + } 319 + 320 + collector.take() 321 + } 322 + 323 + #[cfg(test)] 324 + mod tests { 325 + use super::*; 326 + use jacquard::IntoStatic; 327 + 328 + #[test] 329 + fn test_entry_index_resolve_by_title() { 330 + let mut index = EntryIndex::new(); 331 + index.add_entry( 332 + "My First Note", 333 + "my_first_note", 334 + "/alice/notebook/my_first_note", 335 + ); 336 + 337 + let result = index.resolve("My First Note"); 338 + assert!(result.is_some()); 339 + let (path, title, fragment) = result.unwrap(); 340 + assert_eq!(path, "/alice/notebook/my_first_note"); 341 + assert_eq!(title, "My First Note"); 342 + assert_eq!(fragment, None); 343 + } 344 + 345 + #[test] 346 + fn test_entry_index_resolve_case_insensitive() { 347 + let mut index = EntryIndex::new(); 348 + index.add_entry( 349 + "My First Note", 350 + "my_first_note", 351 + "/alice/notebook/my_first_note", 352 + ); 353 + 354 + let result = index.resolve("my first note"); 355 + assert!(result.is_some()); 356 + } 357 + 358 + #[test] 359 + fn test_entry_index_resolve_by_path() { 360 + let mut index = EntryIndex::new(); 361 + index.add_entry( 362 + "My First Note", 363 + "my_first_note", 364 + "/alice/notebook/my_first_note", 365 + ); 366 + 367 + let result = index.resolve("my_first_note"); 368 + assert!(result.is_some()); 369 + } 370 + 371 + #[test] 372 + fn test_entry_index_resolve_with_fragment() { 373 + let mut index = EntryIndex::new(); 374 + index.add_entry("My Note", "my_note", "/alice/notebook/my_note"); 375 + 376 + let result = index.resolve("My Note#section"); 377 + assert!(result.is_some()); 378 + let (path, title, fragment) = result.unwrap(); 379 + assert_eq!(path, "/alice/notebook/my_note"); 380 + assert_eq!(title, "My Note"); 381 + assert_eq!(fragment, Some("section")); 382 + } 383 + 384 + #[test] 385 + fn test_collect_refs_wikilink() { 386 + let markdown = "Check out [[My Note]] for more info."; 387 + let refs = collect_refs_from_markdown(markdown); 388 + 389 + assert_eq!(refs.len(), 1); 390 + assert!(matches!( 391 + &refs[0], 392 + ExtractedRef::Wikilink { target, .. } if target == "My Note" 393 + )); 394 + } 395 + 396 + #[test] 397 + fn test_collect_refs_at_link() { 398 + let markdown = "See [this post](at://did:plc:xyz/app.bsky.feed.post/abc)"; 399 + let refs = collect_refs_from_markdown(markdown); 400 + 401 + assert_eq!(refs.len(), 1); 402 + assert!(matches!( 403 + &refs[0], 404 + ExtractedRef::AtLink { uri } if uri == "at://did:plc:xyz/app.bsky.feed.post/abc" 405 + )); 406 + } 407 + 408 + #[test] 409 + fn test_collect_refs_at_embed() { 410 + let markdown = "![[at://did:plc:xyz/app.bsky.feed.post/abc]]"; 411 + let refs = collect_refs_from_markdown(markdown); 412 + 413 + assert_eq!(refs.len(), 1); 414 + assert!(matches!( 415 + &refs[0], 416 + ExtractedRef::AtEmbed { uri, .. } if uri == "at://did:plc:xyz/app.bsky.feed.post/abc" 417 + )); 418 + } 419 + 420 + #[test] 421 + fn test_resolved_content_wikilink_lookup() { 422 + let mut content = ResolvedContent::new(); 423 + content.add_entry("My Note", "/alice/notebook/my_note", "My Note"); 424 + 425 + let result = content.resolve_wikilink("my note"); 426 + assert!(result.is_some()); 427 + assert_eq!( 428 + result.unwrap().canonical_path.as_ref(), 429 + "/alice/notebook/my_note" 430 + ); 431 + } 432 + 433 + #[test] 434 + fn test_resolved_content_embed_lookup() { 435 + let mut content = ResolvedContent::new(); 436 + let uri = AtUri::new("at://did:plc:xyz/app.bsky.feed.post/abc").unwrap(); 437 + content.add_embed(uri.into_static(), "<div>post content</div>", None); 438 + 439 + let lookup_uri = AtUri::new("at://did:plc:xyz/app.bsky.feed.post/abc").unwrap(); 440 + let result = content.get_embed_content(&lookup_uri); 441 + assert!(result.is_some()); 442 + assert_eq!(result.unwrap(), "<div>post content</div>"); 443 + } 444 + }
+1
crates/weaver-renderer/Cargo.toml
··· 25 25 pin-utils = "0.1.0" 26 26 pin-project = "1.1.10" 27 27 smol_str = { version = "0.3", features = ["serde"] } 28 + pulldown-latex = "0.6" 28 29 mime-sniffer = "0.1.3" 29 30 reqwest = { version = "0.12.7", default-features = false, features = [ 30 31 "json",
+1 -1
crates/weaver-renderer/src/atproto.rs
··· 14 14 15 15 pub use client::{ClientContext, DefaultEmbedResolver, EmbedResolver}; 16 16 pub use embed_renderer::{ 17 - fetch_and_render_generic, fetch_and_render_post, fetch_and_render_profile, 17 + fetch_and_render, fetch_and_render_generic, fetch_and_render_post, fetch_and_render_profile, 18 18 }; 19 19 pub use error::{AtProtoPreprocessError, ClientRenderError}; 20 20 pub use markdown_writer::MarkdownWriter;
+196 -100
crates/weaver-renderer/src/atproto/client.rs
··· 5 5 prelude::IdentityResolver, 6 6 types::string::{AtUri, Cid, Did}, 7 7 }; 8 - use markdown_weaver::{CowStr as MdCowStr, Tag, WeaverAttributes}; 8 + use markdown_weaver::{CowStr as MdCowStr, LinkType, Tag, WeaverAttributes}; 9 9 use std::collections::HashMap; 10 10 use std::sync::Arc; 11 11 use weaver_api::sh_weaver::notebook::entry::Entry; 12 + use weaver_common::{EntryIndex, ResolvedContent}; 12 13 13 14 /// Trait for resolving embed content on the client side 14 15 /// ··· 52 53 impl<A: AgentSession + IdentityResolver> EmbedResolver for DefaultEmbedResolver<A> { 53 54 async fn resolve_profile(&self, uri: &AtUri<'_>) -> Result<String, ClientRenderError> { 54 55 use crate::atproto::fetch_and_render_profile; 55 - use jacquard::types::ident::AtIdentifier; 56 - 57 - // Extract DID from authority 58 - let did = match uri.authority() { 59 - AtIdentifier::Did(did) => did, 60 - AtIdentifier::Handle(_) => { 61 - return Err(ClientRenderError::EntryFetch { 62 - uri: uri.as_ref().to_string(), 63 - source: "Profile URI should use DID not handle".into(), 64 - }); 65 - } 66 - }; 67 - 68 - fetch_and_render_profile(&did, &*self.agent) 56 + fetch_and_render_profile(uri.authority(), &*self.agent) 69 57 .await 70 58 .map_err(|e| ClientRenderError::EntryFetch { 71 59 uri: uri.as_ref().to_string(), ··· 144 132 embed_resolver: Option<Arc<R>>, 145 133 embed_depth: usize, 146 134 135 + // Pre-resolved content for sync rendering 136 + entry_index: Option<EntryIndex>, 137 + resolved_content: Option<ResolvedContent>, 138 + 147 139 // Shared state 148 140 frontmatter: Frontmatter, 149 141 title: MdCowStr<'a>, ··· 160 152 blob_map, 161 153 embed_resolver: None, 162 154 embed_depth: 0, 155 + entry_index: None, 156 + resolved_content: None, 163 157 frontmatter: Frontmatter::default(), 164 158 title, 165 159 } ··· 173 167 blob_map: self.blob_map, 174 168 embed_resolver: Some(resolver), 175 169 embed_depth: self.embed_depth, 170 + entry_index: self.entry_index, 171 + resolved_content: self.resolved_content, 176 172 frontmatter: self.frontmatter, 177 173 title: self.title, 178 174 } 179 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 + } 180 188 } 181 189 182 190 impl<'a, R: EmbedResolver> ClientContext<'a, R> { ··· 191 199 blob_map: self.blob_map.clone(), 192 200 embed_resolver: self.embed_resolver.clone(), 193 201 embed_depth: depth, 202 + entry_index: self.entry_index.clone(), 203 + resolved_content: self.resolved_content.clone(), 194 204 frontmatter: self.frontmatter.clone(), 195 205 title: self.title.clone(), 196 206 } 197 207 } 198 208 209 + /// Build an embed tag with resolved content attached 210 + fn build_embed_with_content<'s>( 211 + &self, 212 + embed_type: markdown_weaver::EmbedType, 213 + url: String, 214 + title: MdCowStr<'s>, 215 + id: MdCowStr<'s>, 216 + content: String, 217 + is_at_uri: bool, 218 + ) -> Tag<'s> { 219 + let mut attrs = WeaverAttributes { 220 + classes: vec![], 221 + attrs: vec![], 222 + }; 223 + 224 + attrs.attrs.push(("content".into(), content.into())); 225 + 226 + // Add metadata for client-side enhancement 227 + if is_at_uri { 228 + attrs 229 + .attrs 230 + .push(("data-embed-uri".into(), url.clone().into())); 231 + 232 + if let Ok(at_uri) = AtUri::new(&url) { 233 + if at_uri.collection().is_none() { 234 + attrs 235 + .attrs 236 + .push(("data-embed-type".into(), "profile".into())); 237 + } else { 238 + attrs.attrs.push(("data-embed-type".into(), "post".into())); 239 + } 240 + } 241 + } 242 + 243 + Tag::Embed { 244 + embed_type, 245 + dest_url: MdCowStr::Boxed(url.into_boxed_str()), 246 + title, 247 + id, 248 + attrs: Some(attrs), 249 + } 250 + } 251 + 199 252 fn build_blob_map<'b>(entry: &Entry<'b>) -> HashMap<BlobName<'static>, Cid<'static>> { 200 253 use jacquard::IntoStatic; 201 254 ··· 295 348 title, 296 349 id, 297 350 } => { 351 + // Handle WikiLinks via EntryIndex 352 + if matches!(link_type, LinkType::WikiLink { .. }) { 353 + if let Some(index) = &self.entry_index { 354 + let url = dest_url.as_ref(); 355 + if let Some((path, _title, fragment)) = index.resolve(url) { 356 + // Build resolved URL with optional fragment 357 + let resolved_url = match fragment { 358 + Some(frag) => format!("{}#{}", path, frag), 359 + None => path.to_string(), 360 + }; 361 + 362 + return Tag::Link { 363 + link_type: *link_type, 364 + dest_url: MdCowStr::Boxed(resolved_url.into_boxed_str()), 365 + title: title.clone(), 366 + id: id.clone(), 367 + }; 368 + } 369 + } 370 + // Unresolved wikilink - render as broken link 371 + return Tag::Link { 372 + link_type: *link_type, 373 + dest_url: MdCowStr::Boxed(format!("#{}", dest_url).into_boxed_str()), 374 + title: title.clone(), 375 + id: id.clone(), 376 + }; 377 + } 378 + 298 379 let url = dest_url.as_ref(); 299 380 300 381 // Try to parse as AT URI ··· 324 405 } 325 406 326 407 async fn handle_embed<'s>(&self, embed: Tag<'s>) -> Tag<'s> { 327 - match &embed { 328 - Tag::Embed { 329 - embed_type, 330 - dest_url, 331 - title, 332 - id, 333 - attrs, 334 - } => { 335 - // If content already in attrs (from preprocessor), pass through 336 - if let Some(attrs) = attrs { 337 - if attrs.attrs.iter().any(|(k, _)| k.as_ref() == "content") { 338 - return embed; 339 - } 340 - } 341 - 342 - // Check if we have a resolver 343 - let Some(resolver) = &self.embed_resolver else { 344 - return embed; 345 - }; 346 - 347 - // Check recursion depth 348 - if self.embed_depth >= MAX_EMBED_DEPTH { 349 - return embed; 350 - } 351 - 352 - // Try to fetch content based on URL type 353 - let content_result = if dest_url.starts_with("at://") { 354 - // AT Protocol embed 355 - if let Ok(at_uri) = AtUri::new(dest_url.as_ref()) { 356 - if at_uri.collection().is_none() && at_uri.rkey().is_none() { 357 - // Profile embed 358 - resolver.resolve_profile(&at_uri).await 359 - } else { 360 - // Post/record embed 361 - resolver.resolve_post(&at_uri).await 362 - } 363 - } else { 364 - return embed; 365 - } 366 - } else if dest_url.starts_with("http://") || dest_url.starts_with("https://") { 367 - // Markdown embed (could be other types, but assume markdown for now) 368 - resolver 369 - .resolve_markdown(dest_url.as_ref(), self.embed_depth + 1) 370 - .await 371 - } else { 372 - // Local path or other - skip for now 373 - return embed; 374 - }; 408 + let Tag::Embed { 409 + embed_type, 410 + dest_url, 411 + title, 412 + id, 413 + attrs, 414 + } = &embed 415 + else { 416 + return embed; 417 + }; 375 418 376 - // If we got content, attach it to attrs 377 - if let Ok(content) = content_result { 378 - let mut new_attrs = attrs.clone().unwrap_or_else(|| WeaverAttributes { 379 - classes: vec![], 380 - attrs: vec![], 381 - }); 419 + // If content already in attrs (from preprocessor), pass through 420 + if let Some(attrs) = attrs { 421 + if attrs.attrs.iter().any(|(k, _)| k.as_ref() == "content") { 422 + return embed; 423 + } 424 + } 382 425 383 - new_attrs.attrs.push(("content".into(), content.into())); 426 + // Own the URL to avoid borrow issues 427 + let url: String = dest_url.to_string(); 384 428 385 - // Add metadata for client-side enhancement 386 - if dest_url.starts_with("at://") { 387 - new_attrs 388 - .attrs 389 - .push(("data-embed-uri".into(), dest_url.clone())); 429 + // Check recursion depth 430 + if self.embed_depth >= MAX_EMBED_DEPTH { 431 + return embed; 432 + } 390 433 391 - if let Ok(at_uri) = AtUri::new(dest_url.as_ref()) { 392 - if at_uri.collection().is_none() { 393 - new_attrs 394 - .attrs 395 - .push(("data-embed-type".into(), "profile".into())); 396 - } else { 397 - new_attrs 398 - .attrs 399 - .push(("data-embed-type".into(), "post".into())); 400 - } 401 - } 402 - } else { 403 - new_attrs 404 - .attrs 405 - .push(("data-embed-type".into(), "markdown".into())); 434 + // First check for pre-resolved AT URI content 435 + if url.starts_with("at://") { 436 + if let Ok(at_uri) = AtUri::new(&url) { 437 + if let Some(resolved) = &self.resolved_content { 438 + if let Some(content) = resolved.get_embed_content(&at_uri) { 439 + return self.build_embed_with_content( 440 + *embed_type, 441 + url.clone(), 442 + title.clone(), 443 + id.clone(), 444 + content.to_string(), 445 + true, 446 + ); 406 447 } 448 + } 449 + } 450 + } 407 451 408 - Tag::Embed { 452 + // Check for wikilink-style embed (![[Entry Name]]) via entry index 453 + if !url.starts_with("at://") && !url.starts_with("http://") && !url.starts_with("https://") 454 + { 455 + if let Some(index) = &self.entry_index { 456 + if let Some((path, _title, fragment)) = index.resolve(&url) { 457 + // Entry embed - link to the entry 458 + let resolved_url = match fragment { 459 + Some(frag) => format!("{}#{}", path, frag), 460 + None => path.to_string(), 461 + }; 462 + return Tag::Embed { 409 463 embed_type: *embed_type, 410 - dest_url: dest_url.clone(), 464 + dest_url: MdCowStr::Boxed(resolved_url.into_boxed_str()), 411 465 title: title.clone(), 412 466 id: id.clone(), 413 - attrs: Some(new_attrs), 414 - } 467 + attrs: attrs.clone(), 468 + }; 469 + } 470 + } 471 + // Unresolved entry embed - pass through 472 + return embed; 473 + } 474 + 475 + // Fallback to async resolver if available 476 + let Some(resolver) = &self.embed_resolver else { 477 + return embed; 478 + }; 479 + 480 + // Try to fetch content based on URL type 481 + let content_result = if url.starts_with("at://") { 482 + // AT Protocol embed 483 + if let Ok(at_uri) = AtUri::new(&url) { 484 + if at_uri.collection().is_none() && at_uri.rkey().is_none() { 485 + // Profile embed 486 + resolver.resolve_profile(&at_uri).await 415 487 } else { 416 - // Fetch failed, return original 417 - embed 488 + // Post/record embed 489 + resolver.resolve_post(&at_uri).await 418 490 } 491 + } else { 492 + return embed; 419 493 } 420 - _ => embed, 494 + } else if url.starts_with("http://") || url.starts_with("https://") { 495 + // Markdown embed 496 + resolver.resolve_markdown(&url, self.embed_depth + 1).await 497 + } else { 498 + return embed; 499 + }; 500 + 501 + // If we got content, attach it 502 + if let Ok(content) = content_result { 503 + let is_at = url.starts_with("at://"); 504 + self.build_embed_with_content( 505 + *embed_type, 506 + url, 507 + title.clone(), 508 + id.clone(), 509 + content, 510 + is_at, 511 + ) 512 + } else { 513 + embed 421 514 } 422 515 } 423 516 ··· 452 545 #[test] 453 546 fn test_at_uri_to_web_url_profile() { 454 547 let uri = AtUri::new("at://did:plc:xyz123").unwrap(); 455 - assert_eq!(at_uri_to_web_url(&uri), "https://weaver.sh/did:plc:xyz123"); 548 + assert_eq!( 549 + at_uri_to_web_url(&uri), 550 + "https://alpha.weaver.sh/did:plc:xyz123" 551 + ); 456 552 } 457 553 458 554 #[test] ··· 496 592 let uri = AtUri::new("at://did:plc:xyz123/sh.weaver.notebook.entry/entry123").unwrap(); 497 593 assert_eq!( 498 594 at_uri_to_web_url(&uri), 499 - "https://weaver.sh/did:plc:xyz123/sh.weaver.notebook.entry/entry123" 595 + "https://alpha.weaver.sh/record/at://did:plc:xyz123/sh.weaver.notebook.entry/entry123" 500 596 ); 501 597 } 502 598 ··· 505 601 let uri = AtUri::new("at://did:plc:xyz123/com.example.unknown/rkey").unwrap(); 506 602 assert_eq!( 507 603 at_uri_to_web_url(&uri), 508 - "https://weaver.sh/did:plc:xyz123/com.example.unknown/rkey" 604 + "https://alpha.weaver.sh/record/at://did:plc:xyz123/com.example.unknown/rkey" 509 605 ); 510 606 } 511 607 }
+1170 -147
crates/weaver-renderer/src/atproto/embed_renderer.rs
··· 2 2 //! 3 3 //! This module provides functions to fetch records from PDSs and render them 4 4 //! as HTML strings suitable for embedding in markdown content. 5 + //! 6 + //! # Reusable render functions 7 + //! 8 + //! The `render_*` functions can be used standalone for rendering different embed types: 9 + //! - `render_external_link` - Link cards with title, description, thumbnail 10 + //! - `render_images` - Image galleries 11 + //! - `render_quoted_record` - Quoted posts/records 12 + //! - `render_author_block` - Author avatar + name + handle 5 13 14 + use super::error::AtProtoPreprocessError; 6 15 use jacquard::{ 7 - client::{Agent, AgentSession, AgentSessionExt, ClientError}, 8 - prelude::IdentityResolver, 9 - types::string::{AtUri, Did, Nsid}, 10 - xrpc::{self, Response, XrpcClient}, 11 - http_client::HttpClient, 12 - Data, 16 + Data, IntoStatic, 17 + client::AgentSessionExt, 18 + types::{ident::AtIdentifier, string::AtUri}, 13 19 }; 14 - use weaver_api::com_atproto::repo::get_record::{GetRecord, GetRecordResponse, GetRecordOutput}; 15 - use super::error::AtProtoPreprocessError; 20 + use weaver_api::app_bsky::{ 21 + actor::ProfileViewBasic, 22 + embed::{ 23 + external::ViewExternal, 24 + images::ViewImage, 25 + record::{ViewRecord, ViewUnionRecord}, 26 + }, 27 + feed::{PostView, PostViewEmbed, get_posts::GetPosts}, 28 + }; 29 + use weaver_api::sh_weaver::actor::ProfileDataViewInner; 30 + use weaver_common::agent::WeaverExt; 16 31 17 - /// Get a record without type validation, returning untyped Data 32 + /// Fetch and render a profile record as HTML 18 33 /// 19 - /// This is similar to jacquard's `get_record` but skips collection validation 20 - /// and returns the raw Data value instead of a typed response. 21 - async fn get_record_untyped<'a, A: AgentSession + IdentityResolver>( 22 - uri: &AtUri<'_>, 23 - agent: &Agent<A>, 24 - ) -> Result<Response<GetRecordResponse>, ClientError> { 25 - use jacquard::types::ident::AtIdentifier; 34 + /// Resolves handle to DID if needed, then fetches profile data from 35 + /// weaver or bsky appview, returning a rich profile view. 36 + pub async fn fetch_and_render_profile<A>( 37 + ident: &AtIdentifier<'_>, 38 + agent: &A, 39 + ) -> Result<String, AtProtoPreprocessError> 40 + where 41 + A: AgentSessionExt, 42 + { 43 + use jacquard::types::string::Did; 26 44 27 - // Extract collection and rkey from URI 28 - let collection = uri.collection().ok_or_else(|| { 29 - ClientError::invalid_request("AtUri missing collection") 30 - .with_help("ensure the URI includes a collection") 31 - })?; 32 - 33 - let rkey = uri.rkey().ok_or_else(|| { 34 - ClientError::invalid_request("AtUri missing rkey") 35 - .with_help("ensure the URI includes a record key after the collection") 36 - })?; 37 - 38 - // Resolve authority (DID or handle) to get DID and PDS 39 - let (repo_did, pds_url) = match uri.authority() { 40 - AtIdentifier::Did(did) => { 41 - let pds = agent.pds_for_did(did).await.map_err(|e| { 42 - ClientError::from(e) 43 - .with_context("DID document resolution failed during record retrieval") 44 - })?; 45 - (did.clone(), pds) 45 + // Resolve to DID if we have a handle 46 + let did = match ident { 47 + AtIdentifier::Did(d) => d.clone(), 48 + AtIdentifier::Handle(h) => { 49 + let did_str = agent 50 + .resolve_handle(h) 51 + .await 52 + .map_err(|e| AtProtoPreprocessError::FetchFailed(e.to_string()))?; 53 + Did::new(&did_str) 54 + .map_err(|e| AtProtoPreprocessError::InvalidUri(e.to_string()))? 55 + .into_static() 46 56 } 47 - AtIdentifier::Handle(handle) => agent.pds_for_handle(handle).await.map_err(|e| { 48 - ClientError::from(e) 49 - .with_context("handle resolution failed during record retrieval") 50 - })?, 51 57 }; 52 58 53 - // Make stateless XRPC call to that PDS (no auth required for public records) 54 - let request = GetRecord::new() 55 - .repo(AtIdentifier::Did(repo_did)) 56 - .collection(collection.clone()) 57 - .rkey(rkey.clone()) 58 - .build(); 59 - 60 - let http_request = xrpc::build_http_request(&pds_url, &request, &agent.opts().await)?; 61 - 62 - let http_response = agent 63 - .send_http(http_request) 59 + // Use WeaverExt to get hydrated profile (tries weaver profile first, falls back to bsky) 60 + let (_uri, profile_view) = agent 61 + .hydrate_profile_view(&did) 64 62 .await 65 - .map_err(|e| ClientError::transport(e))?; 66 - 67 - xrpc::process_response(http_response) 68 - } 69 - 70 - /// Fetch and render a profile record as HTML 71 - /// 72 - /// Constructs the profile URI `at://did/app.bsky.actor.profile/self` and fetches it. 73 - pub async fn fetch_and_render_profile<A: AgentSession + IdentityResolver>( 74 - did: &Did<'_>, 75 - agent: &Agent<A>, 76 - ) -> Result<String, AtProtoPreprocessError> { 77 - use weaver_api::app_bsky::actor::profile::Profile; 78 - 79 - // Construct profile URI: at://did/app.bsky.actor.profile/self 80 - let profile_uri = format!("at://{}/app.bsky.actor.profile/self", did.as_ref()); 81 - 82 - // Fetch using typed collection 83 - let record_uri = Profile::uri(&profile_uri) 84 - .map_err(|e| AtProtoPreprocessError::InvalidUri(e.to_string()))?; 85 - 86 - let output = agent.fetch_record(&record_uri).await 87 63 .map_err(|e| AtProtoPreprocessError::FetchFailed(e.to_string()))?; 88 64 89 - // Render profile to HTML 90 - render_profile(&output.value, did) 65 + // Render based on which profile type we got 66 + render_profile_data_view(&profile_view.inner) 91 67 } 92 68 93 - /// Fetch and render a Bluesky post as HTML 94 - pub async fn fetch_and_render_post<A: AgentSession + IdentityResolver>( 69 + /// Fetch and render a Bluesky post as HTML using the appview for rich data 70 + pub async fn fetch_and_render_post<A>( 95 71 uri: &AtUri<'_>, 96 - agent: &Agent<A>, 97 - ) -> Result<String, AtProtoPreprocessError> { 98 - use weaver_api::app_bsky::feed::post::Post; 72 + agent: &A, 73 + ) -> Result<String, AtProtoPreprocessError> 74 + where 75 + A: AgentSessionExt, 76 + { 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()))?; 99 82 100 - // Fetch using typed collection 101 - let record_uri = Post::uri(uri.as_ref()) 102 - .map_err(|e| AtProtoPreprocessError::InvalidUri(e.to_string()))?; 83 + let output = response 84 + .into_output() 85 + .map_err(|e| AtProtoPreprocessError::FetchFailed(e.to_string()))?; 103 86 104 - let output = agent.fetch_record(&record_uri).await 105 - .map_err(|e| AtProtoPreprocessError::FetchFailed(e.to_string()))?; 87 + let post_view = output 88 + .posts 89 + .into_iter() 90 + .next() 91 + .ok_or_else(|| AtProtoPreprocessError::FetchFailed("Post not found".to_string()))?; 106 92 107 - // Render post to HTML 108 - render_post(&output.value, uri) 93 + render_post_view(&post_view, uri) 109 94 } 110 95 111 96 /// Fetch and render an unknown record type generically 112 97 /// 113 98 /// This fetches the record as untyped Data and probes for likely meaningful fields. 114 - pub async fn fetch_and_render_generic<A: AgentSession + IdentityResolver>( 99 + pub async fn fetch_and_render_generic<A>( 115 100 uri: &AtUri<'_>, 116 - agent: &Agent<A>, 117 - ) -> Result<String, AtProtoPreprocessError> { 118 - // Use untyped fetch 119 - let response = get_record_untyped(uri, agent).await 101 + agent: &A, 102 + ) -> Result<String, AtProtoPreprocessError> 103 + where 104 + A: AgentSessionExt, 105 + { 106 + // Fetch via slingshot (edge-cached, untyped) 107 + let output = agent 108 + .fetch_record_slingshot(uri) 109 + .await 120 110 .map_err(|e| AtProtoPreprocessError::FetchFailed(e.to_string()))?; 121 - 122 - // Parse to get GetRecordOutput with Data value 123 - let output: GetRecordOutput = response.into_output() 124 - .map_err(|e| AtProtoPreprocessError::ParseFailed(e.to_string()))?; 125 111 126 112 // Probe for meaningful fields 127 113 render_generic_record(&output.value, uri) 128 114 } 129 115 130 - /// Render a profile record as HTML 131 - fn render_profile<'a>( 132 - profile: &weaver_api::app_bsky::actor::profile::Profile<'a>, 133 - did: &Did<'_>, 134 - ) -> Result<String, AtProtoPreprocessError> { 116 + /// Fetch and render a notebook entry with full markdown rendering 117 + /// 118 + /// Renders the entry content as HTML in a scrollable container with title and author info. 119 + pub async fn fetch_and_render_entry<A>( 120 + uri: &AtUri<'_>, 121 + agent: &A, 122 + ) -> Result<String, AtProtoPreprocessError> 123 + where 124 + A: AgentSessionExt, 125 + { 126 + use weaver_common::agent::WeaverExt; 127 + use markdown_weaver::Parser; 128 + use crate::atproto::writer::ClientWriter; 129 + use crate::default_md_options; 130 + 131 + // Get rkey from URI 132 + let rkey = uri 133 + .rkey() 134 + .ok_or_else(|| AtProtoPreprocessError::FetchFailed("Entry URI missing rkey".to_string()))?; 135 + 136 + // Fetch entry with author info 137 + let (entry_view, entry) = agent 138 + .fetch_entry_by_rkey(&uri.authority(), rkey.as_ref()) 139 + .await 140 + .map_err(|e| AtProtoPreprocessError::FetchFailed(e.to_string()))?; 141 + 142 + // Render the markdown content to HTML 143 + let parser = Parser::new_ext(entry.content.as_ref(), default_md_options()); 144 + let mut content_html = String::new(); 145 + ClientWriter::<_, _, ()>::new(parser, &mut content_html) 146 + .run() 147 + .map_err(|e| AtProtoPreprocessError::FetchFailed(format!("Markdown render failed: {}", e)))?; 148 + 149 + // Generate unique ID for the toggle checkbox 150 + let toggle_id = format!("entry-toggle-{}", rkey.as_ref()); 151 + 152 + // Build the embed HTML 135 153 let mut html = String::new(); 154 + html.push_str("<div class=\"atproto-embed atproto-entry\" contenteditable=\"false\">"); 136 155 137 - html.push_str("<div class=\"atproto-embed atproto-profile\">"); 156 + // Hidden checkbox for expand/collapse (must come before content for CSS sibling selector) 157 + html.push_str("<input type=\"checkbox\" class=\"embed-entry-toggle\" id=\""); 158 + html.push_str(&toggle_id); 159 + html.push_str("\">"); 160 + 161 + // Header with title and author 162 + html.push_str("<div class=\"embed-entry-header\">"); 163 + 164 + // Title 165 + html.push_str("<span class=\"embed-entry-title\">"); 166 + html.push_str(&html_escape(entry.title.as_ref())); 167 + html.push_str("</span>"); 138 168 139 - if let Some(display_name) = &profile.display_name { 140 - html.push_str("<div class=\"profile-name\">"); 141 - html.push_str(&html_escape(display_name.as_ref())); 142 - html.push_str("</div>"); 169 + // Author info - just show handle (keep it simple for entry embeds) 170 + if let Some(author) = entry_view.authors.first() { 171 + let handle = match &author.record.inner { 172 + ProfileDataViewInner::ProfileView(p) => p.handle.as_ref(), 173 + ProfileDataViewInner::ProfileViewDetailed(p) => p.handle.as_ref(), 174 + ProfileDataViewInner::TangledProfileView(p) => p.handle.as_ref(), 175 + ProfileDataViewInner::Unknown(_) => "", 176 + }; 177 + if !handle.is_empty() { 178 + html.push_str("<span class=\"embed-entry-author\">@"); 179 + html.push_str(&html_escape(handle)); 180 + html.push_str("</span>"); 181 + } 143 182 } 144 183 145 - html.push_str("<div class=\"profile-did\">"); 146 - html.push_str(&html_escape(did.as_ref())); 184 + html.push_str("</div>"); // end header 185 + 186 + // Scrollable content container 187 + html.push_str("<div class=\"embed-entry-content\">"); 188 + html.push_str(&content_html); 189 + html.push_str("</div>"); 190 + 191 + // Expand/collapse label (clickable, targets the checkbox) 192 + html.push_str("<label class=\"embed-entry-expand\" for=\""); 193 + html.push_str(&toggle_id); 194 + html.push_str("\"></label>"); 195 + 147 196 html.push_str("</div>"); 148 197 149 - if let Some(description) = &profile.description { 150 - html.push_str("<div class=\"profile-description\">"); 151 - html.push_str(&html_escape(description.as_ref())); 152 - html.push_str("</div>"); 198 + Ok(html) 199 + } 200 + 201 + /// Fetch and render any AT URI, dispatching to the appropriate renderer based on collection. 202 + /// 203 + /// Uses typed fetchers for known collections (posts, profiles) and falls back to 204 + /// generic rendering for unknown types. 205 + pub async fn fetch_and_render<A>( 206 + uri: &AtUri<'_>, 207 + agent: &A, 208 + ) -> Result<String, AtProtoPreprocessError> 209 + where 210 + A: AgentSessionExt, 211 + { 212 + let collection = uri.collection().map(|c| c.as_ref()); 213 + 214 + match collection { 215 + Some("app.bsky.feed.post") => fetch_and_render_post(uri, agent).await, 216 + Some("app.bsky.actor.profile") => { 217 + // Extract DID from URI authority 218 + fetch_and_render_profile(uri.authority(), agent).await 219 + } 220 + Some("sh.weaver.notebook.entry") => fetch_and_render_entry(uri, agent).await, 221 + None => fetch_and_render_profile(uri.authority(), agent).await, 222 + _ => fetch_and_render_generic(uri, agent).await, 153 223 } 224 + } 154 225 155 - html.push_str("</div>"); 226 + /// Render a profile from ProfileDataViewInner (weaver, bsky, or tangled) 227 + fn render_profile_data_view(inner: &ProfileDataViewInner<'_>) -> Result<String, AtProtoPreprocessError> { 228 + let mut html = String::new(); 229 + 230 + match inner { 231 + ProfileDataViewInner::ProfileView(profile) => { 232 + // Weaver profile - link to bsky for now 233 + let profile_url = format!("https://bsky.app/profile/{}", profile.handle.as_ref()); 234 + html.push_str("<span class=\"atproto-embed atproto-profile\" contenteditable=\"false\">"); 235 + 236 + // Background link covers whole card 237 + html.push_str("<a class=\"embed-card-link\" href=\""); 238 + html.push_str(&html_escape(&profile_url)); 239 + html.push_str("\" target=\"_blank\" rel=\"noopener\" aria-label=\"View profile\"></a>"); 240 + 241 + html.push_str("<span class=\"embed-author\">"); 242 + if let Some(avatar) = &profile.avatar { 243 + html.push_str("<img class=\"embed-avatar\" src=\""); 244 + html.push_str(&html_escape(avatar.as_ref())); 245 + html.push_str("\" alt=\"\" />"); 246 + } 247 + html.push_str("<span class=\"embed-author-info\">"); 248 + if let Some(display_name) = &profile.display_name { 249 + html.push_str("<span class=\"embed-author-name\">"); 250 + html.push_str(&html_escape(display_name.as_ref())); 251 + html.push_str("</span>"); 252 + } 253 + html.push_str("<span class=\"embed-author-handle\">@"); 254 + html.push_str(&html_escape(profile.handle.as_ref())); 255 + html.push_str("</span>"); 256 + html.push_str("</span>"); 257 + html.push_str("</span>"); 258 + 259 + if let Some(description) = &profile.description { 260 + html.push_str("<span class=\"embed-description\">"); 261 + html.push_str(&html_escape(description.as_ref())); 262 + html.push_str("</span>"); 263 + } 264 + 265 + html.push_str("</span>"); 266 + } 267 + ProfileDataViewInner::ProfileViewDetailed(profile) => { 268 + // Bsky profile 269 + let profile_url = format!("https://bsky.app/profile/{}", profile.handle.as_ref()); 270 + html.push_str("<span class=\"atproto-embed atproto-profile\" contenteditable=\"false\">"); 271 + 272 + // Background link covers whole card 273 + html.push_str("<a class=\"embed-card-link\" href=\""); 274 + html.push_str(&html_escape(&profile_url)); 275 + html.push_str("\" target=\"_blank\" rel=\"noopener\" aria-label=\"View profile\"></a>"); 276 + 277 + html.push_str("<span class=\"embed-author\">"); 278 + if let Some(avatar) = &profile.avatar { 279 + html.push_str("<img class=\"embed-avatar\" src=\""); 280 + html.push_str(&html_escape(avatar.as_ref())); 281 + html.push_str("\" alt=\"\" />"); 282 + } 283 + html.push_str("<span class=\"embed-author-info\">"); 284 + if let Some(display_name) = &profile.display_name { 285 + html.push_str("<span class=\"embed-author-name\">"); 286 + html.push_str(&html_escape(display_name.as_ref())); 287 + html.push_str("</span>"); 288 + } 289 + html.push_str("<span class=\"embed-author-handle\">@"); 290 + html.push_str(&html_escape(profile.handle.as_ref())); 291 + html.push_str("</span>"); 292 + html.push_str("</span>"); 293 + html.push_str("</span>"); 294 + 295 + if let Some(description) = &profile.description { 296 + html.push_str("<span class=\"embed-description\">"); 297 + html.push_str(&html_escape(description.as_ref())); 298 + html.push_str("</span>"); 299 + } 300 + 301 + // Stats for bsky profiles 302 + if profile.followers_count.is_some() || profile.follows_count.is_some() { 303 + html.push_str("<span class=\"embed-meta\">"); 304 + html.push_str("<span class=\"embed-stats\">"); 305 + if let Some(followers) = profile.followers_count { 306 + html.push_str("<span class=\"embed-stat\">"); 307 + html.push_str(&followers.to_string()); 308 + html.push_str(" followers</span>"); 309 + } 310 + if let Some(follows) = profile.follows_count { 311 + html.push_str("<span class=\"embed-stat\">"); 312 + html.push_str(&follows.to_string()); 313 + html.push_str(" following</span>"); 314 + } 315 + html.push_str("</span>"); 316 + html.push_str("</span>"); 317 + } 318 + 319 + html.push_str("</span>"); 320 + } 321 + ProfileDataViewInner::TangledProfileView(profile) => { 322 + // Tangled profile - link to tangled 323 + let profile_url = format!("https://tangled.sh/@{}", profile.handle.as_ref()); 324 + html.push_str("<span class=\"atproto-embed atproto-profile\" contenteditable=\"false\">"); 325 + 326 + // Background link covers whole card 327 + html.push_str("<a class=\"embed-card-link\" href=\""); 328 + html.push_str(&html_escape(&profile_url)); 329 + html.push_str("\" target=\"_blank\" rel=\"noopener\" aria-label=\"View profile\"></a>"); 330 + 331 + html.push_str("<span class=\"embed-author\">"); 332 + html.push_str("<span class=\"embed-author-info\">"); 333 + html.push_str("<span class=\"embed-author-handle\">@"); 334 + html.push_str(&html_escape(profile.handle.as_ref())); 335 + html.push_str("</span>"); 336 + html.push_str("</span>"); 337 + html.push_str("</span>"); 338 + 339 + if let Some(description) = &profile.description { 340 + html.push_str("<span class=\"embed-description\">"); 341 + html.push_str(&html_escape(description.as_ref())); 342 + html.push_str("</span>"); 343 + } 344 + 345 + html.push_str("</span>"); 346 + } 347 + ProfileDataViewInner::Unknown(data) => { 348 + // Unknown - no link, just render 349 + html.push_str("<span class=\"atproto-embed atproto-profile\" contenteditable=\"false\">"); 350 + html.push_str(&render_generic_data(data)); 351 + html.push_str("</span>"); 352 + } 353 + } 156 354 157 355 Ok(html) 158 356 } 159 357 160 - /// Render a Bluesky post as HTML 161 - fn render_post<'a>( 162 - post: &weaver_api::app_bsky::feed::post::Post<'a>, 163 - _uri: &AtUri<'_>, 358 + /// Render a Bluesky post from PostView (rich appview data) 359 + fn render_post_view<'a>( 360 + post: &PostView<'a>, 361 + uri: &AtUri<'_>, 164 362 ) -> Result<String, AtProtoPreprocessError> { 165 363 let mut html = String::new(); 166 364 167 - html.push_str("<div class=\"atproto-embed atproto-post\">"); 365 + // Build link to post on Bluesky 366 + let bsky_link = format!( 367 + "https://bsky.app/profile/{}/post/{}", 368 + post.author.handle.as_ref(), 369 + uri.rkey().map(|r| r.as_ref()).unwrap_or("") 370 + ); 168 371 169 - html.push_str("<div class=\"post-text\">"); 170 - html.push_str(&html_escape(post.text.as_ref())); 171 - html.push_str("</div>"); 372 + html.push_str("<span class=\"atproto-embed atproto-post\" contenteditable=\"false\">"); 172 373 173 - html.push_str("<div class=\"post-meta\">"); 174 - html.push_str("<time>"); 175 - html.push_str(&html_escape(&post.created_at.to_string())); 176 - html.push_str("</time>"); 177 - html.push_str("</div>"); 374 + // Background link covers whole card, other links sit on top 375 + html.push_str("<a class=\"embed-card-link\" href=\""); 376 + html.push_str(&html_escape(&bsky_link)); 377 + html.push_str("\" target=\"_blank\" rel=\"noopener\" aria-label=\"View post on Bluesky\"></a>"); 178 378 179 - html.push_str("</div>"); 379 + // Author header 380 + html.push_str(&render_author_block(&post.author, true)); 381 + 382 + // Post text (parse record as typed Post) 383 + if let Ok(post_record) = 384 + jacquard::from_data::<weaver_api::app_bsky::feed::post::Post>(&post.record) 385 + { 386 + html.push_str("<span class=\"embed-content\">"); 387 + html.push_str(&html_escape(post_record.text.as_ref())); 388 + html.push_str("</span>"); 389 + } 390 + 391 + // Embedded content (images, links, quotes, etc.) 392 + if let Some(embed) = &post.embed { 393 + html.push_str(&render_post_embed(embed)); 394 + } 395 + 396 + // Engagement stats and timestamp 397 + html.push_str("<span class=\"embed-meta\">"); 398 + 399 + // Stats row 400 + html.push_str("<span class=\"embed-stats\">"); 401 + if let Some(replies) = post.reply_count { 402 + html.push_str("<span class=\"embed-stat\">"); 403 + html.push_str(&replies.to_string()); 404 + html.push_str(" replies</span>"); 405 + } 406 + if let Some(reposts) = post.repost_count { 407 + html.push_str("<span class=\"embed-stat\">"); 408 + html.push_str(&reposts.to_string()); 409 + html.push_str(" reposts</span>"); 410 + } 411 + if let Some(likes) = post.like_count { 412 + html.push_str("<span class=\"embed-stat\">"); 413 + html.push_str(&likes.to_string()); 414 + html.push_str(" likes</span>"); 415 + } 416 + html.push_str("</span>"); 417 + 418 + // Timestamp 419 + html.push_str("<span class=\"embed-time\">"); 420 + html.push_str(&html_escape(&post.indexed_at.to_string())); 421 + html.push_str("</span>"); 422 + 423 + html.push_str("</span>"); 424 + html.push_str("</span>"); 180 425 181 426 Ok(html) 182 427 } ··· 188 433 ) -> Result<String, AtProtoPreprocessError> { 189 434 let mut html = String::new(); 190 435 191 - html.push_str("<div class=\"atproto-embed atproto-record\">"); 436 + html.push_str("<span class=\"atproto-embed atproto-record\" contenteditable=\"false\">"); 437 + 438 + // Show record type as header 439 + 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 + html.push_str("<span class=\"embed-author-handle\">"); 443 + html.push_str(&html_escape(type_name)); 444 + html.push_str("</span>"); 445 + } 446 + 447 + // Priority fields to show first (in order) 448 + let priority_fields = ["name", "displayName", "title", "text", "description", "content"]; 449 + let mut shown_fields = Vec::new(); 450 + 451 + if let Some(obj) = data.as_object() { 452 + for field_name in priority_fields { 453 + if let Some(value) = obj.get(field_name) { 454 + if let Some(s) = value.as_str() { 455 + let class = match field_name { 456 + "name" | "displayName" | "title" => "embed-author-name", 457 + "text" | "content" => "embed-content", 458 + "description" => "embed-description", 459 + _ => "embed-field-value", 460 + }; 461 + html.push_str("<span class=\""); 462 + html.push_str(class); 463 + html.push_str("\">"); 464 + // Truncate long content for embed display 465 + let display_text = if s.len() > 300 { 466 + format!("{}...", &s[..300]) 467 + } else { 468 + s.to_string() 469 + }; 470 + html.push_str(&html_escape(&display_text)); 471 + html.push_str("</span>"); 472 + shown_fields.push(field_name); 473 + } 474 + } 475 + } 476 + 477 + // Show remaining fields as a simple list 478 + html.push_str("<span class=\"embed-fields\">"); 479 + for (key, value) in obj.iter() { 480 + let key_str: &str = key.as_ref(); 481 + 482 + // Skip already shown, internal fields, and complex nested objects 483 + if shown_fields.contains(&key_str) 484 + || key_str.starts_with('$') 485 + || key_str == "facets" 486 + || key_str == "labels" 487 + || key_str == "embeds" 488 + { 489 + continue; 490 + } 491 + 492 + if let Some(formatted) = format_field_value(key_str, value) { 493 + html.push_str("<span class=\"embed-field\">"); 494 + html.push_str("<span class=\"embed-field-name\">"); 495 + html.push_str(&html_escape(&format_field_name(key_str))); 496 + html.push_str(":</span> "); 497 + html.push_str(&formatted); 498 + html.push_str("</span>"); 499 + } 500 + } 501 + html.push_str("</span>"); 502 + } 503 + 504 + html.push_str("</span>"); 505 + 506 + Ok(html) 507 + } 508 + 509 + // ============================================================================= 510 + // Reusable render functions for embed components 511 + // ============================================================================= 512 + 513 + /// Render an author block (avatar + name + handle) 514 + /// 515 + /// Used for posts, profiles, and any record with an author. 516 + /// When `link_to_profile` is true, avatar, display name, and handle all link to the profile. 517 + pub fn render_author_block(author: &ProfileViewBasic<'_>, link_to_profile: bool) -> String { 518 + render_author_block_inner( 519 + author.avatar.as_ref().map(|u| u.as_ref()), 520 + author.display_name.as_ref().map(|s| s.as_ref()), 521 + author.handle.as_ref(), 522 + link_to_profile, 523 + ) 524 + } 525 + 526 + /// 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 { 528 + render_author_block_inner( 529 + author.avatar.as_ref().map(|u| u.as_ref()), 530 + author.display_name.as_ref().map(|s| s.as_ref()), 531 + author.handle.as_ref(), 532 + link_to_profile, 533 + ) 534 + } 535 + 536 + fn render_author_block_inner( 537 + avatar: Option<&str>, 538 + display_name: Option<&str>, 539 + handle: &str, 540 + link_to_profile: bool, 541 + ) -> String { 542 + let mut html = String::new(); 543 + let profile_url = format!("https://bsky.app/profile/{}", handle); 544 + 545 + html.push_str("<span class=\"embed-author\">"); 546 + 547 + if let Some(avatar_url) = avatar { 548 + if link_to_profile { 549 + html.push_str("<a class=\"embed-avatar-link\" href=\""); 550 + html.push_str(&html_escape(&profile_url)); 551 + html.push_str("\" target=\"_blank\" rel=\"noopener\">"); 552 + html.push_str("<img class=\"embed-avatar\" src=\""); 553 + html.push_str(&html_escape(avatar_url)); 554 + html.push_str("\" alt=\"\" />"); 555 + html.push_str("</a>"); 556 + } else { 557 + html.push_str("<img class=\"embed-avatar\" src=\""); 558 + html.push_str(&html_escape(avatar_url)); 559 + html.push_str("\" alt=\"\" />"); 560 + } 561 + } 562 + 563 + html.push_str("<span class=\"embed-author-info\">"); 564 + 565 + if let Some(name) = display_name { 566 + if link_to_profile { 567 + html.push_str("<a class=\"embed-author-name\" href=\""); 568 + html.push_str(&html_escape(&profile_url)); 569 + html.push_str("\" target=\"_blank\" rel=\"noopener\">"); 570 + html.push_str(&html_escape(name)); 571 + html.push_str("</a>"); 572 + } else { 573 + html.push_str("<span class=\"embed-author-name\">"); 574 + html.push_str(&html_escape(name)); 575 + html.push_str("</span>"); 576 + } 577 + } 578 + 579 + if link_to_profile { 580 + html.push_str("<a class=\"embed-author-handle\" href=\""); 581 + html.push_str(&html_escape(&profile_url)); 582 + html.push_str("\" target=\"_blank\" rel=\"noopener\">@"); 583 + html.push_str(&html_escape(handle)); 584 + html.push_str("</a>"); 585 + } else { 586 + html.push_str("<span class=\"embed-author-handle\">@"); 587 + html.push_str(&html_escape(handle)); 588 + html.push_str("</span>"); 589 + } 590 + 591 + html.push_str("</span>"); 592 + html.push_str("</span>"); 593 + 594 + html 595 + } 596 + 597 + /// Render an external link card (title, description, thumbnail) 598 + /// 599 + /// Used for link previews in posts and standalone link embeds. 600 + pub fn render_external_link(external: &ViewExternal<'_>) -> String { 601 + let mut html = String::new(); 602 + 603 + html.push_str("<a class=\"embed-external\" href=\""); 604 + html.push_str(&html_escape(external.uri.as_ref())); 605 + html.push_str("\" target=\"_blank\" rel=\"noopener\">"); 606 + 607 + if let Some(thumb) = &external.thumb { 608 + html.push_str("<img class=\"embed-external-thumb\" src=\""); 609 + html.push_str(&html_escape(thumb.as_ref())); 610 + html.push_str("\" alt=\"\" />"); 611 + } 612 + 613 + html.push_str("<span class=\"embed-external-info\">"); 614 + html.push_str("<span class=\"embed-external-title\">"); 615 + html.push_str(&html_escape(external.title.as_ref())); 616 + html.push_str("</span>"); 617 + 618 + if !external.description.is_empty() { 619 + html.push_str("<span class=\"embed-external-description\">"); 620 + html.push_str(&html_escape(external.description.as_ref())); 621 + html.push_str("</span>"); 622 + } 623 + 624 + html.push_str("<span class=\"embed-external-url\">"); 625 + // Show just the domain 626 + if let Some(domain) = extract_domain(external.uri.as_ref()) { 627 + html.push_str(&html_escape(domain)); 628 + } else { 629 + html.push_str(&html_escape(external.uri.as_ref())); 630 + } 631 + html.push_str("</span>"); 632 + 633 + html.push_str("</span>"); 634 + html.push_str("</a>"); 635 + 636 + html 637 + } 638 + 639 + /// Render an image gallery 640 + /// 641 + /// Used for image embeds in posts. 642 + pub fn render_images(images: &[ViewImage<'_>]) -> String { 643 + let mut html = String::new(); 644 + 645 + let class = match images.len() { 646 + 1 => "embed-images embed-images-1", 647 + 2 => "embed-images embed-images-2", 648 + 3 => "embed-images embed-images-3", 649 + _ => "embed-images embed-images-4", 650 + }; 651 + 652 + html.push_str("<span class=\""); 653 + html.push_str(class); 654 + html.push_str("\">"); 655 + 656 + for img in images { 657 + html.push_str("<a class=\"embed-image-link\" href=\""); 658 + html.push_str(&html_escape(img.fullsize.as_ref())); 659 + html.push_str("\" target=\"_blank\""); 660 + 661 + // Add aspect-ratio style if available 662 + if let Some(aspect) = &img.aspect_ratio { 663 + html.push_str(" style=\"aspect-ratio: "); 664 + html.push_str(&aspect.width.to_string()); 665 + html.push_str(" / "); 666 + html.push_str(&aspect.height.to_string()); 667 + html.push_str(";\""); 668 + } 669 + 670 + html.push_str(">"); 671 + html.push_str("<img class=\"embed-image\" src=\""); 672 + html.push_str(&html_escape(img.thumb.as_ref())); 673 + html.push_str("\" alt=\""); 674 + html.push_str(&html_escape(img.alt.as_ref())); 675 + html.push_str("\" />"); 676 + html.push_str("</a>"); 677 + } 678 + 679 + html.push_str("</span>"); 680 + 681 + html 682 + } 683 + 684 + /// Render a quoted/embedded record 685 + /// 686 + /// Used for quote posts and record embeds. Dispatches based on record type. 687 + pub fn render_quoted_record(record: &ViewRecord<'_>) -> String { 688 + let mut html = String::new(); 689 + 690 + html.push_str("<span class=\"embed-quote\">"); 192 691 193 - // Try common field patterns 194 - if let Some(text) = data.query("/text").single().and_then(|d| d.as_str()) { 195 - html.push_str("<div class=\"record-text\">"); 196 - html.push_str(&html_escape(text)); 197 - html.push_str("</div>"); 692 + // Dispatch based on record type 693 + match record.value.type_discriminator() { 694 + Some("app.bsky.feed.post") => { 695 + // Post - show author and text 696 + html.push_str(&render_author_block(&record.author, true)); 697 + if let Ok(post) = 698 + jacquard::from_data::<weaver_api::app_bsky::feed::post::Post>(&record.value) 699 + { 700 + html.push_str("<span class=\"embed-content\">"); 701 + html.push_str(&html_escape(post.text.as_ref())); 702 + html.push_str("</span>"); 703 + } 704 + } 705 + Some("app.bsky.feed.generator") => { 706 + // 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 + ) 711 + { 712 + html.push_str("<span class=\"embed-type\">Custom Feed</span>"); 713 + html.push_str("<span class=\"embed-author-name\">"); 714 + html.push_str(&html_escape(generator.display_name.as_ref())); 715 + html.push_str("</span>"); 716 + if let Some(desc) = &generator.description { 717 + html.push_str("<span class=\"embed-description\">"); 718 + html.push_str(&html_escape(desc.as_ref())); 719 + html.push_str("</span>"); 720 + } 721 + html.push_str(&render_author_block(&record.author, true)); 722 + } 723 + } 724 + Some("app.bsky.graph.list") => { 725 + // List - show list info 726 + if let Ok(list) = 727 + jacquard::from_data::<weaver_api::app_bsky::graph::list::List>(&record.value) 728 + { 729 + html.push_str("<span class=\"embed-type\">List</span>"); 730 + html.push_str("<span class=\"embed-author-name\">"); 731 + html.push_str(&html_escape(list.name.as_ref())); 732 + html.push_str("</span>"); 733 + if let Some(desc) = &list.description { 734 + html.push_str("<span class=\"embed-description\">"); 735 + html.push_str(&html_escape(desc.as_ref())); 736 + html.push_str("</span>"); 737 + } 738 + html.push_str(&render_author_block(&record.author, true)); 739 + } 740 + } 741 + Some("app.bsky.graph.starterpack") => { 742 + // Starter pack 743 + if let Ok(sp) = jacquard::from_data::<weaver_api::app_bsky::graph::starterpack::Starterpack>(&record.value) { 744 + html.push_str("<span class=\"embed-type\">Starter Pack</span>"); 745 + html.push_str("<span class=\"embed-author-name\">"); 746 + html.push_str(&html_escape(sp.name.as_ref())); 747 + html.push_str("</span>"); 748 + if let Some(desc) = &sp.description { 749 + html.push_str("<span class=\"embed-description\">"); 750 + html.push_str(&html_escape(desc.as_ref())); 751 + html.push_str("</span>"); 752 + } 753 + html.push_str(&render_author_block(&record.author, true)); 754 + } 755 + } 756 + _ => { 757 + // Unknown type - show author and probe for common fields 758 + html.push_str(&render_author_block(&record.author, true)); 759 + html.push_str(&render_generic_data(&record.value)); 760 + } 198 761 } 199 762 200 - if let Some(name) = data.query("/name").single().and_then(|d| d.as_str()) { 201 - html.push_str("<div class=\"record-name\">"); 202 - html.push_str(&html_escape(name)); 203 - html.push_str("</div>"); 763 + // Render nested embeds if present (applies to all types) 764 + if let Some(embeds) = &record.embeds { 765 + for embed in embeds { 766 + html.push_str(&render_view_record_embed(embed)); 767 + } 204 768 } 205 769 206 - if let Some(description) = data.query("/description").single().and_then(|d| d.as_str()) { 207 - html.push_str("<div class=\"record-description\">"); 208 - html.push_str(&html_escape(description)); 209 - html.push_str("</div>"); 770 + html.push_str("</span>"); 771 + 772 + html 773 + } 774 + 775 + /// 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 { 777 + use weaver_api::app_bsky::embed::record::ViewRecordEmbedsItem; 778 + 779 + match embed { 780 + ViewRecordEmbedsItem::ImagesView(images) => render_images(&images.images), 781 + ViewRecordEmbedsItem::ExternalView(external) => render_external_link(&external.external), 782 + ViewRecordEmbedsItem::View(record_view) => render_record_embed(&record_view.record), 783 + ViewRecordEmbedsItem::RecordWithMediaView(rwm) => { 784 + let mut html = String::new(); 785 + // Render media first 786 + match &rwm.media { 787 + weaver_api::app_bsky::embed::record_with_media::ViewMedia::ImagesView(img) => { 788 + html.push_str(&render_images(&img.images)); 789 + } 790 + weaver_api::app_bsky::embed::record_with_media::ViewMedia::ExternalView(ext) => { 791 + html.push_str(&render_external_link(&ext.external)); 792 + } 793 + weaver_api::app_bsky::embed::record_with_media::ViewMedia::VideoView(_) => { 794 + html.push_str("<span class=\"embed-video-placeholder\">[Video]</span>"); 795 + } 796 + weaver_api::app_bsky::embed::record_with_media::ViewMedia::Unknown(_) => {} 797 + } 798 + // Then the record 799 + html.push_str(&render_record_embed(&rwm.record.record)); 800 + html 801 + } 802 + ViewRecordEmbedsItem::VideoView(_) => { 803 + "<span class=\"embed-video-placeholder\">[Video]</span>".to_string() 804 + } 805 + ViewRecordEmbedsItem::Unknown(data) => render_generic_data(data), 210 806 } 807 + } 211 808 212 - // Show record type 213 - if let Some(collection) = uri.collection() { 214 - html.push_str("<div class=\"record-type\">"); 215 - html.push_str(&html_escape(collection.as_ref())); 216 - html.push_str("</div>"); 809 + /// Render a PostViewEmbed (images, external, record, video, etc.) 810 + pub fn render_post_embed(embed: &PostViewEmbed<'_>) -> String { 811 + match embed { 812 + PostViewEmbed::ImagesView(images) => render_images(&images.images), 813 + PostViewEmbed::ExternalView(external) => render_external_link(&external.external), 814 + PostViewEmbed::RecordView(record) => render_record_embed(&record.record), 815 + PostViewEmbed::RecordWithMediaView(rwm) => { 816 + let mut html = String::new(); 817 + // Render media first 818 + match &rwm.media { 819 + weaver_api::app_bsky::embed::record_with_media::ViewMedia::ImagesView(img) => { 820 + html.push_str(&render_images(&img.images)); 821 + } 822 + weaver_api::app_bsky::embed::record_with_media::ViewMedia::ExternalView(ext) => { 823 + html.push_str(&render_external_link(&ext.external)); 824 + } 825 + weaver_api::app_bsky::embed::record_with_media::ViewMedia::VideoView(_) => { 826 + html.push_str("<span class=\"embed-video-placeholder\">[Video]</span>"); 827 + } 828 + weaver_api::app_bsky::embed::record_with_media::ViewMedia::Unknown(_) => {} 829 + } 830 + // Then the record 831 + html.push_str(&render_record_embed(&rwm.record.record)); 832 + html 833 + } 834 + PostViewEmbed::VideoView(_) => { 835 + "<span class=\"embed-video-placeholder\">[Video]</span>".to_string() 836 + } 837 + PostViewEmbed::Unknown(data) => render_generic_data(data), 217 838 } 839 + } 218 840 219 - html.push_str("</div>"); 841 + /// Render a ViewUnionRecord (the actual content of a record embed) 842 + fn render_record_embed(record: &ViewUnionRecord<'_>) -> String { 843 + match record { 844 + ViewUnionRecord::ViewRecord(r) => render_quoted_record(r), 845 + ViewUnionRecord::ViewNotFound(_) => { 846 + "<span class=\"embed-not-found\">Record not found</span>".to_string() 847 + } 848 + ViewUnionRecord::ViewBlocked(_) => { 849 + "<span class=\"embed-blocked\">Content blocked</span>".to_string() 850 + } 851 + ViewUnionRecord::ViewDetached(_) => { 852 + "<span class=\"embed-detached\">Content unavailable</span>".to_string() 853 + } 854 + ViewUnionRecord::GeneratorView(generator) => { 855 + let mut html = String::new(); 856 + html.push_str("<span class=\"embed-record-card\">"); 220 857 221 - Ok(html) 858 + // Icon + title + type (like author block layout) 859 + html.push_str("<span class=\"embed-author\">"); 860 + if let Some(avatar) = &generator.avatar { 861 + html.push_str("<img class=\"embed-avatar\" src=\""); 862 + html.push_str(&html_escape(avatar.as_ref())); 863 + html.push_str("\" alt=\"\" />"); 864 + } 865 + html.push_str("<span class=\"embed-author-info\">"); 866 + html.push_str("<span class=\"embed-author-name\">"); 867 + html.push_str(&html_escape(generator.display_name.as_ref())); 868 + html.push_str("</span>"); 869 + html.push_str("<span class=\"embed-author-handle\">Feed</span>"); 870 + html.push_str("</span>"); 871 + html.push_str("</span>"); 872 + 873 + // Description 874 + if let Some(desc) = &generator.description { 875 + html.push_str("<span class=\"embed-description\">"); 876 + html.push_str(&html_escape(desc.as_ref())); 877 + html.push_str("</span>"); 878 + } 879 + 880 + // Creator 881 + html.push_str(&render_author_block_full(&generator.creator, true)); 882 + 883 + // Stats 884 + if let Some(likes) = generator.like_count { 885 + html.push_str("<span class=\"embed-stats\">"); 886 + html.push_str("<span class=\"embed-stat\">"); 887 + html.push_str(&likes.to_string()); 888 + html.push_str(" likes</span>"); 889 + html.push_str("</span>"); 890 + } 891 + 892 + html.push_str("</span>"); 893 + html 894 + } 895 + ViewUnionRecord::ListView(list) => { 896 + let mut html = String::new(); 897 + html.push_str("<span class=\"embed-record-card\">"); 898 + 899 + // Icon + title + type (like author block layout) 900 + html.push_str("<span class=\"embed-author\">"); 901 + if let Some(avatar) = &list.avatar { 902 + html.push_str("<img class=\"embed-avatar\" src=\""); 903 + html.push_str(&html_escape(avatar.as_ref())); 904 + html.push_str("\" alt=\"\" />"); 905 + } 906 + html.push_str("<span class=\"embed-author-info\">"); 907 + html.push_str("<span class=\"embed-author-name\">"); 908 + html.push_str(&html_escape(list.name.as_ref())); 909 + html.push_str("</span>"); 910 + html.push_str("<span class=\"embed-author-handle\">List</span>"); 911 + html.push_str("</span>"); 912 + html.push_str("</span>"); 913 + 914 + // Description 915 + if let Some(desc) = &list.description { 916 + html.push_str("<span class=\"embed-description\">"); 917 + html.push_str(&html_escape(desc.as_ref())); 918 + html.push_str("</span>"); 919 + } 920 + 921 + // Creator 922 + html.push_str(&render_author_block_full(&list.creator, true)); 923 + 924 + // Stats 925 + if let Some(count) = list.list_item_count { 926 + html.push_str("<span class=\"embed-stats\">"); 927 + html.push_str("<span class=\"embed-stat\">"); 928 + html.push_str(&count.to_string()); 929 + html.push_str(" members</span>"); 930 + html.push_str("</span>"); 931 + } 932 + 933 + html.push_str("</span>"); 934 + html 935 + } 936 + ViewUnionRecord::LabelerView(labeler) => { 937 + let mut html = String::new(); 938 + html.push_str("<span class=\"embed-record-card\">"); 939 + 940 + // Labeler uses creator as the identity, add type label 941 + html.push_str("<span class=\"embed-author\">"); 942 + if let Some(avatar) = &labeler.creator.avatar { 943 + html.push_str("<img class=\"embed-avatar\" src=\""); 944 + html.push_str(&html_escape(avatar.as_ref())); 945 + html.push_str("\" alt=\"\" />"); 946 + } 947 + html.push_str("<span class=\"embed-author-info\">"); 948 + if let Some(name) = &labeler.creator.display_name { 949 + html.push_str("<span class=\"embed-author-name\">"); 950 + html.push_str(&html_escape(name.as_ref())); 951 + html.push_str("</span>"); 952 + } 953 + html.push_str("<span class=\"embed-author-handle\">Labeler</span>"); 954 + html.push_str("</span>"); 955 + html.push_str("</span>"); 956 + 957 + // Stats 958 + if let Some(likes) = labeler.like_count { 959 + html.push_str("<span class=\"embed-stats\">"); 960 + html.push_str("<span class=\"embed-stat\">"); 961 + html.push_str(&likes.to_string()); 962 + html.push_str(" likes</span>"); 963 + html.push_str("</span>"); 964 + } 965 + 966 + html.push_str("</span>"); 967 + html 968 + } 969 + ViewUnionRecord::StarterPackViewBasic(sp) => { 970 + let mut html = String::new(); 971 + html.push_str("<span class=\"embed-record-card\">"); 972 + 973 + // Use author block layout: avatar + info (name, subtitle) 974 + html.push_str("<span class=\"embed-author\">"); 975 + if let Some(avatar) = &sp.creator.avatar { 976 + html.push_str("<img class=\"embed-avatar\" src=\""); 977 + html.push_str(&html_escape(avatar.as_ref())); 978 + html.push_str("\" alt=\"\" />"); 979 + } 980 + html.push_str("<span class=\"embed-author-info\">"); 981 + 982 + // Name as title 983 + if let Some(name) = sp.record.query("name").single().and_then(|d| d.as_str()) { 984 + html.push_str("<span class=\"embed-author-name\">"); 985 + html.push_str(&html_escape(name)); 986 + html.push_str("</span>"); 987 + } 988 + 989 + // "Starter pack by @handle" 990 + html.push_str("<span class=\"embed-author-handle\">by @"); 991 + html.push_str(&html_escape(sp.creator.handle.as_ref())); 992 + html.push_str("</span>"); 993 + 994 + html.push_str("</span>"); // end info 995 + html.push_str("</span>"); // end author 996 + 997 + // Description 998 + if let Some(desc) = sp.record.query("description").single().and_then(|d| d.as_str()) { 999 + html.push_str("<span class=\"embed-description\">"); 1000 + html.push_str(&html_escape(desc)); 1001 + html.push_str("</span>"); 1002 + } 1003 + 1004 + // Stats 1005 + let has_stats = sp.list_item_count.is_some() || sp.joined_all_time_count.is_some(); 1006 + if has_stats { 1007 + html.push_str("<span class=\"embed-stats\">"); 1008 + if let Some(count) = sp.list_item_count { 1009 + html.push_str("<span class=\"embed-stat\">"); 1010 + html.push_str(&count.to_string()); 1011 + html.push_str(" users</span>"); 1012 + } 1013 + if let Some(joined) = sp.joined_all_time_count { 1014 + html.push_str("<span class=\"embed-stat\">"); 1015 + html.push_str(&joined.to_string()); 1016 + html.push_str(" joined</span>"); 1017 + } 1018 + html.push_str("</span>"); 1019 + } 1020 + 1021 + html.push_str("</span>"); 1022 + html 1023 + } 1024 + ViewUnionRecord::Unknown(data) => render_generic_data(data), 1025 + } 1026 + } 1027 + 1028 + /// Render generic/unknown data by iterating fields intelligently 1029 + /// 1030 + /// Used as fallback for Unknown variants of open unions. 1031 + fn render_generic_data(data: &Data<'_>) -> String { 1032 + let mut html = String::new(); 1033 + 1034 + html.push_str("<span class=\"embed-record-card\">"); 1035 + 1036 + // Show record type as header if present 1037 + 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 + html.push_str("<span class=\"embed-author-handle\">"); 1041 + html.push_str(&html_escape(type_name)); 1042 + html.push_str("</span>"); 1043 + } 1044 + 1045 + // Priority fields to show first (in order) 1046 + let priority_fields = ["name", "displayName", "title", "text", "description"]; 1047 + let mut shown_fields = Vec::new(); 1048 + 1049 + if let Some(obj) = data.as_object() { 1050 + for field_name in priority_fields { 1051 + if let Some(value) = obj.get(field_name) { 1052 + if let Some(s) = value.as_str() { 1053 + let class = match field_name { 1054 + "name" | "displayName" | "title" => "embed-author-name", 1055 + "text" => "embed-content", 1056 + "description" => "embed-description", 1057 + _ => "embed-field-value", 1058 + }; 1059 + html.push_str("<span class=\""); 1060 + html.push_str(class); 1061 + html.push_str("\">"); 1062 + html.push_str(&html_escape(s)); 1063 + html.push_str("</span>"); 1064 + shown_fields.push(field_name); 1065 + } 1066 + } 1067 + } 1068 + 1069 + // Show remaining fields as a simple list 1070 + html.push_str("<span class=\"embed-fields\">"); 1071 + for (key, value) in obj.iter() { 1072 + let key_str: &str = key.as_ref(); 1073 + 1074 + // Skip already shown, internal fields, and complex nested objects 1075 + if shown_fields.contains(&key_str) 1076 + || key_str.starts_with('$') 1077 + || key_str == "facets" 1078 + || key_str == "labels" 1079 + { 1080 + continue; 1081 + } 1082 + 1083 + if let Some(formatted) = format_field_value(key_str, value) { 1084 + html.push_str("<span class=\"embed-field\">"); 1085 + html.push_str("<span class=\"embed-field-name\">"); 1086 + html.push_str(&html_escape(&format_field_name(key_str))); 1087 + html.push_str(":</span> "); 1088 + html.push_str(&formatted); 1089 + html.push_str("</span>"); 1090 + } 1091 + } 1092 + html.push_str("</span>"); 1093 + } 1094 + 1095 + html.push_str("</span>"); 1096 + html 1097 + } 1098 + 1099 + /// Format a field name for display (camelCase -> "Camel Case") 1100 + fn format_field_name(name: &str) -> String { 1101 + let mut result = String::new(); 1102 + for (i, c) in name.chars().enumerate() { 1103 + if c.is_uppercase() && i > 0 { 1104 + result.push(' '); 1105 + } 1106 + if i == 0 { 1107 + result.extend(c.to_uppercase()); 1108 + } else { 1109 + result.push(c); 1110 + } 1111 + } 1112 + result 1113 + } 1114 + 1115 + /// Format a field value for display, returning None for complex/unrenderable values 1116 + fn format_field_value(key: &str, value: &Data<'_>) -> Option<String> { 1117 + // String values - detect AT Protocol types 1118 + if let Some(s) = value.as_str() { 1119 + return Some(format_string_value(key, s)); 1120 + } 1121 + 1122 + // Numbers 1123 + if let Some(n) = value.as_integer() { 1124 + return Some(format!("<span class=\"embed-field-number\">{}</span>", n)); 1125 + } 1126 + 1127 + // Booleans 1128 + 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" })); 1131 + } 1132 + 1133 + // Arrays - show count 1134 + 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 + )); 1141 + } 1142 + 1143 + // Skip nested objects - too complex for inline display 1144 + None 1145 + } 1146 + 1147 + /// Format a string value with smart detection of AT Protocol types 1148 + fn format_string_value(key: &str, s: &str) -> String { 1149 + // AT URI - link to record 1150 + if s.starts_with("at://") { 1151 + return format!( 1152 + "<a class=\"embed-field-aturi\" href=\"{}\">{}</a>", 1153 + html_escape(s), 1154 + format_aturi_display(s) 1155 + ); 1156 + } 1157 + 1158 + // DID 1159 + if s.starts_with("did:") { 1160 + return format_did_display(s); 1161 + } 1162 + 1163 + // Regular URL 1164 + if s.starts_with("http://") || s.starts_with("https://") { 1165 + let domain = extract_domain(s).unwrap_or(s); 1166 + return format!( 1167 + "<a class=\"embed-field-link\" href=\"{}\">{}</a>", 1168 + html_escape(s), 1169 + html_escape(domain) 1170 + ); 1171 + } 1172 + 1173 + // Datetime fields - show just the date 1174 + if key.ends_with("At") || key == "createdAt" || key == "indexedAt" { 1175 + let date_part = s.split('T').next().unwrap_or(s); 1176 + return format!("<span class=\"embed-field-date\">{}</span>", html_escape(date_part)); 1177 + } 1178 + 1179 + // NSID (e.g., app.bsky.feed.post) 1180 + if s.contains('.') && s.chars().all(|c| c.is_alphanumeric() || c == '.') && s.matches('.').count() >= 2 { 1181 + return format!("<span class=\"embed-field-nsid\">{}</span>", html_escape(s)); 1182 + } 1183 + 1184 + // 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)); 1187 + } 1188 + 1189 + // Plain string 1190 + html_escape(s) 1191 + } 1192 + 1193 + /// Format an AT URI for display with highlighted parts 1194 + fn format_aturi_display(uri: &str) -> String { 1195 + if let Some(rest) = uri.strip_prefix("at://") { 1196 + let parts: Vec<&str> = rest.splitn(3, '/').collect(); 1197 + let mut result = String::from("<span class=\"aturi-scheme\">at://</span>"); 1198 + 1199 + if !parts.is_empty() { 1200 + result.push_str(&format!("<span class=\"aturi-authority\">{}</span>", html_escape(parts[0]))); 1201 + } 1202 + if parts.len() > 1 { 1203 + result.push_str("<span class=\"aturi-slash\">/</span>"); 1204 + result.push_str(&format!("<span class=\"aturi-collection\">{}</span>", html_escape(parts[1]))); 1205 + } 1206 + if parts.len() > 2 { 1207 + result.push_str("<span class=\"aturi-slash\">/</span>"); 1208 + result.push_str(&format!("<span class=\"aturi-rkey\">{}</span>", html_escape(parts[2]))); 1209 + } 1210 + result 1211 + } else { 1212 + html_escape(uri) 1213 + } 1214 + } 1215 + 1216 + /// Format a DID for display with highlighted parts 1217 + fn format_did_display(did: &str) -> String { 1218 + if let Some(rest) = did.strip_prefix("did:") { 1219 + if let Some((method, identifier)) = rest.split_once(':') { 1220 + return format!( 1221 + "<span class=\"embed-field-did\">\ 1222 + <span class=\"did-scheme\">did:</span>\ 1223 + <span class=\"did-method\">{}</span>\ 1224 + <span class=\"did-separator\">:</span>\ 1225 + <span class=\"did-identifier\">{}</span>\ 1226 + </span>", 1227 + html_escape(method), 1228 + html_escape(identifier) 1229 + ); 1230 + } 1231 + } 1232 + format!("<span class=\"embed-field-did\">{}</span>", html_escape(did)) 1233 + } 1234 + 1235 + // ============================================================================= 1236 + // Helper functions 1237 + // ============================================================================= 1238 + 1239 + /// Extract domain from a URL 1240 + fn extract_domain(url: &str) -> Option<&str> { 1241 + let without_scheme = url 1242 + .strip_prefix("https://") 1243 + .or_else(|| url.strip_prefix("http://"))?; 1244 + without_scheme.split('/').next() 222 1245 } 223 1246 224 1247 /// Simple HTML escaping
+20 -4
crates/weaver-renderer/src/atproto/preprocess.rs
··· 4 4 use jacquard::{ 5 5 client::{Agent, AgentSession, AgentSessionExt}, 6 6 prelude::IdentityResolver, 7 - types::string::{CowStr, Did, Handle}, 7 + types::{ 8 + ident::AtIdentifier, 9 + string::{CowStr, Did, Handle}, 10 + }, 8 11 }; 9 12 use markdown_weaver::{CowStr as MdCowStr, Tag, WeaverAttributes}; 10 13 use std::{path::PathBuf, sync::Arc}; ··· 264 267 265 268 tracing::debug!("Reading image file: {}", file_path.display()); 266 269 if let Ok(image_data) = fs::read(&file_path).await { 267 - tracing::debug!("Read {} bytes from {}", image_data.len(), file_path.display()); 270 + tracing::debug!( 271 + "Read {} bytes from {}", 272 + image_data.len(), 273 + file_path.display() 274 + ); 268 275 // Derive blob name from filename 269 276 let filename = file_path 270 277 .file_stem() ··· 281 288 ); 282 289 283 290 // Upload blob (dereference Arc) 284 - tracing::debug!("Uploading image blob: {} ({} bytes)", file_path.display(), bytes.len()); 291 + tracing::debug!( 292 + "Uploading image blob: {} ({} bytes)", 293 + file_path.display(), 294 + bytes.len() 295 + ); 285 296 if let Ok(blob) = (*self.agent).upload_blob(bytes, mime.clone()).await { 286 297 use jacquard::IntoStatic; 287 298 ··· 468 479 469 480 tracing::debug!("Fetching profile embed: {}", did.as_ref()); 470 481 // Fetch and render the profile 471 - let content = match fetch_and_render_profile(&did, &*self.agent).await { 482 + let content = match fetch_and_render_profile( 483 + &AtIdentifier::Did(did.clone()), 484 + &*self.agent, 485 + ) 486 + .await 487 + { 472 488 Ok(html) => Some(html), 473 489 Err(e) => { 474 490 eprintln!("Failed to fetch profile {}: {}", did.as_ref(), e);
+20 -6
crates/weaver-renderer/src/atproto/writer.rs
··· 174 174 self.write("</code>")?; 175 175 } 176 176 InlineMath(text) => { 177 - self.write(r#"<span class="math math-inline">"#)?; 178 - escape_html(&mut self.writer, &text)?; 179 - self.write("</span>")?; 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 + } 186 + } 180 187 } 181 188 DisplayMath(text) => { 182 - self.write(r#"<span class="math math-display">"#)?; 183 - escape_html(&mut self.writer, &text)?; 184 - self.write("</span>")?; 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 + } 198 + } 185 199 } 186 200 Html(html) | InlineHtml(html) => { 187 201 self.write(&html)?;
+20 -6
crates/weaver-renderer/src/base_html.rs
··· 97 97 self.write("</code>")?; 98 98 } 99 99 InlineMath(text) => { 100 - self.write(r#"<span class="math math-inline">"#)?; 101 - escape_html(&mut self.writer, &text)?; 102 - self.write("</span>")?; 100 + match crate::math::render_math(&text, false) { 101 + crate::math::MathResult::Success(mathml) => { 102 + self.write(r#"<span class="math math-inline">"#)?; 103 + self.write(&mathml)?; 104 + self.write("</span>")?; 105 + } 106 + crate::math::MathResult::Error { html, .. } => { 107 + self.write(&html)?; 108 + } 109 + } 103 110 } 104 111 DisplayMath(text) => { 105 - self.write(r#"<span class="math math-display">"#)?; 106 - escape_html(&mut self.writer, &text)?; 107 - self.write("</span>")?; 112 + match crate::math::render_math(&text, true) { 113 + crate::math::MathResult::Success(mathml) => { 114 + self.write(r#"<span class="math math-display">"#)?; 115 + self.write(&mathml)?; 116 + self.write("</span>")?; 117 + } 118 + crate::math::MathResult::Error { html, .. } => { 119 + self.write(&html)?; 120 + } 121 + } 108 122 } 109 123 Html(html) | InlineHtml(html) => { 110 124 self.write(&html)?;
+595
crates/weaver-renderer/src/css.rs
··· 140 140 text-decoration: underline; 141 141 }} 142 142 143 + /* Wikilink validation (editor) */ 144 + .link-valid {{ 145 + color: var(--color-link); 146 + }} 147 + 148 + .link-broken {{ 149 + color: var(--color-error); 150 + text-decoration: underline wavy; 151 + text-decoration-color: var(--color-error); 152 + opacity: 0.8; 153 + }} 154 + 143 155 /* Selection */ 144 156 ::selection {{ 145 157 background: var(--color-highlight); ··· 259 271 display: block; 260 272 margin: 1rem 0; 261 273 border-radius: 4px; 274 + }} 275 + 276 + /* AT Protocol Embeds - Container */ 277 + /* Light mode: paper with shadow, dark mode: blueprint with borders */ 278 + .atproto-embed {{ 279 + display: block; 280 + position: relative; 281 + max-width: 550px; 282 + margin: 1rem 0; 283 + padding: 1rem; 284 + background: var(--color-surface); 285 + border-left: 2px solid var(--color-secondary); 286 + box-shadow: 0 1px 2px color-mix(in srgb, var(--color-text) 8%, transparent); 287 + }} 288 + 289 + .atproto-embed:hover {{ 290 + border-left-color: var(--color-primary); 291 + }} 292 + 293 + @media (prefers-color-scheme: dark) {{ 294 + .atproto-embed {{ 295 + box-shadow: none; 296 + border: 1px solid var(--color-border); 297 + border-left: 2px solid var(--color-secondary); 298 + }} 299 + }} 300 + 301 + .atproto-embed-placeholder {{ 302 + color: var(--color-muted); 303 + font-style: italic; 304 + }} 305 + 306 + .embed-loading {{ 307 + display: block; 308 + padding: 0.5rem 0; 309 + color: var(--color-subtle); 310 + font-family: var(--font-mono); 311 + font-size: 0.85rem; 312 + }} 313 + 314 + /* Embed Author Block */ 315 + .embed-author {{ 316 + display: flex; 317 + align-items: center; 318 + gap: 0.75rem; 319 + margin-bottom: 0.75rem; 320 + }} 321 + 322 + .embed-avatar {{ 323 + width: 42px; 324 + height: 42px; 325 + min-width: 42px; 326 + min-height: 42px; 327 + margin: 0; 328 + flex-shrink: 0; 329 + object-fit: cover; 330 + }} 331 + 332 + .embed-author-info {{ 333 + display: flex; 334 + flex-direction: column; 335 + gap: 0.1rem; 336 + min-width: 0; 337 + }} 338 + 339 + .embed-avatar-link {{ 340 + display: block; 341 + flex-shrink: 0; 342 + }} 343 + 344 + .embed-author-name {{ 345 + font-weight: 600; 346 + color: var(--color-text); 347 + overflow: hidden; 348 + text-overflow: ellipsis; 349 + white-space: nowrap; 350 + text-decoration: none; 351 + }} 352 + 353 + a.embed-author-name:hover {{ 354 + color: var(--color-link); 355 + }} 356 + 357 + .embed-author-handle {{ 358 + font-size: 0.9em; 359 + font-family: var(--font-mono); 360 + color: var(--color-subtle); 361 + text-decoration: none; 362 + overflow: hidden; 363 + text-overflow: ellipsis; 364 + white-space: nowrap; 365 + }} 366 + 367 + .embed-author-handle:hover {{ 368 + color: var(--color-link); 369 + }} 370 + 371 + /* Card-wide clickable link (sits behind content) */ 372 + .embed-card-link {{ 373 + position: absolute; 374 + inset: 0; 375 + z-index: 0; 376 + }} 377 + 378 + .embed-card-link:focus {{ 379 + outline: 2px solid var(--color-primary); 380 + outline-offset: 2px; 381 + }} 382 + 383 + /* Interactive elements sit above the card link */ 384 + .embed-author, 385 + .embed-external, 386 + .embed-quote, 387 + .embed-images, 388 + .embed-meta {{ 389 + position: relative; 390 + z-index: 1; 391 + }} 392 + 393 + /* Embed Content Block */ 394 + .embed-content {{ 395 + display: block; 396 + color: var(--color-text); 397 + line-height: 1.5; 398 + margin-bottom: 0.75rem; 399 + white-space: pre-wrap; 400 + }} 401 + 402 + .embed-description {{ 403 + display: block; 404 + color: var(--color-text); 405 + font-size: 0.95em; 406 + line-height: 1.4; 407 + }} 408 + 409 + /* Embed Metadata Block */ 410 + .embed-meta {{ 411 + display: flex; 412 + justify-content: space-between; 413 + align-items: center; 414 + font-size: 0.85em; 415 + color: var(--color-muted); 416 + margin-top: 0.75rem; 417 + }} 418 + 419 + .embed-stats {{ 420 + display: flex; 421 + gap: 1rem; 422 + font-family: var(--font-mono); 423 + }} 424 + 425 + .embed-stat {{ 426 + color: var(--color-subtle); 427 + font-size: 0.9em; 428 + }} 429 + 430 + .embed-time {{ 431 + color: var(--color-subtle); 432 + text-decoration: none; 433 + font-family: var(--font-mono); 434 + font-size: 0.9em; 435 + }} 436 + 437 + .embed-time:hover {{ 438 + color: var(--color-link); 439 + }} 440 + 441 + .embed-type {{ 442 + font-size: 0.8em; 443 + color: var(--color-subtle); 444 + font-family: var(--font-mono); 445 + text-transform: uppercase; 446 + letter-spacing: 0.05em; 447 + }} 448 + 449 + /* Embed URL link (shown with syntax in editor) */ 450 + .embed-url {{ 451 + color: var(--color-link); 452 + font-family: var(--font-mono); 453 + font-size: 0.9em; 454 + word-break: break-all; 455 + }} 456 + 457 + /* External link cards */ 458 + .embed-external {{ 459 + display: flex; 460 + gap: 0.75rem; 461 + padding: 0.75rem; 462 + background: var(--color-surface); 463 + border: 1px dashed var(--color-border); 464 + text-decoration: none; 465 + color: inherit; 466 + margin-top: 0.5rem; 467 + }} 468 + 469 + .embed-external:hover {{ 470 + border-left: 2px solid var(--color-primary); 471 + margin-left: -1px; 472 + }} 473 + 474 + @media (prefers-color-scheme: dark) {{ 475 + .embed-external {{ 476 + border: 1px solid var(--color-border); 477 + }} 478 + 479 + .embed-external:hover {{ 480 + border-left: 2px solid var(--color-primary); 481 + margin-left: -1px; 482 + }} 483 + }} 484 + 485 + .embed-external-thumb {{ 486 + width: 120px; 487 + height: 80px; 488 + object-fit: cover; 489 + flex-shrink: 0; 490 + }} 491 + 492 + .embed-external-info {{ 493 + display: flex; 494 + flex-direction: column; 495 + gap: 0.25rem; 496 + min-width: 0; 497 + }} 498 + 499 + .embed-external-title {{ 500 + font-weight: 600; 501 + color: var(--color-text); 502 + overflow: hidden; 503 + text-overflow: ellipsis; 504 + white-space: nowrap; 505 + }} 506 + 507 + .embed-external-description {{ 508 + font-size: 0.9em; 509 + color: var(--color-muted); 510 + overflow: hidden; 511 + text-overflow: ellipsis; 512 + display: -webkit-box; 513 + -webkit-line-clamp: 2; 514 + -webkit-box-orient: vertical; 515 + }} 516 + 517 + .embed-external-url {{ 518 + font-size: 0.8em; 519 + font-family: var(--font-mono); 520 + color: var(--color-subtle); 521 + }} 522 + 523 + /* Image embeds */ 524 + .embed-images {{ 525 + display: grid; 526 + gap: 4px; 527 + margin-top: 0.5rem; 528 + overflow: hidden; 529 + }} 530 + 531 + .embed-images-1 {{ 532 + grid-template-columns: 1fr; 533 + }} 534 + 535 + .embed-images-2 {{ 536 + grid-template-columns: 1fr 1fr; 537 + }} 538 + 539 + .embed-images-3 {{ 540 + grid-template-columns: 1fr 1fr; 541 + }} 542 + 543 + .embed-images-4 {{ 544 + grid-template-columns: 1fr 1fr; 545 + }} 546 + 547 + .embed-image-link {{ 548 + display: block; 549 + line-height: 0; 550 + }} 551 + 552 + .embed-image {{ 553 + width: 100%; 554 + height: auto; 555 + max-height: 500px; 556 + object-fit: cover; 557 + object-position: center; 558 + margin: 0; 559 + }} 560 + 561 + /* Quoted records */ 562 + .embed-quote {{ 563 + display: block; 564 + margin-top: 0.5rem; 565 + padding: 0.75rem; 566 + background: var(--color-overlay); 567 + border-left: 2px solid var(--color-tertiary); 568 + }} 569 + 570 + @media (prefers-color-scheme: dark) {{ 571 + .embed-quote {{ 572 + border: 1px solid var(--color-border); 573 + border-left: 2px solid var(--color-tertiary); 574 + }} 575 + }} 576 + 577 + .embed-quote .embed-author {{ 578 + margin-bottom: 0.5rem; 579 + }} 580 + 581 + .embed-quote .embed-avatar {{ 582 + width: 24px; 583 + height: 24px; 584 + min-width: 24px; 585 + min-height: 24px; 586 + }} 587 + 588 + .embed-quote .embed-content {{ 589 + font-size: 0.95em; 590 + margin-bottom: 0; 591 + }} 592 + 593 + /* Placeholder states */ 594 + .embed-video-placeholder, 595 + .embed-not-found, 596 + .embed-blocked, 597 + .embed-detached, 598 + .embed-unknown {{ 599 + display: block; 600 + padding: 1rem; 601 + background: var(--color-overlay); 602 + border-left: 2px solid var(--color-border); 603 + color: var(--color-muted); 604 + font-style: italic; 605 + margin-top: 0.5rem; 606 + font-family: var(--font-mono); 607 + font-size: 0.9em; 608 + }} 609 + 610 + @media (prefers-color-scheme: dark) {{ 611 + .embed-video-placeholder, 612 + .embed-not-found, 613 + .embed-blocked, 614 + .embed-detached, 615 + .embed-unknown {{ 616 + border: 1px dashed var(--color-border); 617 + }} 618 + }} 619 + 620 + /* Record card embeds (feeds, lists, labelers, starter packs) */ 621 + .embed-record-card {{ 622 + display: block; 623 + margin-top: 0.5rem; 624 + padding: 0.75rem; 625 + background: var(--color-overlay); 626 + border-left: 2px solid var(--color-tertiary); 627 + }} 628 + 629 + .embed-record-card > .embed-author-name {{ 630 + display: block; 631 + font-size: 1.1em; 632 + }} 633 + 634 + .embed-subtitle {{ 635 + display: block; 636 + font-size: 0.85em; 637 + color: var(--color-muted); 638 + margin-bottom: 0.5rem; 639 + }} 640 + 641 + .embed-record-card .embed-description {{ 642 + display: block; 643 + margin: 0.5rem 0; 644 + }} 645 + 646 + .embed-record-card .embed-stats {{ 647 + display: block; 648 + margin-top: 0.25rem; 649 + }} 650 + 651 + /* Generic record fields */ 652 + .embed-fields {{ 653 + display: block; 654 + margin-top: 0.5rem; 655 + font-size: 0.85em; 656 + color: var(--color-muted); 657 + }} 658 + 659 + .embed-field {{ 660 + display: block; 661 + margin-top: 0.25rem; 662 + }} 663 + 664 + .embed-field-name {{ 665 + color: var(--color-subtle); 666 + }} 667 + 668 + .embed-field-number {{ 669 + color: var(--color-tertiary); 670 + }} 671 + 672 + .embed-field-date {{ 673 + color: var(--color-muted); 674 + }} 675 + 676 + .embed-field-count {{ 677 + color: var(--color-muted); 678 + font-style: italic; 679 + }} 680 + 681 + .embed-field-bool-true {{ 682 + color: var(--color-success); 683 + }} 684 + 685 + .embed-field-bool-false {{ 686 + color: var(--color-muted); 687 + }} 688 + 689 + .embed-field-link, 690 + .embed-field-aturi {{ 691 + color: var(--color-link); 692 + text-decoration: none; 693 + }} 694 + 695 + .embed-field-link:hover, 696 + .embed-field-aturi:hover {{ 697 + text-decoration: underline; 698 + }} 699 + 700 + .embed-field-did {{ 701 + font-family: var(--font-mono); 702 + font-size: 0.9em; 703 + }} 704 + 705 + .embed-field-did .did-scheme, 706 + .embed-field-did .did-separator {{ 707 + color: var(--color-muted); 708 + }} 709 + 710 + .embed-field-did .did-method {{ 711 + color: var(--color-tertiary); 712 + }} 713 + 714 + .embed-field-did .did-identifier {{ 715 + color: var(--color-text); 716 + }} 717 + 718 + .embed-field-nsid {{ 719 + color: var(--color-secondary); 720 + }} 721 + 722 + .embed-field-handle {{ 723 + color: var(--color-link); 724 + }} 725 + 726 + /* AT URI highlighting */ 727 + .aturi-scheme {{ 728 + color: var(--color-muted); 729 + }} 730 + 731 + .aturi-slash {{ 732 + color: var(--color-muted); 733 + }} 734 + 735 + .aturi-authority {{ 736 + color: var(--color-link); 737 + }} 738 + 739 + .aturi-collection {{ 740 + color: var(--color-secondary); 741 + }} 742 + 743 + .aturi-rkey {{ 744 + color: var(--color-tertiary); 745 + }} 746 + 747 + /* Generic AT Protocol record embed */ 748 + .atproto-record > .embed-author-handle {{ 749 + display: block; 750 + margin-bottom: 0.25rem; 751 + text-transform: capitalize; 752 + }} 753 + 754 + .atproto-record > .embed-author-name {{ 755 + display: block; 756 + margin-bottom: 0.5rem; 757 + }} 758 + 759 + .atproto-record > .embed-content {{ 760 + margin-bottom: 0.5rem; 761 + }} 762 + 763 + /* Notebook entry embed - full width, expandable */ 764 + .atproto-entry {{ 765 + max-width: none; 766 + width: 100%; 767 + margin: 1.5rem 0; 768 + padding: 0; 769 + background: var(--color-surface); 770 + border: 1px solid var(--color-border); 771 + border-left: 1px solid var(--color-border); 772 + box-shadow: none; 773 + overflow: hidden; 774 + }} 775 + 776 + .atproto-entry:hover {{ 777 + border-left-color: var(--color-border); 778 + }} 779 + 780 + @media (prefers-color-scheme: dark) {{ 781 + .atproto-entry {{ 782 + border: 1px solid var(--color-border); 783 + border-left: 1px solid var(--color-border); 784 + }} 785 + }} 786 + 787 + .embed-entry-header {{ 788 + display: flex; 789 + flex-wrap: wrap; 790 + align-items: baseline; 791 + gap: 0.5rem 1rem; 792 + padding: 0.75rem 1rem; 793 + background: var(--color-overlay); 794 + border-bottom: 1px solid var(--color-border); 795 + }} 796 + 797 + .embed-entry-title {{ 798 + font-size: 1.1em; 799 + font-weight: 600; 800 + color: var(--color-text); 801 + }} 802 + 803 + .embed-entry-author {{ 804 + font-size: 0.85em; 805 + color: var(--color-muted); 806 + }} 807 + 808 + /* Hidden checkbox for expand/collapse */ 809 + .embed-entry-toggle {{ 810 + display: none; 811 + }} 812 + 813 + /* Content wrapper - scrollable when collapsed */ 814 + .embed-entry-content {{ 815 + max-height: 30rem; 816 + overflow-y: auto; 817 + padding: 1rem; 818 + transition: max-height 0.3s ease; 819 + }} 820 + 821 + /* When checkbox is checked, expand fully */ 822 + .embed-entry-toggle:checked ~ .embed-entry-content {{ 823 + max-height: none; 824 + }} 825 + 826 + /* Expand/collapse button */ 827 + .embed-entry-expand {{ 828 + display: block; 829 + width: 100%; 830 + padding: 0.5rem; 831 + text-align: center; 832 + font-size: 0.85em; 833 + color: var(--color-muted); 834 + background: var(--color-overlay); 835 + border-top: 1px solid var(--color-border); 836 + cursor: pointer; 837 + user-select: none; 838 + }} 839 + 840 + .embed-entry-expand:hover {{ 841 + color: var(--color-text); 842 + background: var(--color-surface); 843 + }} 844 + 845 + /* Toggle button text */ 846 + .embed-entry-expand::before {{ 847 + content: "Expand ↓"; 848 + }} 849 + 850 + .embed-entry-toggle:checked ~ .embed-entry-expand::before {{ 851 + content: "Collapse ↑"; 852 + }} 853 + 854 + /* Hide expand button if content doesn't overflow (via JS class) */ 855 + .atproto-entry.no-overflow .embed-entry-expand {{ 856 + display: none; 262 857 }} 263 858 264 859 /* Horizontal Rule */
+1
crates/weaver-renderer/src/lib.rs
··· 27 27 pub mod base_html; 28 28 pub mod code_pretty; 29 29 pub mod css; 30 + pub mod math; 30 31 #[cfg(not(target_family = "wasm"))] 31 32 pub mod static_site; 32 33 pub mod theme;
+117
crates/weaver-renderer/src/math.rs
··· 1 + //! LaTeX math rendering via pulldown-latex → MathML 2 + 3 + use markdown_weaver_escape::escape_html; 4 + use pulldown_latex::{ 5 + config::DisplayMode, config::RenderConfig, mathml::push_mathml, Parser, Storage, 6 + }; 7 + 8 + /// Result of attempting to render LaTeX math 9 + pub enum MathResult { 10 + /// Successfully rendered MathML 11 + Success(String), 12 + /// Rendering failed - contains fallback HTML with source and error message 13 + Error { html: String, message: String }, 14 + } 15 + 16 + /// Render LaTeX math to MathML 17 + /// 18 + /// # Arguments 19 + /// * `latex` - The LaTeX source string (without delimiters like $ or $$) 20 + /// * `display_mode` - If true, render as display math (block); if false, inline 21 + pub fn render_math(latex: &str, display_mode: bool) -> MathResult { 22 + let storage = Storage::new(); 23 + let parser = Parser::new(latex, &storage); 24 + let config = RenderConfig { 25 + display_mode: if display_mode { 26 + DisplayMode::Block 27 + } else { 28 + DisplayMode::Inline 29 + }, 30 + ..Default::default() 31 + }; 32 + 33 + let mut mathml = String::new(); 34 + 35 + // Collect events, tracking any errors 36 + let events: Vec<_> = parser.collect(); 37 + let errors: Vec<String> = events 38 + .iter() 39 + .filter_map(|e| e.as_ref().err().map(|err| err.to_string())) 40 + .collect(); 41 + 42 + if errors.is_empty() { 43 + // All events parsed successfully - push_mathml wants the Results directly 44 + if let Err(e) = push_mathml(&mut mathml, events.into_iter(), config) { 45 + return MathResult::Error { 46 + html: format_error_html(latex, &e.to_string(), display_mode), 47 + message: e.to_string(), 48 + }; 49 + } 50 + MathResult::Success(mathml) 51 + } else { 52 + // Had parse errors - return error HTML 53 + let error_msg = errors.join("; "); 54 + MathResult::Error { 55 + html: format_error_html(latex, &error_msg, display_mode), 56 + message: error_msg, 57 + } 58 + } 59 + } 60 + 61 + fn format_error_html(latex: &str, error: &str, display_mode: bool) -> String { 62 + let mode_class = if display_mode { 63 + "math-display" 64 + } else { 65 + "math-inline" 66 + }; 67 + let mut escaped_latex = String::new(); 68 + let mut escaped_error = String::new(); 69 + // These won't fail writing to String 70 + let _ = escape_html(&mut escaped_latex, latex); 71 + let _ = escape_html(&mut escaped_error, error); 72 + format!( 73 + r#"<span class="math math-error {mode_class}" title="{escaped_error}"><code>{escaped_latex}</code></span>"# 74 + ) 75 + } 76 + 77 + #[cfg(test)] 78 + mod tests { 79 + use super::*; 80 + 81 + #[test] 82 + fn renders_inline_math() { 83 + let result = render_math("x^2", false); 84 + assert!(matches!(result, MathResult::Success(_))); 85 + if let MathResult::Success(mathml) = result { 86 + assert!(mathml.contains("<math")); 87 + assert!(mathml.contains("</math>")); 88 + } 89 + } 90 + 91 + #[test] 92 + fn renders_display_math() { 93 + let result = render_math(r"\frac{a}{b}", true); 94 + assert!(matches!(result, MathResult::Success(_))); 95 + if let MathResult::Success(mathml) = result { 96 + assert!(mathml.contains("<math")); 97 + assert!(mathml.contains("<mfrac")); 98 + } 99 + } 100 + 101 + #[test] 102 + fn renders_complex_math() { 103 + let result = render_math(r"\sum_{i=0}^{n} x_i", true); 104 + assert!(matches!(result, MathResult::Success(_))); 105 + } 106 + 107 + #[test] 108 + fn handles_invalid_latex() { 109 + // Unclosed brace 110 + let result = render_math(r"\frac{a", false); 111 + assert!(matches!(result, MathResult::Error { .. })); 112 + if let MathResult::Error { html, message } = result { 113 + assert!(html.contains("math-error")); 114 + assert!(!message.is_empty()); 115 + } 116 + } 117 + }
+3 -3
crates/weaver-renderer/src/static_site/snapshots/weaver_renderer__static_site__tests__code_block_rendering.snap
··· 2 2 source: crates/weaver-renderer/src/static_site/tests.rs 3 3 expression: output 4 4 --- 5 - <pre><code class="wvrcode-code language-Rust"><span class="wvrcode-source wvrcode-rust"><span class="wvrcode-meta wvrcode-function wvrcode-rust"><span class="wvrcode-meta wvrcode-function wvrcode-rust"><span class="wvrcode-storage wvrcode-type wvrcode-function wvrcode-rust">fn</span> </span><span class="wvrcode-entity wvrcode-name wvrcode-function wvrcode-rust">main</span></span><span class="wvrcode-meta wvrcode-function wvrcode-rust"><span class="wvrcode-meta wvrcode-function wvrcode-parameters wvrcode-rust"><span class="wvrcode-punctuation wvrcode-section wvrcode-parameters wvrcode-begin wvrcode-rust">(</span></span><span class="wvrcode-meta wvrcode-function wvrcode-rust"><span class="wvrcode-meta wvrcode-function wvrcode-parameters wvrcode-rust"><span class="wvrcode-punctuation wvrcode-section wvrcode-parameters wvrcode-end wvrcode-rust">)</span></span></span></span><span class="wvrcode-meta wvrcode-function wvrcode-rust"> </span><span class="wvrcode-meta wvrcode-function wvrcode-rust"><span class="wvrcode-meta wvrcode-block wvrcode-rust"><span class="wvrcode-punctuation wvrcode-section wvrcode-block wvrcode-begin wvrcode-rust">{</span> 6 - <span class="wvrcode-support wvrcode-macro wvrcode-rust">println!</span><span class="wvrcode-meta wvrcode-group wvrcode-rust"><span class="wvrcode-punctuation wvrcode-section wvrcode-group wvrcode-begin wvrcode-rust">(</span></span><span class="wvrcode-meta wvrcode-group wvrcode-rust"><span class="wvrcode-string wvrcode-quoted wvrcode-double wvrcode-rust"><span class="wvrcode-punctuation wvrcode-definition wvrcode-string wvrcode-begin wvrcode-rust">&quot;</span>Hello<span class="wvrcode-punctuation wvrcode-definition wvrcode-string wvrcode-end wvrcode-rust">&quot;</span></span></span><span class="wvrcode-meta wvrcode-group wvrcode-rust"><span class="wvrcode-punctuation wvrcode-section wvrcode-group wvrcode-end wvrcode-rust">)</span></span><span class="wvrcode-punctuation wvrcode-terminator wvrcode-rust">;</span> 7 - </span><span class="wvrcode-meta wvrcode-block wvrcode-rust"><span class="wvrcode-punctuation wvrcode-section wvrcode-block wvrcode-end wvrcode-rust">}</span></span></span> 5 + <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> 6 + <span class="wvc-support wvc-macro wvc-rust">println!</span><span class="wvc-meta wvc-group wvc-rust"><span class="wvc-punctuation wvc-section wvc-group wvc-begin wvc-rust">(</span></span><span class="wvc-meta wvc-group wvc-rust"><span class="wvc-string wvc-quoted wvc-double wvc-rust"><span class="wvc-punctuation wvc-definition wvc-string wvc-begin wvc-rust">&quot;</span>Hello<span class="wvc-punctuation wvc-definition wvc-string wvc-end wvc-rust">&quot;</span></span></span><span class="wvc-meta wvc-group wvc-rust"><span class="wvc-punctuation wvc-section wvc-group wvc-end wvc-rust">)</span></span><span class="wvc-punctuation wvc-terminator wvc-rust">;</span> 7 + </span><span class="wvc-meta wvc-block wvc-rust"><span class="wvc-punctuation wvc-section wvc-block wvc-end wvc-rust">}</span></span></span> 8 8 </span></code></pre>
+7 -3
lexicons/notebook/entry.json
··· 13 13 "title": { "type": "ref", "ref": "sh.weaver.notebook.defs#title" }, 14 14 "path": { "type": "ref", "ref": "sh.weaver.notebook.defs#path" }, 15 15 "tags": { "type": "ref", "ref": "sh.weaver.notebook.defs#tags" }, 16 - 16 + "authors": { 17 + "type": "array", 18 + "items": { 19 + "type": "ref", 20 + "ref": "sh.weaver.actor.defs#author" 21 + } 22 + }, 17 23 "content": { 18 24 "type": "string", 19 - "maxLength": 200000, 20 25 "description": "The content of the notebook entry. This should be some flavor of Markdown." 21 26 }, 22 - 23 27 "createdAt": { 24 28 "type": "string", 25 29 "format": "datetime",
+46 -7
lexicons/notebook/theme.json
··· 28 28 "fonts": { 29 29 "type": "object", 30 30 "required": ["body", "heading", "monospace"], 31 + "description": "Fonts to be used in the notebook. Can specify a name or list of names (will load if available) or a file or list of files for each. Empty lists will use site defaults." 31 32 "properties": { 32 33 "body": { 33 - "type": "string" 34 + "type": "array", 35 + "items": { 36 + "type": "ref", 37 + "ref": "#font" 38 + } 34 39 }, 35 40 "heading": { 36 - "type": "string" 41 + "type": "array", 42 + "items": { 43 + "type": "ref", 44 + "ref": "#font" 45 + } 37 46 }, 38 47 "monospace": { 39 - "type": "string" 48 + "type": "array", 49 + "items": { 50 + "type": "ref", 51 + "ref": "#font" 52 + } 40 53 } 41 54 } 42 55 }, ··· 73 86 }, 74 87 "codeThemeFile": { 75 88 "type": "object", 76 - "required": ["name", "did", "content"], 89 + "required": ["name", "content"], 90 + "description": "Custom syntax highlighting theme file (sublime text/textmate theme format)", 77 91 "properties": { 78 92 "name": { 79 93 "type": "string" 80 94 }, 81 - "did": { 82 - "type": "string", 83 - "format": "did" 95 + "content": { 96 + "type": "blob", 97 + "accept": ["*/*"], 98 + "maxSize": 20000 99 + } 100 + } 101 + }, 102 + "font": { 103 + "type": "object", 104 + "required": ["value"], 105 + "properties": { 106 + "value": { 107 + "type": "union", 108 + "refs": ["#fontName", "#fontFile"], 109 + "description": "Font for a notebook" 110 + } 111 + } 112 + }, 113 + "fontName": { 114 + "type": "string" 115 + }, 116 + "fontFile": { 117 + "type": "object", 118 + "required": ["name", "content"], 119 + "description": "Custom woff(2) or ttf font file" 120 + "properties": { 121 + "name": { 122 + "type": "string" 84 123 }, 85 124 "content": { 86 125 "type": "blob",