pretty record view

Orual ac5685d4 29e57a4e

+874 -2
+11
Cargo.lock
··· 3659 3659 checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" 3660 3660 3661 3661 [[package]] 3662 + name = "humansize" 3663 + version = "2.1.3" 3664 + source = "registry+https://github.com/rust-lang/crates.io-index" 3665 + checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" 3666 + dependencies = [ 3667 + "libm", 3668 + ] 3669 + 3670 + [[package]] 3662 3671 name = "hyper" 3663 3672 version = "1.7.0" 3664 3673 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 8634 8643 "dioxus", 8635 8644 "dioxus-free-icons", 8636 8645 "dioxus-primitives", 8646 + "hex_fmt", 8647 + "humansize", 8637 8648 "jacquard", 8638 8649 "jacquard-axum", 8639 8650 "js-sys",
+2
crates/weaver-app/Cargo.toml
··· 37 37 chrono = { version = "0.4" } 38 38 serde = { version = "1.0", features = ["derive"] } 39 39 serde_json = "1.0" 40 + hex_fmt = "0.3" 41 + humansize = "2.0.0" 40 42 reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } 41 43 dioxus-free-icons = { version = "0.10.0" } 42 44 diesel = { version = "2.3", features = ["sqlite", "returning_clauses_for_sqlite_3_35", "chrono", "serde_json"] }
+332
crates/weaver-app/assets/styling/record-view.css
··· 1 + .record-view-container { 2 + font-family: var(--font-mono); 3 + max-width: 1200px; 4 + margin: 2rem auto; 5 + padding: 0 1rem; 6 + } 7 + 8 + .record-header { 9 + margin-bottom: 2rem; 10 + padding-bottom: 0.5rem; 11 + border-bottom: 1px solid var(--color-border); 12 + } 13 + 14 + .record-header h1 { 15 + font-family: var(--font-mono); 16 + font-size: 1.2rem; 17 + font-weight: 400; 18 + margin-bottom: 1rem; 19 + color: var(--color-text); 20 + text-transform: uppercase; 21 + letter-spacing: 0.15em; 22 + } 23 + 24 + .record-metadata { 25 + display: flex-wrap; 26 + flex-direction: column; 27 + gap: 0.75rem; 28 + } 29 + 30 + .metadata-row { 31 + display: flex; 32 + gap: 0.5rem; 33 + padding: 0.35rem; 34 + align-items: baseline; 35 + } 36 + 37 + .metadata-label { 38 + color: var(--color-muted); 39 + font-size: 0.85rem; 40 + text-transform: uppercase; 41 + letter-spacing: 0.1em; 42 + white-space: nowrap; 43 + } 44 + 45 + .metadata-label::after { 46 + content: ":"; 47 + font-size: 0.8rem; 48 + margin-left: 0.25rem; 49 + } 50 + 51 + .metadata-value { 52 + font-family: var(--font-mono); 53 + color: var(--color-text); 54 + font-size: 1rem; 55 + word-break: break-all; 56 + } 57 + 58 + .uri-link { 59 + text-decoration: none; 60 + } 61 + 62 + .uri-link:hover .aturi-scheme, 63 + .uri-link:hover .aturi-authority, 64 + .uri-link:hover .aturi-collection, 65 + .uri-link:hover .aturi-rkey, 66 + .uri-link:hover .uri-scheme, 67 + .uri-link:hover .uri-authority, 68 + .uri-link:hover .uri-path { 69 + text-decoration: underline; 70 + } 71 + 72 + .tab-bar { 73 + display: flex; 74 + gap: 0; 75 + border-bottom: 1px solid var(--color-border); 76 + margin-bottom: 1.5rem; 77 + margin-top: 1.5rem; 78 + } 79 + 80 + .tab-button { 81 + font-family: var(--font-mono); 82 + background: transparent; 83 + border: none; 84 + padding: 0.5rem 1rem; 85 + cursor: pointer; 86 + color: var(--color-muted); 87 + text-transform: uppercase; 88 + font-size: 0.9rem; 89 + letter-spacing: 0.1em; 90 + border-bottom: 2px solid transparent; 91 + margin-bottom: -1px; 92 + transition: all 0.2s; 93 + } 94 + 95 + .tab-button:hover { 96 + color: var(--color-text); 97 + } 98 + 99 + .tab-button.active { 100 + color: var(--color-text); 101 + border-bottom-color: var(--color-primary); 102 + } 103 + 104 + .tab-content { 105 + min-height: 300px; 106 + } 107 + 108 + .pretty-record { 109 + display: flex; 110 + flex-direction: column; 111 + align-items: flex-start; 112 + } 113 + 114 + .record-field { 115 + display: flex; 116 + flex-direction: column; 117 + padding: 0rem 0 0rem 1rem; 118 + 119 + padding-right: 1rem; 120 + border-left: 2px solid var(--color-secondary); 121 + border-bottom: 1px dashed var(--color-muted); 122 + } 123 + 124 + .field-label { 125 + font-family: var(--font-mono); 126 + color: var(--color-subtle); 127 + font-size: 0.7rem; 128 + padding-top: 0.5rem; 129 + } 130 + 131 + .field-value { 132 + font-family: var(--font-mono); 133 + color: var(--color-text); 134 + font-size: 1rem; 135 + padding-top: 0.2rem; 136 + padding-bottom: 0.1rem; 137 + word-break: break-word; 138 + } 139 + 140 + .field-value.muted { 141 + color: var(--color-subtle); 142 + font-style: italic; 143 + } 144 + 145 + .field-value.mime { 146 + color: var(--color-subtle); 147 + font-style: italic; 148 + font-size: 0.85rem; 149 + } 150 + 151 + .field-value.bytes { 152 + color: var(--color-subtle); 153 + font-size: 0.75rem; 154 + } 155 + 156 + .record-section { 157 + padding-left: 1.5rem; 158 + position: relative; 159 + border-left: 1px dashed var(--color-border); 160 + } 161 + 162 + .section-label { 163 + font-family: var(--font-mono); 164 + color: var(--color-primary); 165 + font-size: 1rem; 166 + font-weight: 600; 167 + padding-left: 1rem; 168 + padding-top: 0.5rem; 169 + padding-bottom: 0.25rem; 170 + border-left: 2px solid var(--color-primary); 171 + border-bottom: 1px dashed var(--color-muted); 172 + margin-left: -1.51rem; 173 + } 174 + 175 + .section-content .section-label { 176 + font-family: var(--font-mono); 177 + color: var(--color-tertiary); 178 + font-size: 0.9rem; 179 + font-weight: 600; 180 + padding-left: 1rem; 181 + padding-top: 0.5rem; 182 + border-left: 2px solid var(--color-secondary); 183 + } 184 + 185 + .section-content { 186 + display: flex; 187 + flex-direction: column; 188 + align-items: flex-start; 189 + padding-right: 1rem; 190 + } 191 + 192 + .section-content .record-field { 193 + border-left-color: var(--color-secondary); 194 + opacity: 0.95; 195 + } 196 + 197 + .blob-image { 198 + max-width: 600px; 199 + max-height: 400px; 200 + margin-top: 0.5rem; 201 + border: 1px solid var(--color-border); 202 + margin-bottom: 0.5rem; 203 + } 204 + 205 + .string-type-tag { 206 + font-size: 0.7rem; 207 + color: var(--color-muted); 208 + text-transform: uppercase; 209 + letter-spacing: 0.05em; 210 + } 211 + 212 + .string-did, 213 + .string-handle, 214 + .string-at-identifier { 215 + color: var(--color-primary); 216 + } 217 + 218 + .string-at-uri, 219 + .string-uri { 220 + color: var(--color-secondary); 221 + } 222 + 223 + .string-cid, 224 + .string-tid { 225 + color: var(--color-tertiary); 226 + font-family: var(--font-mono); 227 + } 228 + 229 + .string-nsid { 230 + color: var(--color-emphasis); 231 + } 232 + 233 + .string-datetime { 234 + color: var(--color-text); 235 + font-style: italic; 236 + } 237 + 238 + /* NSID highlighting */ 239 + .nsid-dot { 240 + color: var(--color-muted); 241 + opacity: 0.6; 242 + } 243 + 244 + .nsid-segment-0 { 245 + color: var(--color-primary); 246 + } 247 + 248 + .nsid-segment-1 { 249 + color: var(--color-secondary); 250 + } 251 + 252 + .nsid-segment-2 { 253 + color: var(--color-tertiary); 254 + } 255 + 256 + /* DID highlighting */ 257 + .did-scheme { 258 + color: var(--color-muted); 259 + opacity: 0.7; 260 + } 261 + 262 + .did-method { 263 + color: var(--color-secondary); 264 + font-weight: 500; 265 + } 266 + 267 + .did-separator { 268 + color: var(--color-muted); 269 + opacity: 0.6; 270 + } 271 + 272 + .did-identifier { 273 + color: var(--color-primary); 274 + } 275 + 276 + /* Handle highlighting */ 277 + .handle-dot { 278 + color: var(--color-muted); 279 + opacity: 0.6; 280 + } 281 + 282 + .handle-segment-0 { 283 + color: var(--color-primary); 284 + font-weight: 500; 285 + } 286 + 287 + .handle-segment-1 { 288 + color: var(--color-secondary); 289 + } 290 + 291 + /* AT URI highlighting */ 292 + .aturi-scheme { 293 + color: var(--color-muted); 294 + opacity: 0.7; 295 + } 296 + 297 + .aturi-authority { 298 + color: var(--color-primary); 299 + } 300 + 301 + .aturi-slash { 302 + color: var(--color-muted); 303 + opacity: 0.6; 304 + } 305 + 306 + .aturi-collection { 307 + color: var(--color-secondary); 308 + } 309 + 310 + .aturi-rkey { 311 + color: var(--color-tertiary); 312 + } 313 + 314 + /* URI highlighting */ 315 + .uri-scheme { 316 + color: var(--color-muted); 317 + opacity: 0.7; 318 + font-weight: 500; 319 + } 320 + 321 + .uri-separator { 322 + color: var(--color-muted); 323 + opacity: 0.6; 324 + } 325 + 326 + .uri-authority { 327 + color: var(--color-primary); 328 + } 329 + 330 + .uri-path { 331 + color: var(--color-secondary); 332 + }
+1 -1
crates/weaver-app/src/components/mod.rs
··· 6 6 pub use css::NotebookCss; 7 7 8 8 mod entry; 9 - pub use entry::{Entry, EntryCard}; 9 + pub use entry::{Entry, EntryCard, EntryMarkdown}; 10 10 11 11 pub mod identity; 12 12 pub use identity::{NotebookCard, Repository, RepositoryIndex};
+3 -1
crates/weaver-app/src/main.rs
··· 17 17 18 18 use std::sync::Arc; 19 19 #[allow(unused)] 20 - use views::{Home, Navbar, Notebook, NotebookIndex, NotebookPage}; 20 + use views::{Home, Navbar, Notebook, NotebookIndex, NotebookPage, RecordView}; 21 21 22 22 #[cfg(feature = "server")] 23 23 mod blobcache; ··· 46 46 #[route("/")] 47 47 Home {}, 48 48 #[layout(ErrorLayout)] 49 + #[route("/record#:uri")] 50 + RecordView { uri: SmolStr }, 49 51 #[nest("/:ident")] 50 52 #[layout(Repository)] 51 53 #[route("/")]
+3
crates/weaver-app/src/views/mod.rs
··· 19 19 20 20 mod notebook; 21 21 pub use notebook::{Notebook, NotebookIndex}; 22 + 23 + mod record; 24 + pub use record::RecordView;
+436
crates/weaver-app/src/views/record.rs
··· 1 + use crate::fetch::CachedFetcher; 2 + use dioxus::prelude::*; 3 + use hex_fmt::HexFmt; 4 + use humansize::format_size; 5 + use jacquard::{ 6 + client::AgentSessionExt, 7 + common::{Data, IntoStatic}, 8 + smol_str::SmolStr, 9 + types::aturi::AtUri, 10 + }; 11 + use weaver_renderer::{code_pretty::highlight_code, css::generate_default_css}; 12 + 13 + #[derive(Clone, Copy, PartialEq)] 14 + enum ViewMode { 15 + Pretty, 16 + Json, 17 + } 18 + 19 + #[component] 20 + pub fn RecordView(uri: SmolStr) -> Element { 21 + let fetcher = use_context::<CachedFetcher>(); 22 + let at_uri = AtUri::new_owned(uri.clone()); 23 + if let Err(err) = &at_uri { 24 + let error = format!("{:?}", err); 25 + return rsx! { 26 + div { 27 + h1 { "Record View" } 28 + p { "URI: {uri}" } 29 + p { "Error: {error}" } 30 + } 31 + }; 32 + } 33 + let uri = use_signal(|| at_uri.unwrap()); 34 + let mut view_mode = use_signal(|| ViewMode::Pretty); 35 + let record = use_resource(move || { 36 + let fetcher = fetcher.clone(); 37 + async move { fetcher.client.fetch_record_slingshot(&uri()).await } 38 + }); 39 + if let Some(Ok(record)) = &*record.read_unchecked() { 40 + let record_value = record.value.clone().into_static(); 41 + let json = serde_json::to_string_pretty(&record_value).unwrap(); 42 + rsx! { 43 + document::Stylesheet { href: asset!("/assets/styling/record-view.css") } 44 + div { 45 + class: "record-view-container", 46 + div { 47 + class: "record-header", 48 + h1 { "Record Inspector" } 49 + div { 50 + class: "record-metadata", 51 + div { class: "metadata-row", 52 + span { class: "metadata-label", "URI" } 53 + span { class: "metadata-value", 54 + HighlightedUri { uri: uri().clone() } 55 + } 56 + } 57 + if let Some(cid) = &record.cid { 58 + div { class: "metadata-row", 59 + span { class: "metadata-label", "CID" } 60 + code { class: "metadata-value", "{cid}" } 61 + } 62 + } 63 + } 64 + } 65 + div { 66 + class: "tab-bar", 67 + button { 68 + class: if view_mode() == ViewMode::Pretty { "tab-button active" } else { "tab-button" }, 69 + onclick: move |_| view_mode.set(ViewMode::Pretty), 70 + "View" 71 + } 72 + button { 73 + class: if view_mode() == ViewMode::Json { "tab-button active" } else { "tab-button" }, 74 + onclick: move |_| view_mode.set(ViewMode::Json), 75 + "JSON" 76 + } 77 + } 78 + div { 79 + class: "tab-content", 80 + match view_mode() { 81 + ViewMode::Pretty => rsx! { 82 + PrettyRecordView { record: record_value.clone(), uri: uri().clone() } 83 + }, 84 + ViewMode::Json => rsx! { 85 + CodeView { 86 + code: use_signal(|| json.clone()), 87 + lang: Some("json".to_string()), 88 + } 89 + }, 90 + } 91 + } 92 + } 93 + } 94 + } else { 95 + rsx! { 96 + div { 97 + class: "record-view-container", 98 + h1 { "Record Inspector" } 99 + p { "URI: {uri}" } 100 + p { "Loading..." } 101 + } 102 + } 103 + } 104 + } 105 + 106 + #[component] 107 + fn PrettyRecordView(record: Data<'static>, uri: AtUri<'static>) -> Element { 108 + let did = uri.authority().to_string(); 109 + rsx! { 110 + div { 111 + class: "pretty-record", 112 + DataView { data: record, path: String::new(), did } 113 + } 114 + } 115 + } 116 + fn get_hex_rep(byte_array: &mut [u8]) -> String { 117 + let build_string_vec: Vec<String> = byte_array 118 + .chunks(2) 119 + .enumerate() 120 + .map(|(i, c)| { 121 + let sep = if i % 16 == 0 && i > 0 { 122 + "\n" 123 + } else if i == 0 { 124 + "" 125 + } else { 126 + " " 127 + }; 128 + if c.len() == 2 { 129 + format!("{}{:02x}{:02x}", sep, c[0], c[1]) 130 + } else { 131 + format!("{}{:02x}", sep, c[0]) 132 + } 133 + }) 134 + .collect(); 135 + build_string_vec.join("") 136 + } 137 + 138 + #[component] 139 + fn DataView(data: Data<'static>, path: String, did: String) -> Element { 140 + match &data { 141 + Data::Null => rsx! { 142 + div { class: "record-field", 143 + span { class: "field-label", "{path}" } 144 + span { class: "field-value muted", "null" } 145 + } 146 + }, 147 + Data::Boolean(b) => rsx! { 148 + div { class: "record-field", 149 + span { class: "field-label", "{path}" } 150 + span { class: "field-value", "{b}" } 151 + } 152 + }, 153 + Data::Integer(i) => rsx! { 154 + div { class: "record-field", 155 + span { class: "field-label", "{path}" } 156 + span { class: "field-value", "{i}" } 157 + } 158 + }, 159 + Data::String(s) => { 160 + use jacquard::types::string::AtprotoStr; 161 + 162 + let type_label = match s { 163 + AtprotoStr::Datetime(_) => "datetime", 164 + AtprotoStr::Language(_) => "language", 165 + AtprotoStr::Tid(_) => "tid", 166 + AtprotoStr::Nsid(_) => "nsid", 167 + AtprotoStr::Did(_) => "did", 168 + AtprotoStr::Handle(_) => "handle", 169 + AtprotoStr::AtIdentifier(_) => "at-identifier", 170 + AtprotoStr::AtUri(_) => "at-uri", 171 + AtprotoStr::Uri(_) => "uri", 172 + AtprotoStr::Cid(_) => "cid", 173 + AtprotoStr::RecordKey(_) => "record-key", 174 + AtprotoStr::String(_) => "string", 175 + }; 176 + 177 + rsx! { 178 + div { class: "record-field", 179 + span { class: "field-label", "{path}" } 180 + span { class: "field-value", 181 + 182 + HighlightedString { string_type: s.clone() } 183 + if type_label != "string" { 184 + span { class: "string-type-tag", " [{type_label}]" } 185 + } 186 + } 187 + } 188 + } 189 + } 190 + Data::Bytes(b) => { 191 + let hex_string = get_hex_rep(&mut b.to_vec()); 192 + let byte_size = if b.len() > 128 { 193 + format_size(b.len(), humansize::BINARY) 194 + } else { 195 + format!("{} bytes", b.len()) 196 + }; 197 + rsx! { 198 + div { class: "record-field", 199 + span { class: "field-label", "{path}" } 200 + pre { class: "field-value bytes", "{hex_string} [{byte_size}]" } 201 + } 202 + } 203 + } 204 + Data::CidLink(cid) => rsx! { 205 + div { class: "record-field", 206 + span { class: "field-label", "{path}" } 207 + span { class: "field-value", "{cid}" } 208 + } 209 + }, 210 + Data::Array(arr) => rsx! { 211 + div { class: "record-section", 212 + div { class: "section-label", "{path}" span { class: "field-label", "[{arr.len()}] " } } 213 + 214 + div { class: "section-content", 215 + for (idx, item) in arr.iter().enumerate() { 216 + DataView { 217 + data: item.clone(), 218 + path: format!("{}[{}]", path, idx), 219 + did: did.clone() 220 + } 221 + } 222 + } 223 + } 224 + }, 225 + Data::Object(obj) => rsx! { 226 + for (key, value) in obj.iter() { 227 + { 228 + let new_path = if path.is_empty() { 229 + key.to_string() 230 + } else { 231 + format!("{}.{}", path, key) 232 + }; 233 + let did_clone = did.clone(); 234 + 235 + match value { 236 + Data::Object(_) | Data::Array(_) => rsx! { 237 + div { class: "record-section", 238 + div { class: "section-label", "{key}" } 239 + div { class: "section-content", 240 + DataView { data: value.clone(), path: new_path, did: did_clone } 241 + } 242 + } 243 + }, 244 + _ => rsx! { 245 + DataView { data: value.clone(), path: new_path, did: did_clone } 246 + } 247 + } 248 + } 249 + } 250 + }, 251 + Data::Blob(blob) => { 252 + let is_image = blob.mime_type.starts_with("image/"); 253 + let format = blob.mime_type.strip_prefix("image/").unwrap_or("jpeg"); 254 + let image_url = format!( 255 + "https://cdn.bsky.app/img/feed_fullsize/plain/{}/{}@{}", 256 + did, 257 + blob.cid(), 258 + format 259 + ); 260 + 261 + let blob_size = format_size(blob.size, humansize::BINARY); 262 + rsx! { 263 + div { class: "record-field", 264 + span { class: "field-label", "{path}" } 265 + span { class: "field-value mime", "[mimeType: {blob.mime_type}, size: {blob_size}]" } 266 + if is_image { 267 + img { 268 + src: "{image_url}", 269 + alt: "Blob image", 270 + class: "blob-image", 271 + } 272 + } 273 + } 274 + } 275 + } 276 + } 277 + } 278 + 279 + #[component] 280 + fn HighlightedUri(uri: AtUri<'static>) -> Element { 281 + let s = uri.as_str(); 282 + let link = format!("/record#{}", s); 283 + 284 + if let Some(rest) = s.strip_prefix("at://") { 285 + let parts: Vec<&str> = rest.splitn(3, '/').collect(); 286 + return rsx! { 287 + a { 288 + href: "{link}", 289 + class: "uri-link", 290 + span { class: "string-at-uri", 291 + span { class: "aturi-scheme", "at://" } 292 + span { class: "aturi-authority", "{uri.authority()}" } 293 + 294 + if parts.len() > 1 { 295 + span { class: "aturi-slash", "/" } 296 + if let Some(collection) = uri.collection() { 297 + span { class: "aturi-collection", "{collection.as_ref()}" } 298 + } 299 + } 300 + if parts.len() > 2 { 301 + span { class: "aturi-slash", "/" } 302 + if let Some(rkey) = uri.rkey() { 303 + span { class: "aturi-rkey", "{rkey.as_ref()}" } 304 + } 305 + } 306 + } 307 + } 308 + }; 309 + } 310 + 311 + rsx! { span { class: "string-at-uri", "{s}" } } 312 + } 313 + 314 + #[component] 315 + fn HighlightedString(string_type: jacquard::types::string::AtprotoStr<'static>) -> Element { 316 + use jacquard::types::string::AtprotoStr; 317 + 318 + match &string_type { 319 + AtprotoStr::Nsid(nsid) => { 320 + let parts: Vec<&str> = nsid.as_str().split('.').collect(); 321 + rsx! { 322 + span { class: "string-nsid", 323 + for (i, part) in parts.iter().enumerate() { 324 + span { class: "nsid-segment nsid-segment-{i % 3}", "{part}" } 325 + if i < parts.len() - 1 { 326 + span { class: "nsid-dot", "." } 327 + } 328 + } 329 + } 330 + } 331 + } 332 + AtprotoStr::Did(did) => { 333 + let s = did.as_str(); 334 + if let Some(rest) = s.strip_prefix("did:") { 335 + if let Some((method, identifier)) = rest.split_once(':') { 336 + return rsx! { 337 + span { class: "string-did", 338 + span { class: "did-scheme", "did:" } 339 + span { class: "did-method", "{method}" } 340 + span { class: "did-separator", ":" } 341 + span { class: "did-identifier", "{identifier}" } 342 + } 343 + }; 344 + } 345 + } 346 + rsx! { span { class: "string-did", "{s}" } } 347 + } 348 + AtprotoStr::Handle(handle) => { 349 + let parts: Vec<&str> = handle.as_str().split('.').collect(); 350 + rsx! { 351 + span { class: "string-handle", 352 + for (i, part) in parts.iter().enumerate() { 353 + span { class: "handle-segment handle-segment-{i % 2}", "{part}" } 354 + if i < parts.len() - 1 { 355 + span { class: "handle-dot", "." } 356 + } 357 + } 358 + } 359 + } 360 + } 361 + AtprotoStr::AtUri(uri) => { 362 + rsx! { 363 + HighlightedUri { uri: uri.clone().into_static() } 364 + } 365 + } 366 + AtprotoStr::Uri(uri) => { 367 + let s = uri.as_str(); 368 + if let Ok(at_uri) = AtUri::new(s) { 369 + return rsx! { 370 + HighlightedUri { uri: at_uri.into_static() } 371 + }; 372 + } 373 + 374 + // Try to parse scheme 375 + if let Some((scheme, rest)) = s.split_once("://") { 376 + // Split authority and path 377 + let (authority, path) = if let Some(idx) = rest.find('/') { 378 + (&rest[..idx], &rest[idx..]) 379 + } else { 380 + (rest, "") 381 + }; 382 + 383 + return rsx! { 384 + a { 385 + href: "{s}", 386 + target: "_blank", 387 + rel: "noopener noreferrer", 388 + class: "uri-link", 389 + span { class: "string-uri", 390 + span { class: "uri-scheme", "{scheme}" } 391 + span { class: "uri-separator", "://" } 392 + span { class: "uri-authority", "{authority}" } 393 + if !path.is_empty() { 394 + span { class: "uri-path", "{path}" } 395 + } 396 + } 397 + } 398 + }; 399 + } 400 + 401 + rsx! { span { class: "string-uri", "{s}" } } 402 + } 403 + _ => { 404 + let value = string_type.as_str(); 405 + rsx! { "{value}" } 406 + } 407 + } 408 + } 409 + 410 + #[derive(Props, Clone, PartialEq)] 411 + pub struct CodeViewProps { 412 + #[props(default)] 413 + id: Signal<String>, 414 + #[props(default)] 415 + class: Signal<String>, 416 + code: ReadSignal<String>, 417 + lang: Option<String>, 418 + } 419 + 420 + /// Render some text as markdown. 421 + #[component] 422 + pub fn CodeView(props: CodeViewProps) -> Element { 423 + let code = &*props.code.read(); 424 + 425 + let mut html_buf = String::new(); 426 + highlight_code(props.lang.as_deref(), code, &mut html_buf).unwrap(); 427 + 428 + rsx! { 429 + document::Style { {generate_default_css().unwrap()}} 430 + div { 431 + id: "{&*props.id.read()}", 432 + class: "{&*props.class.read()}", 433 + dangerous_inner_html: "{html_buf}" 434 + } 435 + } 436 + }
+38
crates/weaver-renderer/src/code_pretty.rs
··· 47 47 } 48 48 49 49 pub const CSS_PREFIX: &str = "wvc-"; 50 + 51 + pub fn highlight_code<M>( 52 + lang: Option<&str>, 53 + code: impl AsRef<str>, 54 + writer: &mut M, 55 + ) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> 56 + where 57 + M: StrWrite, 58 + <M as StrWrite>::Error: std::error::Error + Send + Sync + 'static, 59 + { 60 + let syn_set = SyntaxSet::load_defaults_newlines(); 61 + let lang_syn = if let Some(lang) = lang { 62 + syn_set 63 + .find_syntax_by_token(lang) 64 + .unwrap_or_else(|| syn_set.find_syntax_plain_text()) 65 + } else { 66 + syn_set 67 + .find_syntax_by_first_line(code.as_ref()) 68 + .unwrap_or_else(|| syn_set.find_syntax_plain_text()) 69 + }; 70 + writer.write_str("<pre><code class=\"wvc-code language-")?; 71 + writer.write_str(&lang_syn.name)?; 72 + writer.write_str("\">")?; 73 + 74 + let mut html_gen = ClassedHTMLGenerator::new_with_class_style( 75 + lang_syn, 76 + &syn_set, 77 + ClassStyle::SpacedPrefixed { prefix: CSS_PREFIX }, 78 + ); 79 + for line in LinesWithEndings::from(code.as_ref()) { 80 + html_gen 81 + .parse_html_for_line_which_includes_newline(line) 82 + .unwrap(); 83 + } 84 + writer.write_str(&html_gen.finalize())?; 85 + writer.write_str("</code></pre>")?; 86 + Ok(()) 87 + }
+48
crates/weaver-renderer/src/css.rs
··· 461 461 462 462 Ok(result) 463 463 } 464 + 465 + pub fn generate_default_css() -> miette::Result<String> { 466 + let mut theme_set = ThemeSet::load_defaults(); 467 + let rose_pine = { 468 + let mut cursor = Cursor::new(ROSE_PINE_THEME.as_bytes()); 469 + ThemeSet::load_from_reader(&mut cursor) 470 + .into_diagnostic() 471 + .map_err(|e| miette::miette!("Failed to load embedded rose-pine theme: {}", e))? 472 + }; 473 + let rose_pine_dawn = { 474 + let mut cursor = Cursor::new(ROSE_PINE_DAWN_THEME.as_bytes()); 475 + ThemeSet::load_from_reader(&mut cursor) 476 + .into_diagnostic() 477 + .map_err(|e| miette::miette!("Failed to load embedded rose-pine-dawn theme: {}", e))? 478 + }; 479 + theme_set.themes.insert("rose-pine".to_string(), rose_pine); 480 + theme_set 481 + .themes 482 + .insert("rose-pine-dawn".to_string(), rose_pine_dawn); 483 + // Generate dark mode CSS (default) 484 + let dark_css = css_for_theme_with_class_style( 485 + theme_set.themes.get("rose-pine").unwrap(), 486 + ClassStyle::SpacedPrefixed { 487 + prefix: crate::code_pretty::CSS_PREFIX, 488 + }, 489 + ) 490 + .into_diagnostic()?; 491 + 492 + // Generate light mode CSS 493 + let light_css = css_for_theme_with_class_style( 494 + theme_set.themes.get("rose-pine-dawn").unwrap(), 495 + ClassStyle::SpacedPrefixed { 496 + prefix: crate::code_pretty::CSS_PREFIX, 497 + }, 498 + ) 499 + .into_diagnostic()?; 500 + 501 + // Combine with media queries 502 + let mut result = String::new(); 503 + result.push_str("/* Syntax highlighting - Light Mode (default) */\n"); 504 + result.push_str(&light_css); 505 + result.push_str("\n\n/* Syntax highlighting - Dark Mode */\n"); 506 + result.push_str("@media (prefers-color-scheme: dark) {\n"); 507 + result.push_str(&dark_css); 508 + result.push_str("}\n"); 509 + 510 + Ok(result) 511 + }