atproto blogging
1//! Weaver common library - thin wrapper around jacquard with notebook-specific conveniences
2
3pub mod agent;
4#[cfg(feature = "cache")]
5pub mod cache;
6pub mod constellation;
7pub mod domain_encoding;
8pub mod error;
9#[cfg(feature = "perf")]
10pub mod perf;
11pub mod resolve;
12#[cfg(feature = "telemetry")]
13pub mod telemetry;
14pub mod transport;
15pub mod worker_rt;
16
17// Re-export jacquard for convenience
18pub use agent::{SessionPeer, WeaverExt};
19pub use domain_encoding::{decode_publication_subdomain, encode_publication_subdomain};
20pub use error::WeaverError;
21
22// Re-export blake3 for topic hashing
23pub use blake3;
24pub use resolve::{EntryIndex, ExtractedRef, RefCollector, ResolvedContent, ResolvedEntry};
25
26pub use jacquard;
27use jacquard::CowStr;
28use jacquard::client::{Agent, AgentSession};
29use jacquard::prelude::*;
30use jacquard::types::ident::AtIdentifier;
31use jacquard::types::string::{AtUri, Cid, Did, Handle};
32use jacquard::types::tid::Ticker;
33pub use resolve::collect_refs_from_markdown;
34use std::sync::LazyLock;
35use tokio::sync::Mutex;
36use weaver_api::com_atproto::repo::strong_ref::StrongRef;
37
38static W_TICKER: LazyLock<Mutex<Ticker>> = LazyLock::new(|| Mutex::new(Ticker::new()));
39
40/// Result of publishing a notebook
41#[derive(Debug, Clone)]
42pub struct PublishResult<'a> {
43 /// AT-URI of the published book
44 pub uri: AtUri<'a>,
45 /// CID of the book record
46 pub cid: Cid<'a>,
47 /// URIs of published entries
48 pub entries: Vec<StrongRef<'a>>,
49}
50
51pub fn mcow_to_cow(cow: CowStr<'_>) -> std::borrow::Cow<'_, str> {
52 match cow {
53 CowStr::Borrowed(s) => std::borrow::Cow::Borrowed(s),
54 CowStr::Owned(s) => std::borrow::Cow::Owned(s.to_string()),
55 }
56}
57
58pub fn cow_to_mcow(cow: std::borrow::Cow<'_, str>) -> CowStr<'_> {
59 match cow {
60 std::borrow::Cow::Borrowed(s) => CowStr::Borrowed(s),
61 std::borrow::Cow::Owned(s) => CowStr::Owned(s.into()),
62 }
63}
64
65pub fn mdcow_to_cow(cow: markdown_weaver::CowStr<'_>) -> std::borrow::Cow<'_, str> {
66 match cow {
67 markdown_weaver::CowStr::Borrowed(s) => std::borrow::Cow::Borrowed(s),
68 markdown_weaver::CowStr::Boxed(s) => std::borrow::Cow::Owned(s.to_string()),
69 markdown_weaver::CowStr::Inlined(s) => std::borrow::Cow::Owned(s.as_ref().to_owned()),
70 }
71}
72
73/// Utility: Generate CDN URL for avatar blob
74pub fn avatar_cdn_url(did: &Did, cid: &Cid) -> String {
75 format!(
76 "https://cdn.bsky.app/img/avatar/plain/{}/{}",
77 did.as_str(),
78 cid
79 )
80}
81
82/// Utility: Generate PDS URL for blob retrieval
83pub fn blob_url(did: &Did, pds: &str, cid: &Cid) -> String {
84 format!(
85 "https://{}/xrpc/com.atproto.repo.getBlob?did={}&cid={}",
86 pds,
87 did.as_str(),
88 cid
89 )
90}
91
92pub fn match_identifier(maybe_identifier: &str) -> Option<&str> {
93 if jacquard::types::string::AtIdentifier::new(maybe_identifier).is_ok() {
94 Some(maybe_identifier)
95 } else {
96 None
97 }
98}
99
100pub fn match_nsid(maybe_nsid: &str) -> Option<&str> {
101 if jacquard::types::string::Nsid::new(maybe_nsid).is_ok() {
102 Some(maybe_nsid)
103 } else {
104 None
105 }
106}
107
108/// Convert an ATURI to a HTTP URL
109/// Currently has some failure modes and should restrict the NSIDs to a known subset
110pub fn aturi_to_http<'s>(aturi: &'s str, appview: &'s str) -> Option<markdown_weaver::CowStr<'s>> {
111 use markdown_weaver::CowStr;
112
113 if aturi.starts_with("at://") {
114 let rest = aturi.strip_prefix("at://").unwrap();
115 let mut split = rest.splitn(2, '/');
116 let maybe_identifier = split.next()?;
117 let maybe_nsid = split.next()?;
118 let maybe_rkey = split.next()?;
119
120 // https://atproto.com/specs/handle#handle-identifier-syntax
121 let identifier = match_identifier(maybe_identifier)?;
122
123 let nsid = if let Some(nsid) = match_nsid(maybe_nsid) {
124 // Last part of the nsid is generally the middle component of the URL
125 // TODO: check for bsky ones specifically, because those are the ones where this is valid
126 nsid.rsplitn(1, '.').next()?
127 } else {
128 return None;
129 };
130 Some(CowStr::Boxed(
131 format!(
132 "https://{}/profile/{}/{}/{}",
133 appview, identifier, nsid, maybe_rkey
134 )
135 .into_boxed_str(),
136 ))
137 } else {
138 Some(CowStr::Borrowed(aturi))
139 }
140}
141
142pub enum LinkUri<'a> {
143 AtRecord(AtUri<'a>),
144 AtIdent(Did<'a>, Handle<'a>),
145 Web(jacquard::url::Url),
146 Path(markdown_weaver::CowStr<'a>),
147 Heading(markdown_weaver::CowStr<'a>),
148 Footnote(markdown_weaver::CowStr<'a>),
149}
150
151impl<'a> LinkUri<'a> {
152 pub async fn resolve<A>(dest_url: &'a str, agent: &Agent<A>) -> LinkUri<'a>
153 where
154 A: AgentSession + IdentityResolver,
155 {
156 if dest_url.starts_with('@') {
157 if let Ok(handle) = Handle::new(dest_url) {
158 if let Ok(did) = agent.resolve_handle(&handle).await {
159 return Self::AtIdent(did, handle);
160 }
161 }
162 } else if dest_url.starts_with("did:") {
163 if let Ok(did) = Did::new(dest_url) {
164 if let Ok(doc) = agent.resolve_did_doc(&did).await {
165 if let Ok(doc) = doc.parse_validated() {
166 if let Some(handle) = doc.handles().first() {
167 return Self::AtIdent(did, handle.clone());
168 }
169 }
170 }
171 }
172 } else if dest_url.starts_with('#') {
173 // local fragment
174 return Self::Heading(markdown_weaver::CowStr::Borrowed(dest_url));
175 } else if dest_url.starts_with('^') {
176 // footnote
177 return Self::Footnote(markdown_weaver::CowStr::Borrowed(dest_url));
178 }
179 if let Ok(url) = jacquard::url::Url::parse(dest_url) {
180 if let Some(uri) = jacquard::richtext::extract_at_uri_from_url(
181 url.as_str(),
182 jacquard::richtext::DEFAULT_EMBED_DOMAINS,
183 ) {
184 if let AtIdentifier::Handle(handle) = uri.authority() {
185 if let Ok(did) = agent.resolve_handle(handle).await {
186 let mut aturi = format!("at://{did}");
187 if let Some(collection) = uri.collection() {
188 aturi.push_str(&format!("/{}", collection));
189 if let Some(record) = uri.rkey() {
190 aturi.push_str(&format!("/{}", record.0));
191 }
192 }
193 if let Ok(aturi) = AtUri::new_owned(aturi) {
194 return Self::AtRecord(aturi);
195 }
196 }
197 return Self::AtRecord(uri);
198 } else {
199 return Self::AtRecord(uri);
200 }
201 } else if url.scheme() == "http" || url.scheme() == "https" {
202 return Self::Web(url);
203 }
204 }
205
206 LinkUri::Path(markdown_weaver::CowStr::Borrowed(dest_url))
207 }
208}
209
210pub fn normalize_title_path(title: &str) -> String {
211 title.replace(' ', "_").to_lowercase()
212}
213
214/// Convert a title to a URL-friendly slug.
215///
216/// Lowercases, replaces whitespace/dashes/underscores with dashes,
217/// removes other non-alphanumeric characters, and collapses multiple dashes.
218pub fn slugify(title: &str) -> String {
219 title
220 .to_lowercase()
221 .chars()
222 .map(|c| {
223 if c.is_ascii_alphanumeric() {
224 c
225 } else if c.is_whitespace() || c == '-' || c == '_' {
226 '-'
227 } else {
228 '\0'
229 }
230 })
231 .filter(|&c| c != '\0')
232 .collect::<String>()
233 .split('-')
234 .filter(|s| !s.is_empty())
235 .collect::<Vec<_>>()
236 .join("-")
237}