A better Rust ATProto crate

richtext prework

Orual 0410c011 69e210de

+206
+4
crates/jacquard/src/lib.rs
··· 223 223 /// Experimental streaming endpoints 224 224 pub mod streaming; 225 225 226 + #[cfg(feature = "api_bluesky")] 227 + /// Rich text utilities for Bluesky posts 228 + pub mod richtext; 229 + 226 230 pub use common::*; 227 231 #[cfg(feature = "api")] 228 232 pub use jacquard_api as api;
+202
crates/jacquard/src/richtext.rs
··· 1 + //! Rich text utilities for Bluesky posts 2 + //! 3 + //! Provides parsing and building of rich text with facets (mentions, links, tags) 4 + //! and detection of embed candidates (record and external embeds). 5 + 6 + use crate::common::CowStr; 7 + use std::marker::PhantomData; 8 + use std::ops::Range; 9 + 10 + /// Marker type indicating all facets are resolved (no handles pending DID resolution) 11 + pub struct Resolved; 12 + 13 + /// Marker type indicating some facets may need resolution (handles → DIDs) 14 + pub struct Unresolved; 15 + 16 + /// Detected embed candidate from URL or at-URI 17 + #[derive(Debug, Clone)] 18 + #[cfg(feature = "api_bluesky")] 19 + pub enum EmbedCandidate<'a> { 20 + /// Bluesky record (post, list, starterpack, feed) 21 + Record { 22 + /// The at:// URI identifying the record 23 + at_uri: crate::types::aturi::AtUri<'a>, 24 + /// Strong reference (repo + CID) if resolved 25 + strong_ref: Option<crate::api::com_atproto::repo::strong_ref::StrongRef<'a>>, 26 + }, 27 + /// External link embed 28 + External { 29 + /// The URL 30 + url: CowStr<'a>, 31 + /// OpenGraph metadata if fetched 32 + metadata: Option<ExternalMetadata<'a>>, 33 + }, 34 + } 35 + 36 + /// External embed metadata (OpenGraph) 37 + #[derive(Debug, Clone)] 38 + #[cfg(feature = "api_bluesky")] 39 + pub struct ExternalMetadata<'a> { 40 + /// Page title 41 + pub title: CowStr<'a>, 42 + /// Page description 43 + pub description: CowStr<'a>, 44 + /// Thumbnail URL 45 + pub thumbnail: Option<CowStr<'a>>, 46 + } 47 + 48 + /// Rich text builder supporting both parsing and manual construction 49 + #[derive(Debug)] 50 + pub struct RichTextBuilder<'a, State> { 51 + text: String, 52 + facet_candidates: Vec<FacetCandidate<'a>>, 53 + _state: PhantomData<State>, 54 + } 55 + 56 + /// Internal representation of facet before resolution 57 + #[derive(Debug, Clone)] 58 + enum FacetCandidate<'a> { 59 + Mention { 60 + handle_or_did: CowStr<'a>, 61 + range: Range<usize>, 62 + /// DID when provided, otherwise resolved later 63 + did: Option<Did<'static>>, 64 + }, 65 + Link { 66 + url: CowStr<'a>, 67 + range: Range<usize>, 68 + }, 69 + Tag { 70 + tag: CowStr<'a>, 71 + range: Range<usize>, 72 + }, 73 + } 74 + 75 + impl<'a> RichTextBuilder<'a, Unresolved> { 76 + /// Entry point for parsing text with automatic facet detection 77 + pub fn parse(text: impl Into<String>) -> Self { 78 + todo!("Task 2") 79 + } 80 + } 81 + 82 + impl<'a> RichTextBuilder<'a, Resolved> { 83 + /// Entry point for manual richtext construction 84 + pub fn builder() -> Self { 85 + RichTextBuilder { 86 + text: String::new(), 87 + facet_candidates: Vec::new(), 88 + #[cfg(feature = "api_bluesky")] 89 + embed_candidates: Vec::new(), 90 + _state: PhantomData, 91 + } 92 + } 93 + 94 + /// Add a mention by handle (transitions to Unresolved state) 95 + pub fn mention_handle( 96 + self, 97 + handle: impl AsRef<str>, 98 + range: Option<Range<usize>>, 99 + ) -> RichTextBuilder<Unresolved> { 100 + let handle = handle.as_ref(); 101 + let range = range.unwrap_or_else(|| { 102 + // Scan text for @handle 103 + let search = format!("@{}", handle); 104 + self.find_substring(&search).unwrap_or(0..0) 105 + }); 106 + 107 + let mut facet_candidates = self.facet_candidates; 108 + facet_candidates.push(FacetCandidate::Mention { range, did: None }); 109 + 110 + RichTextBuilder { 111 + text: self.text, 112 + facet_candidates, 113 + #[cfg(feature = "api_bluesky")] 114 + embed_candidates: self.embed_candidates, 115 + _state: PhantomData, 116 + } 117 + } 118 + } 119 + 120 + impl<S> RichTextBuilder<S> { 121 + /// Set the text content 122 + pub fn text(mut self, text: impl Into<String>) -> Self { 123 + self.text = text.into(); 124 + self 125 + } 126 + 127 + /// Add a mention facet with a resolved DID (requires explicit range) 128 + pub fn mention(mut self, did: &crate::types::did::Did<'_>, range: Range<usize>) -> Self { 129 + self.facet_candidates.push(FacetCandidate::Mention { 130 + range, 131 + did: Some(did.clone().into_static()), 132 + }); 133 + self 134 + } 135 + 136 + /// Add a link facet (auto-detects range if None) 137 + pub fn link(mut self, url: impl AsRef<str>, range: Option<Range<usize>>) -> Self { 138 + let url = url.as_ref(); 139 + let range = range.unwrap_or_else(|| { 140 + // Scan text for the URL 141 + self.find_substring(url).unwrap_or(0..0) 142 + }); 143 + 144 + self.facet_candidates.push(FacetCandidate::Link { range }); 145 + self 146 + } 147 + 148 + /// Add a tag facet (auto-detects range if None) 149 + pub fn tag(mut self, tag: impl AsRef<str>, range: Option<Range<usize>>) -> Self { 150 + let tag = tag.as_ref(); 151 + let range = range.unwrap_or_else(|| { 152 + // Scan text for #tag 153 + let search = format!("#{}", tag); 154 + self.find_substring(&search).unwrap_or(0..0) 155 + }); 156 + 157 + self.facet_candidates.push(FacetCandidate::Tag { range }); 158 + self 159 + } 160 + 161 + /// Add a markdown-style link with display text 162 + pub fn markdown_link(mut self, url: impl Into<String>, display_range: Range<usize>) -> Self { 163 + self.facet_candidates.push(FacetCandidate::MarkdownLink { 164 + url: url.into(), 165 + display_range, 166 + }); 167 + self 168 + } 169 + 170 + #[cfg(feature = "api_bluesky")] 171 + /// Add a record embed candidate 172 + pub fn embed_record( 173 + mut self, 174 + at_uri: crate::types::aturi::AtUri<'static>, 175 + strong_ref: Option<crate::api::com_atproto::repo::strong_ref::StrongRef<'static>>, 176 + ) -> Self { 177 + self.embed_candidates 178 + .push(EmbedCandidate::Record { at_uri, strong_ref }); 179 + self 180 + } 181 + 182 + #[cfg(feature = "api_bluesky")] 183 + /// Add an external embed candidate 184 + pub fn embed_external( 185 + mut self, 186 + url: impl Into<CowStr<'static>>, 187 + metadata: Option<ExternalMetadata<'static>>, 188 + ) -> Self { 189 + self.embed_candidates.push(EmbedCandidate::External { 190 + url: url.into(), 191 + metadata, 192 + }); 193 + self 194 + } 195 + 196 + fn find_substring(&self, needle: &str) -> Option<Range<usize>> { 197 + self.text.find(needle).map(|start| { 198 + let end = start + needle.len(); 199 + start..end 200 + }) 201 + } 202 + }