js renderer wrapper crate

Orual 5ba1942a 1ce85230

+423 -28
+45
Cargo.lock
··· 11399 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 11400 11401 [[package]] 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", 12353 "smol_str", 12354 "syntect", 12355 "thiserror 2.0.17", ··· 12362 "weaver-api", 12363 "weaver-common", 12364 "yaml-rust2", 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
···
··· 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
···
··· 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
···
··· 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 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"] }
··· 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
··· 1 use std::ops::Range; 2 3 - #[derive(Debug, Clone, Copy, PartialEq, Eq)] 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 23 } 24 } 25 26 - #[derive(Debug, Clone, PartialEq, Eq)] 27 pub enum FacetFeature<'a> { 28 Bold, 29 Italic, 30 Code, 31 Underline, 32 Strikethrough, 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> }, 39 } 40 41 - #[derive(Debug, Clone)] 42 pub struct NormalizedFacet<'a> { 43 - pub range: ByteRange, 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 } 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 }