tangled
alpha
login
or
join now
nonbinary.computer
/
weaver
atproto blogging
24
fork
atom
overview
issues
2
pulls
pipelines
js renderer wrapper crate
Orual
1 month ago
5ba1942a
1ce85230
+423
-28
7 changed files
expand all
collapse all
unified
split
Cargo.lock
crates
weaver-renderer
Cargo.toml
src
facet
processor.rs
types.rs
weaver-renderer-js
Cargo.toml
src
lib.rs
types.rs
+45
Cargo.lock
···
11399
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
11400
11401
[[package]]
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
11402
name = "ttf-parser"
11403
version = "0.24.1"
11404
source = "registry+https://github.com/rust-lang/crates.io-index"
···
12350
"regex",
12351
"regex-lite",
12352
"reqwest",
0
0
12353
"smol_str",
12354
"syntect",
12355
"thiserror 2.0.17",
···
12362
"weaver-api",
12363
"weaver-common",
12364
"yaml-rust2",
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
12365
]
12366
12367
[[package]]
···
11399
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
11400
11401
[[package]]
11402
+
name = "tsify-next"
11403
+
version = "0.5.6"
11404
+
source = "registry+https://github.com/rust-lang/crates.io-index"
11405
+
checksum = "7d0f2208feeb5f7a6edb15a2389c14cd42480ef6417318316bb866da5806a61d"
11406
+
dependencies = [
11407
+
"gloo-utils",
11408
+
"serde",
11409
+
"serde_json",
11410
+
"tsify-next-macros",
11411
+
"wasm-bindgen",
11412
+
]
11413
+
11414
+
[[package]]
11415
+
name = "tsify-next-macros"
11416
+
version = "0.5.6"
11417
+
source = "registry+https://github.com/rust-lang/crates.io-index"
11418
+
checksum = "f81253930d0d388a3ab8fa4ae56da9973ab171ef833d1be2e9080fc3ce502bd6"
11419
+
dependencies = [
11420
+
"proc-macro2",
11421
+
"quote",
11422
+
"serde_derive_internals",
11423
+
"syn 2.0.111",
11424
+
]
11425
+
11426
+
[[package]]
11427
name = "ttf-parser"
11428
version = "0.24.1"
11429
source = "registry+https://github.com/rust-lang/crates.io-index"
···
12375
"regex",
12376
"regex-lite",
12377
"reqwest",
12378
+
"serde",
12379
+
"serde_json",
12380
"smol_str",
12381
"syntect",
12382
"thiserror 2.0.17",
···
12389
"weaver-api",
12390
"weaver-common",
12391
"yaml-rust2",
12392
+
]
12393
+
12394
+
[[package]]
12395
+
name = "weaver-renderer-js"
12396
+
version = "0.1.0"
12397
+
dependencies = [
12398
+
"console_error_panic_hook",
12399
+
"jacquard",
12400
+
"js-sys",
12401
+
"markdown-weaver",
12402
+
"markdown-weaver-escape",
12403
+
"serde",
12404
+
"serde-wasm-bindgen 0.6.5",
12405
+
"tsify-next",
12406
+
"wasm-bindgen",
12407
+
"weaver-api",
12408
+
"weaver-common",
12409
+
"weaver-renderer",
12410
]
12411
12412
[[package]]
+29
crates/weaver-renderer-js/Cargo.toml
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
[package]
2
+
name = "weaver-renderer-js"
3
+
version.workspace = true
4
+
edition.workspace = true
5
+
license.workspace = true
6
+
authors.workspace = true
7
+
description = "WASM bindings for weaver-renderer"
8
+
9
+
[lib]
10
+
crate-type = ["cdylib", "rlib"]
11
+
12
+
[dependencies]
13
+
weaver-renderer = { path = "../weaver-renderer" }
14
+
weaver-common = { path = "../weaver-common" }
15
+
weaver-api = { path = "../weaver-api" }
16
+
jacquard = { workspace = true }
17
+
markdown-weaver = { workspace = true }
18
+
markdown-weaver-escape = { workspace = true }
19
+
20
+
wasm-bindgen = "0.2"
21
+
serde = { workspace = true }
22
+
serde-wasm-bindgen = "0.6"
23
+
tsify-next = "0.5"
24
+
js-sys = "0.3"
25
+
console_error_panic_hook = "0.1"
26
+
27
+
[profile.release]
28
+
lto = true
29
+
opt-level = "z"
+124
crates/weaver-renderer-js/src/lib.rs
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
//! WASM bindings for weaver-renderer.
2
+
//!
3
+
//! Exposes sync rendering functions to JS/TS apps via wasm-bindgen.
4
+
5
+
use jacquard::types::string::AtUri;
6
+
use jacquard::types::value::Data;
7
+
use serde::Deserialize;
8
+
use serde_wasm_bindgen::Deserializer;
9
+
use wasm_bindgen::prelude::*;
10
+
11
+
mod types;
12
+
13
+
pub use types::*;
14
+
15
+
/// Initialize panic hook for better error messages in console.
16
+
#[wasm_bindgen(start)]
17
+
pub fn init() {
18
+
console_error_panic_hook::set_once();
19
+
}
20
+
21
+
/// Render an AT Protocol record as HTML.
22
+
///
23
+
/// Takes a record URI and the record data (typically fetched from an appview).
24
+
/// Returns the rendered HTML string.
25
+
///
26
+
/// # Arguments
27
+
/// * `at_uri` - The AT Protocol URI (e.g., "at://did:plc:.../app.bsky.feed.post/...")
28
+
/// * `record_json` - The record data as JSON
29
+
/// * `fallback_author` - Optional author profile for records that don't include author info
30
+
/// * `resolved_content` - Optional pre-rendered embed content
31
+
#[wasm_bindgen]
32
+
pub fn render_record(
33
+
at_uri: &str,
34
+
record_json: JsValue,
35
+
fallback_author: Option<JsValue>,
36
+
resolved_content: Option<JsResolvedContent>,
37
+
) -> Result<String, JsError> {
38
+
let uri = AtUri::new(at_uri).map_err(|e| JsError::new(&format!("Invalid AT URI: {}", e)))?;
39
+
40
+
// Use Deserializer directly to avoid DeserializeOwned bounds (breaks Jacquard types).
41
+
let deserializer = Deserializer::from(record_json);
42
+
let data = Data::deserialize(deserializer)
43
+
.map_err(|e| JsError::new(&format!("Invalid record JSON: {}", e)))?;
44
+
45
+
let author: Option<weaver_api::sh_weaver::actor::ProfileDataView<'_>> = fallback_author
46
+
.map(|v| {
47
+
let deserializer = Deserializer::from(v);
48
+
weaver_api::sh_weaver::actor::ProfileDataView::deserialize(deserializer)
49
+
})
50
+
.transpose()
51
+
.map_err(|e| JsError::new(&format!("Invalid author JSON: {}", e)))?;
52
+
53
+
let resolved = resolved_content.map(|r| r.into_inner());
54
+
55
+
weaver_renderer::atproto::render_record(&uri, &data, author.as_ref(), resolved.as_ref())
56
+
.map_err(|e| JsError::new(&e.to_string()))
57
+
}
58
+
59
+
/// Render markdown to HTML.
60
+
///
61
+
/// # Arguments
62
+
/// * `markdown` - The markdown source text
63
+
/// * `resolved_content` - Optional pre-rendered embed content
64
+
#[wasm_bindgen]
65
+
pub fn render_markdown(
66
+
markdown: &str,
67
+
resolved_content: Option<JsResolvedContent>,
68
+
) -> Result<String, JsError> {
69
+
use weaver_renderer::atproto::ClientWriter;
70
+
71
+
let resolved = resolved_content.map(|r| r.into_inner()).unwrap_or_default();
72
+
73
+
let parser = markdown_weaver::Parser::new_ext(markdown, weaver_renderer::default_md_options())
74
+
.into_offset_iter();
75
+
let events: Vec<_> = parser.collect();
76
+
77
+
let mut html_buf = String::new();
78
+
let writer = ClientWriter::new(events.into_iter(), &mut html_buf, markdown)
79
+
.with_embed_provider(&resolved);
80
+
81
+
writer.run().map_err(|_| JsError::new("Render error"))?;
82
+
83
+
Ok(html_buf)
84
+
}
85
+
86
+
/// Render LaTeX math to MathML.
87
+
///
88
+
/// # Arguments
89
+
/// * `latex` - The LaTeX math expression
90
+
/// * `display_mode` - true for display math (block), false for inline math
91
+
#[wasm_bindgen]
92
+
pub fn render_math(latex: &str, display_mode: bool) -> JsMathResult {
93
+
match weaver_renderer::math::render_math(latex, display_mode) {
94
+
weaver_renderer::math::MathResult::Success(html) => JsMathResult {
95
+
success: true,
96
+
html,
97
+
error: None,
98
+
},
99
+
weaver_renderer::math::MathResult::Error { html, message } => JsMathResult {
100
+
success: false,
101
+
html,
102
+
error: Some(message),
103
+
},
104
+
}
105
+
}
106
+
107
+
/// Render faceted text (rich text with mentions, links, etc.) to HTML.
108
+
///
109
+
/// Accepts facets from several AT Protocol lexicons (app.bsky, pub.leaflet, blog.pckt).
110
+
///
111
+
/// # Arguments
112
+
/// * `text` - The plain text content
113
+
/// * `facets_json` - Array of facets with `index` (byteStart/byteEnd) and `features` array
114
+
#[wasm_bindgen]
115
+
pub fn render_faceted_text(text: &str, facets_json: JsValue) -> Result<String, JsError> {
116
+
use weaver_renderer::facet::NormalizedFacet;
117
+
118
+
let deserializer = Deserializer::from(facets_json);
119
+
let facets = Vec::<NormalizedFacet<'_>>::deserialize(deserializer)
120
+
.map_err(|e| JsError::new(&format!("Invalid facets JSON: {}", e)))?;
121
+
122
+
weaver_renderer::facet::render_faceted_html(text, &facets)
123
+
.map_err(|e| JsError::new(&e.to_string()))
124
+
}
+73
crates/weaver-renderer-js/src/types.rs
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
//! Types exposed to JavaScript via wasm-bindgen.
2
+
3
+
use jacquard::IntoStatic;
4
+
use wasm_bindgen::prelude::*;
5
+
use weaver_common::ResolvedContent;
6
+
7
+
/// Result from rendering LaTeX math.
8
+
#[wasm_bindgen]
9
+
pub struct JsMathResult {
10
+
pub success: bool,
11
+
#[wasm_bindgen(getter_with_clone)]
12
+
pub html: String,
13
+
#[wasm_bindgen(getter_with_clone)]
14
+
pub error: Option<String>,
15
+
}
16
+
17
+
/// Pre-rendered embed content for synchronous rendering.
18
+
///
19
+
/// Build this by calling `create_resolved_content()` and adding embeds
20
+
/// with `resolved_content_add_embed()`.
21
+
#[wasm_bindgen]
22
+
pub struct JsResolvedContent {
23
+
inner: ResolvedContent,
24
+
}
25
+
26
+
#[wasm_bindgen]
27
+
impl JsResolvedContent {
28
+
/// Create an empty resolved content container.
29
+
#[wasm_bindgen(constructor)]
30
+
pub fn new() -> Self {
31
+
Self {
32
+
inner: ResolvedContent::new(),
33
+
}
34
+
}
35
+
36
+
/// Add pre-rendered embed HTML for an AT URI.
37
+
///
38
+
/// # Arguments
39
+
/// * `at_uri` - The AT Protocol URI (e.g., "at://did:plc:.../app.bsky.feed.post/...")
40
+
/// * `html` - The pre-rendered HTML for this embed
41
+
#[wasm_bindgen(js_name = addEmbed)]
42
+
pub fn add_embed(&mut self, at_uri: &str, html: &str) -> Result<(), JsError> {
43
+
use jacquard::types::string::AtUri;
44
+
use jacquard::CowStr;
45
+
46
+
let uri = AtUri::new(at_uri)
47
+
.map_err(|e| JsError::new(&format!("Invalid AT URI: {}", e)))?
48
+
.into_static();
49
+
50
+
self.inner.add_embed(uri, CowStr::from(html.to_string()), None);
51
+
Ok(())
52
+
}
53
+
}
54
+
55
+
impl JsResolvedContent {
56
+
pub fn into_inner(self) -> ResolvedContent {
57
+
self.inner
58
+
}
59
+
}
60
+
61
+
impl Default for JsResolvedContent {
62
+
fn default() -> Self {
63
+
Self::new()
64
+
}
65
+
}
66
+
67
+
/// Create an empty resolved content container.
68
+
///
69
+
/// Use this to pre-render embeds before calling render functions.
70
+
#[wasm_bindgen]
71
+
pub fn create_resolved_content() -> JsResolvedContent {
72
+
JsResolvedContent::new()
73
+
}
+2
crates/weaver-renderer/Cargo.toml
···
14
weaver-common = { path = "../weaver-common" }
15
weaver-api = { path = "../weaver-api" }
16
jacquard.workspace = true
0
17
markdown-weaver = { workspace = true }
18
http = "1.3.1"
19
url = "2.5.4"
···
48
49
[dev-dependencies]
50
insta = { version = "1.40", features = ["yaml"] }
0
···
14
weaver-common = { path = "../weaver-common" }
15
weaver-api = { path = "../weaver-api" }
16
jacquard.workspace = true
17
+
serde.workspace = true
18
markdown-weaver = { workspace = true }
19
http = "1.3.1"
20
url = "2.5.4"
···
49
50
[dev-dependencies]
51
insta = { version = "1.40", features = ["yaml"] }
52
+
serde_json = "1.0"
+7
-7
crates/weaver-renderer/src/facet/processor.rs
···
68
let mut events: Vec<FacetEvent<'a>> = Vec::new();
69
70
for (idx, facet) in facets.iter().enumerate() {
71
-
if facet.range.is_empty() {
72
continue;
73
}
74
for feature in &facet.features {
75
-
events.push(FacetEvent::start(facet.range.start, feature.clone(), idx));
76
-
events.push(FacetEvent::end(facet.range.end, feature.clone(), idx));
77
}
78
}
79
···
190
fn test_simple_bold() {
191
let text = "hello world";
192
let facets = vec![NormalizedFacet {
193
-
range: ByteRange::new(0, 5),
194
features: vec![FacetFeature::Bold],
195
}];
196
···
208
let text = "bold and italic just italic";
209
let facets = vec![
210
NormalizedFacet {
211
-
range: ByteRange::new(0, 15),
212
features: vec![FacetFeature::Bold],
213
},
214
NormalizedFacet {
215
-
range: ByteRange::new(5, 27),
216
features: vec![FacetFeature::Italic],
217
},
218
];
···
242
fn test_link_facet() {
243
let text = "click here for more";
244
let facets = vec![NormalizedFacet {
245
-
range: ByteRange::new(6, 10),
246
features: vec![FacetFeature::Link {
247
uri: "https://example.com",
248
}],
···
68
let mut events: Vec<FacetEvent<'a>> = Vec::new();
69
70
for (idx, facet) in facets.iter().enumerate() {
71
+
if facet.index.is_empty() {
72
continue;
73
}
74
for feature in &facet.features {
75
+
events.push(FacetEvent::start(facet.index.start(), feature.clone(), idx));
76
+
events.push(FacetEvent::end(facet.index.end(), feature.clone(), idx));
77
}
78
}
79
···
190
fn test_simple_bold() {
191
let text = "hello world";
192
let facets = vec![NormalizedFacet {
193
+
index: ByteRange::new(0, 5),
194
features: vec![FacetFeature::Bold],
195
}];
196
···
208
let text = "bold and italic just italic";
209
let facets = vec![
210
NormalizedFacet {
211
+
index: ByteRange::new(0, 15),
212
features: vec![FacetFeature::Bold],
213
},
214
NormalizedFacet {
215
+
index: ByteRange::new(5, 27),
216
features: vec![FacetFeature::Italic],
217
},
218
];
···
242
fn test_link_facet() {
243
let text = "click here for more";
244
let facets = vec![NormalizedFacet {
245
+
index: ByteRange::new(6, 10),
246
features: vec![FacetFeature::Link {
247
uri: "https://example.com",
248
}],
+143
-21
crates/weaver-renderer/src/facet/types.rs
···
0
1
use std::ops::Range;
2
3
-
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
0
4
pub struct ByteRange {
5
-
pub start: usize,
6
-
pub end: usize,
7
}
8
9
impl ByteRange {
10
pub fn new(start: i64, end: i64) -> Self {
11
Self {
12
-
start: start.max(0) as usize,
13
-
end: end.max(0) as usize,
14
}
15
}
16
17
pub fn to_range(self) -> Range<usize> {
18
-
self.start..self.end
19
}
20
21
pub fn is_empty(&self) -> bool {
22
-
self.start >= self.end
0
0
0
0
0
0
0
0
23
}
24
}
25
26
-
#[derive(Debug, Clone, PartialEq, Eq)]
0
27
pub enum FacetFeature<'a> {
0
0
28
Bold,
0
0
29
Italic,
0
0
30
Code,
0
0
31
Underline,
0
0
32
Strikethrough,
0
0
33
Highlight,
34
-
Link { uri: &'a str },
35
-
DidMention { did: &'a str },
36
-
AtMention { at_uri: &'a str },
37
-
Tag { tag: &'a str },
38
-
Id { id: Option<&'a str> },
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
39
}
40
41
-
#[derive(Debug, Clone)]
42
pub struct NormalizedFacet<'a> {
43
-
pub range: ByteRange,
0
44
pub features: Vec<FacetFeature<'a>>,
45
}
46
···
48
fn from(facet: &'a weaver_api::app_bsky::richtext::facet::Facet<'a>) -> Self {
49
use weaver_api::app_bsky::richtext::facet::FacetFeaturesItem;
50
51
-
let range = ByteRange::new(facet.index.byte_start, facet.index.byte_end);
52
53
let features = facet
54
.features
···
67
})
68
.collect();
69
70
-
Self { range, features }
71
}
72
}
73
···
75
fn from(facet: &'a weaver_api::pub_leaflet::richtext::facet::Facet<'a>) -> Self {
76
use weaver_api::pub_leaflet::richtext::facet::FacetFeaturesItem;
77
78
-
let range = ByteRange::new(facet.index.byte_start, facet.index.byte_end);
79
80
let features = facet
81
.features
···
103
})
104
.collect();
105
106
-
Self { range, features }
107
}
108
}
109
···
111
fn from(facet: &'a weaver_api::blog_pckt::richtext::facet::Facet<'a>) -> Self {
112
use weaver_api::blog_pckt::richtext::facet::FacetFeaturesItem;
113
114
-
let range = ByteRange::new(facet.index.byte_start, facet.index.byte_end);
115
116
let features = facet
117
.features
···
139
})
140
.collect();
141
142
-
Self { range, features }
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
143
}
144
}
···
1
+
use serde::{Deserialize, Serialize};
2
use std::ops::Range;
3
4
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
5
+
#[serde(rename_all = "camelCase")]
6
pub struct ByteRange {
7
+
pub byte_start: usize,
8
+
pub byte_end: usize,
9
}
10
11
impl ByteRange {
12
pub fn new(start: i64, end: i64) -> Self {
13
Self {
14
+
byte_start: start.max(0) as usize,
15
+
byte_end: end.max(0) as usize,
16
}
17
}
18
19
pub fn to_range(self) -> Range<usize> {
20
+
self.byte_start..self.byte_end
21
}
22
23
pub fn is_empty(&self) -> bool {
24
+
self.byte_start >= self.byte_end
25
+
}
26
+
27
+
pub fn start(&self) -> usize {
28
+
self.byte_start
29
+
}
30
+
31
+
pub fn end(&self) -> usize {
32
+
self.byte_end
33
}
34
}
35
36
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37
+
#[serde(tag = "$type")]
38
pub enum FacetFeature<'a> {
39
+
#[serde(rename = "pub.leaflet.richtext.facet#bold")]
40
+
#[serde(alias = "blog.pckt.richtext.facet#bold")]
41
Bold,
42
+
#[serde(rename = "pub.leaflet.richtext.facet#italic")]
43
+
#[serde(alias = "blog.pckt.richtext.facet#italic")]
44
Italic,
45
+
#[serde(rename = "pub.leaflet.richtext.facet#code")]
46
+
#[serde(alias = "blog.pckt.richtext.facet#code")]
47
Code,
48
+
#[serde(rename = "pub.leaflet.richtext.facet#underline")]
49
+
#[serde(alias = "blog.pckt.richtext.facet#underline")]
50
Underline,
51
+
#[serde(rename = "pub.leaflet.richtext.facet#strikethrough")]
52
+
#[serde(alias = "blog.pckt.richtext.facet#strikethrough")]
53
Strikethrough,
54
+
#[serde(rename = "pub.leaflet.richtext.facet#highlight")]
55
+
#[serde(alias = "blog.pckt.richtext.facet#highlight")]
56
Highlight,
57
+
#[serde(rename = "pub.leaflet.richtext.facet#link")]
58
+
#[serde(alias = "blog.pckt.richtext.facet#link")]
59
+
#[serde(alias = "app.bsky.richtext.facet#link")]
60
+
Link {
61
+
#[serde(borrow)]
62
+
uri: &'a str,
63
+
},
64
+
#[serde(rename = "pub.leaflet.richtext.facet#didMention")]
65
+
#[serde(alias = "blog.pckt.richtext.facet#didMention")]
66
+
#[serde(alias = "app.bsky.richtext.facet#mention")]
67
+
DidMention {
68
+
#[serde(borrow)]
69
+
did: &'a str,
70
+
},
71
+
#[serde(rename = "pub.leaflet.richtext.facet#atMention")]
72
+
#[serde(alias = "blog.pckt.richtext.facet#atMention")]
73
+
AtMention {
74
+
#[serde(borrow, rename = "atUri")]
75
+
at_uri: &'a str,
76
+
},
77
+
#[serde(rename = "app.bsky.richtext.facet#tag")]
78
+
Tag {
79
+
#[serde(borrow)]
80
+
tag: &'a str,
81
+
},
82
+
#[serde(rename = "pub.leaflet.richtext.facet#id")]
83
+
#[serde(alias = "blog.pckt.richtext.facet#id")]
84
+
Id {
85
+
#[serde(borrow)]
86
+
id: Option<&'a str>,
87
+
},
88
}
89
90
+
#[derive(Debug, Clone, Serialize, Deserialize)]
91
pub struct NormalizedFacet<'a> {
92
+
pub index: ByteRange,
93
+
#[serde(borrow)]
94
pub features: Vec<FacetFeature<'a>>,
95
}
96
···
98
fn from(facet: &'a weaver_api::app_bsky::richtext::facet::Facet<'a>) -> Self {
99
use weaver_api::app_bsky::richtext::facet::FacetFeaturesItem;
100
101
+
let index = ByteRange::new(facet.index.byte_start, facet.index.byte_end);
102
103
let features = facet
104
.features
···
117
})
118
.collect();
119
120
+
Self { index, features }
121
}
122
}
123
···
125
fn from(facet: &'a weaver_api::pub_leaflet::richtext::facet::Facet<'a>) -> Self {
126
use weaver_api::pub_leaflet::richtext::facet::FacetFeaturesItem;
127
128
+
let index = ByteRange::new(facet.index.byte_start, facet.index.byte_end);
129
130
let features = facet
131
.features
···
153
})
154
.collect();
155
156
+
Self { index, features }
157
}
158
}
159
···
161
fn from(facet: &'a weaver_api::blog_pckt::richtext::facet::Facet<'a>) -> Self {
162
use weaver_api::blog_pckt::richtext::facet::FacetFeaturesItem;
163
164
+
let index = ByteRange::new(facet.index.byte_start, facet.index.byte_end);
165
166
let features = facet
167
.features
···
189
})
190
.collect();
191
192
+
Self { index, features }
193
+
}
194
+
}
195
+
196
+
#[cfg(test)]
197
+
mod tests {
198
+
use super::*;
199
+
200
+
#[test]
201
+
fn test_deserialize_leaflet_facet() {
202
+
let json = r#"{
203
+
"index": {"byteStart": 0, "byteEnd": 5},
204
+
"features": [
205
+
{"$type": "pub.leaflet.richtext.facet#bold"},
206
+
{"$type": "pub.leaflet.richtext.facet#italic"}
207
+
]
208
+
}"#;
209
+
210
+
let facet: NormalizedFacet = serde_json::from_str(json).unwrap();
211
+
assert_eq!(facet.index.byte_start, 0);
212
+
assert_eq!(facet.index.byte_end, 5);
213
+
assert_eq!(facet.features.len(), 2);
214
+
assert!(matches!(facet.features[0], FacetFeature::Bold));
215
+
assert!(matches!(facet.features[1], FacetFeature::Italic));
216
+
}
217
+
218
+
#[test]
219
+
fn test_deserialize_bsky_facet() {
220
+
let json = r#"{
221
+
"index": {"byteStart": 0, "byteEnd": 10},
222
+
"features": [
223
+
{"$type": "app.bsky.richtext.facet#link", "uri": "https://example.com"},
224
+
{"$type": "app.bsky.richtext.facet#mention", "did": "did:plc:abc123"}
225
+
]
226
+
}"#;
227
+
228
+
let facet: NormalizedFacet = serde_json::from_str(json).unwrap();
229
+
assert_eq!(facet.index.byte_start, 0);
230
+
assert_eq!(facet.index.byte_end, 10);
231
+
assert_eq!(facet.features.len(), 2);
232
+
assert!(matches!(facet.features[0], FacetFeature::Link { uri: "https://example.com" }));
233
+
assert!(matches!(facet.features[1], FacetFeature::DidMention { did: "did:plc:abc123" }));
234
+
}
235
+
236
+
#[test]
237
+
fn test_deserialize_pckt_facet() {
238
+
let json = r#"{
239
+
"index": {"byteStart": 5, "byteEnd": 15},
240
+
"features": [
241
+
{"$type": "blog.pckt.richtext.facet#code"},
242
+
{"$type": "blog.pckt.richtext.facet#strikethrough"}
243
+
]
244
+
}"#;
245
+
246
+
let facet: NormalizedFacet = serde_json::from_str(json).unwrap();
247
+
assert_eq!(facet.index.byte_start, 5);
248
+
assert_eq!(facet.index.byte_end, 15);
249
+
assert_eq!(facet.features.len(), 2);
250
+
assert!(matches!(facet.features[0], FacetFeature::Code));
251
+
assert!(matches!(facet.features[1], FacetFeature::Strikethrough));
252
+
}
253
+
254
+
#[test]
255
+
fn test_deserialize_tag_facet() {
256
+
let json = r#"{
257
+
"index": {"byteStart": 0, "byteEnd": 8},
258
+
"features": [
259
+
{"$type": "app.bsky.richtext.facet#tag", "tag": "rust"}
260
+
]
261
+
}"#;
262
+
263
+
let facet: NormalizedFacet = serde_json::from_str(json).unwrap();
264
+
assert!(matches!(facet.features[0], FacetFeature::Tag { tag: "rust" }));
265
}
266
}