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