atproto blogging
1//! Fetch and render AT Protocol records as HTML embeds
2//!
3//! This module provides functions to fetch records from PDSs and render them
4//! as HTML strings suitable for embedding in markdown content.
5//!
6//! # Reusable render functions
7//!
8//! The `render_*` functions can be used standalone for rendering different embed types:
9//! - `render_external_link` - Link cards with title, description, thumbnail
10//! - `render_images` - Image galleries
11//! - `render_quoted_record` - Quoted posts/records
12//! - `render_author_block` - Author avatar + name + handle
13
14use super::error::AtProtoPreprocessError;
15use jacquard::{
16 Data, IntoStatic,
17 client::AgentSessionExt,
18 cowstr::ToCowStr,
19 types::{ident::AtIdentifier, string::AtUri},
20};
21use weaver_api::app_bsky::{
22 actor::ProfileViewBasic,
23 embed::{
24 external::ViewExternal,
25 images::ViewImage,
26 record::{ViewRecord, ViewUnionRecord},
27 },
28 feed::{PostView, PostViewEmbed, get_posts::GetPosts},
29};
30use weaver_api::sh_weaver::actor::ProfileDataViewInner;
31use weaver_common::agent::WeaverExt;
32
33/// Fetch and render a profile record as HTML
34///
35/// Resolves handle to DID if needed, then fetches profile data from
36/// weaver or bsky appview, returning a rich profile view.
37pub async fn fetch_and_render_profile<A>(
38 ident: &AtIdentifier<'_>,
39 agent: &A,
40) -> Result<String, AtProtoPreprocessError>
41where
42 A: AgentSessionExt,
43{
44 // Use WeaverExt to get hydrated profile (tries weaver profile first, falls back to bsky)
45 let (_uri, profile_view) = agent
46 .hydrate_profile_view(&ident)
47 .await
48 .map_err(|e| AtProtoPreprocessError::FetchFailed(format!("{:?}", e)))?;
49
50 // Render based on which profile type we got
51 render_profile_data_view(&profile_view.inner)
52}
53
54/// Fetch and render a Bluesky post as HTML using the appview for rich data
55pub async fn fetch_and_render_post<A>(
56 uri: &AtUri<'_>,
57 agent: &A,
58) -> Result<String, AtProtoPreprocessError>
59where
60 A: AgentSessionExt,
61{
62 // Use GetPosts for richer data (author info, engagement counts)
63 let request = GetPosts::new().uris(vec![uri.clone()]).build();
64 let response = agent.send(request).await;
65 let response = response.map_err(|e| {
66 AtProtoPreprocessError::FetchFailed(format!("getting post from appview {:?}", e))
67 })?;
68
69 let output = response
70 .into_output()
71 .map_err(|e| AtProtoPreprocessError::FetchFailed(format!("{:?}", e)))?;
72
73 let post_view = output
74 .posts
75 .into_iter()
76 .next()
77 .ok_or_else(|| AtProtoPreprocessError::FetchFailed("Post not found".to_string()))?;
78
79 render_post_view(&post_view, uri)
80}
81
82/// Fetch and render an unknown record type generically
83///
84/// This fetches the record as untyped Data and probes for likely meaningful fields.
85pub async fn fetch_and_render_generic<A>(
86 uri: &AtUri<'_>,
87 agent: &A,
88) -> Result<String, AtProtoPreprocessError>
89where
90 A: AgentSessionExt,
91{
92 // Fetch via slingshot (edge-cached, untyped)
93 let output = agent
94 .fetch_record_slingshot(uri)
95 .await
96 .map_err(|e| AtProtoPreprocessError::FetchFailed(format!("{:?}", e)))?;
97
98 // Probe for meaningful fields
99 render_generic_record(&output.value, uri)
100}
101
102/// Fetch and render a notebook entry with full markdown rendering
103///
104/// Renders the entry content as HTML in a scrollable container with title and author info.
105pub async fn fetch_and_render_entry<A>(
106 uri: &AtUri<'_>,
107 agent: &A,
108) -> Result<String, AtProtoPreprocessError>
109where
110 A: AgentSessionExt,
111{
112 use crate::atproto::writer::ClientWriter;
113 use crate::default_md_options;
114 use markdown_weaver::Parser;
115 use weaver_common::agent::WeaverExt;
116
117 // Get rkey from URI
118 let rkey = uri
119 .rkey()
120 .ok_or_else(|| AtProtoPreprocessError::FetchFailed("Entry URI missing rkey".to_string()))?;
121
122 // Fetch entry with author info
123 let (entry_view, entry) = agent
124 .fetch_entry_by_rkey(&uri.authority(), rkey.as_ref())
125 .await
126 .map_err(|e| AtProtoPreprocessError::FetchFailed(e.to_string()))?;
127
128 // Render the markdown content to HTML
129 let content = entry.content.as_ref();
130 let parser = Parser::new_ext(content, default_md_options()).into_offset_iter();
131 let mut content_html = String::new();
132 ClientWriter::<_, _, ()>::new(parser, &mut content_html, content)
133 .run()
134 .map_err(|e| {
135 AtProtoPreprocessError::FetchFailed(format!("Markdown render failed: {:?}", e))
136 })?;
137
138 // Generate unique ID for the toggle checkbox
139 let toggle_id = format!("entry-toggle-{}", rkey.as_ref());
140
141 // Build the embed HTML
142 let mut html = String::new();
143 html.push_str("<div class=\"atproto-embed atproto-entry\" contenteditable=\"false\">");
144
145 // Hidden checkbox for expand/collapse (must come before content for CSS sibling selector)
146 html.push_str("<input type=\"checkbox\" class=\"embed-entry-toggle\" id=\"");
147 html.push_str(&toggle_id);
148 html.push_str("\">");
149
150 // Header with title and author
151 html.push_str("<div class=\"embed-entry-header\">");
152
153 // Title
154 html.push_str("<span class=\"embed-entry-title\">");
155 html.push_str(&html_escape(entry.title.as_ref()));
156 html.push_str("</span>");
157
158 // Author info - just show handle (keep it simple for entry embeds)
159 if let Some(author) = entry_view.authors.first() {
160 let handle = match &author.record.inner {
161 ProfileDataViewInner::ProfileView(p) => p.handle.as_ref(),
162 ProfileDataViewInner::ProfileViewDetailed(p) => p.handle.as_ref(),
163 ProfileDataViewInner::TangledProfileView(p) => p.handle.as_ref(),
164 ProfileDataViewInner::Unknown(_) => "",
165 };
166 if !handle.is_empty() {
167 html.push_str("<span class=\"embed-entry-author\">@");
168 html.push_str(&html_escape(handle));
169 html.push_str("</span>");
170 }
171 }
172
173 html.push_str("</div>"); // end header
174
175 // Scrollable content container
176 html.push_str("<div class=\"embed-entry-content\">");
177 html.push_str(&content_html);
178 html.push_str("</div>");
179
180 // Expand/collapse label (clickable, targets the checkbox)
181 html.push_str("<label class=\"embed-entry-expand\" for=\"");
182 html.push_str(&toggle_id);
183 html.push_str("\"></label>");
184
185 html.push_str("</div>");
186
187 Ok(html)
188}
189
190/// Fetch and render a notebook entry with full markdown rendering
191///
192/// Renders the entry content as HTML in a scrollable container with title and author info.
193pub async fn fetch_and_render_whitewind_entry<A>(
194 uri: &AtUri<'_>,
195 agent: &A,
196) -> Result<String, AtProtoPreprocessError>
197where
198 A: AgentSessionExt,
199{
200 use crate::atproto::writer::ClientWriter;
201 use crate::default_md_options;
202 use markdown_weaver::Parser;
203 use weaver_api::com_whtwnd::blog::entry::Entry as WhitewindEntry;
204 use weaver_common::agent::WeaverExt;
205
206 let (_, profile) = agent
207 .hydrate_profile_view(uri.authority())
208 .await
209 .map_err(|e| {
210 AtProtoPreprocessError::FetchFailed(format!("Profile fetch failed: {:?}", e))
211 })?;
212 let entry_uri = WhitewindEntry::uri(uri.to_cowstr()).map_err(|e| {
213 AtProtoPreprocessError::FetchFailed(format!("Entry URI incorrect: {:?}", e))
214 })?;
215 let entry = agent
216 .fetch_record(&entry_uri)
217 .await
218 .map_err(|e| AtProtoPreprocessError::FetchFailed(format!("Entry fetch failed: {:?}", e)))?;
219
220 // Render the markdown content to HTML
221 let content = entry.value.content.as_ref();
222 let parser = Parser::new_ext(content, default_md_options()).into_offset_iter();
223 let mut content_html = String::new();
224 ClientWriter::<_, _, ()>::new(parser, &mut content_html, content)
225 .run()
226 .map_err(|e| {
227 AtProtoPreprocessError::FetchFailed(format!("Markdown render failed: {:?}", e))
228 })?;
229
230 // Generate unique ID for the toggle checkbox
231 let toggle_id = format!(
232 "entry-toggle-{}",
233 entry.uri.rkey().expect("valid rkey").as_ref()
234 );
235 let entry = entry.value;
236
237 // Build the embed HTML
238 let mut html = String::new();
239 html.push_str("<div class=\"atproto-embed atproto-entry\" contenteditable=\"false\">");
240
241 // Hidden checkbox for expand/collapse (must come before content for CSS sibling selector)
242 html.push_str("<input type=\"checkbox\" class=\"embed-entry-toggle\" id=\"");
243 html.push_str(&toggle_id);
244 html.push_str("\">");
245
246 // Header with title and author
247 html.push_str("<div class=\"embed-entry-header\">");
248
249 // Title
250 html.push_str("<span class=\"embed-entry-title\">");
251 html.push_str(&html_escape(
252 entry.title.as_ref().unwrap_or(&"".to_cowstr()),
253 ));
254 html.push_str("</span>");
255
256 // Author info - just show handle (keep it simple for entry embeds)
257 let handle = match &profile.inner {
258 ProfileDataViewInner::ProfileView(p) => p.handle.as_ref(),
259 ProfileDataViewInner::ProfileViewDetailed(p) => p.handle.as_ref(),
260 ProfileDataViewInner::TangledProfileView(p) => p.handle.as_ref(),
261 ProfileDataViewInner::Unknown(_) => "",
262 };
263 if !handle.is_empty() {
264 html.push_str("<span class=\"embed-entry-author\">@");
265 html.push_str(&html_escape(handle));
266 html.push_str("</span>");
267 }
268
269 html.push_str("</div>"); // end header
270
271 // Scrollable content container
272 html.push_str("<div class=\"embed-entry-content\">");
273 html.push_str(&content_html);
274 html.push_str("</div>");
275
276 // Expand/collapse label (clickable, targets the checkbox)
277 html.push_str("<label class=\"embed-entry-expand\" for=\"");
278 html.push_str(&toggle_id);
279 html.push_str("\"></label>");
280
281 html.push_str("</div>");
282
283 Ok(html)
284}
285
286/// Fetch and render a Leaflet document as HTML
287///
288/// Renders the document's pages (currently only LinearDocument is supported).
289pub async fn fetch_and_render_leaflet<A>(
290 uri: &AtUri<'_>,
291 agent: &A,
292) -> Result<String, AtProtoPreprocessError>
293where
294 A: AgentSessionExt,
295{
296 use crate::leaflet::{LeafletRenderContext, render_linear_document};
297 use weaver_api::pub_leaflet::document::{Document, DocumentPagesItem};
298 use weaver_api::pub_leaflet::publication::Publication;
299
300 let doc_uri = Document::uri(uri.to_cowstr()).map_err(|e| {
301 AtProtoPreprocessError::FetchFailed(format!("Invalid document URI: {:?}", e))
302 })?;
303
304 let doc = agent.fetch_record(&doc_uri).await.map_err(|e| {
305 AtProtoPreprocessError::FetchFailed(format!("Document fetch failed: {:?}", e))
306 })?;
307
308 // Fetch publication to get base_path for external link
309 let publication_base_path: Option<String> = if let Some(pub_uri) = &doc.value.publication {
310 if let Ok(pub_typed_uri) = Publication::uri(pub_uri.as_ref()) {
311 agent
312 .fetch_record(&pub_typed_uri)
313 .await
314 .ok()
315 .and_then(|rec| rec.value.base_path.as_ref().map(|p| p.as_ref().to_string()))
316 } else {
317 None
318 }
319 } else {
320 None
321 };
322
323 // Get author DID and handle
324 use jacquard::types::string::{Did, Handle};
325 let (author_did, author_handle): (Did<'static>, Option<Handle<'static>>) =
326 match &doc.value.author {
327 AtIdentifier::Did(d) => {
328 let did = d.clone().into_static();
329 let handle = agent
330 .resolve_did_doc_owned(d)
331 .await
332 .ok()
333 .and_then(|doc| doc.handles().first().cloned());
334 (did, handle)
335 }
336 AtIdentifier::Handle(h) => {
337 let handle = Some(h.clone().into_static());
338 let did = agent
339 .resolve_handle(h)
340 .await
341 .map(|d| d.into_static())
342 .map_err(|e| {
343 AtProtoPreprocessError::FetchFailed(format!(
344 "Handle resolution failed: {:?}",
345 e
346 ))
347 })?;
348 (did, handle)
349 }
350 };
351
352 let ctx = LeafletRenderContext::new(author_did);
353
354 // Generate unique toggle ID
355 let rkey = uri.rkey().map(|r| r.as_ref()).unwrap_or("unknown");
356 let toggle_id = format!("leaflet-toggle-{}", rkey);
357
358 let mut html = String::new();
359 html.push_str("<div class=\"atproto-embed atproto-entry\" contenteditable=\"false\">");
360
361 // Hidden checkbox for expand/collapse
362 html.push_str("<input type=\"checkbox\" class=\"embed-entry-toggle\" id=\"");
363 html.push_str(&toggle_id);
364 html.push_str("\">");
365
366 // Header with title and author
367 html.push_str("<div class=\"embed-entry-header\">");
368
369 // Title as link if we have the publication base_path
370 if let Some(base_path) = &publication_base_path {
371 html.push_str("<a class=\"embed-entry-title\" href=\"https://");
372 html.push_str(&html_escape(base_path));
373 html.push('/');
374 html.push_str(&html_escape(rkey));
375 html.push_str("\" target=\"_blank\" rel=\"noopener\">");
376 html.push_str(&html_escape(doc.value.title.as_ref()));
377 html.push_str("</a>");
378 } else {
379 html.push_str("<span class=\"embed-entry-title\">");
380 html.push_str(&html_escape(doc.value.title.as_ref()));
381 html.push_str("</span>");
382 }
383
384 // Author info
385 if let Some(handle) = &author_handle {
386 html.push_str("<span class=\"embed-entry-author\">@");
387 html.push_str(&html_escape(handle.as_ref()));
388 html.push_str("</span>");
389 }
390
391 html.push_str("</div>"); // end header
392
393 // Scrollable content container
394 html.push_str("<div class=\"embed-entry-content\">");
395
396 // Render each page
397 for page in &doc.value.pages {
398 match page {
399 DocumentPagesItem::LinearDocument(linear_doc) => {
400 html.push_str(&render_linear_document(linear_doc, &ctx, agent).await);
401 }
402 DocumentPagesItem::Canvas(_) => {
403 html.push_str("<div class=\"embed-video-placeholder\">[Canvas layout not yet supported]</div>");
404 }
405 DocumentPagesItem::Unknown(_) => {
406 html.push_str("<div class=\"embed-video-placeholder\">[Unknown page type]</div>");
407 }
408 }
409 }
410
411 html.push_str("</div>"); // end content
412
413 // Expand/collapse label
414 html.push_str("<label class=\"embed-entry-expand\" for=\"");
415 html.push_str(&toggle_id);
416 html.push_str("\"></label>");
417
418 html.push_str("</div>");
419
420 Ok(html)
421}
422
423#[cfg(feature = "pckt")]
424/// Fetch and render a pckt/site.standard document as HTML
425///
426/// Renders the document's content blocks using the pckt block renderer.
427/// Supports both `site.standard.document` and `blog.pckt.document` (which wraps site.standard).
428///
429/// TODO: site.standard.document is designed to be a shared envelope for different block formats.
430/// Currently hardcoded to use pckt block renderer, but should probe the first block's $type
431/// and dispatch to the appropriate renderer (blog.pckt.block.* → pckt, pub.leaflet.blocks.* → leaflet,
432/// sh.weaver.block.* → weaver, etc).
433pub async fn fetch_and_render_pckt<A>(
434 uri: &AtUri<'_>,
435 agent: &A,
436) -> Result<String, AtProtoPreprocessError>
437where
438 A: AgentSessionExt,
439{
440 use crate::pckt::{PcktRenderContext, render_content_blocks};
441 use weaver_api::site_standard::document::Document as SiteStandardDocument;
442
443 // Fetch the record as untyped first to check the structure
444 let output = agent
445 .fetch_record_slingshot(uri)
446 .await
447 .map_err(|e| AtProtoPreprocessError::FetchFailed(format!("{:?}", e)))?;
448
449 // Extract the site.standard.document - either directly or from blog.pckt.document wrapper
450 let doc: SiteStandardDocument<'_> = if output
451 .value
452 .type_discriminator()
453 .map(|t| t == "blog.pckt.document")
454 .unwrap_or(false)
455 {
456 // blog.pckt.document wraps site.standard.document in a "document" field
457 let pckt_doc =
458 jacquard::from_data::<weaver_api::blog_pckt::document::Document>(&output.value)
459 .map_err(|e| {
460 AtProtoPreprocessError::FetchFailed(format!("Parse error: {:?}", e))
461 })?;
462 pckt_doc.document
463 } else {
464 // Direct site.standard.document
465 jacquard::from_data::<SiteStandardDocument>(&output.value)
466 .map_err(|e| AtProtoPreprocessError::FetchFailed(format!("Parse error: {:?}", e)))?
467 };
468
469 // Fetch publication to get base URL for external link
470 use weaver_api::site_standard::publication::Publication;
471 let Uri::At(uri) = &doc.site else {
472 return Err(AtProtoPreprocessError::FetchFailed(
473 "Invalid site URI".to_string(),
474 ));
475 };
476 let publication_url: Option<String> =
477 agent
478 .fetch_record_slingshot(uri)
479 .await
480 .ok()
481 .and_then(|rec| {
482 jacquard::from_data::<Publication>(&rec.value)
483 .ok()
484 .map(|pub_rec| pub_rec.url.as_ref().to_string())
485 });
486
487 // Get author DID and handle from URI authority
488 use jacquard::types::{
489 string::{Did, Handle},
490 uri::Uri,
491 };
492 let (author_did, author_handle): (Did<'static>, Option<Handle<'static>>) = match uri.authority()
493 {
494 jacquard::types::ident::AtIdentifier::Did(d) => {
495 let did = d.clone().into_static();
496 let handle = agent
497 .resolve_did_doc_owned(d)
498 .await
499 .ok()
500 .and_then(|doc| doc.handles().first().cloned());
501 (did, handle)
502 }
503 jacquard::types::ident::AtIdentifier::Handle(h) => {
504 let handle = Some(h.clone().into_static());
505 let did = agent
506 .resolve_handle(h)
507 .await
508 .map(|d| d.into_static())
509 .map_err(|e| {
510 AtProtoPreprocessError::FetchFailed(format!(
511 "Handle resolution failed: {:?}",
512 e
513 ))
514 })?;
515 (did, handle)
516 }
517 };
518
519 let ctx = PcktRenderContext::new(author_did);
520
521 // Generate unique toggle ID
522 let rkey = uri.rkey().map(|r| r.as_ref()).unwrap_or("unknown");
523 let toggle_id = format!("pckt-toggle-{}", rkey);
524
525 // Document path for URL (use path field if present, otherwise rkey)
526 let doc_path = doc.path.as_ref().map(|p| p.as_ref()).unwrap_or(rkey);
527
528 let mut html = String::new();
529 html.push_str("<div class=\"atproto-embed atproto-entry\" contenteditable=\"false\">");
530
531 // Hidden checkbox for expand/collapse
532 html.push_str("<input type=\"checkbox\" class=\"embed-entry-toggle\" id=\"");
533 html.push_str(&toggle_id);
534 html.push_str("\">");
535
536 // Header with title and author
537 html.push_str("<div class=\"embed-entry-header\">");
538
539 // Title as link if we have the publication URL
540 if let Some(base_url) = &publication_url {
541 let base_url = base_url.trim_end_matches('/');
542 html.push_str("<a class=\"embed-entry-title\" href=\"");
543 html.push_str(&html_escape(base_url));
544 html.push('/');
545 html.push_str(&html_escape(doc_path));
546 html.push_str("\" target=\"_blank\" rel=\"noopener\">");
547 html.push_str(&html_escape(doc.title.as_ref()));
548 html.push_str("</a>");
549 } else {
550 html.push_str("<span class=\"embed-entry-title\">");
551 html.push_str(&html_escape(doc.title.as_ref()));
552 html.push_str("</span>");
553 }
554
555 // Author info
556 if let Some(handle) = &author_handle {
557 html.push_str("<span class=\"embed-entry-author\">@");
558 html.push_str(&html_escape(handle.as_ref()));
559 html.push_str("</span>");
560 }
561
562 html.push_str("</div>"); // end header
563
564 // Scrollable content container
565 html.push_str("<div class=\"embed-entry-content\">");
566
567 // Render content blocks if present
568 if let Some(content) = &doc.content {
569 html.push_str(&render_content_blocks(vec![content.clone()].as_slice(), &ctx, agent).await);
570 } else if let Some(text_content) = &doc.text_content {
571 // Fallback to text_content if no structured content
572 html.push_str("<p>");
573 html.push_str(&html_escape(text_content.as_ref()));
574 html.push_str("</p>");
575 }
576
577 html.push_str("</div>"); // end content
578
579 // Expand/collapse label
580 html.push_str("<label class=\"embed-entry-expand\" for=\"");
581 html.push_str(&toggle_id);
582 html.push_str("\"></label>");
583
584 html.push_str("</div>");
585
586 Ok(html)
587}
588
589/// Fetch and render any AT URI, dispatching to the appropriate renderer based on collection.
590///
591/// Uses typed fetchers for known collections (posts, profiles) and falls back to
592/// generic rendering for unknown types.
593pub async fn fetch_and_render<A>(
594 uri: &AtUri<'_>,
595 agent: &A,
596) -> Result<String, AtProtoPreprocessError>
597where
598 A: AgentSessionExt,
599{
600 let collection = uri.collection().map(|c| c.as_ref());
601
602 match collection {
603 Some("app.bsky.feed.post") => {
604 let result = fetch_and_render_post(uri, agent).await;
605 result
606 }
607 Some("app.bsky.actor.profile") => {
608 // Extract DID from URI authority
609 fetch_and_render_profile(uri.authority(), agent).await
610 }
611 Some("sh.weaver.notebook.entry") => fetch_and_render_entry(uri, agent).await,
612 Some("com.whtwnd.blog.entry") => fetch_and_render_whitewind_entry(uri, agent).await,
613 Some("pub.leaflet.document") => fetch_and_render_leaflet(uri, agent).await,
614 #[cfg(feature = "pckt")]
615 Some("site.standard.document") | Some("blog.pckt.document") => {
616 fetch_and_render_pckt(uri, agent).await
617 }
618 None => fetch_and_render_profile(uri.authority(), agent).await,
619 _ => fetch_and_render_generic(uri, agent).await,
620 }
621}
622
623/// Render any AT Protocol record synchronously from pre-fetched data.
624///
625/// This is the pure sync version of `fetch_and_render`. Takes a URI and the
626/// record data, dispatches to the appropriate renderer based on collection type.
627///
628/// # Arguments
629///
630/// * `uri` - The AT URI of the record
631/// * `data` - The record data (either raw record or hydrated view type)
632/// * `fallback_author` - Optional author profile to use when data is a raw record
633/// without embedded author info. Used for entries and other content types.
634/// * `resolved_content` - Optional pre-resolved embeds for rendering markdown with embeds
635///
636/// # Supported collections
637///
638/// **Profiles** (pass hydrated view from appview):
639/// - `app.bsky.actor.profile` - Bluesky profiles (ProfileViewDetailed from getProfile)
640/// - `sh.weaver.actor.profile` - Weaver profiles (ProfileView from weaver appview)
641/// - Tangled profiles also supported via type discriminator
642///
643/// **Posts**:
644/// - `app.bsky.feed.post` - Posts (PostView from getPosts, or raw record for basic)
645///
646/// **Entries** (pass view type for author info, or provide fallback_author):
647/// - `sh.weaver.notebook.entry` - Weaver entries (EntryView or raw Entry)
648/// - `com.whtwnd.blog.entry` - Whitewind entries
649/// - `pub.leaflet.document` - Leaflet documents
650/// - `site.standard.document` / `blog.pckt.document` - pckt documents
651///
652/// **Lists & Feeds**:
653/// - `app.bsky.graph.list` - User lists
654/// - `app.bsky.feed.generator` - Custom feeds
655/// - `app.bsky.graph.starterpack` - Starter packs
656/// - `app.bsky.labeler.service` - Labelers
657///
658/// **Other** - Generic field display for unknown types
659pub fn render_record(
660 uri: &AtUri<'_>,
661 data: &Data<'_>,
662 fallback_author: Option<&weaver_api::sh_weaver::actor::ProfileDataView<'_>>,
663 resolved_content: Option<&weaver_common::ResolvedContent>,
664) -> Result<String, AtProtoPreprocessError> {
665 let collection = uri.collection().map(|c| c.as_ref());
666
667 match collection {
668 // No collection = just an identity reference, try as profile
669 None => render_profile_from_data(data, uri),
670
671 // Profiles - try multiple profile view types
672 Some("app.bsky.actor.profile") | Some("sh.weaver.actor.profile") => {
673 render_profile_from_data(data, uri)
674 }
675
676 // Posts
677 Some("app.bsky.feed.post") => {
678 // Try PostView first (from getPosts), fall back to raw record
679 if let Ok(post_view) = jacquard::from_data::<PostView>(data) {
680 render_post_view(&post_view, uri)
681 } else {
682 render_basic_post(data, uri)
683 }
684 }
685
686 // Lists
687 Some("app.bsky.graph.list") => render_list_record(data, uri),
688
689 // Custom feeds
690 Some("app.bsky.feed.generator") => render_generator_record(data, uri),
691
692 // Starter packs
693 Some("app.bsky.graph.starterpack") => render_starterpack_record(data, uri),
694
695 // Labelers
696 Some("app.bsky.labeler.service") => render_labeler_record(data, uri),
697
698 // Weaver entries
699 Some("sh.weaver.notebook.entry") => {
700 render_weaver_entry_record(data, uri, fallback_author, resolved_content)
701 }
702
703 // Whitewind entries
704 Some("com.whtwnd.blog.entry") => {
705 render_whitewind_entry_record(data, uri, fallback_author, resolved_content)
706 }
707
708 // Leaflet documents
709 Some("pub.leaflet.document") => {
710 render_leaflet_record(data, uri, fallback_author, resolved_content)
711 }
712
713 // pckt / site.standard documents
714 #[cfg(feature = "pckt")]
715 Some("site.standard.document") | Some("blog.pckt.document") => {
716 render_site_standard_record(data, uri, fallback_author, resolved_content)
717 }
718
719 // Default: generic rendering
720 _ => render_generic_record(data, uri),
721 }
722}
723
724/// Try to render profile data by detecting the view type.
725fn render_profile_from_data(
726 data: &Data<'_>,
727 uri: &AtUri<'_>,
728) -> Result<String, AtProtoPreprocessError> {
729 // Check type discriminator first for union types
730 if let Some(type_disc) = data.type_discriminator() {
731 match type_disc {
732 "app.bsky.actor.defs#profileViewDetailed" => {
733 if let Ok(profile) =
734 jacquard::from_data::<weaver_api::app_bsky::actor::ProfileViewDetailed>(data)
735 {
736 return render_profile_data_view(&ProfileDataViewInner::ProfileViewDetailed(
737 Box::new(profile),
738 ));
739 }
740 }
741 "sh.weaver.actor.defs#profileView" => {
742 if let Ok(profile) =
743 jacquard::from_data::<weaver_api::sh_weaver::actor::ProfileView>(data)
744 {
745 return render_profile_data_view(&ProfileDataViewInner::ProfileView(Box::new(
746 profile,
747 )));
748 }
749 }
750 "sh.weaver.actor.defs#tangledProfileView" => {
751 if let Ok(profile) =
752 jacquard::from_data::<weaver_api::sh_weaver::actor::TangledProfileView>(data)
753 {
754 return render_profile_data_view(&ProfileDataViewInner::TangledProfileView(
755 Box::new(profile),
756 ));
757 }
758 }
759 _ => {}
760 }
761 }
762
763 // Try each type without discriminator
764 if let Ok(profile) =
765 jacquard::from_data::<weaver_api::app_bsky::actor::ProfileViewDetailed>(data)
766 {
767 return render_profile_data_view(&ProfileDataViewInner::ProfileViewDetailed(Box::new(
768 profile,
769 )));
770 }
771 if let Ok(profile) = jacquard::from_data::<weaver_api::sh_weaver::actor::ProfileView>(data) {
772 return render_profile_data_view(&ProfileDataViewInner::ProfileView(Box::new(profile)));
773 }
774 if let Ok(profile) =
775 jacquard::from_data::<weaver_api::sh_weaver::actor::TangledProfileView>(data)
776 {
777 return render_profile_data_view(&ProfileDataViewInner::TangledProfileView(Box::new(
778 profile,
779 )));
780 }
781
782 // Fall back to generic
783 render_generic_record(data, uri)
784}
785
786/// Render a list record.
787fn render_list_record(data: &Data<'_>, uri: &AtUri<'_>) -> Result<String, AtProtoPreprocessError> {
788 let list = match jacquard::from_data::<weaver_api::app_bsky::graph::list::List>(data) {
789 Ok(l) => l,
790 Err(_) => return render_generic_record(data, uri),
791 };
792
793 let mut html = String::new();
794 html.push_str("<span class=\"atproto-embed atproto-record\" contenteditable=\"false\">");
795 html.push_str("<span class=\"embed-type\">List</span>");
796 html.push_str("<span class=\"embed-author-name\">");
797 html.push_str(&html_escape(list.name.as_ref()));
798 html.push_str("</span>");
799 if let Some(desc) = &list.description {
800 html.push_str("<span class=\"embed-description\">");
801 html.push_str(&html_escape(desc.as_ref()));
802 html.push_str("</span>");
803 }
804 html.push_str("</span>");
805
806 Ok(html)
807}
808
809/// Render a feed generator record.
810fn render_generator_record(
811 data: &Data<'_>,
812 uri: &AtUri<'_>,
813) -> Result<String, AtProtoPreprocessError> {
814 let generator =
815 match jacquard::from_data::<weaver_api::app_bsky::feed::generator::Generator>(data) {
816 Ok(g) => g,
817 Err(_) => return render_generic_record(data, uri),
818 };
819
820 let mut html = String::new();
821 html.push_str("<span class=\"atproto-embed atproto-record\" contenteditable=\"false\">");
822 html.push_str("<span class=\"embed-type\">Custom Feed</span>");
823 html.push_str("<span class=\"embed-author-name\">");
824 html.push_str(&html_escape(generator.display_name.as_ref()));
825 html.push_str("</span>");
826 if let Some(desc) = &generator.description {
827 html.push_str("<span class=\"embed-description\">");
828 html.push_str(&html_escape(desc.as_ref()));
829 html.push_str("</span>");
830 }
831 html.push_str("</span>");
832
833 Ok(html)
834}
835
836/// Render a starter pack record.
837fn render_starterpack_record(
838 data: &Data<'_>,
839 uri: &AtUri<'_>,
840) -> Result<String, AtProtoPreprocessError> {
841 let sp =
842 match jacquard::from_data::<weaver_api::app_bsky::graph::starterpack::Starterpack>(data) {
843 Ok(s) => s,
844 Err(_) => return render_generic_record(data, uri),
845 };
846
847 let mut html = String::new();
848 html.push_str("<span class=\"atproto-embed atproto-record\" contenteditable=\"false\">");
849 html.push_str("<span class=\"embed-type\">Starter Pack</span>");
850 html.push_str("<span class=\"embed-author-name\">");
851 html.push_str(&html_escape(sp.name.as_ref()));
852 html.push_str("</span>");
853 if let Some(desc) = &sp.description {
854 html.push_str("<span class=\"embed-description\">");
855 html.push_str(&html_escape(desc.as_ref()));
856 html.push_str("</span>");
857 }
858 html.push_str("</span>");
859
860 Ok(html)
861}
862
863/// Render a labeler service record.
864fn render_labeler_record(
865 data: &Data<'_>,
866 uri: &AtUri<'_>,
867) -> Result<String, AtProtoPreprocessError> {
868 let labeler = match jacquard::from_data::<weaver_api::app_bsky::labeler::service::Service>(data)
869 {
870 Ok(l) => l,
871 Err(_) => return render_generic_record(data, uri),
872 };
873
874 let mut html = String::new();
875 html.push_str("<span class=\"atproto-embed atproto-record\" contenteditable=\"false\">");
876 html.push_str("<span class=\"embed-type\">Labeler</span>");
877
878 // Labeler policies
879 html.push_str("<span class=\"embed-fields\">");
880 let label_count = labeler.policies.label_values.len();
881 html.push_str("<span class=\"embed-field\">");
882 html.push_str(&label_count.to_string());
883 html.push_str(" label");
884 if label_count != 1 {
885 html.push_str("s");
886 }
887 html.push_str(" defined</span>");
888 html.push_str("</span>");
889
890 html.push_str("</span>");
891
892 Ok(html)
893}
894
895/// Render a weaver notebook entry record.
896///
897/// Accepts either:
898/// - `EntryView` (from appview) - includes author info
899/// - Raw `Entry` record with optional fallback_author
900fn render_weaver_entry_record(
901 data: &Data<'_>,
902 uri: &AtUri<'_>,
903 fallback_author: Option<&weaver_api::sh_weaver::actor::ProfileDataView<'_>>,
904 resolved_content: Option<&weaver_common::ResolvedContent>,
905) -> Result<String, AtProtoPreprocessError> {
906 use crate::atproto::writer::ClientWriter;
907 use crate::default_md_options;
908 use markdown_weaver::Parser;
909 use weaver_api::sh_weaver::notebook::EntryView;
910
911 // Try to parse as EntryView first (has author info), then raw Entry
912 let (title, content, author_handle): (String, String, Option<String>) = if let Ok(view) =
913 jacquard::from_data::<EntryView>(data)
914 {
915 // EntryView has embedded record data, extract content from it
916 let content = view
917 .record
918 .query("content")
919 .single()
920 .and_then(|d| d.as_str())
921 .unwrap_or_default()
922 .to_string();
923 let title = view
924 .record
925 .query("title")
926 .single()
927 .and_then(|d| d.as_str())
928 .unwrap_or_default()
929 .to_string();
930 let handle = view
931 .authors
932 .first()
933 .and_then(|author| extract_handle_from_profile_data_view(&author.record.inner));
934 (title, content, handle.map(|h| h.to_string()))
935 } else if let Ok(entry) =
936 jacquard::from_data::<weaver_api::sh_weaver::notebook::entry::Entry>(data)
937 {
938 let handle = fallback_author.and_then(|p| extract_handle_from_profile_data_view(&p.inner));
939 (
940 entry.title.as_ref().to_string(),
941 entry.content.as_ref().to_string(),
942 handle.map(|h| h.to_string()),
943 )
944 } else {
945 return render_generic_record(data, uri);
946 };
947
948 // Render markdown content to HTML using resolved_content for embeds
949 let parser = Parser::new_ext(&content, default_md_options()).into_offset_iter();
950 let mut content_html = String::new();
951 if let Some(resolved) = resolved_content {
952 ClientWriter::new(parser, &mut content_html, &content)
953 .with_embed_provider(resolved)
954 .run()
955 .map_err(|e| {
956 AtProtoPreprocessError::FetchFailed(format!("Markdown render failed: {:?}", e))
957 })?;
958 } else {
959 ClientWriter::<_, _, ()>::new(parser, &mut content_html, &content)
960 .run()
961 .map_err(|e| {
962 AtProtoPreprocessError::FetchFailed(format!("Markdown render failed: {:?}", e))
963 })?;
964 }
965
966 // Generate unique ID for the toggle checkbox
967 let rkey = uri.rkey().map(|r| r.as_ref()).unwrap_or("unknown");
968 let toggle_id = format!("entry-toggle-{}", rkey);
969
970 // Build the embed HTML - matches fetch_and_render_entry exactly
971 let mut html = String::new();
972 html.push_str("<div class=\"atproto-embed atproto-entry\" contenteditable=\"false\">");
973
974 // Hidden checkbox for expand/collapse (must come before content for CSS sibling selector)
975 html.push_str("<input type=\"checkbox\" class=\"embed-entry-toggle\" id=\"");
976 html.push_str(&toggle_id);
977 html.push_str("\">");
978
979 // Header with title and author
980 html.push_str("<div class=\"embed-entry-header\">");
981
982 // Title
983 html.push_str("<span class=\"embed-entry-title\">");
984 html.push_str(&html_escape(&title));
985 html.push_str("</span>");
986
987 // Author info - just show handle (keep it simple for entry embeds)
988 if let Some(ref handle) = author_handle {
989 if !handle.is_empty() {
990 html.push_str("<span class=\"embed-entry-author\">@");
991 html.push_str(&html_escape(handle));
992 html.push_str("</span>");
993 }
994 }
995
996 html.push_str("</div>"); // end header
997
998 // Scrollable content container
999 html.push_str("<div class=\"embed-entry-content\">");
1000 html.push_str(&content_html);
1001 html.push_str("</div>");
1002
1003 // Expand/collapse label (clickable, targets the checkbox)
1004 html.push_str("<label class=\"embed-entry-expand\" for=\"");
1005 html.push_str(&toggle_id);
1006 html.push_str("\"></label>");
1007
1008 html.push_str("</div>");
1009
1010 Ok(html)
1011}
1012
1013/// Extract handle from ProfileDataViewInner.
1014fn extract_handle_from_profile_data_view<'a>(
1015 inner: &'a ProfileDataViewInner<'a>,
1016) -> Option<&'a str> {
1017 match inner {
1018 ProfileDataViewInner::ProfileView(p) => Some(p.handle.as_ref()),
1019 ProfileDataViewInner::ProfileViewDetailed(p) => Some(p.handle.as_ref()),
1020 ProfileDataViewInner::TangledProfileView(p) => Some(p.handle.as_ref()),
1021 ProfileDataViewInner::Unknown(_) => None,
1022 }
1023}
1024
1025fn extract_did_from_profile_data_view(
1026 inner: &ProfileDataViewInner<'_>,
1027) -> Option<jacquard::types::string::Did<'static>> {
1028 use jacquard::IntoStatic;
1029 match inner {
1030 ProfileDataViewInner::ProfileView(p) => Some(p.did.clone().into_static()),
1031 ProfileDataViewInner::ProfileViewDetailed(p) => Some(p.did.clone().into_static()),
1032 ProfileDataViewInner::TangledProfileView(p) => Some(p.did.clone().into_static()),
1033 ProfileDataViewInner::Unknown(_) => None,
1034 }
1035}
1036
1037/// Render a whitewind blog entry record.
1038///
1039/// Whitewind entries don't have a view type, so author info comes from fallback_author.
1040fn render_whitewind_entry_record(
1041 data: &Data<'_>,
1042 uri: &AtUri<'_>,
1043 fallback_author: Option<&weaver_api::sh_weaver::actor::ProfileDataView<'_>>,
1044 resolved_content: Option<&weaver_common::ResolvedContent>,
1045) -> Result<String, AtProtoPreprocessError> {
1046 use crate::atproto::writer::ClientWriter;
1047 use crate::default_md_options;
1048 use markdown_weaver::Parser;
1049
1050 let entry = match jacquard::from_data::<weaver_api::com_whtwnd::blog::entry::Entry>(data) {
1051 Ok(e) => e,
1052 Err(_) => return render_generic_record(data, uri),
1053 };
1054
1055 // Render the markdown content to HTML using resolved_content for embeds
1056 let content = entry.content.as_ref();
1057 let parser = Parser::new_ext(content, default_md_options()).into_offset_iter();
1058 let mut content_html = String::new();
1059 if let Some(resolved) = resolved_content {
1060 ClientWriter::new(parser, &mut content_html, content)
1061 .with_embed_provider(resolved)
1062 .run()
1063 .map_err(|e| {
1064 AtProtoPreprocessError::FetchFailed(format!("Markdown render failed: {:?}", e))
1065 })?;
1066 } else {
1067 ClientWriter::<_, _, ()>::new(parser, &mut content_html, content)
1068 .run()
1069 .map_err(|e| {
1070 AtProtoPreprocessError::FetchFailed(format!("Markdown render failed: {:?}", e))
1071 })?;
1072 }
1073
1074 // Generate unique ID for the toggle checkbox
1075 let rkey = uri.rkey().map(|r| r.as_ref()).unwrap_or("unknown");
1076 let toggle_id = format!("entry-toggle-{}", rkey);
1077
1078 // Build the embed HTML - matches fetch_and_render_whitewind_entry exactly
1079 let mut html = String::new();
1080 html.push_str("<div class=\"atproto-embed atproto-entry\" contenteditable=\"false\">");
1081
1082 // Hidden checkbox for expand/collapse (must come before content for CSS sibling selector)
1083 html.push_str("<input type=\"checkbox\" class=\"embed-entry-toggle\" id=\"");
1084 html.push_str(&toggle_id);
1085 html.push_str("\">");
1086
1087 // Header with title and author
1088 html.push_str("<div class=\"embed-entry-header\">");
1089
1090 // Title
1091 html.push_str("<span class=\"embed-entry-title\">");
1092 html.push_str(&html_escape(
1093 entry.title.as_ref().map(|t| t.as_ref()).unwrap_or(""),
1094 ));
1095 html.push_str("</span>");
1096
1097 // Author info - just show handle (keep it simple for entry embeds)
1098 if let Some(author) = fallback_author {
1099 let handle = extract_handle_from_profile_data_view(&author.inner).unwrap_or("");
1100 if !handle.is_empty() {
1101 html.push_str("<span class=\"embed-entry-author\">@");
1102 html.push_str(&html_escape(handle));
1103 html.push_str("</span>");
1104 }
1105 }
1106
1107 html.push_str("</div>"); // end header
1108
1109 // Scrollable content container
1110 html.push_str("<div class=\"embed-entry-content\">");
1111 html.push_str(&content_html);
1112 html.push_str("</div>");
1113
1114 // Expand/collapse label (clickable, targets the checkbox)
1115 html.push_str("<label class=\"embed-entry-expand\" for=\"");
1116 html.push_str(&toggle_id);
1117 html.push_str("\"></label>");
1118
1119 html.push_str("</div>");
1120
1121 Ok(html)
1122}
1123
1124/// Render a leaflet document record.
1125///
1126/// Uses the sync block renderer to render page content directly. Embedded posts
1127/// within the document will be looked up from resolved_content by their AT URI.
1128fn render_leaflet_record(
1129 data: &Data<'_>,
1130 uri: &AtUri<'_>,
1131 fallback_author: Option<&weaver_api::sh_weaver::actor::ProfileDataView<'_>>,
1132 resolved_content: Option<&weaver_common::ResolvedContent>,
1133) -> Result<String, AtProtoPreprocessError> {
1134 use crate::leaflet::{LeafletRenderContext, render_linear_document_sync};
1135 use weaver_api::pub_leaflet::document::{Document, DocumentPagesItem};
1136
1137 let doc = match jacquard::from_data::<Document>(data) {
1138 Ok(d) => d,
1139 Err(_) => return render_generic_record(data, uri),
1140 };
1141
1142 // Get author DID from fallback_author or from document/URI.
1143 let author_did = if let Some(author) = fallback_author {
1144 extract_did_from_profile_data_view(&author.inner)
1145 } else {
1146 None
1147 }
1148 .or_else(|| {
1149 // Try to get DID from document author field.
1150 match &doc.author {
1151 jacquard::types::ident::AtIdentifier::Did(d) => Some(d.clone().into_static()),
1152 _ => None,
1153 }
1154 })
1155 .or_else(|| {
1156 // Fall back to URI authority if it's a DID.
1157 jacquard::types::string::Did::new(uri.authority().as_ref())
1158 .ok()
1159 .map(|d| d.into_static())
1160 });
1161
1162 let ctx = author_did
1163 .map(LeafletRenderContext::new)
1164 .unwrap_or_else(|| {
1165 LeafletRenderContext::new(jacquard::types::string::Did::raw("did:plc:unknown".into()))
1166 });
1167
1168 // Generate unique toggle ID.
1169 let rkey = uri.rkey().map(|r| r.as_ref()).unwrap_or("unknown");
1170 let toggle_id = format!("leaflet-toggle-{}", rkey);
1171
1172 let mut html = String::new();
1173 html.push_str("<div class=\"atproto-embed atproto-entry\" contenteditable=\"false\">");
1174
1175 // Hidden checkbox for expand/collapse.
1176 html.push_str("<input type=\"checkbox\" class=\"embed-entry-toggle\" id=\"");
1177 html.push_str(&toggle_id);
1178 html.push_str("\">");
1179
1180 // Header with title and author.
1181 html.push_str("<div class=\"embed-entry-header\">");
1182
1183 // Title (no link in sync version since we don't have publication base_path).
1184 html.push_str("<span class=\"embed-entry-title\">");
1185 html.push_str(&html_escape(doc.title.as_ref()));
1186 html.push_str("</span>");
1187
1188 // Author info.
1189 if let Some(author) = fallback_author {
1190 let handle = extract_handle_from_profile_data_view(&author.inner).unwrap_or("");
1191 if !handle.is_empty() {
1192 html.push_str("<span class=\"embed-entry-author\">@");
1193 html.push_str(&html_escape(handle));
1194 html.push_str("</span>");
1195 }
1196 }
1197
1198 html.push_str("</div>"); // end header
1199
1200 // Scrollable content container.
1201 html.push_str("<div class=\"embed-entry-content\">");
1202
1203 // Render each page using the sync block renderer.
1204 for page in &doc.pages {
1205 match page {
1206 DocumentPagesItem::LinearDocument(linear_doc) => {
1207 html.push_str(&render_linear_document_sync(
1208 linear_doc,
1209 &ctx,
1210 resolved_content,
1211 ));
1212 }
1213 DocumentPagesItem::Canvas(_) => {
1214 html.push_str(
1215 "<div class=\"embed-video-placeholder\">[Canvas layout not yet supported]</div>",
1216 );
1217 }
1218 DocumentPagesItem::Unknown(_) => {
1219 html.push_str("<div class=\"embed-video-placeholder\">[Unknown page type]</div>");
1220 }
1221 }
1222 }
1223
1224 html.push_str("</div>"); // end content
1225
1226 // Expand/collapse label.
1227 html.push_str("<label class=\"embed-entry-expand\" for=\"");
1228 html.push_str(&toggle_id);
1229 html.push_str("\"></label>");
1230
1231 html.push_str("</div>");
1232
1233 Ok(html)
1234}
1235
1236/// Render a site.standard or blog.pckt document record.
1237///
1238/// Uses the sync block renderer to render content blocks directly. Embedded posts
1239/// within the document will be looked up from resolved_content by their AT URI.
1240#[cfg(feature = "pckt")]
1241fn render_site_standard_record(
1242 data: &Data<'_>,
1243 uri: &AtUri<'_>,
1244 fallback_author: Option<&weaver_api::sh_weaver::actor::ProfileDataView<'_>>,
1245 resolved_content: Option<&weaver_common::ResolvedContent>,
1246) -> Result<String, AtProtoPreprocessError> {
1247 use crate::pckt::{PcktRenderContext, render_content_blocks_sync};
1248 use weaver_api::site_standard::document::Document as SiteStandardDocument;
1249
1250 // Extract the document - either directly or from blog.pckt.document wrapper.
1251 let doc: SiteStandardDocument<'_> = if data
1252 .type_discriminator()
1253 .map(|t| t == "blog.pckt.document")
1254 .unwrap_or(false)
1255 {
1256 let pckt_doc = match jacquard::from_data::<weaver_api::blog_pckt::document::Document>(data)
1257 {
1258 Ok(d) => d,
1259 Err(_) => return render_generic_record(data, uri),
1260 };
1261 pckt_doc.document
1262 } else {
1263 match jacquard::from_data::<SiteStandardDocument>(data) {
1264 Ok(d) => d,
1265 Err(_) => return render_generic_record(data, uri),
1266 }
1267 };
1268
1269 // Get author DID from fallback_author or from URI authority.
1270 let author_did = if let Some(author) = fallback_author {
1271 extract_did_from_profile_data_view(&author.inner)
1272 } else {
1273 None
1274 }
1275 .or_else(|| {
1276 // Fall back to URI authority if it's a DID.
1277 jacquard::types::string::Did::new(uri.authority().as_ref())
1278 .ok()
1279 .map(|d| d.into_static())
1280 });
1281
1282 let ctx = author_did
1283 .map(PcktRenderContext::new)
1284 .unwrap_or_else(|| unsafe {
1285 PcktRenderContext::new(jacquard::types::string::Did::unchecked(
1286 "did:plc:unknown".into(),
1287 ))
1288 });
1289
1290 let rkey = uri.rkey().map(|r| r.as_ref()).unwrap_or("unknown");
1291 let toggle_id = format!("pckt-toggle-{}", rkey);
1292
1293 let mut html = String::new();
1294 html.push_str("<div class=\"atproto-embed atproto-entry\" contenteditable=\"false\">");
1295
1296 // Toggle checkbox.
1297 html.push_str("<input type=\"checkbox\" class=\"embed-entry-toggle\" id=\"");
1298 html.push_str(&toggle_id);
1299 html.push_str("\">");
1300
1301 // Header.
1302 html.push_str("<div class=\"embed-entry-header\">");
1303 html.push_str("<span class=\"embed-entry-title\">");
1304 html.push_str(&html_escape(doc.title.as_ref()));
1305 html.push_str("</span>");
1306
1307 // Author info.
1308 if let Some(author) = fallback_author {
1309 let handle = extract_handle_from_profile_data_view(&author.inner).unwrap_or("");
1310 if !handle.is_empty() {
1311 html.push_str("<span class=\"embed-entry-author\">@");
1312 html.push_str(&html_escape(handle));
1313 html.push_str("</span>");
1314 }
1315 }
1316
1317 html.push_str("</div>");
1318
1319 // Content.
1320 html.push_str("<div class=\"embed-entry-content\">");
1321 if let Some(content) = &doc.content {
1322 // Render actual content blocks using the sync renderer.
1323 html.push_str(&render_content_blocks_sync(
1324 vec![content.clone()].as_slice(),
1325 &ctx,
1326 resolved_content,
1327 ));
1328 } else if let Some(text_content) = &doc.text_content {
1329 // Fallback to text_content if no structured blocks.
1330 html.push_str("<p>");
1331 html.push_str(&html_escape(text_content.as_ref()));
1332 html.push_str("</p>");
1333 }
1334 html.push_str("</div>");
1335
1336 // Expand label.
1337 html.push_str("<label class=\"embed-entry-expand\" for=\"");
1338 html.push_str(&toggle_id);
1339 html.push_str("\"></label>");
1340
1341 html.push_str("</div>");
1342
1343 Ok(html)
1344}
1345
1346/// Render a basic post from record data (no engagement stats or author info).
1347///
1348/// This is a simpler version than `render_post_view` for cases where you only
1349/// have the raw record, not the full PostView from the appview.
1350fn render_basic_post(data: &Data<'_>, uri: &AtUri<'_>) -> Result<String, AtProtoPreprocessError> {
1351 let mut html = String::new();
1352
1353 // Try to parse as Post
1354 let post = jacquard::from_data::<weaver_api::app_bsky::feed::post::Post>(data)
1355 .map_err(|e| AtProtoPreprocessError::FetchFailed(format!("Parse error: {:?}", e)))?;
1356
1357 // Build link to post on Bluesky
1358 let authority = uri.authority();
1359 let rkey = uri.rkey().map(|r| r.as_ref()).unwrap_or("");
1360 let bsky_link = format!("https://bsky.app/profile/{}/post/{}", authority, rkey);
1361
1362 html.push_str("<span class=\"atproto-embed atproto-post\" contenteditable=\"false\">");
1363
1364 // Background link
1365 html.push_str("<a class=\"embed-card-link\" href=\"");
1366 html.push_str(&html_escape(&bsky_link));
1367 html.push_str("\" target=\"_blank\" rel=\"noopener\" aria-label=\"View post on Bluesky\"></a>");
1368
1369 // Post text
1370 html.push_str("<span class=\"embed-content\">");
1371 html.push_str(&html_escape(post.text.as_ref()));
1372 html.push_str("</span>");
1373
1374 // Timestamp
1375 html.push_str("<span class=\"embed-meta\">");
1376 html.push_str("<span class=\"embed-time\">");
1377 html.push_str(&html_escape(&post.created_at.to_string()));
1378 html.push_str("</span>");
1379 html.push_str("</span>");
1380
1381 html.push_str("</span>");
1382
1383 Ok(html)
1384}
1385
1386/// Render a profile from ProfileDataViewInner (weaver, bsky, or tangled).
1387///
1388/// Takes pre-fetched profile data - no network calls.
1389pub fn render_profile_data_view(
1390 inner: &ProfileDataViewInner<'_>,
1391) -> Result<String, AtProtoPreprocessError> {
1392 let mut html = String::new();
1393
1394 match inner {
1395 ProfileDataViewInner::ProfileView(profile) => {
1396 // Weaver profile - link to bsky for now
1397 let profile_url = format!("https://bsky.app/profile/{}", profile.handle.as_ref());
1398 html.push_str(
1399 "<span class=\"atproto-embed atproto-profile\" contenteditable=\"false\">",
1400 );
1401
1402 // Background link covers whole card
1403 html.push_str("<a class=\"embed-card-link\" href=\"");
1404 html.push_str(&html_escape(&profile_url));
1405 html.push_str("\" target=\"_blank\" rel=\"noopener\" aria-label=\"View profile\"></a>");
1406
1407 html.push_str("<span class=\"embed-author\">");
1408 if let Some(avatar) = &profile.avatar {
1409 html.push_str("<img class=\"embed-avatar\" src=\"");
1410 html.push_str(&html_escape(avatar.as_ref()));
1411 html.push_str("\" alt=\"\" width=\"42\" height=\"42\" />");
1412 }
1413 html.push_str("<span class=\"embed-author-info\">");
1414 if let Some(display_name) = &profile.display_name {
1415 html.push_str("<span class=\"embed-author-name\">");
1416 html.push_str(&html_escape(display_name.as_ref()));
1417 html.push_str("</span>");
1418 }
1419 html.push_str("<span class=\"embed-author-handle\">@");
1420 html.push_str(&html_escape(profile.handle.as_ref()));
1421 html.push_str("</span>");
1422 html.push_str("</span>");
1423 html.push_str("</span>");
1424
1425 if let Some(description) = &profile.description {
1426 html.push_str("<span class=\"embed-description\">");
1427 html.push_str(&html_escape(description.as_ref()));
1428 html.push_str("</span>");
1429 }
1430
1431 html.push_str("</span>");
1432 }
1433 ProfileDataViewInner::ProfileViewDetailed(profile) => {
1434 // Bsky profile
1435 let profile_url = format!("https://bsky.app/profile/{}", profile.handle.as_ref());
1436 html.push_str(
1437 "<span class=\"atproto-embed atproto-profile\" contenteditable=\"false\">",
1438 );
1439
1440 // Background link covers whole card
1441 html.push_str("<a class=\"embed-card-link\" href=\"");
1442 html.push_str(&html_escape(&profile_url));
1443 html.push_str("\" target=\"_blank\" rel=\"noopener\" aria-label=\"View profile\"></a>");
1444
1445 html.push_str("<span class=\"embed-author\">");
1446 if let Some(avatar) = &profile.avatar {
1447 html.push_str("<img class=\"embed-avatar\" src=\"");
1448 html.push_str(&html_escape(avatar.as_ref()));
1449 html.push_str("\" alt=\"\" width=\"42\" height=\"42\" />");
1450 }
1451 html.push_str("<span class=\"embed-author-info\">");
1452 if let Some(display_name) = &profile.display_name {
1453 html.push_str("<span class=\"embed-author-name\">");
1454 html.push_str(&html_escape(display_name.as_ref()));
1455 html.push_str("</span>");
1456 }
1457 html.push_str("<span class=\"embed-author-handle\">@");
1458 html.push_str(&html_escape(profile.handle.as_ref()));
1459 html.push_str("</span>");
1460 html.push_str("</span>");
1461 html.push_str("</span>");
1462
1463 if let Some(description) = &profile.description {
1464 html.push_str("<span class=\"embed-description\">");
1465 html.push_str(&html_escape(description.as_ref()));
1466 html.push_str("</span>");
1467 }
1468
1469 // Stats for bsky profiles
1470 if profile.followers_count.is_some() || profile.follows_count.is_some() {
1471 html.push_str("<span class=\"embed-meta\">");
1472 html.push_str("<span class=\"embed-stats\">");
1473 if let Some(followers) = profile.followers_count {
1474 html.push_str("<span class=\"embed-stat\">");
1475 html.push_str(&followers.to_string());
1476 html.push_str(" followers</span>");
1477 }
1478 if let Some(follows) = profile.follows_count {
1479 html.push_str("<span class=\"embed-stat\">");
1480 html.push_str(&follows.to_string());
1481 html.push_str(" following</span>");
1482 }
1483 html.push_str("</span>");
1484 html.push_str("</span>");
1485 }
1486
1487 html.push_str("</span>");
1488 }
1489 ProfileDataViewInner::TangledProfileView(profile) => {
1490 // Tangled profile - link to tangled
1491 let profile_url = format!("https://tangled.sh/@{}", profile.handle.as_ref());
1492 html.push_str(
1493 "<span class=\"atproto-embed atproto-profile\" contenteditable=\"false\">",
1494 );
1495
1496 // Background link covers whole card
1497 html.push_str("<a class=\"embed-card-link\" href=\"");
1498 html.push_str(&html_escape(&profile_url));
1499 html.push_str("\" target=\"_blank\" rel=\"noopener\" aria-label=\"View profile\"></a>");
1500
1501 html.push_str("<span class=\"embed-author\">");
1502 html.push_str("<span class=\"embed-author-info\">");
1503 html.push_str("<span class=\"embed-author-handle\">@");
1504 html.push_str(&html_escape(profile.handle.as_ref()));
1505 html.push_str("</span>");
1506 html.push_str("</span>");
1507 html.push_str("</span>");
1508
1509 if let Some(description) = &profile.description {
1510 html.push_str("<span class=\"embed-description\">");
1511 html.push_str(&html_escape(description.as_ref()));
1512 html.push_str("</span>");
1513 }
1514
1515 html.push_str("</span>");
1516 }
1517 ProfileDataViewInner::Unknown(data) => {
1518 // Unknown - no link, just render
1519 html.push_str(
1520 "<span class=\"atproto-embed atproto-profile\" contenteditable=\"false\">",
1521 );
1522 html.push_str(&render_generic_data(data));
1523 html.push_str("</span>");
1524 }
1525 }
1526
1527 Ok(html)
1528}
1529
1530/// Render a Bluesky post from PostView (rich appview data).
1531///
1532/// Takes pre-fetched PostView from getPosts - no network calls.
1533pub fn render_post_view<'a>(
1534 post: &PostView<'a>,
1535 uri: &AtUri<'_>,
1536) -> Result<String, AtProtoPreprocessError> {
1537 let mut html = String::new();
1538
1539 // Build link to post on Bluesky
1540 let bsky_link = format!(
1541 "https://bsky.app/profile/{}/post/{}",
1542 post.author.handle.as_ref(),
1543 uri.rkey().map(|r| r.as_ref()).unwrap_or("")
1544 );
1545
1546 html.push_str("<span class=\"atproto-embed atproto-post\" contenteditable=\"false\">");
1547
1548 // Background link covers whole card, other links sit on top
1549 html.push_str("<a class=\"embed-card-link\" href=\"");
1550 html.push_str(&html_escape(&bsky_link));
1551 html.push_str("\" target=\"_blank\" rel=\"noopener\" aria-label=\"View post on Bluesky\"></a>");
1552
1553 // Author header
1554 html.push_str(&render_author_block(&post.author, true));
1555
1556 // Post text (parse record as typed Post)
1557 if let Ok(post_record) =
1558 jacquard::from_data::<weaver_api::app_bsky::feed::post::Post>(&post.record)
1559 {
1560 html.push_str("<span class=\"embed-content\">");
1561 html.push_str(&html_escape(post_record.text.as_ref()));
1562 html.push_str("</span>");
1563 }
1564
1565 // Embedded content (images, links, quotes, etc.)
1566 if let Some(embed) = &post.embed {
1567 html.push_str(&render_post_embed(embed));
1568 }
1569
1570 // Engagement stats and timestamp
1571 html.push_str("<span class=\"embed-meta\">");
1572
1573 // Stats row
1574 html.push_str("<span class=\"embed-stats\">");
1575 if let Some(replies) = post.reply_count {
1576 html.push_str("<span class=\"embed-stat\">");
1577 html.push_str(&replies.to_string());
1578 html.push_str(" replies</span>");
1579 }
1580 if let Some(reposts) = post.repost_count {
1581 html.push_str("<span class=\"embed-stat\">");
1582 html.push_str(&reposts.to_string());
1583 html.push_str(" reposts</span>");
1584 }
1585 if let Some(likes) = post.like_count {
1586 html.push_str("<span class=\"embed-stat\">");
1587 html.push_str(&likes.to_string());
1588 html.push_str(" likes</span>");
1589 }
1590 html.push_str("</span>");
1591
1592 // Timestamp
1593 html.push_str("<span class=\"embed-time\">");
1594 html.push_str(&html_escape(&post.indexed_at.to_string()));
1595 html.push_str("</span>");
1596
1597 html.push_str("</span>");
1598 html.push_str("</span>");
1599
1600 Ok(html)
1601}
1602
1603/// Render a generic record by probing Data for meaningful fields.
1604///
1605/// Takes pre-fetched record data - no network calls.
1606/// Probes for common fields like name, title, text, description.
1607pub fn render_generic_record(
1608 data: &Data<'_>,
1609 uri: &AtUri<'_>,
1610) -> Result<String, AtProtoPreprocessError> {
1611 let mut html = String::new();
1612
1613 html.push_str("<span class=\"atproto-embed atproto-record\" contenteditable=\"false\">");
1614
1615 // Show record type as header (full NSID)
1616 if let Some(collection) = uri.collection() {
1617 html.push_str("<span class=\"embed-author-handle\">");
1618 html.push_str(&html_escape(collection.as_ref()));
1619 html.push_str("</span>");
1620 }
1621
1622 // Priority fields to show first (in order)
1623 let priority_fields = [
1624 "name",
1625 "displayName",
1626 "title",
1627 "text",
1628 "description",
1629 "content",
1630 ];
1631 let mut shown_fields = Vec::new();
1632
1633 if let Some(obj) = data.as_object() {
1634 for field_name in priority_fields {
1635 if let Some(value) = obj.get(field_name) {
1636 if let Some(s) = value.as_str() {
1637 let class = match field_name {
1638 "name" | "displayName" | "title" => "embed-author-name",
1639 "text" | "content" => "embed-content",
1640 "description" => "embed-description",
1641 _ => "embed-field-value",
1642 };
1643 html.push_str("<span class=\"");
1644 html.push_str(class);
1645 html.push_str("\">");
1646 // Truncate long content for embed display
1647 let display_text = if s.len() > 300 {
1648 format!("{}...", &s[..300])
1649 } else {
1650 s.to_string()
1651 };
1652 html.push_str(&html_escape(&display_text));
1653 html.push_str("</span>");
1654 shown_fields.push(field_name);
1655 }
1656 }
1657 }
1658
1659 // Show remaining fields as a simple list
1660 html.push_str("<span class=\"embed-fields\">");
1661 for (key, value) in obj.iter() {
1662 let key_str: &str = key.as_ref();
1663
1664 // Skip already shown, internal fields, and complex nested objects
1665 if shown_fields.contains(&key_str)
1666 || key_str.starts_with('$')
1667 || key_str == "facets"
1668 || key_str == "labels"
1669 || key_str == "embeds"
1670 {
1671 continue;
1672 }
1673
1674 if let Some(formatted) = format_field_value(key_str, value) {
1675 html.push_str("<span class=\"embed-field\">");
1676 html.push_str("<span class=\"embed-field-name\">");
1677 html.push_str(&html_escape(&format_field_name(key_str)));
1678 html.push_str(":</span> ");
1679 html.push_str(&formatted);
1680 html.push_str("</span>");
1681 }
1682 }
1683 html.push_str("</span>");
1684 }
1685
1686 html.push_str("</span>");
1687
1688 Ok(html)
1689}
1690
1691// =============================================================================
1692// Reusable render functions for embed components
1693// =============================================================================
1694
1695/// Render an author block (avatar + name + handle)
1696///
1697/// Used for posts, profiles, and any record with an author.
1698/// When `link_to_profile` is true, avatar, display name, and handle all link to the profile.
1699pub fn render_author_block(author: &ProfileViewBasic<'_>, link_to_profile: bool) -> String {
1700 render_author_block_inner(
1701 author.avatar.as_ref().map(|u| u.as_ref()),
1702 author.display_name.as_ref().map(|s| s.as_ref()),
1703 author.handle.as_ref(),
1704 link_to_profile,
1705 )
1706}
1707
1708/// Render author block from ProfileView (has same fields as ProfileViewBasic)
1709pub fn render_author_block_full(
1710 author: &weaver_api::app_bsky::actor::ProfileView<'_>,
1711 link_to_profile: bool,
1712) -> String {
1713 render_author_block_inner(
1714 author.avatar.as_ref().map(|u| u.as_ref()),
1715 author.display_name.as_ref().map(|s| s.as_ref()),
1716 author.handle.as_ref(),
1717 link_to_profile,
1718 )
1719}
1720
1721fn render_author_block_inner(
1722 avatar: Option<&str>,
1723 display_name: Option<&str>,
1724 handle: &str,
1725 link_to_profile: bool,
1726) -> String {
1727 let mut html = String::new();
1728 let profile_url = format!("https://bsky.app/profile/{}", handle);
1729
1730 html.push_str("<span class=\"embed-author\">");
1731
1732 if let Some(avatar_url) = avatar {
1733 if link_to_profile {
1734 html.push_str("<a class=\"embed-avatar-link\" href=\"");
1735 html.push_str(&html_escape(&profile_url));
1736 html.push_str("\" target=\"_blank\" rel=\"noopener\">");
1737 html.push_str("<img class=\"embed-avatar\" src=\"");
1738 html.push_str(&html_escape(avatar_url));
1739 html.push_str("\" alt=\"\" width=\"42\" height=\"42\" />");
1740 html.push_str("</a>");
1741 } else {
1742 html.push_str("<img class=\"embed-avatar\" src=\"");
1743 html.push_str(&html_escape(avatar_url));
1744 html.push_str("\" alt=\"\" width=\"42\" height=\"42\" />");
1745 }
1746 }
1747
1748 html.push_str("<span class=\"embed-author-info\">");
1749
1750 if let Some(name) = display_name {
1751 if link_to_profile {
1752 html.push_str("<a class=\"embed-author-name\" href=\"");
1753 html.push_str(&html_escape(&profile_url));
1754 html.push_str("\" target=\"_blank\" rel=\"noopener\">");
1755 html.push_str(&html_escape(name));
1756 html.push_str("</a>");
1757 } else {
1758 html.push_str("<span class=\"embed-author-name\">");
1759 html.push_str(&html_escape(name));
1760 html.push_str("</span>");
1761 }
1762 }
1763
1764 if link_to_profile {
1765 html.push_str("<a class=\"embed-author-handle\" href=\"");
1766 html.push_str(&html_escape(&profile_url));
1767 html.push_str("\" target=\"_blank\" rel=\"noopener\">@");
1768 html.push_str(&html_escape(handle));
1769 html.push_str("</a>");
1770 } else {
1771 html.push_str("<span class=\"embed-author-handle\">@");
1772 html.push_str(&html_escape(handle));
1773 html.push_str("</span>");
1774 }
1775
1776 html.push_str("</span>");
1777 html.push_str("</span>");
1778
1779 html
1780}
1781
1782/// Render an external link card (title, description, thumbnail)
1783///
1784/// Used for link previews in posts and standalone link embeds.
1785pub fn render_external_link(external: &ViewExternal<'_>) -> String {
1786 let mut html = String::new();
1787
1788 html.push_str("<a class=\"embed-external\" href=\"");
1789 html.push_str(&html_escape(external.uri.as_ref()));
1790 html.push_str("\" target=\"_blank\" rel=\"noopener\">");
1791
1792 if let Some(thumb) = &external.thumb {
1793 html.push_str("<img class=\"embed-external-thumb\" src=\"");
1794 html.push_str(&html_escape(thumb.as_ref()));
1795 html.push_str("\" alt=\"\" />");
1796 }
1797
1798 html.push_str("<span class=\"embed-external-info\">");
1799 html.push_str("<span class=\"embed-external-title\">");
1800 html.push_str(&html_escape(external.title.as_ref()));
1801 html.push_str("</span>");
1802
1803 if !external.description.is_empty() {
1804 html.push_str("<span class=\"embed-external-description\">");
1805 html.push_str(&html_escape(external.description.as_ref()));
1806 html.push_str("</span>");
1807 }
1808
1809 html.push_str("<span class=\"embed-external-url\">");
1810 // Show just the domain
1811 if let Some(domain) = extract_domain(external.uri.as_ref()) {
1812 html.push_str(&html_escape(domain));
1813 } else {
1814 html.push_str(&html_escape(external.uri.as_ref()));
1815 }
1816 html.push_str("</span>");
1817
1818 html.push_str("</span>");
1819 html.push_str("</a>");
1820
1821 html
1822}
1823
1824/// Render an image gallery
1825///
1826/// Used for image embeds in posts.
1827pub fn render_images(images: &[ViewImage<'_>]) -> String {
1828 let mut html = String::new();
1829
1830 let class = match images.len() {
1831 1 => "embed-images embed-images-1",
1832 2 => "embed-images embed-images-2",
1833 3 => "embed-images embed-images-3",
1834 _ => "embed-images embed-images-4",
1835 };
1836
1837 html.push_str("<span class=\"");
1838 html.push_str(class);
1839 html.push_str("\">");
1840
1841 for img in images {
1842 html.push_str("<a class=\"embed-image-link\" href=\"");
1843 html.push_str(&html_escape(img.fullsize.as_ref()));
1844 html.push_str("\" target=\"_blank\"");
1845
1846 // Add aspect-ratio style if available
1847 if let Some(aspect) = &img.aspect_ratio {
1848 html.push_str(" style=\"aspect-ratio: ");
1849 html.push_str(&aspect.width.to_string());
1850 html.push_str(" / ");
1851 html.push_str(&aspect.height.to_string());
1852 html.push_str(";\"");
1853 }
1854
1855 html.push_str(">");
1856 html.push_str("<img class=\"embed-image\" src=\"");
1857 html.push_str(&html_escape(img.thumb.as_ref()));
1858 html.push_str("\" alt=\"");
1859 html.push_str(&html_escape(img.alt.as_ref()));
1860 html.push_str("\" />");
1861 html.push_str("</a>");
1862 }
1863
1864 html.push_str("</span>");
1865
1866 html
1867}
1868
1869/// Render a quoted/embedded record
1870///
1871/// Used for quote posts and record embeds. Dispatches based on record type.
1872pub fn render_quoted_record(record: &ViewRecord<'_>) -> String {
1873 let mut html = String::new();
1874
1875 html.push_str("<span class=\"embed-quote\">");
1876
1877 // Dispatch based on record type
1878 match record.value.type_discriminator() {
1879 Some("app.bsky.feed.post") => {
1880 // Post - show author and text
1881 html.push_str(&render_author_block(&record.author, true));
1882 if let Ok(post) =
1883 jacquard::from_data::<weaver_api::app_bsky::feed::post::Post>(&record.value)
1884 {
1885 html.push_str("<span class=\"embed-content\">");
1886 html.push_str(&html_escape(post.text.as_ref()));
1887 html.push_str("</span>");
1888 }
1889 }
1890 Some("app.bsky.feed.generator") => {
1891 // Custom feed - show feed info with type label
1892 if let Ok(generator) = jacquard::from_data::<
1893 weaver_api::app_bsky::feed::generator::Generator,
1894 >(&record.value)
1895 {
1896 html.push_str("<span class=\"embed-type\">Custom Feed</span>");
1897 html.push_str("<span class=\"embed-author-name\">");
1898 html.push_str(&html_escape(generator.display_name.as_ref()));
1899 html.push_str("</span>");
1900 if let Some(desc) = &generator.description {
1901 html.push_str("<span class=\"embed-description\">");
1902 html.push_str(&html_escape(desc.as_ref()));
1903 html.push_str("</span>");
1904 }
1905 html.push_str(&render_author_block(&record.author, true));
1906 }
1907 }
1908 Some("app.bsky.graph.list") => {
1909 // List - show list info
1910 if let Ok(list) =
1911 jacquard::from_data::<weaver_api::app_bsky::graph::list::List>(&record.value)
1912 {
1913 html.push_str("<span class=\"embed-type\">List</span>");
1914 html.push_str("<span class=\"embed-author-name\">");
1915 html.push_str(&html_escape(list.name.as_ref()));
1916 html.push_str("</span>");
1917 if let Some(desc) = &list.description {
1918 html.push_str("<span class=\"embed-description\">");
1919 html.push_str(&html_escape(desc.as_ref()));
1920 html.push_str("</span>");
1921 }
1922 html.push_str(&render_author_block(&record.author, true));
1923 }
1924 }
1925 Some("app.bsky.graph.starterpack") => {
1926 // Starter pack
1927 if let Ok(sp) = jacquard::from_data::<
1928 weaver_api::app_bsky::graph::starterpack::Starterpack,
1929 >(&record.value)
1930 {
1931 html.push_str("<span class=\"embed-type\">Starter Pack</span>");
1932 html.push_str("<span class=\"embed-author-name\">");
1933 html.push_str(&html_escape(sp.name.as_ref()));
1934 html.push_str("</span>");
1935 if let Some(desc) = &sp.description {
1936 html.push_str("<span class=\"embed-description\">");
1937 html.push_str(&html_escape(desc.as_ref()));
1938 html.push_str("</span>");
1939 }
1940 html.push_str(&render_author_block(&record.author, true));
1941 }
1942 }
1943 _ => {
1944 // Unknown type - show author and probe for common fields
1945 html.push_str(&render_author_block(&record.author, true));
1946 html.push_str(&render_generic_data(&record.value));
1947 }
1948 }
1949
1950 // Render nested embeds if present (applies to all types)
1951 if let Some(embeds) = &record.embeds {
1952 for embed in embeds {
1953 html.push_str(&render_view_record_embed(embed));
1954 }
1955 }
1956
1957 html.push_str("</span>");
1958
1959 html
1960}
1961
1962/// Render an embed item from a ViewRecord (nested embeds in quotes)
1963fn render_view_record_embed(
1964 embed: &weaver_api::app_bsky::embed::record::ViewRecordEmbedsItem<'_>,
1965) -> String {
1966 use weaver_api::app_bsky::embed::record::ViewRecordEmbedsItem;
1967
1968 match embed {
1969 ViewRecordEmbedsItem::ImagesView(images) => render_images(&images.images),
1970 ViewRecordEmbedsItem::ExternalView(external) => render_external_link(&external.external),
1971 ViewRecordEmbedsItem::View(record_view) => render_record_embed(&record_view.record),
1972 ViewRecordEmbedsItem::RecordWithMediaView(rwm) => {
1973 let mut html = String::new();
1974 // Render media first
1975 match &rwm.media {
1976 weaver_api::app_bsky::embed::record_with_media::ViewMedia::ImagesView(img) => {
1977 html.push_str(&render_images(&img.images));
1978 }
1979 weaver_api::app_bsky::embed::record_with_media::ViewMedia::ExternalView(ext) => {
1980 html.push_str(&render_external_link(&ext.external));
1981 }
1982 weaver_api::app_bsky::embed::record_with_media::ViewMedia::VideoView(_) => {
1983 html.push_str("<span class=\"embed-video-placeholder\">[Video]</span>");
1984 }
1985 weaver_api::app_bsky::embed::record_with_media::ViewMedia::Unknown(_) => {}
1986 }
1987 // Then the record
1988 html.push_str(&render_record_embed(&rwm.record.record));
1989 html
1990 }
1991 ViewRecordEmbedsItem::VideoView(_) => {
1992 "<span class=\"embed-video-placeholder\">[Video]</span>".to_string()
1993 }
1994 ViewRecordEmbedsItem::Unknown(data) => render_generic_data(data),
1995 }
1996}
1997
1998/// Render a PostViewEmbed (images, external, record, video, etc.)
1999pub fn render_post_embed(embed: &PostViewEmbed<'_>) -> String {
2000 match embed {
2001 PostViewEmbed::ImagesView(images) => render_images(&images.images),
2002 PostViewEmbed::ExternalView(external) => render_external_link(&external.external),
2003 PostViewEmbed::RecordView(record) => render_record_embed(&record.record),
2004 PostViewEmbed::RecordWithMediaView(rwm) => {
2005 let mut html = String::new();
2006 // Render media first
2007 match &rwm.media {
2008 weaver_api::app_bsky::embed::record_with_media::ViewMedia::ImagesView(img) => {
2009 html.push_str(&render_images(&img.images));
2010 }
2011 weaver_api::app_bsky::embed::record_with_media::ViewMedia::ExternalView(ext) => {
2012 html.push_str(&render_external_link(&ext.external));
2013 }
2014 weaver_api::app_bsky::embed::record_with_media::ViewMedia::VideoView(_) => {
2015 html.push_str("<span class=\"embed-video-placeholder\">[Video]</span>");
2016 }
2017 weaver_api::app_bsky::embed::record_with_media::ViewMedia::Unknown(_) => {}
2018 }
2019 // Then the record
2020 html.push_str(&render_record_embed(&rwm.record.record));
2021 html
2022 }
2023 PostViewEmbed::VideoView(_) => {
2024 "<span class=\"embed-video-placeholder\">[Video]</span>".to_string()
2025 }
2026 PostViewEmbed::Unknown(data) => render_generic_data(data),
2027 }
2028}
2029
2030/// Render a ViewUnionRecord (the actual content of a record embed)
2031fn render_record_embed(record: &ViewUnionRecord<'_>) -> String {
2032 match record {
2033 ViewUnionRecord::ViewRecord(r) => render_quoted_record(r),
2034 ViewUnionRecord::ViewNotFound(_) => {
2035 "<span class=\"embed-not-found\">Record not found</span>".to_string()
2036 }
2037 ViewUnionRecord::ViewBlocked(_) => {
2038 "<span class=\"embed-blocked\">Content blocked</span>".to_string()
2039 }
2040 ViewUnionRecord::ViewDetached(_) => {
2041 "<span class=\"embed-detached\">Content unavailable</span>".to_string()
2042 }
2043 ViewUnionRecord::GeneratorView(generator) => {
2044 let mut html = String::new();
2045 html.push_str("<span class=\"embed-record-card\">");
2046
2047 // Icon + title + type (like author block layout)
2048 html.push_str("<span class=\"embed-author\">");
2049 if let Some(avatar) = &generator.avatar {
2050 html.push_str("<img class=\"embed-avatar\" src=\"");
2051 html.push_str(&html_escape(avatar.as_ref()));
2052 html.push_str("\" alt=\"\" width=\"42\" height=\"42\" />");
2053 }
2054 html.push_str("<span class=\"embed-author-info\">");
2055 html.push_str("<span class=\"embed-author-name\">");
2056 html.push_str(&html_escape(generator.display_name.as_ref()));
2057 html.push_str("</span>");
2058 html.push_str("<span class=\"embed-author-handle\">Feed</span>");
2059 html.push_str("</span>");
2060 html.push_str("</span>");
2061
2062 // Description
2063 if let Some(desc) = &generator.description {
2064 html.push_str("<span class=\"embed-description\">");
2065 html.push_str(&html_escape(desc.as_ref()));
2066 html.push_str("</span>");
2067 }
2068
2069 // Creator
2070 html.push_str(&render_author_block_full(&generator.creator, true));
2071
2072 // Stats
2073 if let Some(likes) = generator.like_count {
2074 html.push_str("<span class=\"embed-stats\">");
2075 html.push_str("<span class=\"embed-stat\">");
2076 html.push_str(&likes.to_string());
2077 html.push_str(" likes</span>");
2078 html.push_str("</span>");
2079 }
2080
2081 html.push_str("</span>");
2082 html
2083 }
2084 ViewUnionRecord::ListView(list) => {
2085 let mut html = String::new();
2086 html.push_str("<span class=\"embed-record-card\">");
2087
2088 // Icon + title + type (like author block layout)
2089 html.push_str("<span class=\"embed-author\">");
2090 if let Some(avatar) = &list.avatar {
2091 html.push_str("<img class=\"embed-avatar\" src=\"");
2092 html.push_str(&html_escape(avatar.as_ref()));
2093 html.push_str("\" alt=\"\" width=\"42\" height=\"42\" />");
2094 }
2095 html.push_str("<span class=\"embed-author-info\">");
2096 html.push_str("<span class=\"embed-author-name\">");
2097 html.push_str(&html_escape(list.name.as_ref()));
2098 html.push_str("</span>");
2099 html.push_str("<span class=\"embed-author-handle\">List</span>");
2100 html.push_str("</span>");
2101 html.push_str("</span>");
2102
2103 // Description
2104 if let Some(desc) = &list.description {
2105 html.push_str("<span class=\"embed-description\">");
2106 html.push_str(&html_escape(desc.as_ref()));
2107 html.push_str("</span>");
2108 }
2109
2110 // Creator
2111 html.push_str(&render_author_block_full(&list.creator, true));
2112
2113 // Stats
2114 if let Some(count) = list.list_item_count {
2115 html.push_str("<span class=\"embed-stats\">");
2116 html.push_str("<span class=\"embed-stat\">");
2117 html.push_str(&count.to_string());
2118 html.push_str(" members</span>");
2119 html.push_str("</span>");
2120 }
2121
2122 html.push_str("</span>");
2123 html
2124 }
2125 ViewUnionRecord::LabelerView(labeler) => {
2126 let mut html = String::new();
2127 html.push_str("<span class=\"embed-record-card\">");
2128
2129 // Labeler uses creator as the identity, add type label
2130 html.push_str("<span class=\"embed-author\">");
2131 if let Some(avatar) = &labeler.creator.avatar {
2132 html.push_str("<img class=\"embed-avatar\" src=\"");
2133 html.push_str(&html_escape(avatar.as_ref()));
2134 html.push_str("\" alt=\"\" width=\"42\" height=\"42\" />");
2135 }
2136 html.push_str("<span class=\"embed-author-info\">");
2137 if let Some(name) = &labeler.creator.display_name {
2138 html.push_str("<span class=\"embed-author-name\">");
2139 html.push_str(&html_escape(name.as_ref()));
2140 html.push_str("</span>");
2141 }
2142 html.push_str("<span class=\"embed-author-handle\">Labeler</span>");
2143 html.push_str("</span>");
2144 html.push_str("</span>");
2145
2146 // Stats
2147 if let Some(likes) = labeler.like_count {
2148 html.push_str("<span class=\"embed-stats\">");
2149 html.push_str("<span class=\"embed-stat\">");
2150 html.push_str(&likes.to_string());
2151 html.push_str(" likes</span>");
2152 html.push_str("</span>");
2153 }
2154
2155 html.push_str("</span>");
2156 html
2157 }
2158 ViewUnionRecord::StarterPackViewBasic(sp) => {
2159 let mut html = String::new();
2160 html.push_str("<span class=\"embed-record-card\">");
2161
2162 // Use author block layout: avatar + info (name, subtitle)
2163 html.push_str("<span class=\"embed-author\">");
2164 if let Some(avatar) = &sp.creator.avatar {
2165 html.push_str("<img class=\"embed-avatar\" src=\"");
2166 html.push_str(&html_escape(avatar.as_ref()));
2167 html.push_str("\" alt=\"\" width=\"42\" height=\"42\" />");
2168 }
2169 html.push_str("<span class=\"embed-author-info\">");
2170
2171 // Name as title
2172 if let Some(name) = sp.record.query("name").single().and_then(|d| d.as_str()) {
2173 html.push_str("<span class=\"embed-author-name\">");
2174 html.push_str(&html_escape(name));
2175 html.push_str("</span>");
2176 }
2177
2178 // "Starter pack by @handle"
2179 html.push_str("<span class=\"embed-author-handle\">by @");
2180 html.push_str(&html_escape(sp.creator.handle.as_ref()));
2181 html.push_str("</span>");
2182
2183 html.push_str("</span>"); // end info
2184 html.push_str("</span>"); // end author
2185
2186 // Description
2187 if let Some(desc) = sp
2188 .record
2189 .query("description")
2190 .single()
2191 .and_then(|d| d.as_str())
2192 {
2193 html.push_str("<span class=\"embed-description\">");
2194 html.push_str(&html_escape(desc));
2195 html.push_str("</span>");
2196 }
2197
2198 // Stats
2199 let has_stats = sp.list_item_count.is_some() || sp.joined_all_time_count.is_some();
2200 if has_stats {
2201 html.push_str("<span class=\"embed-stats\">");
2202 if let Some(count) = sp.list_item_count {
2203 html.push_str("<span class=\"embed-stat\">");
2204 html.push_str(&count.to_string());
2205 html.push_str(" users</span>");
2206 }
2207 if let Some(joined) = sp.joined_all_time_count {
2208 html.push_str("<span class=\"embed-stat\">");
2209 html.push_str(&joined.to_string());
2210 html.push_str(" joined</span>");
2211 }
2212 html.push_str("</span>");
2213 }
2214
2215 html.push_str("</span>");
2216 html
2217 }
2218 ViewUnionRecord::Unknown(data) => render_generic_data(data),
2219 }
2220}
2221
2222/// Render generic/unknown data by iterating fields intelligently
2223///
2224/// Used as fallback for Unknown variants of open unions.
2225fn render_generic_data(data: &Data<'_>) -> String {
2226 render_generic_data_with_depth(data, 0)
2227}
2228
2229/// Render generic data with depth tracking for nested objects
2230fn render_generic_data_with_depth(data: &Data<'_>, depth: u8) -> String {
2231 let mut html = String::new();
2232
2233 // Only wrap in card at top level
2234 let is_nested = depth > 0;
2235 if is_nested {
2236 html.push_str("<span class=\"embed-fields\">");
2237 } else {
2238 html.push_str("<span class=\"embed-record-card\">");
2239 }
2240
2241 // Show record type as header if present
2242 if let Some(record_type) = data.type_discriminator() {
2243 html.push_str("<span class=\"embed-author-handle\">");
2244 html.push_str(&html_escape(record_type));
2245 html.push_str("</span>");
2246 }
2247
2248 // Priority fields to show first (in order)
2249 let priority_fields = ["name", "displayName", "title", "text", "description"];
2250 let mut shown_fields = Vec::new();
2251
2252 if let Some(obj) = data.as_object() {
2253 for field_name in priority_fields {
2254 if let Some(value) = obj.get(field_name) {
2255 if let Some(s) = value.as_str() {
2256 let class = match field_name {
2257 "name" | "displayName" | "title" => "embed-author-name",
2258 "text" => "embed-content",
2259 "description" => "embed-description",
2260 _ => "embed-field-value",
2261 };
2262 html.push_str("<span class=\"");
2263 html.push_str(class);
2264 html.push_str("\">");
2265 html.push_str(&html_escape(s));
2266 html.push_str("</span>");
2267 shown_fields.push(field_name);
2268 }
2269 }
2270 }
2271
2272 // Show remaining fields as a simple list
2273 if !is_nested {
2274 html.push_str("<span class=\"embed-fields\">");
2275 }
2276 for (key, value) in obj.iter() {
2277 let key_str: &str = key.as_ref();
2278
2279 // Skip already shown, internal fields
2280 if shown_fields.contains(&key_str)
2281 || key_str.starts_with('$')
2282 || key_str == "facets"
2283 || key_str == "labels"
2284 {
2285 continue;
2286 }
2287
2288 if let Some(formatted) = format_field_value_with_depth(key_str, value, depth) {
2289 html.push_str("<span class=\"embed-field\">");
2290 html.push_str("<span class=\"embed-field-name\">");
2291 html.push_str(&html_escape(&format_field_name(key_str)));
2292 html.push_str(":</span> ");
2293 html.push_str(&formatted);
2294 html.push_str("</span>");
2295 }
2296 }
2297 if !is_nested {
2298 html.push_str("</span>");
2299 }
2300 }
2301
2302 html.push_str("</span>");
2303 html
2304}
2305
2306/// Format a field name for display (camelCase -> "Camel Case")
2307fn format_field_name(name: &str) -> String {
2308 let mut result = String::new();
2309 for (i, c) in name.chars().enumerate() {
2310 if c.is_uppercase() && i > 0 {
2311 result.push(' ');
2312 }
2313 if i == 0 {
2314 result.extend(c.to_uppercase());
2315 } else {
2316 result.push(c);
2317 }
2318 }
2319 result
2320}
2321
2322/// Format a field value for display, returning None for complex/unrenderable values
2323fn format_field_value(key: &str, value: &Data<'_>) -> Option<String> {
2324 format_field_value_with_depth(key, value, 0)
2325}
2326
2327/// Maximum nesting depth for rendering nested objects
2328const MAX_NESTED_DEPTH: u8 = 2;
2329
2330/// Format a field value for display with depth tracking
2331fn format_field_value_with_depth(key: &str, value: &Data<'_>, depth: u8) -> Option<String> {
2332 // String values - detect AT Protocol types
2333 if let Some(s) = value.as_str() {
2334 return Some(format_string_value(key, s));
2335 }
2336
2337 // Numbers
2338 if let Some(n) = value.as_integer() {
2339 return Some(format!("<span class=\"embed-field-number\">{}</span>", n));
2340 }
2341
2342 // Booleans
2343 if let Some(b) = value.as_boolean() {
2344 let class = if b {
2345 "embed-field-bool-true"
2346 } else {
2347 "embed-field-bool-false"
2348 };
2349 return Some(format!(
2350 "<span class=\"{}\">{}</span>",
2351 class,
2352 if b { "yes" } else { "no" }
2353 ));
2354 }
2355
2356 // Arrays - show count or render items if simple
2357 if let Some(arr) = value.as_array() {
2358 return Some(format_array_value(arr, depth));
2359 }
2360
2361 // Nested objects - render if within depth limit
2362 if value.as_object().is_some() {
2363 if depth < MAX_NESTED_DEPTH {
2364 return Some(render_generic_data_with_depth(value, depth + 1));
2365 } else {
2366 // At max depth, just show field count
2367 let count = value.as_object().map(|o| o.len()).unwrap_or(0);
2368 return Some(format!(
2369 "<span class=\"embed-field-count\">{} field{}</span>",
2370 count,
2371 if count == 1 { "" } else { "s" }
2372 ));
2373 }
2374 }
2375
2376 None
2377}
2378
2379/// Format an array value, rendering items if simple enough
2380fn format_array_value(arr: &jacquard::Array<'_>, depth: u8) -> String {
2381 let len = arr.len();
2382
2383 // Empty array
2384 if len == 0 {
2385 return "<span class=\"embed-field-count\">empty</span>".to_string();
2386 }
2387
2388 // For small arrays of simple values, show them inline
2389 if len <= 3 && depth < MAX_NESTED_DEPTH {
2390 let mut items = Vec::new();
2391 let mut all_simple = true;
2392
2393 for item in arr.iter() {
2394 if let Some(formatted) = format_simple_value(item) {
2395 items.push(formatted);
2396 } else {
2397 all_simple = false;
2398 break;
2399 }
2400 }
2401
2402 if all_simple {
2403 return format!(
2404 "<span class=\"embed-field-value\">[{}]</span>",
2405 items.join(", ")
2406 );
2407 }
2408 }
2409
2410 // Otherwise just show count
2411 format!(
2412 "<span class=\"embed-field-count\">{} item{}</span>",
2413 len,
2414 if len == 1 { "" } else { "s" }
2415 )
2416}
2417
2418/// Format a simple value (string, number, bool) without field name context
2419fn format_simple_value(value: &Data<'_>) -> Option<String> {
2420 if let Some(s) = value.as_str() {
2421 // Keep it short for array display
2422 let display = if s.len() > 50 {
2423 format!("{}…", &s[..50])
2424 } else {
2425 s.to_string()
2426 };
2427 return Some(format!("\"{}\"", html_escape(&display)));
2428 }
2429
2430 if let Some(n) = value.as_integer() {
2431 return Some(n.to_string());
2432 }
2433
2434 if let Some(b) = value.as_boolean() {
2435 return Some(if b { "true" } else { "false" }.to_string());
2436 }
2437
2438 None
2439}
2440
2441/// Format a string value with smart detection of AT Protocol types
2442fn format_string_value(key: &str, s: &str) -> String {
2443 // AT URI - link to record
2444 if s.starts_with("at://") {
2445 return format!(
2446 "<a class=\"embed-field-aturi\" href=\"{}\">{}</a>",
2447 html_escape(s),
2448 format_aturi_display(s)
2449 );
2450 }
2451
2452 // DID
2453 if s.starts_with("did:") {
2454 return format_did_display(s);
2455 }
2456
2457 // Regular URL
2458 if s.starts_with("http://") || s.starts_with("https://") {
2459 let domain = extract_domain(s).unwrap_or(s);
2460 return format!(
2461 "<a class=\"embed-field-link\" href=\"{}\">{}</a>",
2462 html_escape(s),
2463 html_escape(domain)
2464 );
2465 }
2466
2467 // Datetime fields - show just the date
2468 if key.ends_with("At") || key == "createdAt" || key == "indexedAt" {
2469 let date_part = s.split('T').next().unwrap_or(s);
2470 return format!(
2471 "<span class=\"embed-field-date\">{}</span>",
2472 html_escape(date_part)
2473 );
2474 }
2475
2476 // NSID (e.g., app.bsky.feed.post)
2477 if s.contains('.')
2478 && s.chars().all(|c| c.is_alphanumeric() || c == '.')
2479 && s.matches('.').count() >= 2
2480 {
2481 return format!("<span class=\"embed-field-nsid\">{}</span>", html_escape(s));
2482 }
2483
2484 // Handle (contains dots, no colons or slashes)
2485 if s.contains('.')
2486 && !s.contains(':')
2487 && !s.contains('/')
2488 && s.chars()
2489 .all(|c| c.is_alphanumeric() || c == '.' || c == '-' || c == '_')
2490 {
2491 return format!(
2492 "<span class=\"embed-field-handle\">@{}</span>",
2493 html_escape(s)
2494 );
2495 }
2496
2497 // Plain string
2498 html_escape(s)
2499}
2500
2501/// Format an AT URI for display with highlighted parts
2502fn format_aturi_display(uri: &str) -> String {
2503 if let Some(rest) = uri.strip_prefix("at://") {
2504 let parts: Vec<&str> = rest.splitn(3, '/').collect();
2505 let mut result = String::from("<span class=\"aturi-scheme\">at://</span>");
2506
2507 if !parts.is_empty() {
2508 result.push_str(&format!(
2509 "<span class=\"aturi-authority\">{}</span>",
2510 html_escape(parts[0])
2511 ));
2512 }
2513 if parts.len() > 1 {
2514 result.push_str("<span class=\"aturi-slash\">/</span>");
2515 result.push_str(&format!(
2516 "<span class=\"aturi-collection\">{}</span>",
2517 html_escape(parts[1])
2518 ));
2519 }
2520 if parts.len() > 2 {
2521 result.push_str("<span class=\"aturi-slash\">/</span>");
2522 result.push_str(&format!(
2523 "<span class=\"aturi-rkey\">{}</span>",
2524 html_escape(parts[2])
2525 ));
2526 }
2527 result
2528 } else {
2529 html_escape(uri)
2530 }
2531}
2532
2533/// Format a DID for display with highlighted parts
2534fn format_did_display(did: &str) -> String {
2535 if let Some(rest) = did.strip_prefix("did:") {
2536 if let Some((method, identifier)) = rest.split_once(':') {
2537 return format!(
2538 "<span class=\"embed-field-did\">\
2539 <span class=\"did-scheme\">did:</span>\
2540 <span class=\"did-method\">{}</span>\
2541 <span class=\"did-separator\">:</span>\
2542 <span class=\"did-identifier\">{}</span>\
2543 </span>",
2544 html_escape(method),
2545 html_escape(identifier)
2546 );
2547 }
2548 }
2549 format!(
2550 "<span class=\"embed-field-did\">{}</span>",
2551 html_escape(did)
2552 )
2553}
2554
2555// =============================================================================
2556// Helper functions
2557// =============================================================================
2558
2559/// Extract domain from a URL
2560fn extract_domain(url: &str) -> Option<&str> {
2561 let without_scheme = url
2562 .strip_prefix("https://")
2563 .or_else(|| url.strip_prefix("http://"))?;
2564 without_scheme.split('/').next()
2565}
2566
2567/// Simple HTML escaping
2568fn html_escape(s: &str) -> String {
2569 s.replace('&', "&")
2570 .replace('<', "<")
2571 .replace('>', ">")
2572 .replace('"', """)
2573 .replace('\'', "'")
2574}