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
15
pub use client::{ClientContext, DefaultEmbedResolver, EmbedResolver};
16
pub use embed_renderer::{
0
17
fetch_and_render, fetch_and_render_generic, fetch_and_render_post, fetch_and_render_profile,
0
0
18
};
19
pub use error::{AtProtoPreprocessError, ClientRenderError};
20
pub use markdown_writer::MarkdownWriter;
···
14
15
pub use client::{ClientContext, DefaultEmbedResolver, EmbedResolver};
16
pub use embed_renderer::{
17
+
// Async fetch-and-render functions (require agent/network)
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,
21
};
22
pub use error::{AtProtoPreprocessError, ClientRenderError};
23
pub use markdown_writer::MarkdownWriter;
+1
-1
crates/weaver-renderer/src/atproto/client.rs
···
658
let uri = AtUri::new("at://did:plc:xyz123/sh.weaver.notebook.entry/entry123").unwrap();
659
assert_eq!(
660
at_uri_to_web_url(&uri),
661
-
"https://alpha.weaver.sh/record/at://did:plc:xyz123/sh.weaver.notebook.entry/entry123"
662
);
663
}
664
···
658
let uri = AtUri::new("at://did:plc:xyz123/sh.weaver.notebook.entry/entry123").unwrap();
659
assert_eq!(
660
at_uri_to_web_url(&uri),
661
+
"https://alpha.weaver.sh/did:plc:xyz123/e/entry123"
662
);
663
}
664
+802
-39
crates/weaver-renderer/src/atproto/embed_renderer.rs
···
454
.unwrap_or(false)
455
{
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)))?;
0
0
0
459
pckt_doc.document
460
} else {
461
// Direct site.standard.document
···
477
478
// Get author DID and handle from URI authority
479
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
-
};
506
507
let ctx = PcktRenderContext::new(author_did);
508
···
511
let toggle_id = format!("pckt-toggle-{}", rkey);
512
513
// 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);
519
520
let mut html = String::new();
521
html.push_str("<div class=\"atproto-embed atproto-entry\" contenteditable=\"false\">");
···
612
}
613
}
614
615
-
/// Render a profile from ProfileDataViewInner (weaver, bsky, or tangled)
616
-
fn render_profile_data_view(
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
617
inner: &ProfileDataViewInner<'_>,
618
) -> Result<String, AtProtoPreprocessError> {
619
let mut html = String::new();
···
754
Ok(html)
755
}
756
757
-
/// Render a Bluesky post from PostView (rich appview data)
758
-
fn render_post_view<'a>(
0
0
759
post: &PostView<'a>,
760
uri: &AtUri<'_>,
761
) -> Result<String, AtProtoPreprocessError> {
···
825
Ok(html)
826
}
827
828
-
/// Render a generic record by probing Data for meaningful fields
829
-
fn render_generic_record(
0
0
0
830
data: &Data<'_>,
831
uri: &AtUri<'_>,
832
) -> Result<String, AtProtoPreprocessError> {
···
454
.unwrap_or(false)
455
{
456
// blog.pckt.document wraps site.standard.document in a "document" field
457
+
let pckt_doc =
458
+
jacquard::from_data::<weaver_api::blog_pckt::document::Document>(&output.value)
459
+
.map_err(|e| {
460
+
AtProtoPreprocessError::FetchFailed(format!("Parse error: {:?}", e))
461
+
})?;
462
pckt_doc.document
463
} else {
464
// Direct site.standard.document
···
480
481
// Get author DID and handle from URI authority
482
use jacquard::types::string::{Did, Handle};
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
+
};
509
510
let ctx = PcktRenderContext::new(author_did);
511
···
514
let toggle_id = format!("pckt-toggle-{}", rkey);
515
516
// Document path for URL (use path field if present, otherwise rkey)
517
+
let doc_path = doc.path.as_ref().map(|p| p.as_ref()).unwrap_or(rkey);
0
0
0
0
518
519
let mut html = String::new();
520
html.push_str("<div class=\"atproto-embed atproto-entry\" contenteditable=\"false\">");
···
611
}
612
}
613
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(
1375
inner: &ProfileDataViewInner<'_>,
1376
) -> Result<String, AtProtoPreprocessError> {
1377
let mut html = String::new();
···
1512
Ok(html)
1513
}
1514
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>(
1519
post: &PostView<'a>,
1520
uri: &AtUri<'_>,
1521
) -> Result<String, AtProtoPreprocessError> {
···
1585
Ok(html)
1586
}
1587
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(
1593
data: &Data<'_>,
1594
uri: &AtUri<'_>,
1595
) -> Result<String, AtProtoPreprocessError> {
+87
-64
crates/weaver-renderer/src/atproto/writer.rs
···
5
6
use jacquard::types::string::AtUri;
7
use markdown_weaver::{
8
-
Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType,
9
-
ParagraphContext, Tag, WeaverAttributes,
10
};
11
use markdown_weaver_escape::{StrWrite, escape_href, escape_html, escape_html_body_text};
12
use std::collections::HashMap;
···
20
Div,
21
}
22
23
-
/// Synchronous callback for injecting embed content
24
///
25
/// Takes the embed tag and returns optional HTML content to inject.
26
pub trait EmbedContentProvider {
27
-
fn get_embed_content(&self, tag: &Tag<'_>) -> Option<String>;
28
}
29
30
impl EmbedContentProvider for () {
31
-
fn get_embed_content(&self, _tag: &Tag<'_>) -> Option<String> {
32
None
33
}
34
}
35
36
impl EmbedContentProvider for ResolvedContent {
37
-
fn get_embed_content(&self, tag: &Tag<'_>) -> Option<String> {
38
let url = match tag {
39
Tag::Embed { dest_url, .. } => Some(dest_url.as_ref()),
40
-
// WikiLink images with at:// URLs are embeds in disguise
41
Tag::Image {
42
link_type: LinkType::WikiLink { .. },
43
dest_url,
···
51
if let Some(url) = url {
52
if url.starts_with("at://") {
53
if let Ok(at_uri) = AtUri::new(url) {
54
-
return self.get_embed_content(&at_uri).map(|s| s.to_string());
0
55
}
56
}
57
}
···
59
}
60
}
61
0
0
0
0
0
0
62
/// Simple writer that outputs HTML from markdown events
63
///
64
/// This writer is designed for client-side rendering where embeds may have
···
498
if checked {
499
self.write("<input disabled=\"\" type=\"checkbox\" checked=\"\" aria-label=\"Completed task\"/>\n")?;
500
} else {
501
-
self.write("<input disabled=\"\" type=\"checkbox\" aria-label=\"Incomplete task\"/>\n")?;
0
0
502
}
503
}
504
WeaverBlock(text) => {
···
772
&& (dest_url.starts_with("at://") || dest_url.starts_with("did:"))
773
{
774
tracing::debug!("[ClientWriter] AT embed image detected: {}", dest_url);
775
-
if let Some(embed_provider) = &self.embed_provider {
776
if let Some(html) = embed_provider.get_embed_content(&tag) {
777
tracing::debug!("[ClientWriter] Got embed content for {}", dest_url);
778
-
// Consume events without writing - we're replacing with embed HTML
0
0
0
779
self.consume_until_end();
780
-
return self.write(&html);
781
} else {
782
tracing::debug!(
783
"[ClientWriter] No embed content from provider for {}",
···
894
}
895
}
896
897
-
fn end_tag(&mut self, tag: markdown_weaver::TagEnd, range: Range<usize>) -> Result<(), W::Error> {
0
0
0
0
898
use markdown_weaver::TagEnd;
899
match tag {
900
TagEnd::HtmlBlock => self.write("</span>\n"),
···
1064
id: CowStr<'_>,
1065
attrs: Option<markdown_weaver::WeaverAttributes<'_>>,
1066
) -> 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
-
};
1077
1078
-
// If no content in attrs, try provider
1079
-
let content = if let Some(content) = content_from_attrs {
1080
-
Some(content)
0
1081
} else if let Some(ref provider) = self.embed_provider {
1082
let tag = Tag::Embed {
1083
embed_type,
···
1086
id: id.clone(),
1087
attrs: attrs.clone(),
1088
};
1089
-
provider.get_embed_content(&tag)
0
0
0
0
0
0
0
0
0
0
1090
} else {
1091
-
None
1092
-
};
0
0
1093
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("\"")?;
0
1109
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)?;
1118
}
0
0
0
0
0
0
0
0
0
0
0
1119
self.write("\"")?;
1120
}
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
}
1132
-
self.write("></iframe>")?;
1133
}
1134
-
Ok(())
1135
}
1136
}
···
5
6
use jacquard::types::string::AtUri;
7
use markdown_weaver::{
8
+
Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType, ParagraphContext,
9
+
Tag, WeaverAttributes,
10
};
11
use markdown_weaver_escape::{StrWrite, escape_href, escape_html, escape_html_body_text};
12
use std::collections::HashMap;
···
20
Div,
21
}
22
23
+
/// Synchronous callback for injecting embed content.
24
///
25
/// Takes the embed tag and returns optional HTML content to inject.
26
pub trait EmbedContentProvider {
27
+
fn get_embed_content(&self, tag: &Tag<'_>) -> Option<&str>;
28
}
29
30
impl EmbedContentProvider for () {
31
+
fn get_embed_content(&self, _tag: &Tag<'_>) -> Option<&str> {
32
None
33
}
34
}
35
36
impl EmbedContentProvider for ResolvedContent {
37
+
fn get_embed_content(&self, tag: &Tag<'_>) -> Option<&str> {
38
let url = match tag {
39
Tag::Embed { dest_url, .. } => Some(dest_url.as_ref()),
40
+
// WikiLink images with at:// URLs are embeds in disguise.
41
Tag::Image {
42
link_type: LinkType::WikiLink { .. },
43
dest_url,
···
51
if let Some(url) = url {
52
if url.starts_with("at://") {
53
if let Ok(at_uri) = AtUri::new(url) {
54
+
// Call the inherent method which returns Option<&str>.
55
+
return ResolvedContent::get_embed_content(self, &at_uri);
56
}
57
}
58
}
···
60
}
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
+
69
/// Simple writer that outputs HTML from markdown events
70
///
71
/// This writer is designed for client-side rendering where embeds may have
···
505
if checked {
506
self.write("<input disabled=\"\" type=\"checkbox\" checked=\"\" aria-label=\"Completed task\"/>\n")?;
507
} else {
508
+
self.write(
509
+
"<input disabled=\"\" type=\"checkbox\" aria-label=\"Incomplete task\"/>\n",
510
+
)?;
511
}
512
}
513
WeaverBlock(text) => {
···
781
&& (dest_url.starts_with("at://") || dest_url.starts_with("did:"))
782
{
783
tracing::debug!("[ClientWriter] AT embed image detected: {}", dest_url);
784
+
if let Some(ref embed_provider) = self.embed_provider {
785
if let Some(html) = embed_provider.get_embed_content(&tag) {
786
tracing::debug!("[ClientWriter] Got embed content for {}", dest_url);
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.
791
self.consume_until_end();
792
+
return Ok(());
793
} else {
794
tracing::debug!(
795
"[ClientWriter] No embed content from provider for {}",
···
906
}
907
}
908
909
+
fn end_tag(
910
+
&mut self,
911
+
tag: markdown_weaver::TagEnd,
912
+
range: Range<usize>,
913
+
) -> Result<(), W::Error> {
914
use markdown_weaver::TagEnd;
915
match tag {
916
TagEnd::HtmlBlock => self.write("</span>\n"),
···
1080
id: CowStr<'_>,
1081
attrs: Option<markdown_weaver::WeaverAttributes<'_>>,
1082
) -> Result<(), W::Error> {
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());
0
0
0
0
0
1088
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()?;
1093
} else if let Some(ref provider) = self.embed_provider {
1094
let tag = Tag::Embed {
1095
embed_type,
···
1098
id: id.clone(),
1099
attrs: attrs.clone(),
1100
};
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
+
}
1112
} else {
1113
+
self.write_embed_fallback(&dest_url, &title, &id, attrs.as_ref())?;
1114
+
}
1115
+
Ok(())
1116
+
}
1117
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("\"")?;
1134
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(" ")?;
0
0
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)?;
1153
self.write("\"")?;
1154
}
0
0
0
0
0
0
0
0
0
0
1155
}
0
1156
}
1157
+
self.write("></iframe>")
1158
}
1159
}
+187
crates/weaver-renderer/src/leaflet/block_renderer.rs
···
387
.and_then(|s| s.split('/').next())
388
.unwrap_or(url)
389
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
387
.and_then(|s| s.split('/').next())
388
.unwrap_or(url)
389
}
390
+
391
+
/// Sync version of render_linear_document that uses pre-resolved embeds.
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
mod block_renderer;
2
mod markdown_converter;
3
4
-
pub use block_renderer::{render_block, render_linear_document, LeafletRenderContext};
0
0
0
5
pub use markdown_converter::{convert_block, convert_linear_document, LeafletMarkdownContext};
···
1
mod block_renderer;
2
mod markdown_converter;
3
4
+
pub use block_renderer::{
5
+
render_block, render_block_sync, render_linear_document, render_linear_document_sync,
6
+
LeafletRenderContext,
7
+
};
8
pub use markdown_converter::{convert_block, convert_linear_document, LeafletMarkdownContext};
+137
crates/weaver-renderer/src/pckt/block_renderer.rs
···
334
escaped
335
}
336
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
334
escaped
335
}
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, "e);
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
mod block_renderer;
2
3
-
pub use block_renderer::{render_block, render_content_blocks, PcktRenderContext};
0
0
0
···
1
mod block_renderer;
2
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
"node_type": "goal",
18
"title": "Build jacquard - better AT Protocol library for Rust",
19
"description": null,
20
-
"status": "pending",
21
"created_at": "2026-01-06T09:30:53.847514686-05:00",
22
-
"updated_at": "2026-01-06T09:30:53.847514686-05:00",
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
},
25
{
···
886
"node_type": "goal",
887
"title": "Create weaver-editor-browser crate - web_sys DOM layer without Dioxus",
888
"description": null,
889
-
"status": "pending",
890
"created_at": "2026-01-06T10:39:48.632511769-05:00",
891
-
"updated_at": "2026-01-06T10:39:48.632511769-05:00",
892
"metadata_json": "{\"confidence\":90}"
893
},
894
{
···
952
"node_type": "goal",
953
"title": "Create weaver-editor-crdt crate - optional Loro integration (+500KB)",
954
"description": null,
955
-
"status": "pending",
956
"created_at": "2026-01-06T10:40:09.313344768-05:00",
957
-
"updated_at": "2026-01-06T10:40:09.313344768-05:00",
958
"metadata_json": "{\"confidence\":90}"
959
},
960
{
···
985
"node_type": "goal",
986
"title": "Phase 2: Update weaver-app to use extracted crates",
987
"description": null,
988
-
"status": "pending",
989
"created_at": "2026-01-06T10:42:07.645111430-05:00",
990
-
"updated_at": "2026-01-06T10:42:07.645111430-05:00",
991
"metadata_json": "{\"confidence\":85}"
992
},
993
{
···
1381
"node_type": "decision",
1382
"title": "Where should embed worker live?",
1383
"description": null,
1384
-
"status": "pending",
1385
"created_at": "2026-01-06T12:09:01.110078211-05:00",
1386
-
"updated_at": "2026-01-06T12:09:01.110078211-05:00",
1387
"metadata_json": "{\"confidence\":85}"
1388
},
1389
{
···
1601
"node_type": "outcome",
1602
"title": "Embed worker → weaver-embed-worker crate (decided)",
1603
"description": null,
1604
-
"status": "pending",
1605
"created_at": "2026-01-06T13:29:10.289780508-05:00",
1606
-
"updated_at": "2026-01-06T13:29:10.289780508-05:00",
1607
"metadata_json": "{\"confidence\":100}"
1608
},
1609
{
···
1689
"node_type": "goal",
1690
"title": "Extract worker manager from collab.rs to weaver-editor-crdt - non-Dioxus coordination logic",
1691
"description": null,
1692
-
"status": "pending",
1693
"created_at": "2026-01-06T14:34:17.012426477-05:00",
1694
-
"updated_at": "2026-01-06T14:34:17.012426477-05:00",
1695
"metadata_json": "{\"confidence\":85}"
1696
},
1697
{
···
1876
"node_type": "goal",
1877
"title": "Cursor module refactor - make browser cursor functions public and remove app duplicates",
1878
"description": null,
1879
-
"status": "pending",
1880
"created_at": "2026-01-06T17:07:07.731289055-05:00",
1881
-
"updated_at": "2026-01-06T17:07:07.731289055-05:00",
1882
"metadata_json": "{\"confidence\":85}"
1883
},
1884
{
···
1920
"node_type": "goal",
1921
"title": "Evaluate and organize collab.rs and component.rs - consider extraction to core/browser/crdt crates and embed worker host migration",
1922
"description": null,
1923
-
"status": "pending",
1924
"created_at": "2026-01-06T17:25:55.550260849-05:00",
1925
-
"updated_at": "2026-01-06T17:25:55.550260849-05:00",
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
},
1928
{
···
1931
"node_type": "action",
1932
"title": "Migrate embed worker host side to weaver-embed-worker crate",
1933
"description": null,
1934
-
"status": "pending",
1935
"created_at": "2026-01-06T17:31:25.829135397-05:00",
1936
-
"updated_at": "2026-01-06T17:31:25.829135397-05:00",
1937
"metadata_json": "{\"confidence\":90}"
1938
},
1939
{
···
17
"node_type": "goal",
18
"title": "Build jacquard - better AT Protocol library for Rust",
19
"description": null,
20
+
"status": "completed",
21
"created_at": "2026-01-06T09:30:53.847514686-05:00",
22
+
"updated_at": "2026-01-06T19:33:18.788479749-05:00",
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
},
25
{
···
886
"node_type": "goal",
887
"title": "Create weaver-editor-browser crate - web_sys DOM layer without Dioxus",
888
"description": null,
889
+
"status": "completed",
890
"created_at": "2026-01-06T10:39:48.632511769-05:00",
891
+
"updated_at": "2026-01-06T19:33:18.836659545-05:00",
892
"metadata_json": "{\"confidence\":90}"
893
},
894
{
···
952
"node_type": "goal",
953
"title": "Create weaver-editor-crdt crate - optional Loro integration (+500KB)",
954
"description": null,
955
+
"status": "completed",
956
"created_at": "2026-01-06T10:40:09.313344768-05:00",
957
+
"updated_at": "2026-01-06T19:33:18.910494639-05:00",
958
"metadata_json": "{\"confidence\":90}"
959
},
960
{
···
985
"node_type": "goal",
986
"title": "Phase 2: Update weaver-app to use extracted crates",
987
"description": null,
988
+
"status": "completed",
989
"created_at": "2026-01-06T10:42:07.645111430-05:00",
990
+
"updated_at": "2026-01-06T19:33:18.927342199-05:00",
991
"metadata_json": "{\"confidence\":85}"
992
},
993
{
···
1381
"node_type": "decision",
1382
"title": "Where should embed worker live?",
1383
"description": null,
1384
+
"status": "completed",
1385
"created_at": "2026-01-06T12:09:01.110078211-05:00",
1386
+
"updated_at": "2026-01-06T19:33:18.804295632-05:00",
1387
"metadata_json": "{\"confidence\":85}"
1388
},
1389
{
···
1601
"node_type": "outcome",
1602
"title": "Embed worker → weaver-embed-worker crate (decided)",
1603
"description": null,
1604
+
"status": "completed",
1605
"created_at": "2026-01-06T13:29:10.289780508-05:00",
1606
+
"updated_at": "2026-01-06T19:33:24.777994841-05:00",
1607
"metadata_json": "{\"confidence\":100}"
1608
},
1609
{
···
1689
"node_type": "goal",
1690
"title": "Extract worker manager from collab.rs to weaver-editor-crdt - non-Dioxus coordination logic",
1691
"description": null,
1692
+
"status": "completed",
1693
"created_at": "2026-01-06T14:34:17.012426477-05:00",
1694
+
"updated_at": "2026-01-06T19:33:24.794295700-05:00",
1695
"metadata_json": "{\"confidence\":85}"
1696
},
1697
{
···
1876
"node_type": "goal",
1877
"title": "Cursor module refactor - make browser cursor functions public and remove app duplicates",
1878
"description": null,
1879
+
"status": "completed",
1880
"created_at": "2026-01-06T17:07:07.731289055-05:00",
1881
+
"updated_at": "2026-01-06T19:31:34.605897852-05:00",
1882
"metadata_json": "{\"confidence\":85}"
1883
},
1884
{
···
1920
"node_type": "goal",
1921
"title": "Evaluate and organize collab.rs and component.rs - consider extraction to core/browser/crdt crates and embed worker host migration",
1922
"description": null,
1923
+
"status": "completed",
1924
"created_at": "2026-01-06T17:25:55.550260849-05:00",
1925
+
"updated_at": "2026-01-06T19:31:34.588088316-05:00",
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
},
1928
{
···
1931
"node_type": "action",
1932
"title": "Migrate embed worker host side to weaver-embed-worker crate",
1933
"description": null,
1934
+
"status": "completed",
1935
"created_at": "2026-01-06T17:31:25.829135397-05:00",
1936
+
"updated_at": "2026-01-06T19:33:18.820200881-05:00",
1937
"metadata_json": "{\"confidence\":90}"
1938
},
1939
{