atproto blogging
1use std::fmt::Write;
2
3use jacquard::client::AgentSessionExt;
4use jacquard::types::cid::Cid;
5use jacquard::types::string::{AtUri, Did};
6use markdown_weaver_escape::escape_html;
7use weaver_api::pub_leaflet::blocks::{
8 blockquote::Blockquote,
9 bsky_post::BskyPost,
10 button::Button,
11 code::Code,
12 header::Header,
13 iframe::Iframe,
14 image::Image,
15 math::Math,
16 page::Page,
17 poll::Poll,
18 text::Text,
19 unordered_list::{ListItem, ListItemContent, UnorderedList},
20 website::Website,
21};
22use weaver_api::pub_leaflet::pages::linear_document::{Block, BlockBlock, LinearDocument};
23
24use crate::facet::{NormalizedFacet, render_faceted_html};
25
26pub struct LeafletRenderContext {
27 pub author_did: Did<'static>,
28}
29
30impl LeafletRenderContext {
31 pub fn new(author_did: Did<'static>) -> Self {
32 Self { author_did }
33 }
34
35 fn blob_url(&self, cid: &Cid<'_>) -> String {
36 format!(
37 "https://leaflet.pub/api/atproto_images?did={}&cid={}",
38 self.author_did.as_ref(),
39 cid.as_ref()
40 )
41 }
42}
43
44pub async fn render_linear_document<A: AgentSessionExt>(
45 doc: &LinearDocument<'_>,
46 ctx: &LeafletRenderContext,
47 agent: &A,
48) -> String {
49 let mut html = String::new();
50 html.push_str("<div class=\"leaflet-document\">");
51
52 for block in &doc.blocks {
53 html.push_str(&render_block(block, ctx, agent).await);
54 }
55
56 html.push_str("</div>");
57 html
58}
59
60pub async fn render_block<A: AgentSessionExt>(
61 block: &Block<'_>,
62 ctx: &LeafletRenderContext,
63 agent: &A,
64) -> String {
65 let mut html = String::new();
66
67 let alignment_class = block
68 .alignment
69 .as_ref()
70 .map(|a| match a.as_ref() {
71 "pub.leaflet.pages.linearDocument#textAlignCenter" => " align-center",
72 "pub.leaflet.pages.linearDocument#textAlignRight" => " align-right",
73 "pub.leaflet.pages.linearDocument#textAlignJustify" => " align-justify",
74 _ => "",
75 })
76 .unwrap_or("");
77
78 match &block.block {
79 BlockBlock::Text(text) => {
80 render_text_block(&mut html, text, alignment_class);
81 }
82 BlockBlock::Header(header) => {
83 render_header_block(&mut html, header, alignment_class);
84 }
85 BlockBlock::Blockquote(quote) => {
86 render_blockquote_block(&mut html, quote);
87 }
88 BlockBlock::Code(code) => {
89 render_code_block(&mut html, code);
90 }
91 BlockBlock::UnorderedList(list) => {
92 render_unordered_list(&mut html, list, ctx, agent).await;
93 }
94 BlockBlock::Image(image) => {
95 render_image_block(&mut html, image, ctx);
96 }
97 BlockBlock::Website(website) => {
98 render_website_block(&mut html, website, ctx);
99 }
100 BlockBlock::Iframe(iframe) => {
101 render_iframe_block(&mut html, iframe);
102 }
103 BlockBlock::BskyPost(post) => {
104 render_bsky_post_block(&mut html, post, agent).await;
105 }
106 BlockBlock::Button(button) => {
107 render_button_block(&mut html, button);
108 }
109 BlockBlock::Poll(poll) => {
110 render_poll_block(&mut html, poll);
111 }
112 BlockBlock::HorizontalRule(_) => {
113 html.push_str("<hr />\n");
114 }
115 BlockBlock::Page(page) => {
116 render_page_block(&mut html, page);
117 }
118 BlockBlock::Math(math) => {
119 render_math_block(&mut html, math);
120 }
121 BlockBlock::Unknown(data) => {
122 let _ = write!(
123 html,
124 "<div class=\"embed-unknown\">[Unknown block: {:?}]</div>\n",
125 data.type_discriminator()
126 );
127 }
128 }
129
130 html
131}
132
133fn render_text_block(html: &mut String, text: &Text<'_>, alignment_class: &str) {
134 let _ = write!(html, "<p class=\"leaflet-text{}\">", alignment_class);
135 html.push_str(&render_faceted_text(
136 &text.plaintext,
137 text.facets.as_deref(),
138 ));
139 html.push_str("</p>\n");
140}
141
142fn render_header_block(html: &mut String, header: &Header<'_>, alignment_class: &str) {
143 let level = header.level.unwrap_or(1).clamp(1, 6);
144 let _ = write!(html, "<h{}{}>", level, alignment_class);
145 html.push_str(&render_faceted_text(
146 &header.plaintext,
147 header.facets.as_deref(),
148 ));
149 let _ = write!(html, "</h{}>\n", level);
150}
151
152fn render_blockquote_block(html: &mut String, quote: &Blockquote<'_>) {
153 html.push_str("<blockquote>");
154 html.push_str(&render_faceted_text(
155 "e.plaintext,
156 quote.facets.as_deref(),
157 ));
158 html.push_str("</blockquote>\n");
159}
160
161fn render_code_block(html: &mut String, code: &Code<'_>) {
162 html.push_str("<pre><code");
163 if let Some(lang) = &code.language {
164 html.push_str(" class=\"language-");
165 let _ = escape_html(&mut *html, lang.as_ref());
166 html.push('"');
167 }
168 html.push('>');
169 let _ = escape_html(&mut *html, &code.plaintext);
170 html.push_str("</code></pre>\n");
171}
172
173async fn render_unordered_list<A: AgentSessionExt>(
174 html: &mut String,
175 list: &UnorderedList<'_>,
176 ctx: &LeafletRenderContext,
177 agent: &A,
178) {
179 html.push_str("<ul>\n");
180 for item in &list.children {
181 render_list_item(html, item, ctx, agent).await;
182 }
183 html.push_str("</ul>\n");
184}
185
186async fn render_list_item<A: AgentSessionExt>(
187 html: &mut String,
188 item: &ListItem<'_>,
189 ctx: &LeafletRenderContext,
190 agent: &A,
191) {
192 html.push_str("<li>");
193
194 match &item.content {
195 ListItemContent::Text(text) => {
196 html.push_str(&render_faceted_text(
197 &text.plaintext,
198 text.facets.as_deref(),
199 ));
200 }
201 ListItemContent::Header(header) => {
202 let level = header.level.unwrap_or(1).clamp(1, 6);
203 let _ = write!(html, "<h{}>", level);
204 html.push_str(&render_faceted_text(
205 &header.plaintext,
206 header.facets.as_deref(),
207 ));
208 let _ = write!(html, "</h{}>", level);
209 }
210 ListItemContent::Image(image) => {
211 render_image_inline(html, image, ctx);
212 }
213 ListItemContent::Unknown(data) => {
214 let _ = write!(html, "[Unknown: {:?}]", data.type_discriminator());
215 }
216 }
217
218 if let Some(children) = &item.children {
219 html.push_str("\n<ul>\n");
220 for child in children {
221 Box::pin(render_list_item(html, child, ctx, agent)).await;
222 }
223 html.push_str("</ul>\n");
224 }
225
226 html.push_str("</li>\n");
227}
228
229fn render_image_block(html: &mut String, image: &Image<'_>, ctx: &LeafletRenderContext) {
230 html.push_str("<figure>");
231 render_image_inline(html, image, ctx);
232 if let Some(alt) = &image.alt {
233 html.push_str("<figcaption>");
234 let _ = escape_html(&mut *html, alt.as_ref());
235 html.push_str("</figcaption>");
236 }
237 html.push_str("</figure>\n");
238}
239
240fn render_image_inline(html: &mut String, image: &Image<'_>, ctx: &LeafletRenderContext) {
241 let src = ctx.blob_url(image.image.blob().cid());
242 html.push_str("<img src=\"");
243 let _ = escape_html(&mut *html, &src);
244 html.push('"');
245 if let Some(alt) = &image.alt {
246 html.push_str(" alt=\"");
247 let _ = escape_html(&mut *html, alt.as_ref());
248 html.push('"');
249 }
250 let _ = write!(
251 html,
252 " style=\"aspect-ratio: {} / {};\"",
253 image.aspect_ratio.width, image.aspect_ratio.height
254 );
255 html.push_str(" />");
256}
257
258fn render_website_block(html: &mut String, website: &Website<'_>, ctx: &LeafletRenderContext) {
259 html.push_str("<a class=\"embed-external\" href=\"");
260 let _ = escape_html(&mut *html, website.src.as_ref());
261 html.push_str("\" target=\"_blank\" rel=\"noopener\">");
262
263 if let Some(preview) = &website.preview_image {
264 let thumb_url = ctx.blob_url(preview.blob().cid());
265 html.push_str("<img class=\"embed-external-thumb\" src=\"");
266 let _ = escape_html(&mut *html, &thumb_url);
267 html.push_str("\" />");
268 }
269
270 html.push_str("<span class=\"embed-external-info\">");
271
272 if let Some(title) = &website.title {
273 html.push_str("<span class=\"embed-external-title\">");
274 let _ = escape_html(&mut *html, title.as_ref());
275 html.push_str("</span>");
276 }
277
278 if let Some(desc) = &website.description {
279 html.push_str("<span class=\"embed-external-description\">");
280 let _ = escape_html(&mut *html, desc.as_ref());
281 html.push_str("</span>");
282 }
283
284 html.push_str("<span class=\"embed-external-url\">");
285 html.push_str(extract_domain(website.src.as_ref()));
286 html.push_str("</span>");
287
288 html.push_str("</span></a>\n");
289}
290
291fn render_iframe_block(html: &mut String, iframe: &Iframe<'_>) {
292 let height = iframe.height.unwrap_or(400);
293 html.push_str("<iframe class=\"html-embed-block\" src=\"");
294 let _ = escape_html(&mut *html, iframe.url.as_ref());
295 let _ = write!(
296 html,
297 "\" height=\"{}\" frameborder=\"0\" allowfullscreen></iframe>\n",
298 height
299 );
300}
301
302async fn render_bsky_post_block<A: AgentSessionExt>(
303 html: &mut String,
304 post: &BskyPost<'_>,
305 agent: &A,
306) {
307 let uri_str = post.post_ref.uri.as_ref();
308
309 // Try to fetch and render the actual post (using fetch_and_render_post directly
310 // to avoid potential infinite recursion through fetch_and_render dispatch)
311 if let Ok(uri) = AtUri::new(uri_str) {
312 match crate::atproto::fetch_and_render_post(&uri, agent).await {
313 Ok(rendered) => {
314 html.push_str(&rendered);
315 return;
316 }
317 Err(e) => {
318 tracing::warn!("Failed to fetch embedded post {}: {:?}", uri_str, e);
319 }
320 }
321 }
322
323 // Fallback: render as placeholder
324 html.push_str("<div class=\"embed-video-placeholder\" data-aturi=\"");
325 let _ = escape_html(&mut *html, uri_str);
326 html.push_str("\">[Bluesky Post: ");
327 let _ = escape_html(&mut *html, uri_str);
328 html.push_str("]</div>\n");
329}
330
331fn render_button_block(html: &mut String, button: &Button<'_>) {
332 html.push_str("<a class=\"leaflet-button\" href=\"");
333 let _ = escape_html(&mut *html, button.url.as_ref());
334 html.push_str("\">");
335 let _ = escape_html(&mut *html, button.text.as_ref());
336 html.push_str("</a>\n");
337}
338
339fn render_poll_block(html: &mut String, poll: &Poll<'_>) {
340 html.push_str("<div class=\"embed-video-placeholder\">[Poll: ");
341 let _ = escape_html(&mut *html, poll.poll_ref.uri.as_ref());
342 html.push_str("]</div>\n");
343}
344
345fn render_page_block(html: &mut String, page: &Page<'_>) {
346 html.push_str("<div class=\"embed-video-placeholder\">[Page Reference: ");
347 let _ = escape_html(&mut *html, page.id.as_ref());
348 html.push_str("]</div>\n");
349}
350
351fn render_math_block(html: &mut String, math: &Math<'_>) {
352 match crate::math::render_math(&math.tex, true) {
353 crate::math::MathResult::Success(mathml) => {
354 html.push_str("<div class=\"math-display\">");
355 html.push_str(&mathml);
356 html.push_str("</div>\n");
357 }
358 crate::math::MathResult::Error { html: err_html, .. } => {
359 html.push_str(&err_html);
360 html.push('\n');
361 }
362 }
363}
364
365fn render_faceted_text(
366 text: &str,
367 facets: Option<&[weaver_api::pub_leaflet::richtext::facet::Facet<'_>]>,
368) -> String {
369 if let Some(facets) = facets {
370 let normalized: Vec<NormalizedFacet<'_>> =
371 facets.iter().map(NormalizedFacet::from).collect();
372 render_faceted_html(text, &normalized).unwrap_or_else(|_| {
373 let mut escaped = String::new();
374 let _ = escape_html(&mut escaped, text);
375 escaped
376 })
377 } else {
378 let mut escaped = String::new();
379 let _ = escape_html(&mut escaped, text);
380 escaped
381 }
382}
383
384fn extract_domain(url: &str) -> &str {
385 url.strip_prefix("https://")
386 .or_else(|| url.strip_prefix("http://"))
387 .and_then(|s| s.split('/').next())
388 .unwrap_or(url)
389}
390
391/// Sync version of render_linear_document that uses pre-resolved embeds.
392pub fn render_linear_document_sync(
393 doc: &LinearDocument<'_>,
394 ctx: &LeafletRenderContext,
395 resolved_content: Option<&weaver_common::ResolvedContent>,
396) -> String {
397 let mut html = String::new();
398 html.push_str("<div class=\"leaflet-document\">");
399
400 for block in &doc.blocks {
401 html.push_str(&render_block_sync(block, ctx, resolved_content));
402 }
403
404 html.push_str("</div>");
405 html
406}
407
408/// Sync version of render_block that uses pre-resolved embeds for BskyPost blocks.
409pub fn render_block_sync(
410 block: &Block<'_>,
411 ctx: &LeafletRenderContext,
412 resolved_content: Option<&weaver_common::ResolvedContent>,
413) -> String {
414 let mut html = String::new();
415
416 let alignment_class = block
417 .alignment
418 .as_ref()
419 .map(|a| match a.as_ref() {
420 "pub.leaflet.pages.linearDocument#textAlignCenter" => " align-center",
421 "pub.leaflet.pages.linearDocument#textAlignRight" => " align-right",
422 "pub.leaflet.pages.linearDocument#textAlignJustify" => " align-justify",
423 _ => "",
424 })
425 .unwrap_or("");
426
427 match &block.block {
428 BlockBlock::Text(text) => {
429 render_text_block(&mut html, text, alignment_class);
430 }
431 BlockBlock::Header(header) => {
432 render_header_block(&mut html, header, alignment_class);
433 }
434 BlockBlock::Blockquote(quote) => {
435 render_blockquote_block(&mut html, quote);
436 }
437 BlockBlock::Code(code) => {
438 render_code_block(&mut html, code);
439 }
440 BlockBlock::UnorderedList(list) => {
441 render_unordered_list_sync(&mut html, list, ctx, resolved_content);
442 }
443 BlockBlock::Image(image) => {
444 render_image_block(&mut html, image, ctx);
445 }
446 BlockBlock::Website(website) => {
447 render_website_block(&mut html, website, ctx);
448 }
449 BlockBlock::Iframe(iframe) => {
450 render_iframe_block(&mut html, iframe);
451 }
452 BlockBlock::BskyPost(post) => {
453 render_bsky_post_block_sync(&mut html, post, resolved_content);
454 }
455 BlockBlock::Button(button) => {
456 render_button_block(&mut html, button);
457 }
458 BlockBlock::Poll(poll) => {
459 render_poll_block(&mut html, poll);
460 }
461 BlockBlock::HorizontalRule(_) => {
462 html.push_str("<hr />\n");
463 }
464 BlockBlock::Page(page) => {
465 render_page_block(&mut html, page);
466 }
467 BlockBlock::Math(math) => {
468 render_math_block(&mut html, math);
469 }
470 BlockBlock::Unknown(data) => {
471 let _ = write!(
472 html,
473 "<div class=\"embed-unknown\">[Unknown block: {:?}]</div>\n",
474 data.type_discriminator()
475 );
476 }
477 }
478
479 html
480}
481
482fn render_unordered_list_sync(
483 html: &mut String,
484 list: &UnorderedList<'_>,
485 ctx: &LeafletRenderContext,
486 resolved_content: Option<&weaver_common::ResolvedContent>,
487) {
488 html.push_str("<ul>\n");
489 for item in &list.children {
490 render_list_item_sync(html, item, ctx, resolved_content);
491 }
492 html.push_str("</ul>\n");
493}
494
495fn render_list_item_sync(
496 html: &mut String,
497 item: &ListItem<'_>,
498 ctx: &LeafletRenderContext,
499 resolved_content: Option<&weaver_common::ResolvedContent>,
500) {
501 html.push_str("<li>");
502
503 match &item.content {
504 ListItemContent::Text(text) => {
505 html.push_str(&render_faceted_text(
506 &text.plaintext,
507 text.facets.as_deref(),
508 ));
509 }
510 ListItemContent::Header(header) => {
511 let level = header.level.unwrap_or(1).clamp(1, 6);
512 let _ = write!(html, "<h{}>", level);
513 html.push_str(&render_faceted_text(
514 &header.plaintext,
515 header.facets.as_deref(),
516 ));
517 let _ = write!(html, "</h{}>", level);
518 }
519 ListItemContent::Image(image) => {
520 render_image_inline(html, image, ctx);
521 }
522 ListItemContent::Unknown(data) => {
523 let _ = write!(html, "[Unknown: {:?}]", data.type_discriminator());
524 }
525 }
526
527 if let Some(children) = &item.children {
528 html.push_str("\n<ul>\n");
529 for child in children {
530 render_list_item_sync(html, child, ctx, resolved_content);
531 }
532 html.push_str("</ul>\n");
533 }
534
535 html.push_str("</li>\n");
536}
537
538fn render_bsky_post_block_sync(
539 html: &mut String,
540 post: &BskyPost<'_>,
541 resolved_content: Option<&weaver_common::ResolvedContent>,
542) {
543 let uri_str = post.post_ref.uri.as_ref();
544
545 // Look up pre-rendered content.
546 if let Some(resolved) = resolved_content {
547 if let Ok(at_uri) = AtUri::new(uri_str) {
548 if let Some(rendered) = resolved.get_embed_content(&at_uri) {
549 html.push_str(rendered);
550 return;
551 }
552 }
553 }
554
555 // Fallback: use bsky embed iframe.
556 // Format: at://did/app.bsky.feed.post/rkey -> https://bsky.app/profile/did/post/rkey
557 if let Some(rest) = uri_str.strip_prefix("at://") {
558 if let Some((did, path)) = rest.split_once('/') {
559 if let Some(rkey) = path.strip_prefix("app.bsky.feed.post/") {
560 html.push_str("<iframe class=\"bsky-embed-iframe\" src=\"https://embed.bsky.app/embed/");
561 let _ = escape_html(&mut *html, did);
562 html.push_str("/post/");
563 let _ = escape_html(&mut *html, rkey);
564 html.push_str("\" frameborder=\"0\" scrolling=\"no\" loading=\"lazy\" style=\"border: none; width: 100%; height: 240px;\"></iframe>\n");
565 return;
566 }
567 }
568 }
569
570 // Last resort: placeholder.
571 html.push_str("<div class=\"embed-video-placeholder\" data-aturi=\"");
572 let _ = escape_html(&mut *html, uri_str);
573 html.push_str("\">[Bluesky Post: ");
574 let _ = escape_html(&mut *html, uri_str);
575 html.push_str("]</div>\n");
576}