sync embed render methods (with sufficient context)

Orual 1ce85230 bc605280

+1245 -126
+3
crates/weaver-renderer/src/atproto.rs
··· 14 14 15 15 pub use client::{ClientContext, DefaultEmbedResolver, EmbedResolver}; 16 16 pub use embed_renderer::{ 17 + // Async fetch-and-render functions (require agent/network) 17 18 fetch_and_render, fetch_and_render_generic, fetch_and_render_post, fetch_and_render_profile, 19 + // Pure sync render functions (pre-fetched data, no network) 20 + render_generic_record, render_post_view, render_profile_data_view, render_record, 18 21 }; 19 22 pub use error::{AtProtoPreprocessError, ClientRenderError}; 20 23 pub use markdown_writer::MarkdownWriter;
+1 -1
crates/weaver-renderer/src/atproto/client.rs
··· 658 658 let uri = AtUri::new("at://did:plc:xyz123/sh.weaver.notebook.entry/entry123").unwrap(); 659 659 assert_eq!( 660 660 at_uri_to_web_url(&uri), 661 - "https://alpha.weaver.sh/record/at://did:plc:xyz123/sh.weaver.notebook.entry/entry123" 661 + "https://alpha.weaver.sh/did:plc:xyz123/e/entry123" 662 662 ); 663 663 } 664 664
+802 -39
crates/weaver-renderer/src/atproto/embed_renderer.rs
··· 454 454 .unwrap_or(false) 455 455 { 456 456 // blog.pckt.document wraps site.standard.document in a "document" field 457 - let pckt_doc = jacquard::from_data::<weaver_api::blog_pckt::document::Document>(&output.value) 458 - .map_err(|e| AtProtoPreprocessError::FetchFailed(format!("Parse error: {:?}", e)))?; 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 + })?; 459 462 pckt_doc.document 460 463 } else { 461 464 // Direct site.standard.document ··· 477 480 478 481 // Get author DID and handle from URI authority 479 482 use jacquard::types::string::{Did, Handle}; 480 - let (author_did, author_handle): (Did<'static>, Option<Handle<'static>>) = 481 - match uri.authority() { 482 - jacquard::types::ident::AtIdentifier::Did(d) => { 483 - let did = d.clone().into_static(); 484 - let handle = agent 485 - .resolve_did_doc_owned(d) 486 - .await 487 - .ok() 488 - .and_then(|doc| doc.handles().first().cloned()); 489 - (did, handle) 490 - } 491 - jacquard::types::ident::AtIdentifier::Handle(h) => { 492 - let handle = Some(h.clone().into_static()); 493 - let did = agent 494 - .resolve_handle(h) 495 - .await 496 - .map(|d| d.into_static()) 497 - .map_err(|e| { 498 - AtProtoPreprocessError::FetchFailed(format!( 499 - "Handle resolution failed: {:?}", 500 - e 501 - )) 502 - })?; 503 - (did, handle) 504 - } 505 - }; 483 + let (author_did, author_handle): (Did<'static>, Option<Handle<'static>>) = match uri.authority() 484 + { 485 + jacquard::types::ident::AtIdentifier::Did(d) => { 486 + let did = d.clone().into_static(); 487 + let handle = agent 488 + .resolve_did_doc_owned(d) 489 + .await 490 + .ok() 491 + .and_then(|doc| doc.handles().first().cloned()); 492 + (did, handle) 493 + } 494 + jacquard::types::ident::AtIdentifier::Handle(h) => { 495 + let handle = Some(h.clone().into_static()); 496 + let did = agent 497 + .resolve_handle(h) 498 + .await 499 + .map(|d| d.into_static()) 500 + .map_err(|e| { 501 + AtProtoPreprocessError::FetchFailed(format!( 502 + "Handle resolution failed: {:?}", 503 + e 504 + )) 505 + })?; 506 + (did, handle) 507 + } 508 + }; 506 509 507 510 let ctx = PcktRenderContext::new(author_did); 508 511 ··· 511 514 let toggle_id = format!("pckt-toggle-{}", rkey); 512 515 513 516 // Document path for URL (use path field if present, otherwise rkey) 514 - let doc_path = doc 515 - .path 516 - .as_ref() 517 - .map(|p| p.as_ref()) 518 - .unwrap_or(rkey); 517 + let doc_path = doc.path.as_ref().map(|p| p.as_ref()).unwrap_or(rkey); 519 518 520 519 let mut html = String::new(); 521 520 html.push_str("<div class=\"atproto-embed atproto-entry\" contenteditable=\"false\">"); ··· 612 611 } 613 612 } 614 613 615 - /// Render a profile from ProfileDataViewInner (weaver, bsky, or tangled) 616 - fn render_profile_data_view( 614 + /// Render any AT Protocol record synchronously from pre-fetched data. 615 + /// 616 + /// This is the pure sync version of `fetch_and_render`. Takes a URI and the 617 + /// record data, dispatches to the appropriate renderer based on collection type. 618 + /// 619 + /// # Arguments 620 + /// 621 + /// * `uri` - The AT URI of the record 622 + /// * `data` - The record data (either raw record or hydrated view type) 623 + /// * `fallback_author` - Optional author profile to use when data is a raw record 624 + /// without embedded author info. Used for entries and other content types. 625 + /// * `resolved_content` - Optional pre-resolved embeds for rendering markdown with embeds 626 + /// 627 + /// # Supported collections 628 + /// 629 + /// **Profiles** (pass hydrated view from appview): 630 + /// - `app.bsky.actor.profile` - Bluesky profiles (ProfileViewDetailed from getProfile) 631 + /// - `sh.weaver.actor.profile` - Weaver profiles (ProfileView from weaver appview) 632 + /// - Tangled profiles also supported via type discriminator 633 + /// 634 + /// **Posts**: 635 + /// - `app.bsky.feed.post` - Posts (PostView from getPosts, or raw record for basic) 636 + /// 637 + /// **Entries** (pass view type for author info, or provide fallback_author): 638 + /// - `sh.weaver.notebook.entry` - Weaver entries (EntryView or raw Entry) 639 + /// - `com.whtwnd.blog.entry` - Whitewind entries 640 + /// - `pub.leaflet.document` - Leaflet documents 641 + /// - `site.standard.document` / `blog.pckt.document` - pckt documents 642 + /// 643 + /// **Lists & Feeds**: 644 + /// - `app.bsky.graph.list` - User lists 645 + /// - `app.bsky.feed.generator` - Custom feeds 646 + /// - `app.bsky.graph.starterpack` - Starter packs 647 + /// - `app.bsky.labeler.service` - Labelers 648 + /// 649 + /// **Other** - Generic field display for unknown types 650 + pub fn render_record( 651 + uri: &AtUri<'_>, 652 + data: &Data<'_>, 653 + fallback_author: Option<&weaver_api::sh_weaver::actor::ProfileDataView<'_>>, 654 + resolved_content: Option<&weaver_common::ResolvedContent>, 655 + ) -> Result<String, AtProtoPreprocessError> { 656 + let collection = uri.collection().map(|c| c.as_ref()); 657 + 658 + match collection { 659 + // No collection = just an identity reference, try as profile 660 + None => render_profile_from_data(data, uri), 661 + 662 + // Profiles - try multiple profile view types 663 + Some("app.bsky.actor.profile") | Some("sh.weaver.actor.profile") => { 664 + render_profile_from_data(data, uri) 665 + } 666 + 667 + // Posts 668 + Some("app.bsky.feed.post") => { 669 + // Try PostView first (from getPosts), fall back to raw record 670 + if let Ok(post_view) = jacquard::from_data::<PostView>(data) { 671 + render_post_view(&post_view, uri) 672 + } else { 673 + render_basic_post(data, uri) 674 + } 675 + } 676 + 677 + // Lists 678 + Some("app.bsky.graph.list") => render_list_record(data, uri), 679 + 680 + // Custom feeds 681 + Some("app.bsky.feed.generator") => render_generator_record(data, uri), 682 + 683 + // Starter packs 684 + Some("app.bsky.graph.starterpack") => render_starterpack_record(data, uri), 685 + 686 + // Labelers 687 + Some("app.bsky.labeler.service") => render_labeler_record(data, uri), 688 + 689 + // Weaver entries 690 + Some("sh.weaver.notebook.entry") => { 691 + render_weaver_entry_record(data, uri, fallback_author, resolved_content) 692 + } 693 + 694 + // Whitewind entries 695 + Some("com.whtwnd.blog.entry") => { 696 + render_whitewind_entry_record(data, uri, fallback_author, resolved_content) 697 + } 698 + 699 + // Leaflet documents 700 + Some("pub.leaflet.document") => { 701 + render_leaflet_record(data, uri, fallback_author, resolved_content) 702 + } 703 + 704 + // pckt / site.standard documents 705 + #[cfg(feature = "pckt")] 706 + Some("site.standard.document") | Some("blog.pckt.document") => { 707 + render_site_standard_record(data, uri, fallback_author, resolved_content) 708 + } 709 + 710 + // Default: generic rendering 711 + _ => render_generic_record(data, uri), 712 + } 713 + } 714 + 715 + /// Try to render profile data by detecting the view type. 716 + fn render_profile_from_data( 717 + data: &Data<'_>, 718 + uri: &AtUri<'_>, 719 + ) -> Result<String, AtProtoPreprocessError> { 720 + // Check type discriminator first for union types 721 + if let Some(type_disc) = data.type_discriminator() { 722 + match type_disc { 723 + "app.bsky.actor.defs#profileViewDetailed" => { 724 + if let Ok(profile) = 725 + jacquard::from_data::<weaver_api::app_bsky::actor::ProfileViewDetailed>(data) 726 + { 727 + return render_profile_data_view(&ProfileDataViewInner::ProfileViewDetailed( 728 + Box::new(profile), 729 + )); 730 + } 731 + } 732 + "sh.weaver.actor.defs#profileView" => { 733 + if let Ok(profile) = 734 + jacquard::from_data::<weaver_api::sh_weaver::actor::ProfileView>(data) 735 + { 736 + return render_profile_data_view(&ProfileDataViewInner::ProfileView(Box::new( 737 + profile, 738 + ))); 739 + } 740 + } 741 + "sh.weaver.actor.defs#tangledProfileView" => { 742 + if let Ok(profile) = 743 + jacquard::from_data::<weaver_api::sh_weaver::actor::TangledProfileView>(data) 744 + { 745 + return render_profile_data_view(&ProfileDataViewInner::TangledProfileView( 746 + Box::new(profile), 747 + )); 748 + } 749 + } 750 + _ => {} 751 + } 752 + } 753 + 754 + // Try each type without discriminator 755 + if let Ok(profile) = 756 + jacquard::from_data::<weaver_api::app_bsky::actor::ProfileViewDetailed>(data) 757 + { 758 + return render_profile_data_view(&ProfileDataViewInner::ProfileViewDetailed(Box::new( 759 + profile, 760 + ))); 761 + } 762 + if let Ok(profile) = jacquard::from_data::<weaver_api::sh_weaver::actor::ProfileView>(data) { 763 + return render_profile_data_view(&ProfileDataViewInner::ProfileView(Box::new(profile))); 764 + } 765 + if let Ok(profile) = 766 + jacquard::from_data::<weaver_api::sh_weaver::actor::TangledProfileView>(data) 767 + { 768 + return render_profile_data_view(&ProfileDataViewInner::TangledProfileView(Box::new( 769 + profile, 770 + ))); 771 + } 772 + 773 + // Fall back to generic 774 + render_generic_record(data, uri) 775 + } 776 + 777 + /// Render a list record. 778 + fn render_list_record(data: &Data<'_>, uri: &AtUri<'_>) -> Result<String, AtProtoPreprocessError> { 779 + let list = match jacquard::from_data::<weaver_api::app_bsky::graph::list::List>(data) { 780 + Ok(l) => l, 781 + Err(_) => return render_generic_record(data, uri), 782 + }; 783 + 784 + let mut html = String::new(); 785 + html.push_str("<span class=\"atproto-embed atproto-record\" contenteditable=\"false\">"); 786 + html.push_str("<span class=\"embed-type\">List</span>"); 787 + html.push_str("<span class=\"embed-author-name\">"); 788 + html.push_str(&html_escape(list.name.as_ref())); 789 + html.push_str("</span>"); 790 + if let Some(desc) = &list.description { 791 + html.push_str("<span class=\"embed-description\">"); 792 + html.push_str(&html_escape(desc.as_ref())); 793 + html.push_str("</span>"); 794 + } 795 + html.push_str("</span>"); 796 + 797 + Ok(html) 798 + } 799 + 800 + /// Render a feed generator record. 801 + fn render_generator_record( 802 + data: &Data<'_>, 803 + uri: &AtUri<'_>, 804 + ) -> Result<String, AtProtoPreprocessError> { 805 + let generator = 806 + match jacquard::from_data::<weaver_api::app_bsky::feed::generator::Generator>(data) { 807 + Ok(g) => g, 808 + Err(_) => return render_generic_record(data, uri), 809 + }; 810 + 811 + let mut html = String::new(); 812 + html.push_str("<span class=\"atproto-embed atproto-record\" contenteditable=\"false\">"); 813 + html.push_str("<span class=\"embed-type\">Custom Feed</span>"); 814 + html.push_str("<span class=\"embed-author-name\">"); 815 + html.push_str(&html_escape(generator.display_name.as_ref())); 816 + html.push_str("</span>"); 817 + if let Some(desc) = &generator.description { 818 + html.push_str("<span class=\"embed-description\">"); 819 + html.push_str(&html_escape(desc.as_ref())); 820 + html.push_str("</span>"); 821 + } 822 + html.push_str("</span>"); 823 + 824 + Ok(html) 825 + } 826 + 827 + /// Render a starter pack record. 828 + fn render_starterpack_record( 829 + data: &Data<'_>, 830 + uri: &AtUri<'_>, 831 + ) -> Result<String, AtProtoPreprocessError> { 832 + let sp = 833 + match jacquard::from_data::<weaver_api::app_bsky::graph::starterpack::Starterpack>(data) { 834 + Ok(s) => s, 835 + Err(_) => return render_generic_record(data, uri), 836 + }; 837 + 838 + let mut html = String::new(); 839 + html.push_str("<span class=\"atproto-embed atproto-record\" contenteditable=\"false\">"); 840 + html.push_str("<span class=\"embed-type\">Starter Pack</span>"); 841 + html.push_str("<span class=\"embed-author-name\">"); 842 + html.push_str(&html_escape(sp.name.as_ref())); 843 + html.push_str("</span>"); 844 + if let Some(desc) = &sp.description { 845 + html.push_str("<span class=\"embed-description\">"); 846 + html.push_str(&html_escape(desc.as_ref())); 847 + html.push_str("</span>"); 848 + } 849 + html.push_str("</span>"); 850 + 851 + Ok(html) 852 + } 853 + 854 + /// Render a labeler service record. 855 + fn render_labeler_record( 856 + data: &Data<'_>, 857 + uri: &AtUri<'_>, 858 + ) -> Result<String, AtProtoPreprocessError> { 859 + let labeler = match jacquard::from_data::<weaver_api::app_bsky::labeler::service::Service>(data) 860 + { 861 + Ok(l) => l, 862 + Err(_) => return render_generic_record(data, uri), 863 + }; 864 + 865 + let mut html = String::new(); 866 + html.push_str("<span class=\"atproto-embed atproto-record\" contenteditable=\"false\">"); 867 + html.push_str("<span class=\"embed-type\">Labeler</span>"); 868 + 869 + // Labeler policies 870 + html.push_str("<span class=\"embed-fields\">"); 871 + let label_count = labeler.policies.label_values.len(); 872 + html.push_str("<span class=\"embed-field\">"); 873 + html.push_str(&label_count.to_string()); 874 + html.push_str(" label"); 875 + if label_count != 1 { 876 + html.push_str("s"); 877 + } 878 + html.push_str(" defined</span>"); 879 + html.push_str("</span>"); 880 + 881 + html.push_str("</span>"); 882 + 883 + Ok(html) 884 + } 885 + 886 + /// Render a weaver notebook entry record. 887 + /// 888 + /// Accepts either: 889 + /// - `EntryView` (from appview) - includes author info 890 + /// - Raw `Entry` record with optional fallback_author 891 + fn render_weaver_entry_record( 892 + data: &Data<'_>, 893 + uri: &AtUri<'_>, 894 + fallback_author: Option<&weaver_api::sh_weaver::actor::ProfileDataView<'_>>, 895 + resolved_content: Option<&weaver_common::ResolvedContent>, 896 + ) -> Result<String, AtProtoPreprocessError> { 897 + use crate::atproto::writer::ClientWriter; 898 + use crate::default_md_options; 899 + use markdown_weaver::Parser; 900 + use weaver_api::sh_weaver::notebook::EntryView; 901 + 902 + // Try to parse as EntryView first (has author info), then raw Entry 903 + let (title, content, author_handle): (String, String, Option<String>) = if let Ok(view) = 904 + jacquard::from_data::<EntryView>(data) 905 + { 906 + // EntryView has embedded record data, extract content from it 907 + let content = view 908 + .record 909 + .query("content") 910 + .single() 911 + .and_then(|d| d.as_str()) 912 + .unwrap_or_default() 913 + .to_string(); 914 + let title = view 915 + .record 916 + .query("title") 917 + .single() 918 + .and_then(|d| d.as_str()) 919 + .unwrap_or_default() 920 + .to_string(); 921 + let handle = view 922 + .authors 923 + .first() 924 + .and_then(|author| extract_handle_from_profile_data_view(&author.record.inner)); 925 + (title, content, handle.map(|h| h.to_string())) 926 + } else if let Ok(entry) = 927 + jacquard::from_data::<weaver_api::sh_weaver::notebook::entry::Entry>(data) 928 + { 929 + let handle = fallback_author.and_then(|p| extract_handle_from_profile_data_view(&p.inner)); 930 + ( 931 + entry.title.as_ref().to_string(), 932 + entry.content.as_ref().to_string(), 933 + handle.map(|h| h.to_string()), 934 + ) 935 + } else { 936 + return render_generic_record(data, uri); 937 + }; 938 + 939 + // Render markdown content to HTML using resolved_content for embeds 940 + let parser = Parser::new_ext(&content, default_md_options()).into_offset_iter(); 941 + let mut content_html = String::new(); 942 + if let Some(resolved) = resolved_content { 943 + ClientWriter::new(parser, &mut content_html, &content) 944 + .with_embed_provider(resolved) 945 + .run() 946 + .map_err(|e| { 947 + AtProtoPreprocessError::FetchFailed(format!("Markdown render failed: {:?}", e)) 948 + })?; 949 + } else { 950 + ClientWriter::<_, _, ()>::new(parser, &mut content_html, &content) 951 + .run() 952 + .map_err(|e| { 953 + AtProtoPreprocessError::FetchFailed(format!("Markdown render failed: {:?}", e)) 954 + })?; 955 + } 956 + 957 + // Generate unique ID for the toggle checkbox 958 + let rkey = uri.rkey().map(|r| r.as_ref()).unwrap_or("unknown"); 959 + let toggle_id = format!("entry-toggle-{}", rkey); 960 + 961 + // Build the embed HTML - matches fetch_and_render_entry exactly 962 + let mut html = String::new(); 963 + html.push_str("<div class=\"atproto-embed atproto-entry\" contenteditable=\"false\">"); 964 + 965 + // Hidden checkbox for expand/collapse (must come before content for CSS sibling selector) 966 + html.push_str("<input type=\"checkbox\" class=\"embed-entry-toggle\" id=\""); 967 + html.push_str(&toggle_id); 968 + html.push_str("\">"); 969 + 970 + // Header with title and author 971 + html.push_str("<div class=\"embed-entry-header\">"); 972 + 973 + // Title 974 + html.push_str("<span class=\"embed-entry-title\">"); 975 + html.push_str(&html_escape(&title)); 976 + html.push_str("</span>"); 977 + 978 + // Author info - just show handle (keep it simple for entry embeds) 979 + if let Some(ref handle) = author_handle { 980 + if !handle.is_empty() { 981 + html.push_str("<span class=\"embed-entry-author\">@"); 982 + html.push_str(&html_escape(handle)); 983 + html.push_str("</span>"); 984 + } 985 + } 986 + 987 + html.push_str("</div>"); // end header 988 + 989 + // Scrollable content container 990 + html.push_str("<div class=\"embed-entry-content\">"); 991 + html.push_str(&content_html); 992 + html.push_str("</div>"); 993 + 994 + // Expand/collapse label (clickable, targets the checkbox) 995 + html.push_str("<label class=\"embed-entry-expand\" for=\""); 996 + html.push_str(&toggle_id); 997 + html.push_str("\"></label>"); 998 + 999 + html.push_str("</div>"); 1000 + 1001 + Ok(html) 1002 + } 1003 + 1004 + /// Extract handle from ProfileDataViewInner. 1005 + fn extract_handle_from_profile_data_view<'a>( 1006 + inner: &'a ProfileDataViewInner<'a>, 1007 + ) -> Option<&'a str> { 1008 + match inner { 1009 + ProfileDataViewInner::ProfileView(p) => Some(p.handle.as_ref()), 1010 + ProfileDataViewInner::ProfileViewDetailed(p) => Some(p.handle.as_ref()), 1011 + ProfileDataViewInner::TangledProfileView(p) => Some(p.handle.as_ref()), 1012 + ProfileDataViewInner::Unknown(_) => None, 1013 + } 1014 + } 1015 + 1016 + fn extract_did_from_profile_data_view( 1017 + inner: &ProfileDataViewInner<'_>, 1018 + ) -> Option<jacquard::types::string::Did<'static>> { 1019 + use jacquard::IntoStatic; 1020 + match inner { 1021 + ProfileDataViewInner::ProfileView(p) => Some(p.did.clone().into_static()), 1022 + ProfileDataViewInner::ProfileViewDetailed(p) => Some(p.did.clone().into_static()), 1023 + ProfileDataViewInner::TangledProfileView(p) => Some(p.did.clone().into_static()), 1024 + ProfileDataViewInner::Unknown(_) => None, 1025 + } 1026 + } 1027 + 1028 + /// Render a whitewind blog entry record. 1029 + /// 1030 + /// Whitewind entries don't have a view type, so author info comes from fallback_author. 1031 + fn render_whitewind_entry_record( 1032 + data: &Data<'_>, 1033 + uri: &AtUri<'_>, 1034 + fallback_author: Option<&weaver_api::sh_weaver::actor::ProfileDataView<'_>>, 1035 + resolved_content: Option<&weaver_common::ResolvedContent>, 1036 + ) -> Result<String, AtProtoPreprocessError> { 1037 + use crate::atproto::writer::ClientWriter; 1038 + use crate::default_md_options; 1039 + use markdown_weaver::Parser; 1040 + 1041 + let entry = match jacquard::from_data::<weaver_api::com_whtwnd::blog::entry::Entry>(data) { 1042 + Ok(e) => e, 1043 + Err(_) => return render_generic_record(data, uri), 1044 + }; 1045 + 1046 + // Render the markdown content to HTML using resolved_content for embeds 1047 + let content = entry.content.as_ref(); 1048 + let parser = Parser::new_ext(content, default_md_options()).into_offset_iter(); 1049 + let mut content_html = String::new(); 1050 + if let Some(resolved) = resolved_content { 1051 + ClientWriter::new(parser, &mut content_html, content) 1052 + .with_embed_provider(resolved) 1053 + .run() 1054 + .map_err(|e| { 1055 + AtProtoPreprocessError::FetchFailed(format!("Markdown render failed: {:?}", e)) 1056 + })?; 1057 + } else { 1058 + ClientWriter::<_, _, ()>::new(parser, &mut content_html, content) 1059 + .run() 1060 + .map_err(|e| { 1061 + AtProtoPreprocessError::FetchFailed(format!("Markdown render failed: {:?}", e)) 1062 + })?; 1063 + } 1064 + 1065 + // Generate unique ID for the toggle checkbox 1066 + let rkey = uri.rkey().map(|r| r.as_ref()).unwrap_or("unknown"); 1067 + let toggle_id = format!("entry-toggle-{}", rkey); 1068 + 1069 + // Build the embed HTML - matches fetch_and_render_whitewind_entry exactly 1070 + let mut html = String::new(); 1071 + html.push_str("<div class=\"atproto-embed atproto-entry\" contenteditable=\"false\">"); 1072 + 1073 + // Hidden checkbox for expand/collapse (must come before content for CSS sibling selector) 1074 + html.push_str("<input type=\"checkbox\" class=\"embed-entry-toggle\" id=\""); 1075 + html.push_str(&toggle_id); 1076 + html.push_str("\">"); 1077 + 1078 + // Header with title and author 1079 + html.push_str("<div class=\"embed-entry-header\">"); 1080 + 1081 + // Title 1082 + html.push_str("<span class=\"embed-entry-title\">"); 1083 + html.push_str(&html_escape( 1084 + entry.title.as_ref().map(|t| t.as_ref()).unwrap_or(""), 1085 + )); 1086 + html.push_str("</span>"); 1087 + 1088 + // Author info - just show handle (keep it simple for entry embeds) 1089 + if let Some(author) = fallback_author { 1090 + let handle = extract_handle_from_profile_data_view(&author.inner).unwrap_or(""); 1091 + if !handle.is_empty() { 1092 + html.push_str("<span class=\"embed-entry-author\">@"); 1093 + html.push_str(&html_escape(handle)); 1094 + html.push_str("</span>"); 1095 + } 1096 + } 1097 + 1098 + html.push_str("</div>"); // end header 1099 + 1100 + // Scrollable content container 1101 + html.push_str("<div class=\"embed-entry-content\">"); 1102 + html.push_str(&content_html); 1103 + html.push_str("</div>"); 1104 + 1105 + // Expand/collapse label (clickable, targets the checkbox) 1106 + html.push_str("<label class=\"embed-entry-expand\" for=\""); 1107 + html.push_str(&toggle_id); 1108 + html.push_str("\"></label>"); 1109 + 1110 + html.push_str("</div>"); 1111 + 1112 + Ok(html) 1113 + } 1114 + 1115 + /// Render a leaflet document record. 1116 + /// 1117 + /// Uses the sync block renderer to render page content directly. Embedded posts 1118 + /// within the document will be looked up from resolved_content by their AT URI. 1119 + fn render_leaflet_record( 1120 + data: &Data<'_>, 1121 + uri: &AtUri<'_>, 1122 + fallback_author: Option<&weaver_api::sh_weaver::actor::ProfileDataView<'_>>, 1123 + resolved_content: Option<&weaver_common::ResolvedContent>, 1124 + ) -> Result<String, AtProtoPreprocessError> { 1125 + use crate::leaflet::{LeafletRenderContext, render_linear_document_sync}; 1126 + use weaver_api::pub_leaflet::document::{Document, DocumentPagesItem}; 1127 + 1128 + let doc = match jacquard::from_data::<Document>(data) { 1129 + Ok(d) => d, 1130 + Err(_) => return render_generic_record(data, uri), 1131 + }; 1132 + 1133 + // Get author DID from fallback_author or from document/URI. 1134 + let author_did = if let Some(author) = fallback_author { 1135 + extract_did_from_profile_data_view(&author.inner) 1136 + } else { 1137 + None 1138 + } 1139 + .or_else(|| { 1140 + // Try to get DID from document author field. 1141 + match &doc.author { 1142 + jacquard::types::ident::AtIdentifier::Did(d) => Some(d.clone().into_static()), 1143 + _ => None, 1144 + } 1145 + }) 1146 + .or_else(|| { 1147 + // Fall back to URI authority if it's a DID. 1148 + jacquard::types::string::Did::new(uri.authority().as_ref()) 1149 + .ok() 1150 + .map(|d| d.into_static()) 1151 + }); 1152 + 1153 + let ctx = author_did 1154 + .map(LeafletRenderContext::new) 1155 + .unwrap_or_else(|| { 1156 + LeafletRenderContext::new(jacquard::types::string::Did::raw("did:plc:unknown".into())) 1157 + }); 1158 + 1159 + // Generate unique toggle ID. 1160 + let rkey = uri.rkey().map(|r| r.as_ref()).unwrap_or("unknown"); 1161 + let toggle_id = format!("leaflet-toggle-{}", rkey); 1162 + 1163 + let mut html = String::new(); 1164 + html.push_str("<div class=\"atproto-embed atproto-entry\" contenteditable=\"false\">"); 1165 + 1166 + // Hidden checkbox for expand/collapse. 1167 + html.push_str("<input type=\"checkbox\" class=\"embed-entry-toggle\" id=\""); 1168 + html.push_str(&toggle_id); 1169 + html.push_str("\">"); 1170 + 1171 + // Header with title and author. 1172 + html.push_str("<div class=\"embed-entry-header\">"); 1173 + 1174 + // Title (no link in sync version since we don't have publication base_path). 1175 + html.push_str("<span class=\"embed-entry-title\">"); 1176 + html.push_str(&html_escape(doc.title.as_ref())); 1177 + html.push_str("</span>"); 1178 + 1179 + // Author info. 1180 + if let Some(author) = fallback_author { 1181 + let handle = extract_handle_from_profile_data_view(&author.inner).unwrap_or(""); 1182 + if !handle.is_empty() { 1183 + html.push_str("<span class=\"embed-entry-author\">@"); 1184 + html.push_str(&html_escape(handle)); 1185 + html.push_str("</span>"); 1186 + } 1187 + } 1188 + 1189 + html.push_str("</div>"); // end header 1190 + 1191 + // Scrollable content container. 1192 + html.push_str("<div class=\"embed-entry-content\">"); 1193 + 1194 + // Render each page using the sync block renderer. 1195 + for page in &doc.pages { 1196 + match page { 1197 + DocumentPagesItem::LinearDocument(linear_doc) => { 1198 + html.push_str(&render_linear_document_sync( 1199 + linear_doc, 1200 + &ctx, 1201 + resolved_content, 1202 + )); 1203 + } 1204 + DocumentPagesItem::Canvas(_) => { 1205 + html.push_str( 1206 + "<div class=\"embed-video-placeholder\">[Canvas layout not yet supported]</div>", 1207 + ); 1208 + } 1209 + DocumentPagesItem::Unknown(_) => { 1210 + html.push_str("<div class=\"embed-video-placeholder\">[Unknown page type]</div>"); 1211 + } 1212 + } 1213 + } 1214 + 1215 + html.push_str("</div>"); // end content 1216 + 1217 + // Expand/collapse label. 1218 + html.push_str("<label class=\"embed-entry-expand\" for=\""); 1219 + html.push_str(&toggle_id); 1220 + html.push_str("\"></label>"); 1221 + 1222 + html.push_str("</div>"); 1223 + 1224 + Ok(html) 1225 + } 1226 + 1227 + /// Render a site.standard or blog.pckt document record. 1228 + /// 1229 + /// Uses the sync block renderer to render content blocks directly. Embedded posts 1230 + /// within the document will be looked up from resolved_content by their AT URI. 1231 + #[cfg(feature = "pckt")] 1232 + fn render_site_standard_record( 1233 + data: &Data<'_>, 1234 + uri: &AtUri<'_>, 1235 + fallback_author: Option<&weaver_api::sh_weaver::actor::ProfileDataView<'_>>, 1236 + resolved_content: Option<&weaver_common::ResolvedContent>, 1237 + ) -> Result<String, AtProtoPreprocessError> { 1238 + use crate::pckt::{PcktRenderContext, render_content_blocks_sync}; 1239 + use weaver_api::site_standard::document::Document as SiteStandardDocument; 1240 + 1241 + // Extract the document - either directly or from blog.pckt.document wrapper. 1242 + let doc: SiteStandardDocument<'_> = if data 1243 + .type_discriminator() 1244 + .map(|t| t == "blog.pckt.document") 1245 + .unwrap_or(false) 1246 + { 1247 + let pckt_doc = match jacquard::from_data::<weaver_api::blog_pckt::document::Document>(data) 1248 + { 1249 + Ok(d) => d, 1250 + Err(_) => return render_generic_record(data, uri), 1251 + }; 1252 + pckt_doc.document 1253 + } else { 1254 + match jacquard::from_data::<SiteStandardDocument>(data) { 1255 + Ok(d) => d, 1256 + Err(_) => return render_generic_record(data, uri), 1257 + } 1258 + }; 1259 + 1260 + // Get author DID from fallback_author or from URI authority. 1261 + let author_did = if let Some(author) = fallback_author { 1262 + extract_did_from_profile_data_view(&author.inner) 1263 + } else { 1264 + None 1265 + } 1266 + .or_else(|| { 1267 + // Fall back to URI authority if it's a DID. 1268 + jacquard::types::string::Did::new(uri.authority().as_ref()) 1269 + .ok() 1270 + .map(|d| d.into_static()) 1271 + }); 1272 + 1273 + let ctx = author_did.map(PcktRenderContext::new).unwrap_or_else(|| { 1274 + PcktRenderContext::new(jacquard::types::string::Did::unchecked( 1275 + "did:plc:unknown".into(), 1276 + )) 1277 + }); 1278 + 1279 + let rkey = uri.rkey().map(|r| r.as_ref()).unwrap_or("unknown"); 1280 + let toggle_id = format!("pckt-toggle-{}", rkey); 1281 + 1282 + let mut html = String::new(); 1283 + html.push_str("<div class=\"atproto-embed atproto-entry\" contenteditable=\"false\">"); 1284 + 1285 + // Toggle checkbox. 1286 + html.push_str("<input type=\"checkbox\" class=\"embed-entry-toggle\" id=\""); 1287 + html.push_str(&toggle_id); 1288 + html.push_str("\">"); 1289 + 1290 + // Header. 1291 + html.push_str("<div class=\"embed-entry-header\">"); 1292 + html.push_str("<span class=\"embed-entry-title\">"); 1293 + html.push_str(&html_escape(doc.title.as_ref())); 1294 + html.push_str("</span>"); 1295 + 1296 + // Author info. 1297 + if let Some(author) = fallback_author { 1298 + let handle = extract_handle_from_profile_data_view(&author.inner).unwrap_or(""); 1299 + if !handle.is_empty() { 1300 + html.push_str("<span class=\"embed-entry-author\">@"); 1301 + html.push_str(&html_escape(handle)); 1302 + html.push_str("</span>"); 1303 + } 1304 + } 1305 + 1306 + html.push_str("</div>"); 1307 + 1308 + // Content. 1309 + html.push_str("<div class=\"embed-entry-content\">"); 1310 + if let Some(content) = &doc.content { 1311 + // Render actual content blocks using the sync renderer. 1312 + html.push_str(&render_content_blocks_sync(content, &ctx, resolved_content)); 1313 + } else if let Some(text_content) = &doc.text_content { 1314 + // Fallback to text_content if no structured blocks. 1315 + html.push_str("<p>"); 1316 + html.push_str(&html_escape(text_content.as_ref())); 1317 + html.push_str("</p>"); 1318 + } 1319 + html.push_str("</div>"); 1320 + 1321 + // Expand label. 1322 + html.push_str("<label class=\"embed-entry-expand\" for=\""); 1323 + html.push_str(&toggle_id); 1324 + html.push_str("\"></label>"); 1325 + 1326 + html.push_str("</div>"); 1327 + 1328 + Ok(html) 1329 + } 1330 + 1331 + /// Render a basic post from record data (no engagement stats or author info). 1332 + /// 1333 + /// This is a simpler version than `render_post_view` for cases where you only 1334 + /// have the raw record, not the full PostView from the appview. 1335 + fn render_basic_post(data: &Data<'_>, uri: &AtUri<'_>) -> Result<String, AtProtoPreprocessError> { 1336 + let mut html = String::new(); 1337 + 1338 + // Try to parse as Post 1339 + let post = jacquard::from_data::<weaver_api::app_bsky::feed::post::Post>(data) 1340 + .map_err(|e| AtProtoPreprocessError::FetchFailed(format!("Parse error: {:?}", e)))?; 1341 + 1342 + // Build link to post on Bluesky 1343 + let authority = uri.authority(); 1344 + let rkey = uri.rkey().map(|r| r.as_ref()).unwrap_or(""); 1345 + let bsky_link = format!("https://bsky.app/profile/{}/post/{}", authority, rkey); 1346 + 1347 + html.push_str("<span class=\"atproto-embed atproto-post\" contenteditable=\"false\">"); 1348 + 1349 + // Background link 1350 + html.push_str("<a class=\"embed-card-link\" href=\""); 1351 + html.push_str(&html_escape(&bsky_link)); 1352 + html.push_str("\" target=\"_blank\" rel=\"noopener\" aria-label=\"View post on Bluesky\"></a>"); 1353 + 1354 + // Post text 1355 + html.push_str("<span class=\"embed-content\">"); 1356 + html.push_str(&html_escape(post.text.as_ref())); 1357 + html.push_str("</span>"); 1358 + 1359 + // Timestamp 1360 + html.push_str("<span class=\"embed-meta\">"); 1361 + html.push_str("<span class=\"embed-time\">"); 1362 + html.push_str(&html_escape(&post.created_at.to_string())); 1363 + html.push_str("</span>"); 1364 + html.push_str("</span>"); 1365 + 1366 + html.push_str("</span>"); 1367 + 1368 + Ok(html) 1369 + } 1370 + 1371 + /// Render a profile from ProfileDataViewInner (weaver, bsky, or tangled). 1372 + /// 1373 + /// Takes pre-fetched profile data - no network calls. 1374 + pub fn render_profile_data_view( 617 1375 inner: &ProfileDataViewInner<'_>, 618 1376 ) -> Result<String, AtProtoPreprocessError> { 619 1377 let mut html = String::new(); ··· 754 1512 Ok(html) 755 1513 } 756 1514 757 - /// Render a Bluesky post from PostView (rich appview data) 758 - fn render_post_view<'a>( 1515 + /// Render a Bluesky post from PostView (rich appview data). 1516 + /// 1517 + /// Takes pre-fetched PostView from getPosts - no network calls. 1518 + pub fn render_post_view<'a>( 759 1519 post: &PostView<'a>, 760 1520 uri: &AtUri<'_>, 761 1521 ) -> Result<String, AtProtoPreprocessError> { ··· 825 1585 Ok(html) 826 1586 } 827 1587 828 - /// Render a generic record by probing Data for meaningful fields 829 - fn render_generic_record( 1588 + /// Render a generic record by probing Data for meaningful fields. 1589 + /// 1590 + /// Takes pre-fetched record data - no network calls. 1591 + /// Probes for common fields like name, title, text, description. 1592 + pub fn render_generic_record( 830 1593 data: &Data<'_>, 831 1594 uri: &AtUri<'_>, 832 1595 ) -> Result<String, AtProtoPreprocessError> {
+87 -64
crates/weaver-renderer/src/atproto/writer.rs
··· 5 5 6 6 use jacquard::types::string::AtUri; 7 7 use markdown_weaver::{ 8 - Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType, 9 - ParagraphContext, Tag, WeaverAttributes, 8 + Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType, ParagraphContext, 9 + Tag, WeaverAttributes, 10 10 }; 11 11 use markdown_weaver_escape::{StrWrite, escape_href, escape_html, escape_html_body_text}; 12 12 use std::collections::HashMap; ··· 20 20 Div, 21 21 } 22 22 23 - /// Synchronous callback for injecting embed content 23 + /// Synchronous callback for injecting embed content. 24 24 /// 25 25 /// Takes the embed tag and returns optional HTML content to inject. 26 26 pub trait EmbedContentProvider { 27 - fn get_embed_content(&self, tag: &Tag<'_>) -> Option<String>; 27 + fn get_embed_content(&self, tag: &Tag<'_>) -> Option<&str>; 28 28 } 29 29 30 30 impl EmbedContentProvider for () { 31 - fn get_embed_content(&self, _tag: &Tag<'_>) -> Option<String> { 31 + fn get_embed_content(&self, _tag: &Tag<'_>) -> Option<&str> { 32 32 None 33 33 } 34 34 } 35 35 36 36 impl EmbedContentProvider for ResolvedContent { 37 - fn get_embed_content(&self, tag: &Tag<'_>) -> Option<String> { 37 + fn get_embed_content(&self, tag: &Tag<'_>) -> Option<&str> { 38 38 let url = match tag { 39 39 Tag::Embed { dest_url, .. } => Some(dest_url.as_ref()), 40 - // WikiLink images with at:// URLs are embeds in disguise 40 + // WikiLink images with at:// URLs are embeds in disguise. 41 41 Tag::Image { 42 42 link_type: LinkType::WikiLink { .. }, 43 43 dest_url, ··· 51 51 if let Some(url) = url { 52 52 if url.starts_with("at://") { 53 53 if let Ok(at_uri) = AtUri::new(url) { 54 - return self.get_embed_content(&at_uri).map(|s| s.to_string()); 54 + // Call the inherent method which returns Option<&str>. 55 + return ResolvedContent::get_embed_content(self, &at_uri); 55 56 } 56 57 } 57 58 } ··· 59 60 } 60 61 } 61 62 63 + impl EmbedContentProvider for &ResolvedContent { 64 + fn get_embed_content(&self, tag: &Tag<'_>) -> Option<&str> { 65 + <ResolvedContent as EmbedContentProvider>::get_embed_content(*self, tag) 66 + } 67 + } 68 + 62 69 /// Simple writer that outputs HTML from markdown events 63 70 /// 64 71 /// This writer is designed for client-side rendering where embeds may have ··· 498 505 if checked { 499 506 self.write("<input disabled=\"\" type=\"checkbox\" checked=\"\" aria-label=\"Completed task\"/>\n")?; 500 507 } else { 501 - self.write("<input disabled=\"\" type=\"checkbox\" aria-label=\"Incomplete task\"/>\n")?; 508 + self.write( 509 + "<input disabled=\"\" type=\"checkbox\" aria-label=\"Incomplete task\"/>\n", 510 + )?; 502 511 } 503 512 } 504 513 WeaverBlock(text) => { ··· 772 781 && (dest_url.starts_with("at://") || dest_url.starts_with("did:")) 773 782 { 774 783 tracing::debug!("[ClientWriter] AT embed image detected: {}", dest_url); 775 - if let Some(embed_provider) = &self.embed_provider { 784 + if let Some(ref embed_provider) = self.embed_provider { 776 785 if let Some(html) = embed_provider.get_embed_content(&tag) { 777 786 tracing::debug!("[ClientWriter] Got embed content for {}", dest_url); 778 - // Consume events without writing - we're replacing with embed HTML 787 + // Use direct field access to avoid borrow conflict. 788 + self.writer.write_str(html)?; 789 + self.end_newline = html.ends_with('\n'); 790 + // Consume events without writing - we've replaced with embed HTML. 779 791 self.consume_until_end(); 780 - return self.write(&html); 792 + return Ok(()); 781 793 } else { 782 794 tracing::debug!( 783 795 "[ClientWriter] No embed content from provider for {}", ··· 894 906 } 895 907 } 896 908 897 - fn end_tag(&mut self, tag: markdown_weaver::TagEnd, range: Range<usize>) -> Result<(), W::Error> { 909 + fn end_tag( 910 + &mut self, 911 + tag: markdown_weaver::TagEnd, 912 + range: Range<usize>, 913 + ) -> Result<(), W::Error> { 898 914 use markdown_weaver::TagEnd; 899 915 match tag { 900 916 TagEnd::HtmlBlock => self.write("</span>\n"), ··· 1064 1080 id: CowStr<'_>, 1065 1081 attrs: Option<markdown_weaver::WeaverAttributes<'_>>, 1066 1082 ) -> Result<(), W::Error> { 1067 - // Try to get content from attributes first 1068 - let content_from_attrs = if let Some(ref attrs) = attrs { 1069 - attrs 1070 - .attrs 1071 - .iter() 1072 - .find(|(k, _)| k.as_ref() == "content") 1073 - .map(|(_, v)| v.as_ref().to_string()) 1074 - } else { 1075 - None 1076 - }; 1083 + // Try to get content from attributes first. 1084 + let content_from_attrs: Option<&str> = attrs 1085 + .as_ref() 1086 + .and_then(|a| a.attrs.iter().find(|(k, _)| k.as_ref() == "content")) 1087 + .map(|(_, v)| v.as_ref()); 1077 1088 1078 - // If no content in attrs, try provider 1079 - let content = if let Some(content) = content_from_attrs { 1080 - Some(content) 1089 + // Write content if found in attrs, otherwise try provider, otherwise fallback. 1090 + if let Some(content) = content_from_attrs { 1091 + self.write(content)?; 1092 + self.write_newline()?; 1081 1093 } else if let Some(ref provider) = self.embed_provider { 1082 1094 let tag = Tag::Embed { 1083 1095 embed_type, ··· 1086 1098 id: id.clone(), 1087 1099 attrs: attrs.clone(), 1088 1100 }; 1089 - provider.get_embed_content(&tag) 1101 + if let Some(content) = provider.get_embed_content(&tag) { 1102 + // Use direct field access to avoid borrow conflict: 1103 + // `provider` borrows self.embed_provider, `content` borrows from provider, 1104 + // but self.writer is a different field so we can borrow it independently. 1105 + self.writer.write_str(content)?; 1106 + self.end_newline = content.ends_with('\n'); 1107 + self.writer.write_str("\n")?; 1108 + self.end_newline = true; 1109 + } else { 1110 + self.write_embed_fallback(&dest_url, &title, &id, attrs.as_ref())?; 1111 + } 1090 1112 } else { 1091 - None 1092 - }; 1113 + self.write_embed_fallback(&dest_url, &title, &id, attrs.as_ref())?; 1114 + } 1115 + Ok(()) 1116 + } 1093 1117 1094 - if let Some(html_content) = content { 1095 - // Write the pre-rendered content directly 1096 - self.write(&html_content)?; 1097 - self.write_newline()?; 1098 - } else { 1099 - // Fallback: render as iframe 1100 - self.write("<iframe src=\"")?; 1101 - escape_href(&mut self.writer, &dest_url)?; 1102 - self.write("\" title=\"")?; 1103 - escape_html(&mut self.writer, &title)?; 1104 - if !id.is_empty() { 1105 - self.write("\" id=\"")?; 1106 - escape_html(&mut self.writer, &id)?; 1107 - } 1108 - self.write("\"")?; 1118 + fn write_embed_fallback( 1119 + &mut self, 1120 + dest_url: &str, 1121 + title: &str, 1122 + id: &str, 1123 + attrs: Option<&markdown_weaver::WeaverAttributes<'_>>, 1124 + ) -> Result<(), W::Error> { 1125 + self.write("<iframe src=\"")?; 1126 + escape_href(&mut self.writer, dest_url)?; 1127 + self.write("\" title=\"")?; 1128 + escape_html(&mut self.writer, title)?; 1129 + if !id.is_empty() { 1130 + self.write("\" id=\"")?; 1131 + escape_html(&mut self.writer, id)?; 1132 + } 1133 + self.write("\"")?; 1109 1134 1110 - if let Some(attrs) = attrs { 1111 - if !attrs.classes.is_empty() { 1112 - self.write(" class=\"")?; 1113 - for (i, class) in attrs.classes.iter().enumerate() { 1114 - if i > 0 { 1115 - self.write(" ")?; 1116 - } 1117 - escape_html(&mut self.writer, class)?; 1135 + if let Some(attrs) = attrs { 1136 + if !attrs.classes.is_empty() { 1137 + self.write(" class=\"")?; 1138 + for (i, class) in attrs.classes.iter().enumerate() { 1139 + if i > 0 { 1140 + self.write(" ")?; 1118 1141 } 1142 + escape_html(&mut self.writer, class)?; 1143 + } 1144 + self.write("\"")?; 1145 + } 1146 + for (attr, value) in &attrs.attrs { 1147 + // Skip the content attr in HTML output. 1148 + if attr.as_ref() != "content" { 1149 + self.write(" ")?; 1150 + escape_html(&mut self.writer, attr)?; 1151 + self.write("=\"")?; 1152 + escape_html(&mut self.writer, value)?; 1119 1153 self.write("\"")?; 1120 1154 } 1121 - for (attr, value) in &attrs.attrs { 1122 - // Skip the content attr in HTML output 1123 - if attr.as_ref() != "content" { 1124 - self.write(" ")?; 1125 - escape_html(&mut self.writer, attr)?; 1126 - self.write("=\"")?; 1127 - escape_html(&mut self.writer, value)?; 1128 - self.write("\"")?; 1129 - } 1130 - } 1131 1155 } 1132 - self.write("></iframe>")?; 1133 1156 } 1134 - Ok(()) 1157 + self.write("></iframe>") 1135 1158 } 1136 1159 }
+187
crates/weaver-renderer/src/leaflet/block_renderer.rs
··· 387 387 .and_then(|s| s.split('/').next()) 388 388 .unwrap_or(url) 389 389 } 390 + 391 + /// Sync version of render_linear_document that uses pre-resolved embeds. 392 + pub 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. 409 + pub 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 + 482 + fn 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 + 495 + fn 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 + 538 + fn 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 + }
+4 -1
crates/weaver-renderer/src/leaflet/mod.rs
··· 1 1 mod block_renderer; 2 2 mod markdown_converter; 3 3 4 - pub use block_renderer::{render_block, render_linear_document, LeafletRenderContext}; 4 + pub use block_renderer::{ 5 + render_block, render_block_sync, render_linear_document, render_linear_document_sync, 6 + LeafletRenderContext, 7 + }; 5 8 pub use markdown_converter::{convert_block, convert_linear_document, LeafletMarkdownContext};
+137
crates/weaver-renderer/src/pckt/block_renderer.rs
··· 334 334 escaped 335 335 } 336 336 } 337 + 338 + /// Sync version of render_content_blocks that uses pre-resolved embeds. 339 + pub fn render_content_blocks_sync( 340 + blocks: &[jacquard::types::value::Data<'_>], 341 + ctx: &PcktRenderContext, 342 + resolved_content: Option<&weaver_common::ResolvedContent>, 343 + ) -> String { 344 + let mut html = String::new(); 345 + html.push_str("<div class=\"pckt-document\">"); 346 + for block in blocks { 347 + render_block_sync(&mut html, block, ctx, resolved_content); 348 + } 349 + html.push_str("</div>"); 350 + html 351 + } 352 + 353 + /// Sync version of render_block that uses pre-resolved embeds for BlueskyEmbed blocks. 354 + pub fn render_block_sync( 355 + html: &mut String, 356 + block: &jacquard::types::value::Data<'_>, 357 + ctx: &PcktRenderContext, 358 + resolved_content: Option<&weaver_common::ResolvedContent>, 359 + ) { 360 + let Some(type_tag) = block.type_discriminator() else { 361 + return; 362 + }; 363 + 364 + match type_tag { 365 + "blog.pckt.block.text" => { 366 + if let Ok(text) = jacquard::from_data::<Text>(block) { 367 + render_text_block(html, &text); 368 + } 369 + } 370 + "blog.pckt.block.heading" => { 371 + if let Ok(heading) = jacquard::from_data::<Heading>(block) { 372 + render_heading_block(html, &heading); 373 + } 374 + } 375 + "blog.pckt.block.blockquote" => { 376 + if let Ok(quote) = jacquard::from_data::<Blockquote>(block) { 377 + render_blockquote_block(html, &quote); 378 + } 379 + } 380 + "blog.pckt.block.codeBlock" => { 381 + if let Ok(code) = jacquard::from_data::<CodeBlock>(block) { 382 + render_code_block(html, &code); 383 + } 384 + } 385 + "blog.pckt.block.bulletList" => { 386 + if let Ok(list) = jacquard::from_data::<BulletList>(block) { 387 + render_bullet_list(html, &list, ctx); 388 + } 389 + } 390 + "blog.pckt.block.orderedList" => { 391 + if let Ok(list) = jacquard::from_data::<OrderedList>(block) { 392 + render_ordered_list(html, &list, ctx); 393 + } 394 + } 395 + "blog.pckt.block.image" => { 396 + if let Ok(image) = jacquard::from_data::<Image>(block) { 397 + render_image_block(html, &image, ctx); 398 + } 399 + } 400 + "blog.pckt.block.website" => { 401 + if let Ok(website) = jacquard::from_data::<Website>(block) { 402 + render_website_block(html, &website); 403 + } 404 + } 405 + "blog.pckt.block.iframe" => { 406 + if let Ok(iframe) = jacquard::from_data::<Iframe>(block) { 407 + render_iframe_block(html, &iframe); 408 + } 409 + } 410 + "blog.pckt.block.blueskyEmbed" => { 411 + if let Ok(embed) = jacquard::from_data::<BlueskyEmbed>(block) { 412 + render_bluesky_embed_sync(html, &embed, resolved_content); 413 + } 414 + } 415 + "blog.pckt.block.horizontalRule" => { 416 + if jacquard::from_data::<HorizontalRule>(block).is_ok() { 417 + html.push_str("<hr>\n"); 418 + } 419 + } 420 + _ => { 421 + tracing::debug!("Unknown pckt block type: {}", type_tag); 422 + } 423 + } 424 + } 425 + 426 + fn render_bluesky_embed_sync( 427 + html: &mut String, 428 + embed: &BlueskyEmbed<'_>, 429 + resolved_content: Option<&weaver_common::ResolvedContent>, 430 + ) { 431 + let uri_str = embed.post_ref.uri.as_ref(); 432 + 433 + // Look up pre-rendered content. 434 + if let Some(resolved) = resolved_content { 435 + if let Ok(at_uri) = AtUri::new(uri_str) { 436 + if let Some(rendered) = resolved.get_embed_content(&at_uri) { 437 + html.push_str(rendered); 438 + return; 439 + } 440 + } 441 + } 442 + 443 + // Fallback: use bsky embed iframe. 444 + // Format: at://did/app.bsky.feed.post/rkey -> https://embed.bsky.app/embed/did/post/rkey 445 + if let Some(rest) = uri_str.strip_prefix("at://") { 446 + if let Some((did, path)) = rest.split_once('/') { 447 + if let Some(rkey) = path.strip_prefix("app.bsky.feed.post/") { 448 + html.push_str("<iframe class=\"bsky-embed-iframe\" src=\"https://embed.bsky.app/embed/"); 449 + let _ = escape_html(&mut *html, did); 450 + html.push_str("/post/"); 451 + let _ = escape_html(&mut *html, rkey); 452 + html.push_str("\" frameborder=\"0\" scrolling=\"no\" loading=\"lazy\" style=\"border: none; width: 100%; height: 240px;\"></iframe>\n"); 453 + return; 454 + } 455 + } 456 + } 457 + 458 + // Last resort: placeholder link. 459 + html.push_str("<div class=\"bsky-embed-placeholder\">"); 460 + html.push_str("<a href=\"https://bsky.app/profile/"); 461 + 462 + if let Some(rest) = uri_str.strip_prefix("at://") { 463 + if let Some((did, path)) = rest.split_once('/') { 464 + let _ = escape_html(&mut *html, did); 465 + html.push_str("/post/"); 466 + if let Some(rkey) = path.strip_prefix("app.bsky.feed.post/") { 467 + let _ = escape_html(&mut *html, rkey); 468 + } 469 + } 470 + } 471 + 472 + html.push_str("\" target=\"_blank\" rel=\"noopener\">View post on Bluesky</a></div>\n"); 473 + }
+4 -1
crates/weaver-renderer/src/pckt/mod.rs
··· 1 1 mod block_renderer; 2 2 3 - pub use block_renderer::{render_block, render_content_blocks, PcktRenderContext}; 3 + pub use block_renderer::{ 4 + render_block, render_block_sync, render_content_blocks, render_content_blocks_sync, 5 + PcktRenderContext, 6 + };
+20 -20
docs/graph-data.json
··· 17 17 "node_type": "goal", 18 18 "title": "Build jacquard - better AT Protocol library for Rust", 19 19 "description": null, 20 - "status": "pending", 20 + "status": "completed", 21 21 "created_at": "2026-01-06T09:30:53.847514686-05:00", 22 - "updated_at": "2026-01-06T09:30:53.847514686-05:00", 22 + "updated_at": "2026-01-06T19:33:18.788479749-05:00", 23 23 "metadata_json": "{\"confidence\":95,\"prompt\":\"Frustrated with atrium library - significant boilerplate, poor ergonomics, maintenance concerns. Wanted something better, figured others did too. Built jacquard with zero-copy patterns, better type system, built-in OAuth, cleaner APIs. Developed largely for weaver but designed to benefit the broader Rust/ATProto ecosystem.\"}" 24 24 }, 25 25 { ··· 886 886 "node_type": "goal", 887 887 "title": "Create weaver-editor-browser crate - web_sys DOM layer without Dioxus", 888 888 "description": null, 889 - "status": "pending", 889 + "status": "completed", 890 890 "created_at": "2026-01-06T10:39:48.632511769-05:00", 891 - "updated_at": "2026-01-06T10:39:48.632511769-05:00", 891 + "updated_at": "2026-01-06T19:33:18.836659545-05:00", 892 892 "metadata_json": "{\"confidence\":90}" 893 893 }, 894 894 { ··· 952 952 "node_type": "goal", 953 953 "title": "Create weaver-editor-crdt crate - optional Loro integration (+500KB)", 954 954 "description": null, 955 - "status": "pending", 955 + "status": "completed", 956 956 "created_at": "2026-01-06T10:40:09.313344768-05:00", 957 - "updated_at": "2026-01-06T10:40:09.313344768-05:00", 957 + "updated_at": "2026-01-06T19:33:18.910494639-05:00", 958 958 "metadata_json": "{\"confidence\":90}" 959 959 }, 960 960 { ··· 985 985 "node_type": "goal", 986 986 "title": "Phase 2: Update weaver-app to use extracted crates", 987 987 "description": null, 988 - "status": "pending", 988 + "status": "completed", 989 989 "created_at": "2026-01-06T10:42:07.645111430-05:00", 990 - "updated_at": "2026-01-06T10:42:07.645111430-05:00", 990 + "updated_at": "2026-01-06T19:33:18.927342199-05:00", 991 991 "metadata_json": "{\"confidence\":85}" 992 992 }, 993 993 { ··· 1381 1381 "node_type": "decision", 1382 1382 "title": "Where should embed worker live?", 1383 1383 "description": null, 1384 - "status": "pending", 1384 + "status": "completed", 1385 1385 "created_at": "2026-01-06T12:09:01.110078211-05:00", 1386 - "updated_at": "2026-01-06T12:09:01.110078211-05:00", 1386 + "updated_at": "2026-01-06T19:33:18.804295632-05:00", 1387 1387 "metadata_json": "{\"confidence\":85}" 1388 1388 }, 1389 1389 { ··· 1601 1601 "node_type": "outcome", 1602 1602 "title": "Embed worker → weaver-embed-worker crate (decided)", 1603 1603 "description": null, 1604 - "status": "pending", 1604 + "status": "completed", 1605 1605 "created_at": "2026-01-06T13:29:10.289780508-05:00", 1606 - "updated_at": "2026-01-06T13:29:10.289780508-05:00", 1606 + "updated_at": "2026-01-06T19:33:24.777994841-05:00", 1607 1607 "metadata_json": "{\"confidence\":100}" 1608 1608 }, 1609 1609 { ··· 1689 1689 "node_type": "goal", 1690 1690 "title": "Extract worker manager from collab.rs to weaver-editor-crdt - non-Dioxus coordination logic", 1691 1691 "description": null, 1692 - "status": "pending", 1692 + "status": "completed", 1693 1693 "created_at": "2026-01-06T14:34:17.012426477-05:00", 1694 - "updated_at": "2026-01-06T14:34:17.012426477-05:00", 1694 + "updated_at": "2026-01-06T19:33:24.794295700-05:00", 1695 1695 "metadata_json": "{\"confidence\":85}" 1696 1696 }, 1697 1697 { ··· 1876 1876 "node_type": "goal", 1877 1877 "title": "Cursor module refactor - make browser cursor functions public and remove app duplicates", 1878 1878 "description": null, 1879 - "status": "pending", 1879 + "status": "completed", 1880 1880 "created_at": "2026-01-06T17:07:07.731289055-05:00", 1881 - "updated_at": "2026-01-06T17:07:07.731289055-05:00", 1881 + "updated_at": "2026-01-06T19:31:34.605897852-05:00", 1882 1882 "metadata_json": "{\"confidence\":85}" 1883 1883 }, 1884 1884 { ··· 1920 1920 "node_type": "goal", 1921 1921 "title": "Evaluate and organize collab.rs and component.rs - consider extraction to core/browser/crdt crates and embed worker host migration", 1922 1922 "description": null, 1923 - "status": "pending", 1923 + "status": "completed", 1924 1924 "created_at": "2026-01-06T17:25:55.550260849-05:00", 1925 - "updated_at": "2026-01-06T17:25:55.550260849-05:00", 1925 + "updated_at": "2026-01-06T19:31:34.588088316-05:00", 1926 1926 "metadata_json": "{\"confidence\":95,\"prompt\":\"User asked to review collab.rs and component.rs for cleanup/organization opportunities, potentially move code to weaver-editor-core, weaver-editor-browser, weaver-editor-crdt. Also consider moving embed worker host side to weaver-embed-worker\"}" 1927 1927 }, 1928 1928 { ··· 1931 1931 "node_type": "action", 1932 1932 "title": "Migrate embed worker host side to weaver-embed-worker crate", 1933 1933 "description": null, 1934 - "status": "pending", 1934 + "status": "completed", 1935 1935 "created_at": "2026-01-06T17:31:25.829135397-05:00", 1936 - "updated_at": "2026-01-06T17:31:25.829135397-05:00", 1936 + "updated_at": "2026-01-06T19:33:18.820200881-05:00", 1937 1937 "metadata_json": "{\"confidence\":90}" 1938 1938 }, 1939 1939 {