···223223/// Experimental streaming endpoints
224224pub mod streaming;
225225226226+#[cfg(feature = "api_bluesky")]
227227+/// Rich text utilities for Bluesky posts
228228+pub mod richtext;
229229+226230pub use common::*;
227231#[cfg(feature = "api")]
228232pub use jacquard_api as api;
+202
crates/jacquard/src/richtext.rs
···11+//! Rich text utilities for Bluesky posts
22+//!
33+//! Provides parsing and building of rich text with facets (mentions, links, tags)
44+//! and detection of embed candidates (record and external embeds).
55+66+use crate::common::CowStr;
77+use std::marker::PhantomData;
88+use std::ops::Range;
99+1010+/// Marker type indicating all facets are resolved (no handles pending DID resolution)
1111+pub struct Resolved;
1212+1313+/// Marker type indicating some facets may need resolution (handles → DIDs)
1414+pub struct Unresolved;
1515+1616+/// Detected embed candidate from URL or at-URI
1717+#[derive(Debug, Clone)]
1818+#[cfg(feature = "api_bluesky")]
1919+pub enum EmbedCandidate<'a> {
2020+ /// Bluesky record (post, list, starterpack, feed)
2121+ Record {
2222+ /// The at:// URI identifying the record
2323+ at_uri: crate::types::aturi::AtUri<'a>,
2424+ /// Strong reference (repo + CID) if resolved
2525+ strong_ref: Option<crate::api::com_atproto::repo::strong_ref::StrongRef<'a>>,
2626+ },
2727+ /// External link embed
2828+ External {
2929+ /// The URL
3030+ url: CowStr<'a>,
3131+ /// OpenGraph metadata if fetched
3232+ metadata: Option<ExternalMetadata<'a>>,
3333+ },
3434+}
3535+3636+/// External embed metadata (OpenGraph)
3737+#[derive(Debug, Clone)]
3838+#[cfg(feature = "api_bluesky")]
3939+pub struct ExternalMetadata<'a> {
4040+ /// Page title
4141+ pub title: CowStr<'a>,
4242+ /// Page description
4343+ pub description: CowStr<'a>,
4444+ /// Thumbnail URL
4545+ pub thumbnail: Option<CowStr<'a>>,
4646+}
4747+4848+/// Rich text builder supporting both parsing and manual construction
4949+#[derive(Debug)]
5050+pub struct RichTextBuilder<'a, State> {
5151+ text: String,
5252+ facet_candidates: Vec<FacetCandidate<'a>>,
5353+ _state: PhantomData<State>,
5454+}
5555+5656+/// Internal representation of facet before resolution
5757+#[derive(Debug, Clone)]
5858+enum FacetCandidate<'a> {
5959+ Mention {
6060+ handle_or_did: CowStr<'a>,
6161+ range: Range<usize>,
6262+ /// DID when provided, otherwise resolved later
6363+ did: Option<Did<'static>>,
6464+ },
6565+ Link {
6666+ url: CowStr<'a>,
6767+ range: Range<usize>,
6868+ },
6969+ Tag {
7070+ tag: CowStr<'a>,
7171+ range: Range<usize>,
7272+ },
7373+}
7474+7575+impl<'a> RichTextBuilder<'a, Unresolved> {
7676+ /// Entry point for parsing text with automatic facet detection
7777+ pub fn parse(text: impl Into<String>) -> Self {
7878+ todo!("Task 2")
7979+ }
8080+}
8181+8282+impl<'a> RichTextBuilder<'a, Resolved> {
8383+ /// Entry point for manual richtext construction
8484+ pub fn builder() -> Self {
8585+ RichTextBuilder {
8686+ text: String::new(),
8787+ facet_candidates: Vec::new(),
8888+ #[cfg(feature = "api_bluesky")]
8989+ embed_candidates: Vec::new(),
9090+ _state: PhantomData,
9191+ }
9292+ }
9393+9494+ /// Add a mention by handle (transitions to Unresolved state)
9595+ pub fn mention_handle(
9696+ self,
9797+ handle: impl AsRef<str>,
9898+ range: Option<Range<usize>>,
9999+ ) -> RichTextBuilder<Unresolved> {
100100+ let handle = handle.as_ref();
101101+ let range = range.unwrap_or_else(|| {
102102+ // Scan text for @handle
103103+ let search = format!("@{}", handle);
104104+ self.find_substring(&search).unwrap_or(0..0)
105105+ });
106106+107107+ let mut facet_candidates = self.facet_candidates;
108108+ facet_candidates.push(FacetCandidate::Mention { range, did: None });
109109+110110+ RichTextBuilder {
111111+ text: self.text,
112112+ facet_candidates,
113113+ #[cfg(feature = "api_bluesky")]
114114+ embed_candidates: self.embed_candidates,
115115+ _state: PhantomData,
116116+ }
117117+ }
118118+}
119119+120120+impl<S> RichTextBuilder<S> {
121121+ /// Set the text content
122122+ pub fn text(mut self, text: impl Into<String>) -> Self {
123123+ self.text = text.into();
124124+ self
125125+ }
126126+127127+ /// Add a mention facet with a resolved DID (requires explicit range)
128128+ pub fn mention(mut self, did: &crate::types::did::Did<'_>, range: Range<usize>) -> Self {
129129+ self.facet_candidates.push(FacetCandidate::Mention {
130130+ range,
131131+ did: Some(did.clone().into_static()),
132132+ });
133133+ self
134134+ }
135135+136136+ /// Add a link facet (auto-detects range if None)
137137+ pub fn link(mut self, url: impl AsRef<str>, range: Option<Range<usize>>) -> Self {
138138+ let url = url.as_ref();
139139+ let range = range.unwrap_or_else(|| {
140140+ // Scan text for the URL
141141+ self.find_substring(url).unwrap_or(0..0)
142142+ });
143143+144144+ self.facet_candidates.push(FacetCandidate::Link { range });
145145+ self
146146+ }
147147+148148+ /// Add a tag facet (auto-detects range if None)
149149+ pub fn tag(mut self, tag: impl AsRef<str>, range: Option<Range<usize>>) -> Self {
150150+ let tag = tag.as_ref();
151151+ let range = range.unwrap_or_else(|| {
152152+ // Scan text for #tag
153153+ let search = format!("#{}", tag);
154154+ self.find_substring(&search).unwrap_or(0..0)
155155+ });
156156+157157+ self.facet_candidates.push(FacetCandidate::Tag { range });
158158+ self
159159+ }
160160+161161+ /// Add a markdown-style link with display text
162162+ pub fn markdown_link(mut self, url: impl Into<String>, display_range: Range<usize>) -> Self {
163163+ self.facet_candidates.push(FacetCandidate::MarkdownLink {
164164+ url: url.into(),
165165+ display_range,
166166+ });
167167+ self
168168+ }
169169+170170+ #[cfg(feature = "api_bluesky")]
171171+ /// Add a record embed candidate
172172+ pub fn embed_record(
173173+ mut self,
174174+ at_uri: crate::types::aturi::AtUri<'static>,
175175+ strong_ref: Option<crate::api::com_atproto::repo::strong_ref::StrongRef<'static>>,
176176+ ) -> Self {
177177+ self.embed_candidates
178178+ .push(EmbedCandidate::Record { at_uri, strong_ref });
179179+ self
180180+ }
181181+182182+ #[cfg(feature = "api_bluesky")]
183183+ /// Add an external embed candidate
184184+ pub fn embed_external(
185185+ mut self,
186186+ url: impl Into<CowStr<'static>>,
187187+ metadata: Option<ExternalMetadata<'static>>,
188188+ ) -> Self {
189189+ self.embed_candidates.push(EmbedCandidate::External {
190190+ url: url.into(),
191191+ metadata,
192192+ });
193193+ self
194194+ }
195195+196196+ fn find_substring(&self, needle: &str) -> Option<Range<usize>> {
197197+ self.text.find(needle).map(|start| {
198198+ let end = start + needle.len();
199199+ start..end
200200+ })
201201+ }
202202+}