atproto blogging
1//! Rendering traits for the editor.
2//!
3//! These traits abstract over external concerns during rendering:
4//! - Resolving embed URLs to HTML content
5//! - Resolving image URLs to CDN paths
6//! - Validating wikilinks
7//!
8//! Implementations are provided by the consuming application (e.g., weaver-app).
9
10use markdown_weaver::Tag;
11
12/// Provides HTML content for embedded resources.
13///
14/// When rendering markdown with embeds (e.g., `![[at://...]]`), this trait
15/// is consulted to get the pre-rendered HTML for the embed.
16///
17/// The full `Tag::Embed` is provided so implementations can access all context:
18/// embed_type, dest_url, title, id, and attrs.
19pub trait EmbedContentProvider {
20 /// Get HTML content for an embed tag.
21 ///
22 /// Returns `Some(html)` if the embed content is available,
23 /// `None` to render a placeholder.
24 fn get_embed_content(&self, tag: &Tag<'_>) -> Option<String>;
25}
26
27/// Unit type implementation - no embeds available.
28impl EmbedContentProvider for () {
29 fn get_embed_content(&self, _tag: &Tag<'_>) -> Option<String> {
30 None
31 }
32}
33
34/// Resolves image URLs from markdown to actual paths.
35///
36/// Markdown may reference images by name (e.g., `/image/photo.jpg`).
37/// This trait maps those to actual CDN URLs or data URLs.
38pub trait ImageResolver {
39 /// Resolve an image URL from markdown to an actual URL.
40 ///
41 /// Returns `Some(resolved_url)` if the image is found,
42 /// `None` to use the original URL unchanged.
43 fn resolve_image_url(&self, url: &str) -> Option<String>;
44}
45
46/// Unit type implementation - no image resolution.
47impl ImageResolver for () {
48 fn resolve_image_url(&self, _url: &str) -> Option<String> {
49 None
50 }
51}
52
53/// Validates wikilinks during rendering.
54///
55/// Used to add CSS classes indicating whether a wikilink target exists.
56pub trait WikilinkValidator {
57 /// Check if a wikilink target is valid (exists).
58 fn is_valid_link(&self, target: &str) -> bool;
59}
60
61/// Unit type implementation - all links are valid.
62impl WikilinkValidator for () {
63 fn is_valid_link(&self, _target: &str) -> bool {
64 true
65 }
66}
67
68/// Reference implementations for common patterns.
69
70impl<T: EmbedContentProvider> EmbedContentProvider for &T {
71 fn get_embed_content(&self, tag: &Tag<'_>) -> Option<String> {
72 (*self).get_embed_content(tag)
73 }
74}
75
76impl<T: ImageResolver> ImageResolver for &T {
77 fn resolve_image_url(&self, url: &str) -> Option<String> {
78 (*self).resolve_image_url(url)
79 }
80}
81
82impl<T: WikilinkValidator> WikilinkValidator for &T {
83 fn is_valid_link(&self, target: &str) -> bool {
84 (*self).is_valid_link(target)
85 }
86}
87
88impl<T: EmbedContentProvider> EmbedContentProvider for Option<T> {
89 fn get_embed_content(&self, tag: &Tag<'_>) -> Option<String> {
90 self.as_ref().and_then(|p| p.get_embed_content(tag))
91 }
92}
93
94impl<T: ImageResolver> ImageResolver for Option<T> {
95 fn resolve_image_url(&self, url: &str) -> Option<String> {
96 self.as_ref().and_then(|r| r.resolve_image_url(url))
97 }
98}
99
100impl<T: WikilinkValidator> WikilinkValidator for Option<T> {
101 fn is_valid_link(&self, target: &str) -> bool {
102 self.as_ref().map(|v| v.is_valid_link(target)).unwrap_or(true)
103 }
104}
105
106/// Implementation for ResolvedContent from weaver-common.
107///
108/// Resolves AT Protocol embeds by looking up the content in the ResolvedContent map.
109impl EmbedContentProvider for weaver_common::ResolvedContent {
110 fn get_embed_content(&self, tag: &Tag<'_>) -> Option<String> {
111 if let Tag::Embed { dest_url, .. } = tag {
112 let url = dest_url.as_ref();
113 if url.starts_with("at://") {
114 if let Ok(at_uri) = jacquard::types::string::AtUri::new(url) {
115 return weaver_common::ResolvedContent::get_embed_content(self, &at_uri)
116 .map(|s| s.to_string());
117 }
118 }
119 }
120 None
121 }
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127 use markdown_weaver::EmbedType;
128
129 struct TestEmbedProvider;
130
131 impl EmbedContentProvider for TestEmbedProvider {
132 fn get_embed_content(&self, tag: &Tag<'_>) -> Option<String> {
133 if let Tag::Embed { dest_url, .. } = tag {
134 if dest_url.as_ref() == "at://test/embed" {
135 return Some("<div>Test Embed</div>".to_string());
136 }
137 }
138 None
139 }
140 }
141
142 struct TestImageResolver;
143
144 impl ImageResolver for TestImageResolver {
145 fn resolve_image_url(&self, url: &str) -> Option<String> {
146 if url.starts_with("/image/") {
147 Some(format!("https://cdn.example.com{}", url))
148 } else {
149 None
150 }
151 }
152 }
153
154 struct TestWikilinkValidator {
155 valid: Vec<String>,
156 }
157
158 impl WikilinkValidator for TestWikilinkValidator {
159 fn is_valid_link(&self, target: &str) -> bool {
160 self.valid.iter().any(|v| v == target)
161 }
162 }
163
164 fn make_embed_tag(url: &str) -> Tag<'_> {
165 Tag::Embed {
166 embed_type: EmbedType::Other,
167 dest_url: url.into(),
168 title: "".into(),
169 id: "".into(),
170 attrs: None,
171 }
172 }
173
174 #[test]
175 fn test_embed_provider() {
176 let provider = TestEmbedProvider;
177 assert_eq!(
178 provider.get_embed_content(&make_embed_tag("at://test/embed")),
179 Some("<div>Test Embed</div>".to_string())
180 );
181 assert_eq!(provider.get_embed_content(&make_embed_tag("at://other")), None);
182 }
183
184 #[test]
185 fn test_image_resolver() {
186 let resolver = TestImageResolver;
187 assert_eq!(
188 resolver.resolve_image_url("/image/photo.jpg"),
189 Some("https://cdn.example.com/image/photo.jpg".to_string())
190 );
191 assert_eq!(resolver.resolve_image_url("https://other.com/img.png"), None);
192 }
193
194 #[test]
195 fn test_wikilink_validator() {
196 let validator = TestWikilinkValidator {
197 valid: vec!["Home".to_string(), "About".to_string()],
198 };
199 assert!(validator.is_valid_link("Home"));
200 assert!(validator.is_valid_link("About"));
201 assert!(!validator.is_valid_link("Missing"));
202 }
203
204 #[test]
205 fn test_unit_impls() {
206 let embed: () = ();
207 assert_eq!(embed.get_embed_content(&make_embed_tag("anything")), None);
208
209 let image: () = ();
210 assert_eq!(image.resolve_image_url("anything"), None);
211
212 let wiki: () = ();
213 assert!(wiki.is_valid_link("anything")); // default true
214 }
215
216 #[test]
217 fn test_option_impls() {
218 let some_provider: Option<TestEmbedProvider> = Some(TestEmbedProvider);
219 assert_eq!(
220 some_provider.get_embed_content(&make_embed_tag("at://test/embed")),
221 Some("<div>Test Embed</div>".to_string())
222 );
223
224 let none_provider: Option<TestEmbedProvider> = None;
225 assert_eq!(none_provider.get_embed_content(&make_embed_tag("at://test/embed")), None);
226 }
227}