+38
CHANGELOG.md
+38
CHANGELOG.md
···
1
1
# Changelog
2
2
3
+
## [0.7.0] - 2025-10-19
4
+
5
+
### Added
6
+
7
+
**Bluesky-style rich text utilities** (`jacquard`)
8
+
- Rich text parsing with automatic facet detection (mentions, links, hashtags)
9
+
- Compatible with Bluesky, with the addition of support for markdown-style links (`[display](url)` syntax)
10
+
- Embed candidate detection from URLs and at-URIs
11
+
- Record embeds (posts, lists, starter packs, feeds)
12
+
- External embeds with optional OpenGraph metadata fetching
13
+
- Configurable embed domains for at-URI extraction (default: bsky.app, deer.social, blacksky.community, catsky.social)
14
+
- Overlap detection and validation for facet byte ranges
15
+
16
+
**Moderation/labeling client utilities** (`jacquard`)
17
+
- Trait-based content moderation with `Labeled` and `Moderateable` traits
18
+
- Generic moderation decision making via `moderate()` and `moderate_all()`
19
+
- User preference handling (`ModerationPrefs`) with global and per-labeler overrides
20
+
- `ModerationIterExt` trait for filtering/mapping moderation over iterators
21
+
- `Labeled` implementations for Bluesky types (PostView, ProfileView, ListView, Generator, Notification, etc.)
22
+
- `Labeled` implementations for community lexicons (net.anisota, social.grain)
23
+
- `fetch_labels()` and `fetch_labeled_record()` helpers for retrieving labels via XRPC
24
+
- `fetch_labeler_defs()` and `fetch_labeler_defs_direct()` for fetching labeler definitions
25
+
26
+
**Subscription control** (`jacquard-common`)
27
+
- `SubscriptionControlMessage` trait for dynamic subscription configuration
28
+
- `SubscriptionController` for sending control messages to active WebSocket subscriptions
29
+
- Enables runtime reconfiguration of subscriptions (e.g., Jetstream filtering)
30
+
31
+
**Lexicons** (`jacquard-api`)
32
+
- teal.fm alpha lexicons for music sharing (fm.teal.alpha.*)
33
+
- Actor profiles with music service status
34
+
- Feed generation from play history
35
+
- Statistics endpoints (top artists, top releases, user stats)
36
+
37
+
**Examples**
38
+
- Updated `create_post.rs` to demonstrate richtext parsing with automatic facet detection
39
+
40
+
3
41
## [0.6.0] - 2025-10-18
4
42
5
43
### Added
+14
-14
Cargo.lock
+14
-14
Cargo.lock
···
2242
2242
2243
2243
[[package]]
2244
2244
name = "jacquard"
2245
-
version = "0.6.0"
2245
+
version = "0.6.1"
2246
2246
dependencies = [
2247
2247
"bon",
2248
2248
"bytes",
···
2251
2251
"getrandom 0.2.16",
2252
2252
"http",
2253
2253
"image",
2254
-
"jacquard-api 0.6.1",
2254
+
"jacquard-api 0.6.2",
2255
2255
"jacquard-common 0.6.0",
2256
-
"jacquard-derive 0.6.0",
2256
+
"jacquard-derive 0.6.1",
2257
2257
"jacquard-identity 0.6.0",
2258
2258
"jacquard-oauth",
2259
2259
"jose-jwk",
···
2287
2287
"bon",
2288
2288
"bytes",
2289
2289
"jacquard-common 0.6.0 (git+https://tangled.org/@nonbinary.computer/jacquard)",
2290
-
"jacquard-derive 0.6.0 (git+https://tangled.org/@nonbinary.computer/jacquard)",
2290
+
"jacquard-derive 0.6.0",
2291
2291
"miette",
2292
2292
"serde",
2293
2293
"serde_ipld_dagcbor",
···
2296
2296
2297
2297
[[package]]
2298
2298
name = "jacquard-api"
2299
-
version = "0.6.1"
2299
+
version = "0.6.2"
2300
2300
dependencies = [
2301
2301
"bon",
2302
2302
"bytes",
2303
2303
"jacquard-common 0.6.0",
2304
-
"jacquard-derive 0.6.0",
2304
+
"jacquard-derive 0.6.1",
2305
2305
"miette",
2306
2306
"serde",
2307
2307
"serde_ipld_dagcbor",
···
2320
2320
"chrono",
2321
2321
"jacquard",
2322
2322
"jacquard-common 0.6.0",
2323
-
"jacquard-derive 0.6.0",
2323
+
"jacquard-derive 0.6.1",
2324
2324
"jacquard-identity 0.6.0",
2325
2325
"k256",
2326
2326
"miette",
···
2423
2423
[[package]]
2424
2424
name = "jacquard-derive"
2425
2425
version = "0.6.0"
2426
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#861d9e86c582939ed1d50201954ef1368a91f9b7"
2426
2427
dependencies = [
2427
-
"jacquard-common 0.6.0",
2428
2428
"proc-macro2",
2429
2429
"quote",
2430
-
"serde",
2431
-
"serde_json",
2432
2430
"syn 2.0.106",
2433
2431
]
2434
2432
2435
2433
[[package]]
2436
2434
name = "jacquard-derive"
2437
-
version = "0.6.0"
2438
-
source = "git+https://tangled.org/@nonbinary.computer/jacquard#861d9e86c582939ed1d50201954ef1368a91f9b7"
2435
+
version = "0.6.1"
2439
2436
dependencies = [
2437
+
"jacquard-common 0.6.0",
2440
2438
"proc-macro2",
2441
2439
"quote",
2440
+
"serde",
2441
+
"serde_json",
2442
2442
"syn 2.0.106",
2443
2443
]
2444
2444
···
2450
2450
"bytes",
2451
2451
"hickory-resolver",
2452
2452
"http",
2453
-
"jacquard-api 0.6.1",
2453
+
"jacquard-api 0.6.2",
2454
2454
"jacquard-common 0.6.0",
2455
2455
"miette",
2456
2456
"n0-future",
···
2492
2492
2493
2493
[[package]]
2494
2494
name = "jacquard-lexicon"
2495
-
version = "0.6.0"
2495
+
version = "0.6.1"
2496
2496
dependencies = [
2497
2497
"async-trait",
2498
2498
"clap",
+1
-1
Cargo.toml
+1
-1
Cargo.toml
+4
-4
crates/jacquard-api/Cargo.toml
+4
-4
crates/jacquard-api/Cargo.toml
···
2
2
name = "jacquard-api"
3
3
description = "Generated AT Protocol API bindings for Jacquard"
4
4
edition.workspace = true
5
-
version = "0.6.1"
5
+
version = "0.7.0"
6
6
authors.workspace = true
7
7
repository.workspace = true
8
8
keywords.workspace = true
···
12
12
license.workspace = true
13
13
14
14
[package.metadata.docs.rs]
15
-
features = [ "bluesky", "other", "lexicon_community", "ufos", "streaming" ]
15
+
features = [ "bluesky", "other", "lexicon_community", "streaming" ]
16
16
17
17
[dependencies]
18
18
bon.workspace = true
19
19
bytes = { workspace = true, features = ["serde"] }
20
-
jacquard-common = { version = "0.6", path = "../jacquard-common" }
21
-
jacquard-derive = { version = "0.6", path = "../jacquard-derive" }
20
+
jacquard-common = { version = "0.7", path = "../jacquard-common" }
21
+
jacquard-derive = { version = "0.7", path = "../jacquard-derive" }
22
22
miette.workspace = true
23
23
serde.workspace = true
24
24
serde_ipld_dagcbor.workspace = true
+4
-4
crates/jacquard-axum/Cargo.toml
+4
-4
crates/jacquard-axum/Cargo.toml
···
22
22
[dependencies]
23
23
axum = "0.8.6"
24
24
bytes.workspace = true
25
-
jacquard = { version = "0.6", path = "../jacquard", default-features = false, features = ["api"] }
26
-
jacquard-common = { version = "0.6", path = "../jacquard-common", features = ["reqwest-client"] }
27
-
jacquard-derive = { version = "0.6", path = "../jacquard-derive" }
28
-
jacquard-identity = { version = "0.6", path = "../jacquard-identity", optional = true }
25
+
jacquard = { version = "0.7", path = "../jacquard", default-features = false, features = ["api"] }
26
+
jacquard-common = { version = "0.7", path = "../jacquard-common", features = ["reqwest-client"] }
27
+
jacquard-derive = { version = "0.7", path = "../jacquard-derive" }
28
+
jacquard-identity = { version = "0.7", path = "../jacquard-identity", optional = true }
29
29
miette.workspace = true
30
30
multibase = { version = "0.9.1", optional = true }
31
31
serde.workspace = true
+1
-1
crates/jacquard-derive/Cargo.toml
+1
-1
crates/jacquard-derive/Cargo.toml
+2
-2
crates/jacquard-identity/Cargo.toml
+2
-2
crates/jacquard-identity/Cargo.toml
···
1
1
[package]
2
2
name = "jacquard-identity"
3
3
edition.workspace = true
4
-
version = "0.6.0"
4
+
version = "0.7.0"
5
5
authors.workspace = true
6
6
repository.workspace = true
7
7
keywords.workspace = true
···
21
21
trait-variant.workspace = true
22
22
bon.workspace = true
23
23
bytes.workspace = true
24
-
jacquard-common = { version = "0.6", path = "../jacquard-common", features = ["reqwest-client"] }
24
+
jacquard-common = { version = "0.7", path = "../jacquard-common", features = ["reqwest-client"] }
25
25
jacquard-api = { version = "0.6", path = "../jacquard-api", default-features = false, features = ["minimal"] }
26
26
percent-encoding.workspace = true
27
27
reqwest.workspace = true
+3
-3
crates/jacquard-lexicon/Cargo.toml
+3
-3
crates/jacquard-lexicon/Cargo.toml
···
25
25
glob = "0.3"
26
26
heck.workspace = true
27
27
#itertools.workspace = true
28
-
jacquard-api = { version = "0.6", git = "https://tangled.org/@nonbinary.computer/jacquard" }
29
-
jacquard-common = { version = "0.6", features = [ "reqwest-client" ], git = "https://tangled.org/@nonbinary.computer/jacquard" }
30
-
jacquard-identity = { version = "0.6", git = "https://tangled.org/@nonbinary.computer/jacquard" }
28
+
jacquard-api = { version = "0.7", git = "https://tangled.org/@nonbinary.computer/jacquard" }
29
+
jacquard-common = { version = "0.7", features = [ "reqwest-client" ], git = "https://tangled.org/@nonbinary.computer/jacquard" }
30
+
jacquard-identity = { version = "0.7", git = "https://tangled.org/@nonbinary.computer/jacquard" }
31
31
kdl = "6"
32
32
miette = { workspace = true, features = ["fancy"] }
33
33
prettyplease.workspace = true
+2
-2
crates/jacquard-oauth/Cargo.toml
+2
-2
crates/jacquard-oauth/Cargo.toml
···
21
21
streaming = ["jacquard-common/streaming", "dep:n0-future"]
22
22
23
23
[dependencies]
24
-
jacquard-common = { version = "0.6", path = "../jacquard-common", features = ["reqwest-client"] }
25
-
jacquard-identity = { version = "0.6", path = "../jacquard-identity" }
24
+
jacquard-common = { version = "0.7", path = "../jacquard-common", features = ["reqwest-client"] }
25
+
jacquard-identity = { version = "0.7", path = "../jacquard-identity" }
26
26
serde = { workspace = true, features = ["derive"] }
27
27
serde_json = { workspace = true }
28
28
url = { workspace = true }
+5
-5
crates/jacquard/Cargo.toml
+5
-5
crates/jacquard/Cargo.toml
···
122
122
123
123
124
124
[dependencies]
125
-
jacquard-api = { version = "0.6", path = "../jacquard-api" }
126
-
jacquard-common = { version = "0.6", path = "../jacquard-common", features = [
125
+
jacquard-api = { version = "0.7", path = "../jacquard-api" }
126
+
jacquard-common = { version = "0.7", path = "../jacquard-common", features = [
127
127
"reqwest-client",
128
128
] }
129
-
jacquard-oauth = { version = "0.6", path = "../jacquard-oauth" }
130
-
jacquard-derive = { version = "0.6", path = "../jacquard-derive", optional = true }
131
-
jacquard-identity = { version = "0.6", path = "../jacquard-identity" }
129
+
jacquard-oauth = { version = "0.7", path = "../jacquard-oauth" }
130
+
jacquard-derive = { version = "0.7", path = "../jacquard-derive", optional = true }
131
+
jacquard-identity = { version = "0.7", path = "../jacquard-identity" }
132
132
133
133
bon.workspace = true
134
134
trait-variant.workspace = true
+22
-11
crates/jacquard/src/moderation.rs
+22
-11
crates/jacquard/src/moderation.rs
···
1
-
//! Moderation decision making for AT Protocol content
1
+
//! Moderation
2
+
//!
3
+
//! This is an attempt to semi-generalize the Bluesky moderation system. It avoids
4
+
//! depending on their lexicons as much as reasonably possible. This works via a
5
+
//! trait, [`Labeled`], which represents things that have labels for moderation
6
+
//! applied to them. This way the moderation application functions can operate
7
+
//! primarily via the trait, and are thus generic over lexicon types, and are
8
+
//! easy to use with your own types.
2
9
//!
3
-
//! This module provides protocol-agnostic moderation logic for applying label-based
4
-
//! content filtering. It takes labels from various sources (labeler services, self-labels)
5
-
//! and user preferences to produce moderation decisions.
10
+
//! For more complex types which might have labels applied to components,
11
+
//! there is the [`Moderateable`] trait. A mostly complete implementation for
12
+
//! `FeedViewPost` is available for reference. The trait method outputs a `Vec`
13
+
//! of tuples, where the first element is a string tag and the second is the
14
+
//! moderation decision for the tagged element. This lets application developers
15
+
//! change behaviour based on what part of the content got a label. The functions
16
+
//! mostly match Bluesky behaviour (respecting "!hide", and such) by default.
6
17
//!
7
-
//! # Core Concepts
18
+
//! I've taken the time to go through the generated API bindings and implement
19
+
//! the [`Labeled`] trait for a number of types. It's a fairly easy trait to
20
+
//! implement, just not really automatable.
8
21
//!
9
-
//! - **Labels**: Metadata tags applied to content by labelers or authors (see [`Label`](jacquard_api::com_atproto::label::Label))
10
-
//! - **Preferences**: User-configured responses to specific label values (hide, warn, ignore)
11
-
//! - **Definitions**: Labeler-provided metadata about what labels mean and how they should be displayed
12
-
//! - **Decisions**: The output of moderation logic indicating what actions to take
13
22
//!
14
23
//! # Example
15
24
//!
···
27
36
//! ```
28
37
29
38
mod decision;
30
-
#[cfg(feature = "api_bluesky")]
39
+
#[cfg(feature = "api")]
31
40
mod fetch;
32
41
mod labeled;
33
42
mod moderatable;
···
37
46
mod tests;
38
47
39
48
pub use decision::{ModerationIterExt, moderate, moderate_all};
49
+
#[cfg(feature = "api")]
50
+
pub use fetch::{fetch_labeled_record, fetch_labels};
40
51
#[cfg(feature = "api_bluesky")]
41
52
pub use fetch::{fetch_labeler_defs, fetch_labeler_defs_direct};
42
-
pub use labeled::Labeled;
53
+
pub use labeled::{Labeled, LabeledRecord};
43
54
pub use moderatable::Moderateable;
44
55
pub use types::{
45
56
Blur, LabelCause, LabelPref, LabelTarget, LabelerDefs, ModerationDecision, ModerationPrefs,
+92
-15
crates/jacquard/src/moderation/fetch.rs
+92
-15
crates/jacquard/src/moderation/fetch.rs
···
1
1
use super::LabelerDefs;
2
-
use crate::client::AgentSessionExt;
3
-
use jacquard_api::app_bsky::labeler::get_services::{GetServices, GetServicesOutput};
4
-
use jacquard_api::app_bsky::labeler::service::Service;
5
-
use jacquard_common::IntoStatic;
6
-
use jacquard_common::error::ClientError;
2
+
use crate::client::{AgentError, AgentSessionExt, CollectionErr, CollectionOutput};
3
+
use crate::moderation::labeled::LabeledRecord;
4
+
5
+
#[cfg(feature = "api_bluesky")]
6
+
use jacquard_api::app_bsky::labeler::{
7
+
get_services::{GetServices, GetServicesOutput},
8
+
service::Service,
9
+
};
10
+
use jacquard_api::com_atproto::label::{Label, query_labels::QueryLabels};
11
+
use jacquard_common::cowstr::ToCowStr;
12
+
use jacquard_common::error::{ClientError, TransportError};
13
+
use jacquard_common::types::collection::Collection;
7
14
use jacquard_common::types::string::Did;
8
-
use jacquard_common::xrpc::{XrpcClient, XrpcError};
15
+
use jacquard_common::types::uri::RecordUri;
16
+
use jacquard_common::xrpc::{XrpcClient, XrpcError, XrpcResp};
17
+
use jacquard_common::{CowStr, IntoStatic};
18
+
use std::convert::From;
9
19
10
20
/// Fetch labeler definitions from Bluesky's AppView (or a compatible one)
21
+
#[cfg(feature = "api_bluesky")]
11
22
pub async fn fetch_labeler_defs(
12
23
client: &(impl XrpcClient + Sync),
13
24
dids: Vec<Did<'_>>,
···
20
31
let response = client.send(request).await?;
21
32
let output: GetServicesOutput<'static> = response.into_output().map_err(|e| match e {
22
33
XrpcError::Auth(auth) => ClientError::Auth(auth),
23
-
XrpcError::Generic(g) => ClientError::Transport(
24
-
jacquard_common::error::TransportError::Other(g.to_string().into()),
25
-
),
34
+
XrpcError::Generic(g) => {
35
+
ClientError::Transport(TransportError::Other(g.to_string().into()))
36
+
}
26
37
XrpcError::Decode(e) => ClientError::Decode(e),
27
-
XrpcError::Xrpc(typed) => ClientError::Transport(
28
-
jacquard_common::error::TransportError::Other(format!("{:?}", typed).into()),
29
-
),
38
+
XrpcError::Xrpc(typed) => {
39
+
ClientError::Transport(TransportError::Other(format!("{:?}", typed).into()))
40
+
}
30
41
})?;
31
42
32
43
let mut defs = LabelerDefs::new();
···
61
72
/// This fetches the `app.bsky.labeler.service` record directly from the PDS where
62
73
/// the labeler is hosted.
63
74
///
75
+
/// This is much less efficient for the client than querying the AppView, but has
76
+
/// the virtue of working without the Bluesky AppView or a compatible one. Other
77
+
/// alternatives include querying <https://ufos.microcosm.blue> for definitions
78
+
/// created relatively recently, or doing your own scraping and indexing beforehand.
79
+
///
80
+
#[cfg(feature = "api_bluesky")]
64
81
pub async fn fetch_labeler_defs_direct(
65
82
client: &(impl AgentSessionExt + Sync),
66
83
dids: Vec<Did<'_>>,
···
73
90
for did in dids {
74
91
let uri = format!("at://{}/app.bsky.labeler.service/self", did.as_str());
75
92
let record_uri = Service::uri(uri).map_err(|e| {
76
-
ClientError::Transport(jacquard_common::error::TransportError::Other(
77
-
format!("Invalid URI: {}", e).into(),
78
-
))
93
+
ClientError::Transport(TransportError::Other(format!("Invalid URI: {}", e).into()))
79
94
})?;
80
95
81
96
let output = client.fetch_record(&record_uri).await?;
···
88
103
89
104
Ok(defs)
90
105
}
106
+
107
+
/// Convenient wrapper for com.atproto.label.queryLabels
108
+
///
109
+
/// Avoids depending on the Bluesky namespace, though it may call out to the
110
+
/// Bluesky AppView (or a compatible one configured via atproto-proxy header).
111
+
///
112
+
/// Fetches labels directly for a given set of URI patterns.
113
+
/// This one defaults to the max number, assuming that you will be fetching
114
+
/// in bulk. This is not especially efficient and mostly exists as a demonstration.
115
+
///
116
+
/// In practice if you are running an app server, you should call [`subscribeLabels`](https://tangled.org/@nonbinary.computer/jacquard/blob/main/crates/jacquard-api/src/com_atproto/label/subscribe_labels.rs)
117
+
/// on labelers to tail their output, and index them alongside the data your app cares about.
118
+
pub async fn fetch_labels(
119
+
client: &impl AgentSessionExt,
120
+
uri_patterns: Vec<CowStr<'_>>,
121
+
sources: Vec<Did<'_>>,
122
+
cursor: Option<CowStr<'_>>,
123
+
) -> Result<(Vec<Label<'static>>, Option<CowStr<'static>>), AgentError> {
124
+
#[cfg(feature = "tracing")]
125
+
let _span = tracing::debug_span!("fetch_labels", count = sources.len()).entered();
126
+
127
+
let request = QueryLabels::new()
128
+
.maybe_cursor(cursor)
129
+
.limit(250)
130
+
.uri_patterns(uri_patterns)
131
+
.sources(sources)
132
+
.build();
133
+
let labels = client
134
+
.send(request)
135
+
.await?
136
+
.into_output()
137
+
.map_err(|e| match e {
138
+
XrpcError::Generic(e) => AgentError::Generic(e),
139
+
_ => unimplemented!(), // We know the error at this point is always GenericXrpcError
140
+
})?;
141
+
Ok((labels.labels, labels.cursor))
142
+
}
143
+
144
+
/// Minimal helper to fetch a URI and any labels.
145
+
///
146
+
/// This is *extremely* inefficient and should not be used except in experimentation.
147
+
/// It primarily exists as a demonstration that you can hydrate labels without
148
+
/// using any Bluesky appview methods.
149
+
///
150
+
/// In practice if you are running an app server, you should call [`subscribeLabels`](https://tangled.org/@nonbinary.computer/jacquard/blob/main/crates/jacquard-api/src/com_atproto/label/subscribe_labels.rs)
151
+
/// on labelers to tail their output, and index them alongside the data your app cares about.
152
+
pub async fn fetch_labeled_record<R>(
153
+
client: &impl AgentSessionExt,
154
+
record_uri: &RecordUri<'_, R>,
155
+
sources: Vec<Did<'_>>,
156
+
) -> Result<LabeledRecord<'static, R>, AgentError>
157
+
where
158
+
R: Collection + From<CollectionOutput<'static, R>>,
159
+
for<'a> CollectionOutput<'a, R>: IntoStatic<Output = CollectionOutput<'static, R>>,
160
+
for<'a> CollectionErr<'a, R>: IntoStatic<Output = CollectionErr<'static, R>>,
161
+
{
162
+
let record: R = client.fetch_record(record_uri).await?.into();
163
+
let (labels, _) =
164
+
fetch_labels(client, vec![record_uri.as_uri().to_cowstr()], sources, None).await?;
165
+
166
+
Ok(LabeledRecord { record, labels })
167
+
}
+16
crates/jacquard/src/moderation/labeled.rs
+16
crates/jacquard/src/moderation/labeled.rs
···
14
14
}
15
15
}
16
16
17
+
/// Record with applied labels
18
+
///
19
+
/// Exists as a bare minimum RecordView type primarily for testing/demonstration.
20
+
pub struct LabeledRecord<'a, C> {
21
+
/// The record we grabbed labels for
22
+
pub record: C,
23
+
/// The labels applied to the record
24
+
pub labels: Vec<Label<'a>>,
25
+
}
26
+
27
+
impl<'a, C> Labeled<'a> for LabeledRecord<'a, C> {
28
+
fn labels(&self) -> &[Label<'a>] {
29
+
&self.labels
30
+
}
31
+
}
32
+
17
33
// Implementations for common Bluesky types
18
34
#[cfg(feature = "api_bluesky")]
19
35
mod bluesky_impls {
+47
-56
crates/jacquard/src/richtext.rs
+47
-56
crates/jacquard/src/richtext.rs
···
5
5
6
6
#[cfg(feature = "api_bluesky")]
7
7
use crate::api::app_bsky::richtext::facet::Facet;
8
+
#[cfg(feature = "api_bluesky")]
9
+
use crate::api::com_atproto::repo::strong_ref::StrongRef;
8
10
use crate::common::CowStr;
11
+
#[cfg(feature = "api_bluesky")]
12
+
use crate::types::aturi::AtUri;
9
13
use jacquard_common::IntoStatic;
14
+
#[cfg(feature = "api_bluesky")]
15
+
use jacquard_common::http_client::HttpClient;
10
16
use jacquard_common::types::did::{DID_REGEX, Did};
11
17
use jacquard_common::types::handle::HANDLE_REGEX;
18
+
use jacquard_common::types::string::AtStrError;
19
+
use jacquard_common::types::uri::UriParseError;
20
+
use jacquard_identity::resolver::IdentityError;
21
+
#[cfg(feature = "api_bluesky")]
22
+
use jacquard_identity::resolver::IdentityResolver;
12
23
use regex::Regex;
13
24
use std::marker::PhantomData;
14
25
use std::ops::Range;
···
101
112
/// Bluesky record (post, list, starterpack, feed)
102
113
Record {
103
114
/// The at:// URI identifying the record
104
-
at_uri: crate::types::aturi::AtUri<'a>,
115
+
at_uri: AtUri<'a>,
105
116
/// Strong reference (repo + CID) if resolved
106
-
strong_ref: Option<crate::api::com_atproto::repo::strong_ref::StrongRef<'a>>,
117
+
strong_ref: Option<StrongRef<'a>>,
107
118
},
108
119
/// External link embed
109
120
External {
···
221
232
222
233
/// Entry point for parsing text with automatic facet detection
223
234
///
224
-
/// Uses default embed domains (bsky.app, deer.social) for at-URI extraction.
235
+
/// Uses default embed domains (bsky.app, deer.social, blacksky.community, catsky.social) for at-URI extraction.
225
236
/// For custom domains, use [`parse_with_domains`].
226
237
pub fn parse(text: impl AsRef<str>) -> RichTextBuilder<Unresolved> {
227
238
#[cfg(feature = "api_bluesky")]
···
230
241
}
231
242
#[cfg(not(feature = "api_bluesky"))]
232
243
{
233
-
parse_with_domains(text, &[])
244
+
parse_with_domains(text)
234
245
}
235
246
}
236
247
237
248
/// Parse text with custom embed domains for at-URI extraction
238
249
///
239
-
/// This allows specifying additional domains (beyond bsky.app and deer.social)
250
+
/// This allows specifying additional domains (beyond the defaults)
240
251
/// that use the same URL patterns for records (e.g., /profile/{actor}/post/{rkey}).
241
252
#[cfg(feature = "api_bluesky")]
242
253
pub fn parse_with_domains(
···
300
311
301
312
/// Parse text without embed detection (no api_bluesky feature)
302
313
#[cfg(not(feature = "api_bluesky"))]
303
-
pub fn parse_with_domains(
304
-
text: impl AsRef<str>,
305
-
_embed_domains: &[&str],
306
-
) -> RichTextBuilder<Unresolved> {
314
+
pub fn parse_with_domains(text: impl AsRef<str>) -> RichTextBuilder<Unresolved> {
307
315
// Step 0: Sanitize text (remove invisible chars, normalize newlines)
308
316
let text = sanitize_text(text.as_ref());
309
317
···
378
386
}
379
387
380
388
/// Add a mention facet with a resolved DID (requires explicit range)
381
-
pub fn mention(mut self, did: &crate::types::did::Did<'_>, range: Range<usize>) -> Self {
389
+
pub fn mention(mut self, did: &Did<'_>, range: Range<usize>) -> Self {
382
390
self.facet_candidates.push(FacetCandidate::Mention {
383
391
range,
384
392
did: Some(did.clone().into_static()),
···
424
432
/// Add a record embed candidate
425
433
pub fn embed_record(
426
434
mut self,
427
-
at_uri: crate::types::aturi::AtUri<'static>,
428
-
strong_ref: Option<crate::api::com_atproto::repo::strong_ref::StrongRef<'static>>,
435
+
at_uri: AtUri<'static>,
436
+
strong_ref: Option<StrongRef<'static>>,
429
437
) -> Self {
430
438
self.embed_candidates
431
439
.get_or_insert_with(Vec::new)
···
607
615
/// Classifies a URL or at-URI as an embed candidate
608
616
#[cfg(feature = "api_bluesky")]
609
617
fn classify_embed(url: &str, embed_domains: &[&str]) -> Option<EmbedCandidate<'static>> {
610
-
use crate::types::aturi::AtUri;
611
-
612
618
// Check if it's an at:// URI
613
619
if url.starts_with("at://") {
614
620
if let Ok(at_uri) = AtUri::new(url) {
···
650
656
///
651
657
/// Only works for domains in the provided `embed_domains` list.
652
658
#[cfg(feature = "api_bluesky")]
653
-
fn extract_at_uri_from_url(
654
-
url: &str,
655
-
embed_domains: &[&str],
656
-
) -> Option<crate::types::aturi::AtUri<'static>> {
657
-
use crate::types::aturi::AtUri;
658
-
659
+
fn extract_at_uri_from_url(url: &str, embed_domains: &[&str]) -> Option<AtUri<'static>> {
659
660
// Parse URL
660
661
let url_parsed = url::Url::parse(url).ok()?;
661
662
···
693
694
AtUri::new(&at_uri_str).ok().map(|u| u.into_static())
694
695
}
695
696
696
-
use jacquard_common::types::string::AtStrError;
697
-
use thiserror::Error;
698
-
699
697
/// Errors that can occur during richtext building
700
-
#[derive(Debug, Error)]
698
+
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
701
699
pub enum RichTextError {
702
700
/// Handle found that needs resolution but no resolver provided
703
701
#[error("Handle '{0}' requires resolution - use build_async() with an IdentityResolver")]
···
709
707
710
708
/// Identity resolution failed
711
709
#[error("Failed to resolve identity")]
712
-
IdentityResolution(#[from] jacquard_identity::resolver::IdentityError),
710
+
IdentityResolution(#[from] IdentityError),
713
711
714
712
/// Invalid byte range
715
713
#[error("Invalid byte range {start}..{end} for text of length {text_len}")]
···
728
726
729
727
/// Invalid URI
730
728
#[error("Invalid URI")]
731
-
Uri(#[from] jacquard_common::types::uri::UriParseError),
729
+
Uri(#[from] UriParseError),
732
730
}
733
731
734
732
#[cfg(feature = "api_bluesky")]
···
758
756
let text_len = self.text.len();
759
757
760
758
for candidate in candidates {
761
-
use crate::api::app_bsky::richtext::facet::{ByteSlice, Facet};
759
+
use crate::api::app_bsky::richtext::facet::{
760
+
ByteSlice, FacetFeaturesItem, Link, Mention, Tag,
761
+
};
762
+
use crate::types::uri::Uri;
762
763
763
764
let (range, feature) = match candidate {
764
765
FacetCandidate::MarkdownLink { display_range, url } => {
765
766
// MarkdownLink stores URL directly, use display_range for index
766
767
767
-
let feature = crate::api::app_bsky::richtext::facet::FacetFeaturesItem::Link(
768
-
Box::new(crate::api::app_bsky::richtext::facet::Link {
769
-
uri: crate::types::uri::Uri::new_owned(&url)?,
770
-
extra_data: BTreeMap::new(),
771
-
}),
772
-
);
768
+
let feature = FacetFeaturesItem::Link(Box::new(Link {
769
+
uri: Uri::new_owned(&url)?,
770
+
extra_data: BTreeMap::new(),
771
+
}));
773
772
(display_range, feature)
774
773
}
775
774
FacetCandidate::Mention { range, did } => {
···
784
783
RichTextError::HandleNeedsResolution(handle.to_string())
785
784
})?;
786
785
787
-
let feature = crate::api::app_bsky::richtext::facet::FacetFeaturesItem::Mention(
788
-
Box::new(crate::api::app_bsky::richtext::facet::Mention {
789
-
did,
790
-
extra_data: BTreeMap::new(),
791
-
}),
792
-
);
786
+
let feature = FacetFeaturesItem::Mention(Box::new(Mention {
787
+
did,
788
+
extra_data: BTreeMap::new(),
789
+
}));
793
790
(range, feature)
794
791
}
795
792
FacetCandidate::Link { range } => {
···
809
806
url = format!("https://{}", url);
810
807
}
811
808
812
-
let feature = crate::api::app_bsky::richtext::facet::FacetFeaturesItem::Link(
813
-
Box::new(crate::api::app_bsky::richtext::facet::Link {
814
-
uri: crate::types::uri::Uri::new_owned(&url)?,
815
-
extra_data: BTreeMap::new(),
816
-
}),
817
-
);
809
+
let feature = FacetFeaturesItem::Link(Box::new(Link {
810
+
uri: Uri::new_owned(&url)?,
811
+
extra_data: BTreeMap::new(),
812
+
}));
818
813
(range, feature)
819
814
}
820
815
FacetCandidate::Tag { range } => {
···
835
830
.trim_start_matches('#')
836
831
.trim_start_matches('#');
837
832
838
-
let feature = crate::api::app_bsky::richtext::facet::FacetFeaturesItem::Tag(
839
-
Box::new(crate::api::app_bsky::richtext::facet::Tag {
840
-
tag: CowStr::from(tag.to_smolstr()),
841
-
extra_data: BTreeMap::new(),
842
-
}),
843
-
);
833
+
let feature = FacetFeaturesItem::Tag(Box::new(Tag {
834
+
tag: CowStr::from(tag.to_smolstr()),
835
+
extra_data: BTreeMap::new(),
836
+
}));
844
837
(range, feature)
845
838
}
846
839
};
···
884
877
/// Build richtext, resolving handles to DIDs using the provided resolver
885
878
pub async fn build_async<R>(self, resolver: &R) -> Result<RichText<'static>, RichTextError>
886
879
where
887
-
R: jacquard_identity::resolver::IdentityResolver + Sync,
880
+
R: IdentityResolver + Sync,
888
881
{
889
882
use crate::api::app_bsky::richtext::facet::{
890
883
ByteSlice, FacetFeaturesItem, Link, Mention, Tag,
···
1040
1033
client: &C,
1041
1034
) -> Result<(RichText<'static>, Option<Vec<EmbedCandidate<'static>>>), RichTextError>
1042
1035
where
1043
-
C: jacquard_common::http_client::HttpClient
1044
-
+ jacquard_identity::resolver::IdentityResolver
1045
-
+ Sync,
1036
+
C: HttpClient + IdentityResolver + Sync,
1046
1037
{
1047
1038
// Extract embed candidates
1048
1039
let embed_candidates = self.embed_candidates.take().unwrap_or_default();
···
1096
1087
url: &str,
1097
1088
) -> Result<Option<ExternalMetadata<'static>>, Box<dyn std::error::Error + Send + Sync>>
1098
1089
where
1099
-
C: jacquard_common::http_client::HttpClient,
1090
+
C: HttpClient,
1100
1091
{
1101
1092
// Build HTTP GET request
1102
1093
let request = http::Request::builder()
+18
-5
examples/create_post.rs
+18
-5
examples/create_post.rs
···
4
4
use jacquard::client::{Agent, AgentSessionExt, FileAuthStore};
5
5
use jacquard::oauth::client::OAuthClient;
6
6
use jacquard::oauth::loopback::LoopbackConfig;
7
+
use jacquard::richtext::RichText;
7
8
use jacquard::types::string::Datetime;
8
9
9
10
#[derive(Parser, Debug)]
10
-
#[command(author, version, about = "Create a simple post")]
11
+
#[command(author, version, about = "Create a post with automatic facet detection")]
11
12
struct Args {
12
13
/// Handle (e.g., alice.bsky.social), DID, or PDS URL
13
14
input: CowStr<'static>,
14
15
15
-
/// Post text
16
+
/// Post text (can include @mentions, #hashtags, URLs, and [markdown](links))
16
17
#[arg(short, long)]
17
18
text: String,
18
19
···
32
33
33
34
let agent: Agent<_> = Agent::from(session);
34
35
35
-
// Create a simple text post using the Agent convenience method
36
+
// Parse richtext with automatic facet detection
37
+
// This detects @mentions, #hashtags, URLs, and [markdown](links)
38
+
let richtext = RichText::parse(&args.text).build_async(&agent).await?;
39
+
40
+
println!("Detected {} facets:", richtext.facets.as_ref().map(|f| f.len()).unwrap_or(0));
41
+
if let Some(facets) = &richtext.facets {
42
+
for facet in facets {
43
+
let text_slice = &richtext.text[facet.index.byte_start as usize..facet.index.byte_end as usize];
44
+
println!(" - \"{}\" ({:?})", text_slice, facet.features);
45
+
}
46
+
}
47
+
48
+
// Create post with parsed facets
36
49
let post = Post {
37
-
text: CowStr::from(args.text),
50
+
text: richtext.text,
51
+
facets: richtext.facets,
38
52
created_at: Datetime::now(),
39
53
embed: None,
40
54
entities: None,
41
-
facets: None,
42
55
labels: None,
43
56
langs: None,
44
57
reply: None,