atproto blogging
1//! Embed rendering and image resolution for EditorWriter.
2
3use core::fmt;
4use std::collections::HashMap;
5use std::ops::Range;
6
7use jacquard::IntoStatic;
8use jacquard::types::{ident::AtIdentifier, string::Rkey};
9use markdown_weaver::{Event, Tag};
10use markdown_weaver_escape::{StrWrite, escape_html};
11use smol_str::SmolStr;
12
13use crate::render::{EmbedContentProvider, ImageResolver, WikilinkValidator};
14use crate::syntax::{SyntaxSpanInfo, SyntaxType};
15use crate::types::EditorImage;
16
17use super::EditorWriter;
18
19/// Resolved image path type.
20#[derive(Clone, Debug)]
21enum ResolvedImage {
22 /// Data URL for immediate preview (still uploading)
23 Pending(String),
24 /// Draft image: `/image/{ident}/draft/{blob_rkey}/{name}`
25 Draft {
26 blob_rkey: Rkey<'static>,
27 ident: AtIdentifier<'static>,
28 },
29 /// Published image: `/image/{ident}/{entry_rkey}/{name}`
30 Published {
31 entry_rkey: Rkey<'static>,
32 ident: AtIdentifier<'static>,
33 },
34}
35
36/// Resolves image paths in the editor.
37///
38/// Supports three states for images:
39/// - Pending: uses data URL for immediate preview while upload is in progress
40/// - Draft: uses path format `/image/{did}/draft/{blob_rkey}/{name}`
41/// - Published: uses path format `/image/{did}/{entry_rkey}/{name}`
42///
43/// Image URLs in markdown use the format `/image/{name}`.
44#[derive(Clone, Default)]
45pub struct EditorImageResolver {
46 /// All resolved images: name -> resolved path info
47 images: HashMap<SmolStr, ResolvedImage>,
48}
49
50impl EditorImageResolver {
51 pub fn new() -> Self {
52 Self::default()
53 }
54
55 /// Add a pending image with a data URL for immediate preview.
56 pub fn add_pending(&mut self, name: impl Into<SmolStr>, data_url: String) {
57 self.images
58 .insert(name.into(), ResolvedImage::Pending(data_url));
59 }
60
61 /// Promote a pending image to uploaded (draft) status.
62 pub fn promote_to_uploaded(
63 &mut self,
64 name: &str,
65 blob_rkey: Rkey<'static>,
66 ident: AtIdentifier<'static>,
67 ) {
68 self.images.insert(
69 SmolStr::new(name),
70 ResolvedImage::Draft { blob_rkey, ident },
71 );
72 }
73
74 /// Add an already-uploaded draft image.
75 pub fn add_uploaded(
76 &mut self,
77 name: impl Into<SmolStr>,
78 blob_rkey: Rkey<'static>,
79 ident: AtIdentifier<'static>,
80 ) {
81 self.images
82 .insert(name.into(), ResolvedImage::Draft { blob_rkey, ident });
83 }
84
85 /// Add a published image.
86 pub fn add_published(
87 &mut self,
88 name: impl Into<SmolStr>,
89 entry_rkey: Rkey<'static>,
90 ident: AtIdentifier<'static>,
91 ) {
92 self.images
93 .insert(name.into(), ResolvedImage::Published { entry_rkey, ident });
94 }
95
96 /// Check if an image is pending upload.
97 pub fn is_pending(&self, name: &str) -> bool {
98 matches!(self.images.get(name), Some(ResolvedImage::Pending(_)))
99 }
100
101 /// Build a resolver from editor images and user identifier.
102 ///
103 /// For draft mode (entry_rkey=None), only images with a `published_blob_uri` are included.
104 /// For published mode (entry_rkey=Some), all images are included.
105 pub fn from_images<'a>(
106 images: impl IntoIterator<Item = &'a EditorImage>,
107 ident: AtIdentifier<'static>,
108 entry_rkey: Option<Rkey<'static>>,
109 ) -> Self {
110 let mut resolver = Self::new();
111 for editor_image in images {
112 // Get the name from the Image (use alt text as fallback if name is empty)
113 let name = editor_image
114 .image
115 .name
116 .as_ref()
117 .map(|n| n.to_string())
118 .unwrap_or_else(|| editor_image.image.alt.to_string());
119
120 if name.is_empty() {
121 continue;
122 }
123
124 match &entry_rkey {
125 // Published mode: use entry rkey for all images
126 Some(rkey) => {
127 resolver.add_published(name, rkey.clone(), ident.clone());
128 }
129 // Draft mode: use published_blob_uri rkey
130 None => {
131 let blob_rkey = match &editor_image.published_blob_uri {
132 Some(uri) => match uri.rkey() {
133 Some(rkey) => rkey.0.clone().into_static(),
134 None => continue,
135 },
136 None => continue,
137 };
138 resolver.add_uploaded(name, blob_rkey, ident.clone());
139 }
140 }
141 }
142 resolver
143 }
144}
145
146impl ImageResolver for EditorImageResolver {
147 fn resolve_image_url(&self, url: &str) -> Option<String> {
148 // Extract image name from /image/{name} format
149 let name = url.strip_prefix("/image/").unwrap_or(url);
150
151 let resolved = self.images.get(name)?;
152 match resolved {
153 ResolvedImage::Pending(data_url) => Some(data_url.clone()),
154 ResolvedImage::Draft { blob_rkey, ident } => {
155 Some(format!("/image/{}/draft/{}/{}", ident, blob_rkey, name))
156 }
157 ResolvedImage::Published { entry_rkey, ident } => {
158 Some(format!("/image/{}/{}/{}", ident, entry_rkey, name))
159 }
160 }
161 }
162}
163
164// write_embed implementation
165impl<'a, T, I, E, R, W> EditorWriter<'a, T, I, E, R, W>
166where
167 T: crate::TextBuffer,
168 I: Iterator<Item = (Event<'a>, Range<usize>)>,
169 E: EmbedContentProvider,
170 R: ImageResolver,
171 W: WikilinkValidator,
172{
173 pub(crate) fn write_embed(
174 &mut self,
175 range: Range<usize>,
176 tag: Tag<'_>,
177 ) -> Result<(), fmt::Error> {
178 let Tag::Embed {
179 dest_url,
180 title,
181 attrs,
182 ..
183 } = &tag
184 else {
185 return Ok(());
186 };
187
188 // Embed rendering: all syntax elements share one syn_id for visibility toggling
189 // Structure: ![[ url-as-link ]] <embed-content>
190 let raw_text = &self.source[range.clone()];
191 let syn_id = self.gen_syn_id();
192 let opening_char_start = self.last_char_offset;
193
194 // Extract the URL from raw text (between ![[ and ]])
195 let url_text = if raw_text.starts_with("![[") && raw_text.ends_with("]]") {
196 &raw_text[3..raw_text.len() - 2]
197 } else {
198 dest_url.as_ref()
199 };
200
201 // Calculate char positions
202 let url_char_len = url_text.chars().count();
203 let opening_char_end = opening_char_start + 3; // "![["
204 let url_char_start = opening_char_end;
205 let url_char_end = url_char_start + url_char_len;
206 let closing_char_start = url_char_end;
207 let closing_char_end = closing_char_start + 2; // "]]"
208 let formatted_range = opening_char_start..closing_char_end;
209
210 // 1. Emit opening ![[ syntax span
211 if raw_text.starts_with("![[") {
212 write!(
213 &mut self.writer,
214 "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">![[</span>",
215 syn_id, opening_char_start, opening_char_end
216 )?;
217
218 self.current_para.syntax_spans.push(SyntaxSpanInfo {
219 syn_id: syn_id.clone(),
220 char_range: opening_char_start..opening_char_end,
221 syntax_type: SyntaxType::Inline,
222 formatted_range: Some(formatted_range.clone()),
223 });
224
225 self.record_mapping(
226 range.start..range.start + 3,
227 opening_char_start..opening_char_end,
228 );
229 }
230
231 // 2. Emit URL as a clickable link (same syn_id, shown/hidden with syntax)
232 let url = dest_url.as_ref();
233 let link_href = if url.starts_with("at://") {
234 format!("https://alpha.weaver.sh/record/{}", url)
235 } else {
236 url.to_string()
237 };
238
239 write!(
240 &mut self.writer,
241 "<a class=\"image-alt embed-url\" href=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" target=\"_blank\">",
242 link_href, syn_id, url_char_start, url_char_end
243 )?;
244 escape_html(&mut self.writer, url_text)?;
245 self.write("</a>")?;
246
247 self.current_para.syntax_spans.push(SyntaxSpanInfo {
248 syn_id: syn_id.clone(),
249 char_range: url_char_start..url_char_end,
250 syntax_type: SyntaxType::Inline,
251 formatted_range: Some(formatted_range.clone()),
252 });
253
254 self.record_mapping(range.start + 3..range.end - 2, url_char_start..url_char_end);
255
256 // 3. Emit closing ]] syntax span
257 if raw_text.ends_with("]]") {
258 write!(
259 &mut self.writer,
260 "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">]]</span>",
261 syn_id, closing_char_start, closing_char_end
262 )?;
263
264 self.current_para.syntax_spans.push(SyntaxSpanInfo {
265 syn_id: syn_id.clone(),
266 char_range: closing_char_start..closing_char_end,
267 syntax_type: SyntaxType::Inline,
268 formatted_range: Some(formatted_range.clone()),
269 });
270
271 self.record_mapping(
272 range.end - 2..range.end,
273 closing_char_start..closing_char_end,
274 );
275 }
276
277 // Collect AT URI for later resolution
278 if url.starts_with("at://") || url.starts_with("did:") {
279 self.ref_collector.add_at_embed(
280 url,
281 if title.is_empty() {
282 None
283 } else {
284 Some(title.as_ref())
285 },
286 );
287 }
288
289 // 4. Emit the actual embed content
290 // Try to get content from attributes first
291 let content_from_attrs = if let Some(attrs) = attrs {
292 attrs
293 .attrs
294 .iter()
295 .find(|(k, _)| k.as_ref() == "content")
296 .map(|(_, v)| v.as_ref().to_string())
297 } else {
298 None
299 };
300
301 // If no content in attrs, try provider
302 let content: Option<String> = if content_from_attrs.is_some() {
303 content_from_attrs
304 } else if let Some(ref provider) = self.embed_provider {
305 provider.get_embed_content(&tag)
306 } else {
307 None
308 };
309
310 if let Some(ref html_content) = content {
311 // Write the pre-rendered content directly
312 self.write(html_content)?;
313 } else {
314 // Fallback: render as placeholder div
315 self.write("<div class=\"atproto-embed atproto-embed-placeholder\">")?;
316 self.write("<span class=\"embed-loading\">Loading embed...</span>")?;
317 self.write("</div>")?;
318 }
319
320 // Consume the text events for the URL (they're still in the iterator)
321 // Use consume_until_end() since we already wrote the URL from source
322 self.consume_until_end();
323
324 // Update offsets
325 self.last_char_offset = closing_char_end;
326 self.last_byte_offset = range.end;
327
328 Ok(())
329 }
330}