at main 443 lines 14 kB view raw
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 6use std::collections::HashMap; 7 8use jacquard::CowStr; 9use jacquard::smol_str::SmolStr; 10use jacquard::types::string::AtUri; 11use 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)] 17pub 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)] 28pub 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 35impl 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)] 91pub 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 98impl 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)] 169pub 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)] 190pub struct RefCollector { 191 pub refs: Vec<ExtractedRef>, 192} 193 194impl 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. 255pub fn collect_refs_from_markdown(markdown: &str) -> Vec<ExtractedRef> { 256 use markdown_weaver::{Event, LinkType, Options, Parser, Tag}; 257 258 let mut collector = RefCollector::new(); 259 let options = Options::all(); 260 let parser = Parser::new_ext(markdown, options); 261 262 for event in parser { 263 match event { 264 Event::Start(Tag::Link { 265 link_type, 266 dest_url, 267 .. 268 }) => { 269 let url = dest_url.as_ref(); 270 271 if matches!(link_type, LinkType::WikiLink { .. }) { 272 let (target, fragment) = match url.split_once('#') { 273 Some((t, f)) => (t, Some(f)), 274 None => (url, None), 275 }; 276 collector.add_wikilink(target, fragment, None); 277 } else if url.starts_with("at://") { 278 collector.add_at_link(url); 279 } 280 } 281 Event::Start(Tag::Embed { 282 dest_url, title, .. 283 }) => { 284 let url = dest_url.as_ref(); 285 286 if url.starts_with("at://") || url.starts_with("did:") { 287 let alt = if title.is_empty() { 288 None 289 } else { 290 Some(title.as_ref()) 291 }; 292 collector.add_at_embed(url, alt); 293 } else if !url.starts_with("http://") && !url.starts_with("https://") { 294 let (target, fragment) = match url.split_once('#') { 295 Some((t, f)) => (t, Some(f)), 296 None => (url, None), 297 }; 298 collector.add_wikilink(target, fragment, None); 299 } 300 } 301 Event::Start(Tag::Image { 302 dest_url, title, .. 303 }) => { 304 let url = dest_url.as_ref(); 305 306 if url.starts_with("at://") { 307 let alt = if title.is_empty() { 308 None 309 } else { 310 Some(title.as_ref()) 311 }; 312 collector.add_at_embed(url, alt); 313 } 314 } 315 _ => {} 316 } 317 } 318 319 collector.take() 320} 321 322#[cfg(test)] 323mod tests { 324 use super::*; 325 use jacquard::IntoStatic; 326 327 #[test] 328 fn test_entry_index_resolve_by_title() { 329 let mut index = EntryIndex::new(); 330 index.add_entry( 331 "My First Note", 332 "my_first_note", 333 "/alice/notebook/my_first_note", 334 ); 335 336 let result = index.resolve("My First Note"); 337 assert!(result.is_some()); 338 let (path, title, fragment) = result.unwrap(); 339 assert_eq!(path, "/alice/notebook/my_first_note"); 340 assert_eq!(title, "My First Note"); 341 assert_eq!(fragment, None); 342 } 343 344 #[test] 345 fn test_entry_index_resolve_case_insensitive() { 346 let mut index = EntryIndex::new(); 347 index.add_entry( 348 "My First Note", 349 "my_first_note", 350 "/alice/notebook/my_first_note", 351 ); 352 353 let result = index.resolve("my first note"); 354 assert!(result.is_some()); 355 } 356 357 #[test] 358 fn test_entry_index_resolve_by_path() { 359 let mut index = EntryIndex::new(); 360 index.add_entry( 361 "My First Note", 362 "my_first_note", 363 "/alice/notebook/my_first_note", 364 ); 365 366 let result = index.resolve("my_first_note"); 367 assert!(result.is_some()); 368 } 369 370 #[test] 371 fn test_entry_index_resolve_with_fragment() { 372 let mut index = EntryIndex::new(); 373 index.add_entry("My Note", "my_note", "/alice/notebook/my_note"); 374 375 let result = index.resolve("My Note#section"); 376 assert!(result.is_some()); 377 let (path, title, fragment) = result.unwrap(); 378 assert_eq!(path, "/alice/notebook/my_note"); 379 assert_eq!(title, "My Note"); 380 assert_eq!(fragment, Some("section")); 381 } 382 383 #[test] 384 fn test_collect_refs_wikilink() { 385 let markdown = "Check out [[My Note]] for more info."; 386 let refs = collect_refs_from_markdown(markdown); 387 388 assert_eq!(refs.len(), 1); 389 assert!(matches!( 390 &refs[0], 391 ExtractedRef::Wikilink { target, .. } if target == "My Note" 392 )); 393 } 394 395 #[test] 396 fn test_collect_refs_at_link() { 397 let markdown = "See [this post](at://did:plc:xyz/app.bsky.feed.post/abc)"; 398 let refs = collect_refs_from_markdown(markdown); 399 400 assert_eq!(refs.len(), 1); 401 assert!(matches!( 402 &refs[0], 403 ExtractedRef::AtLink { uri } if uri == "at://did:plc:xyz/app.bsky.feed.post/abc" 404 )); 405 } 406 407 #[test] 408 fn test_collect_refs_at_embed() { 409 let markdown = "![[at://did:plc:xyz/app.bsky.feed.post/abc]]"; 410 let refs = collect_refs_from_markdown(markdown); 411 412 assert_eq!(refs.len(), 1); 413 assert!(matches!( 414 &refs[0], 415 ExtractedRef::AtEmbed { uri, .. } if uri == "at://did:plc:xyz/app.bsky.feed.post/abc" 416 )); 417 } 418 419 #[test] 420 fn test_resolved_content_wikilink_lookup() { 421 let mut content = ResolvedContent::new(); 422 content.add_entry("My Note", "/alice/notebook/my_note", "My Note"); 423 424 let result = content.resolve_wikilink("my note"); 425 assert!(result.is_some()); 426 assert_eq!( 427 result.unwrap().canonical_path.as_ref(), 428 "/alice/notebook/my_note" 429 ); 430 } 431 432 #[test] 433 fn test_resolved_content_embed_lookup() { 434 let mut content = ResolvedContent::new(); 435 let uri = AtUri::new("at://did:plc:xyz/app.bsky.feed.post/abc").unwrap(); 436 content.add_embed(uri.into_static(), "<div>post content</div>", None); 437 438 let lookup_uri = AtUri::new("at://did:plc:xyz/app.bsky.feed.post/abc").unwrap(); 439 let result = content.get_embed_content(&lookup_uri); 440 assert!(result.is_some()); 441 assert_eq!(result.unwrap(), "<div>post content</div>"); 442 } 443}