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