tangled
alpha
login
or
join now
nonbinary.computer
/
jacquard
80
fork
atom
A better Rust ATProto crate
80
fork
atom
overview
issues
9
pulls
pipelines
parser
Orual
5 months ago
62e76554
c4daaadb
+188
1 changed file
expand all
collapse all
unified
split
crates
jacquard
src
richtext.rs
+188
crates/jacquard/src/richtext.rs
···
4
4
//! and detection of embed candidates (record and external embeds).
5
5
6
6
use crate::common::CowStr;
7
7
+
use jacquard_common::IntoStatic;
8
8
+
use jacquard_common::types::did::{DID_REGEX, Did};
9
9
+
use jacquard_common::types::handle::HANDLE_REGEX;
7
10
use regex::Regex;
8
11
use std::marker::PhantomData;
9
12
use std::ops::Range;
···
73
76
pub struct RichTextBuilder<State> {
74
77
text: String,
75
78
facet_candidates: Vec<FacetCandidate>,
79
79
+
#[cfg(feature = "api_bluesky")]
80
80
+
embed_candidates: Vec<EmbedCandidate<'static>>,
76
81
_state: PhantomData<State>,
77
82
}
78
83
···
402
407
403
408
facets
404
409
}
410
410
+
411
411
+
use jacquard_common::types::string::AtStrError;
412
412
+
use thiserror::Error;
413
413
+
414
414
+
/// Errors that can occur during richtext building
415
415
+
#[derive(Debug, Error)]
416
416
+
pub enum RichTextError {
417
417
+
/// Handle found that needs resolution but no resolver provided
418
418
+
#[error("Handle '{0}' requires resolution - use build_async() with an IdentityResolver")]
419
419
+
HandleNeedsResolution(String),
420
420
+
421
421
+
/// Facets overlap (not allowed by spec)
422
422
+
#[error("Facets overlap at byte range {0}..{1}")]
423
423
+
OverlappingFacets(usize, usize),
424
424
+
425
425
+
/// Identity resolution failed
426
426
+
#[error("Failed to resolve identity")]
427
427
+
IdentityResolution(#[from] jacquard_identity::resolver::IdentityError),
428
428
+
429
429
+
/// Invalid byte range
430
430
+
#[error("Invalid byte range {start}..{end} for text of length {text_len}")]
431
431
+
InvalidRange {
432
432
+
start: usize,
433
433
+
end: usize,
434
434
+
text_len: usize,
435
435
+
},
436
436
+
437
437
+
/// Invalid AT Protocol string (URI, DID, or Handle)
438
438
+
#[error("Invalid AT Protocol string")]
439
439
+
InvalidAtStr(#[from] AtStrError),
440
440
+
441
441
+
/// Invalid URI
442
442
+
#[error("Invalid URI")]
443
443
+
Uri(#[from] jacquard_common::types::uri::UriParseError),
444
444
+
}
445
445
+
446
446
+
#[cfg(feature = "api_bluesky")]
447
447
+
impl RichTextBuilder<Resolved> {
448
448
+
/// Build the richtext (sync - all facets must be resolved)
449
449
+
pub fn build(
450
450
+
self,
451
451
+
) -> Result<
452
452
+
(
453
453
+
String,
454
454
+
Option<Vec<crate::api::app_bsky::richtext::facet::Facet<'static>>>,
455
455
+
),
456
456
+
RichTextError,
457
457
+
> {
458
458
+
use std::collections::BTreeMap;
459
459
+
if self.facet_candidates.is_empty() {
460
460
+
return Ok((self.text, None));
461
461
+
}
462
462
+
463
463
+
// Sort facets by start position
464
464
+
let mut candidates = self.facet_candidates;
465
465
+
candidates.sort_by_key(|fc| match fc {
466
466
+
FacetCandidate::MarkdownLink { display_range, .. } => display_range.start,
467
467
+
FacetCandidate::Mention { range, .. } => range.start,
468
468
+
FacetCandidate::Link { range } => range.start,
469
469
+
FacetCandidate::Tag { range } => range.start,
470
470
+
});
471
471
+
472
472
+
// Check for overlaps and convert to Facet types
473
473
+
let mut facets = Vec::with_capacity(candidates.len());
474
474
+
let mut last_end = 0;
475
475
+
let text_len = self.text.len();
476
476
+
477
477
+
for candidate in candidates {
478
478
+
let (range, feature) = match candidate {
479
479
+
FacetCandidate::MarkdownLink { display_range, url } => {
480
480
+
// MarkdownLink stores URL directly, use display_range for index
481
481
+
482
482
+
let feature = crate::api::app_bsky::richtext::facet::FacetFeaturesItem::Link(
483
483
+
Box::new(crate::api::app_bsky::richtext::facet::Link {
484
484
+
uri: crate::types::uri::Uri::new_owned(&url)?,
485
485
+
extra_data: BTreeMap::new(),
486
486
+
}),
487
487
+
);
488
488
+
(display_range, feature)
489
489
+
}
490
490
+
FacetCandidate::Mention { range, did } => {
491
491
+
// In Resolved state, DID must be present
492
492
+
let did = did.ok_or_else(|| {
493
493
+
// Extract handle from text for error message
494
494
+
let handle = if range.end <= text_len {
495
495
+
self.text[range.clone()].trim_start_matches('@')
496
496
+
} else {
497
497
+
"<invalid range>"
498
498
+
};
499
499
+
RichTextError::HandleNeedsResolution(handle.to_string())
500
500
+
})?;
501
501
+
502
502
+
let feature = crate::api::app_bsky::richtext::facet::FacetFeaturesItem::Mention(
503
503
+
Box::new(crate::api::app_bsky::richtext::facet::Mention {
504
504
+
did,
505
505
+
extra_data: BTreeMap::new(),
506
506
+
}),
507
507
+
);
508
508
+
(range, feature)
509
509
+
}
510
510
+
FacetCandidate::Link { range } => {
511
511
+
// Extract URL from text[range] and normalize
512
512
+
if range.end > text_len {
513
513
+
return Err(RichTextError::InvalidRange {
514
514
+
start: range.start,
515
515
+
end: range.end,
516
516
+
text_len,
517
517
+
});
518
518
+
}
519
519
+
520
520
+
let mut url = self.text[range.clone()].to_string();
521
521
+
522
522
+
// Prepend https:// if URL doesn't have a scheme
523
523
+
if !url.starts_with("http://") && !url.starts_with("https://") {
524
524
+
url = format!("https://{}", url);
525
525
+
}
526
526
+
527
527
+
let feature = crate::api::app_bsky::richtext::facet::FacetFeaturesItem::Link(
528
528
+
Box::new(crate::api::app_bsky::richtext::facet::Link {
529
529
+
uri: crate::types::uri::Uri::new_owned(&url)?,
530
530
+
extra_data: BTreeMap::new(),
531
531
+
}),
532
532
+
);
533
533
+
(range, feature)
534
534
+
}
535
535
+
FacetCandidate::Tag { range } => {
536
536
+
// Extract tag from text[range] (includes #), strip # and trailing punct
537
537
+
538
538
+
use smol_str::ToSmolStr;
539
539
+
if range.end > text_len {
540
540
+
return Err(RichTextError::InvalidRange {
541
541
+
start: range.start,
542
542
+
end: range.end,
543
543
+
text_len,
544
544
+
});
545
545
+
}
546
546
+
547
547
+
let tag_with_hash = &self.text[range.clone()];
548
548
+
// Strip # prefix (could be # or #)
549
549
+
let tag = tag_with_hash
550
550
+
.trim_start_matches('#')
551
551
+
.trim_start_matches('#');
552
552
+
553
553
+
let feature = crate::api::app_bsky::richtext::facet::FacetFeaturesItem::Tag(
554
554
+
Box::new(crate::api::app_bsky::richtext::facet::Tag {
555
555
+
tag: CowStr::from(tag.to_smolstr()),
556
556
+
extra_data: BTreeMap::new(),
557
557
+
}),
558
558
+
);
559
559
+
(range, feature)
560
560
+
}
561
561
+
};
562
562
+
563
563
+
// Check overlap
564
564
+
if range.start < last_end {
565
565
+
return Err(RichTextError::OverlappingFacets(range.start, range.end));
566
566
+
}
567
567
+
568
568
+
// Validate range
569
569
+
if range.end > text_len {
570
570
+
return Err(RichTextError::InvalidRange {
571
571
+
start: range.start,
572
572
+
end: range.end,
573
573
+
text_len,
574
574
+
});
575
575
+
}
576
576
+
577
577
+
facets.push(crate::api::app_bsky::richtext::facet::Facet {
578
578
+
index: crate::api::app_bsky::richtext::facet::ByteSlice {
579
579
+
byte_start: range.start as i64,
580
580
+
byte_end: range.end as i64,
581
581
+
extra_data: BTreeMap::new(),
582
582
+
},
583
583
+
features: vec![feature],
584
584
+
extra_data: BTreeMap::new(),
585
585
+
});
586
586
+
587
587
+
last_end = range.end;
588
588
+
}
589
589
+
590
590
+
Ok((self.text, Some(facets.into_static())))
591
591
+
}
592
592
+
}