at main 347 lines 13 kB view raw
1#[allow(unused_imports)] 2use crate::fetch; 3#[allow(unused_imports)] 4use dioxus::{CapturedError, prelude::*}; 5 6#[cfg(feature = "fullstack-server")] 7use dioxus::fullstack::response::Response; 8use jacquard::smol_str::SmolStr; 9#[allow(unused_imports)] 10use std::sync::Arc; 11#[allow(unused_imports)] 12use weaver_renderer::theme::{ResolvedTheme, Theme}; 13 14#[cfg(feature = "server")] 15use axum::{extract::Extension, response::IntoResponse}; 16 17#[cfg(feature = "fullstack-server")] 18#[component] 19pub fn NotebookCss(ident: SmolStr, notebook: SmolStr) -> Element { 20 use dioxus::fullstack::get_server_url; 21 rsx! { 22 document::Stylesheet { 23 href: "{get_server_url()}/css/{ident}/{notebook}" 24 } 25 } 26} 27 28#[cfg(not(feature = "fullstack-server"))] 29#[component] 30pub fn NotebookCss(ident: SmolStr, notebook: SmolStr) -> Element { 31 use jacquard::client::AgentSessionExt; 32 use jacquard::types::ident::AtIdentifier; 33 use jacquard::{CowStr, from_data}; 34 use weaver_api::sh_weaver::notebook::book::Book; 35 use weaver_renderer::css::{generate_base_css, generate_syntax_css}; 36 use weaver_renderer::theme::{default_resolved_theme, resolve_theme}; 37 38 let fetcher = use_context::<fetch::Fetcher>(); 39 40 let css_content = use_resource(move || { 41 let ident = ident.clone(); 42 let notebook = notebook.clone(); 43 let fetcher = fetcher.clone(); 44 45 async move { 46 let ident = AtIdentifier::new_owned(ident).ok()?; 47 let resolved_theme = 48 if let Some(notebook) = fetcher.get_notebook(ident, notebook).await.ok()? { 49 let book: Book = from_data(&notebook.0.record).ok()?; 50 if let Some(theme_ref) = book.theme { 51 if let Ok(theme_response) = 52 fetcher.client.get_record::<Theme>(&theme_ref.uri).await 53 { 54 if let Ok(theme_output) = theme_response.into_output() { 55 let theme: Theme = theme_output.into(); 56 resolve_theme(fetcher.client.as_ref(), &theme) 57 .await 58 .unwrap_or_else(|_| default_resolved_theme()) 59 } else { 60 default_resolved_theme() 61 } 62 } else { 63 default_resolved_theme() 64 } 65 } else { 66 default_resolved_theme() 67 } 68 } else { 69 default_resolved_theme() 70 }; 71 72 let mut css = generate_base_css(&resolved_theme); 73 css.push_str( 74 &generate_syntax_css(&resolved_theme) 75 .await 76 .unwrap_or_default(), 77 ); 78 79 Some(css) 80 } 81 }); 82 83 match css_content() { 84 Some(Some(css)) => rsx! { document::Style { {css} } }, 85 _ => rsx! {}, 86 } 87} 88 89#[component] 90pub fn DefaultNotebookCss() -> Element { 91 rsx! { 92 document::Stylesheet { href: asset!("/assets/styling/theme-defaults.css") } 93 document::Stylesheet { href: asset!("/assets/styling/notebook-defaults.css") } 94 } 95} 96 97#[cfg(feature = "fullstack-server")] 98#[get("/css/{ident}/{notebook}", fetcher: Extension<Arc<fetch::Fetcher>>)] 99pub async fn css(ident: SmolStr, notebook: SmolStr) -> Result<Response> { 100 use dioxus::fullstack::http::header::CONTENT_TYPE; 101 use jacquard::client::AgentSessionExt; 102 use jacquard::types::ident::AtIdentifier; 103 use jacquard::{CowStr, from_data}; 104 105 use weaver_api::sh_weaver::notebook::book::Book; 106 use weaver_renderer::css::{generate_base_css, generate_syntax_css}; 107 use weaver_renderer::theme::{default_resolved_theme, resolve_theme}; 108 109 let ident = AtIdentifier::new_owned(ident)?; 110 let resolved_theme = if let Some(notebook) = fetcher.get_notebook(ident, notebook).await? { 111 let book: Book = from_data(&notebook.0.record).unwrap(); 112 if let Some(theme_ref) = book.theme { 113 if let Ok(theme_response) = fetcher.client.get_record::<Theme>(&theme_ref.uri).await { 114 if let Ok(theme_output) = theme_response.into_output() { 115 let theme: Theme = theme_output.into(); 116 let client = fetcher.get_client(); 117 // Try to resolve the theme (fetch colour schemes from PDS) 118 resolve_theme(client.as_ref(), &theme) 119 .await 120 .unwrap_or_else(|_| default_resolved_theme()) 121 } else { 122 default_resolved_theme() 123 } 124 } else { 125 default_resolved_theme() 126 } 127 } else { 128 default_resolved_theme() 129 } 130 } else { 131 default_resolved_theme() 132 }; 133 134 let mut css = generate_base_css(&resolved_theme); 135 css.push_str( 136 &generate_syntax_css(&resolved_theme) 137 .await 138 .map_err(|e| CapturedError::from_display(e)) 139 .unwrap_or_default(), 140 ); 141 142 let css = minify_css(&css).unwrap_or(css); 143 144 Ok(([(CONTENT_TYPE, "text/css")], css).into_response()) 145} 146 147/// Input for generating theme preview data from 4 base colours. 148#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] 149pub struct ThemePreviewInput { 150 pub background: String, 151 pub text: String, 152 pub primary: String, 153 pub link: String, 154 pub light_background: Option<String>, 155 pub light_text: Option<String>, 156 pub light_primary: Option<String>, 157 pub light_link: Option<String>, 158 pub dark_background: Option<String>, 159 pub dark_text: Option<String>, 160 pub dark_primary: Option<String>, 161 pub dark_link: Option<String>, 162 pub light_code_theme: String, 163 pub dark_code_theme: String, 164} 165 166/// Generated 16-colour palette for a single variant. 167#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] 168pub struct ColourPalette { 169 pub base: String, 170 pub surface: String, 171 pub overlay: String, 172 pub text: String, 173 pub muted: String, 174 pub subtle: String, 175 pub emphasis: String, 176 pub primary: String, 177 pub secondary: String, 178 pub tertiary: String, 179 pub error: String, 180 pub warning: String, 181 pub success: String, 182 pub border: String, 183 pub link: String, 184 pub highlight: String, 185} 186 187impl ColourPalette { 188 pub fn to_css_vars(&self) -> String { 189 // Ensure all colours have # prefix. 190 let fmt = |c: &str| if c.starts_with('#') { c.to_string() } else { format!("#{}", c) }; 191 format!( 192 "--color-base: {}; --color-surface: {}; --color-overlay: {}; \ 193 --color-text: {}; --color-muted: {}; --color-subtle: {}; \ 194 --color-emphasis: {}; --color-primary: {}; --color-secondary: {}; \ 195 --color-tertiary: {}; --color-error: {}; --color-warning: {}; \ 196 --color-success: {}; --color-border: {}; --color-link: {}; \ 197 --color-highlight: {};", 198 fmt(&self.base), fmt(&self.surface), fmt(&self.overlay), 199 fmt(&self.text), fmt(&self.muted), fmt(&self.subtle), 200 fmt(&self.emphasis), fmt(&self.primary), fmt(&self.secondary), 201 fmt(&self.tertiary), fmt(&self.error), fmt(&self.warning), 202 fmt(&self.success), fmt(&self.border), fmt(&self.link), 203 fmt(&self.highlight) 204 ) 205 } 206} 207 208/// Generated theme preview data: palettes + syntax CSS. 209#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] 210pub struct ThemePreviewOutput { 211 pub light_palette: ColourPalette, 212 pub dark_palette: ColourPalette, 213 pub syntax_css: String, 214} 215 216/// Generate theme preview data from 4 base colours. 217#[post("/api/theme-preview")] 218pub async fn generate_theme_preview( 219 input: ThemePreviewInput, 220) -> Result<ThemePreviewOutput, ServerFnError> { 221 use weaver_renderer::colour_gen::{ThemeInputs, ThemeVariant, detect_variant, generate_palette, generate_counterpart_palette}; 222 use weaver_renderer::css::generate_syntax_css; 223 use weaver_renderer::theme::{ResolvedTheme, ThemeDefault, default_fonts, default_spacing, ColourSchemeColours}; 224 225 let light_inputs = ThemeInputs { 226 background: input.light_background.unwrap_or_else(|| input.background.clone()), 227 text: input.light_text.unwrap_or_else(|| input.text.clone()), 228 primary: input.light_primary.unwrap_or_else(|| input.primary.clone()), 229 link: input.light_link.unwrap_or_else(|| input.link.clone()), 230 }; 231 232 let dark_inputs = ThemeInputs { 233 background: input.dark_background.unwrap_or_else(|| input.background.clone()), 234 text: input.dark_text.unwrap_or_else(|| input.text.clone()), 235 primary: input.dark_primary.unwrap_or_else(|| input.primary.clone()), 236 link: input.dark_link.unwrap_or_else(|| input.link.clone()), 237 }; 238 239 let primary_variant = detect_variant(&input.background) 240 .map_err(|e| ServerFnError::new(format!("Invalid background colour: {}", e)))?; 241 242 let (light_scheme, dark_scheme): (ColourSchemeColours, ColourSchemeColours) = match primary_variant { 243 ThemeVariant::Light => { 244 let light = generate_palette(&light_inputs, ThemeVariant::Light) 245 .map_err(|e| ServerFnError::new(format!("Failed to generate light palette: {}", e)))?; 246 let dark = generate_counterpart_palette(&dark_inputs, ThemeVariant::Light) 247 .map_err(|e| ServerFnError::new(format!("Failed to generate dark palette: {}", e)))?; 248 (light, dark) 249 } 250 ThemeVariant::Dark => { 251 let dark = generate_palette(&dark_inputs, ThemeVariant::Dark) 252 .map_err(|e| ServerFnError::new(format!("Failed to generate dark palette: {}", e)))?; 253 let light = generate_counterpart_palette(&light_inputs, ThemeVariant::Dark) 254 .map_err(|e| ServerFnError::new(format!("Failed to generate light palette: {}", e)))?; 255 (light, dark) 256 } 257 }; 258 259 let light_code_theme = weaver_renderer::theme::ThemeLightCodeTheme::CodeThemeName(Box::new(input.light_code_theme.into())); 260 let dark_code_theme = weaver_renderer::theme::ThemeDarkCodeTheme::CodeThemeName(Box::new(input.dark_code_theme.into())); 261 262 let resolved = ResolvedTheme { 263 default: ThemeDefault::Auto, 264 dark_scheme: dark_scheme.clone(), 265 light_scheme: light_scheme.clone(), 266 fonts: default_fonts(), 267 spacing: default_spacing(), 268 dark_code_theme, 269 light_code_theme, 270 }; 271 272 let syntax_css = generate_syntax_css(&resolved).await.unwrap_or_default(); 273 274 let light_palette = ColourPalette { 275 base: light_scheme.base.to_string(), 276 surface: light_scheme.surface.to_string(), 277 overlay: light_scheme.overlay.to_string(), 278 text: light_scheme.text.to_string(), 279 muted: light_scheme.muted.to_string(), 280 subtle: light_scheme.subtle.to_string(), 281 emphasis: light_scheme.emphasis.to_string(), 282 primary: light_scheme.primary.to_string(), 283 secondary: light_scheme.secondary.to_string(), 284 tertiary: light_scheme.tertiary.to_string(), 285 error: light_scheme.error.to_string(), 286 warning: light_scheme.warning.to_string(), 287 success: light_scheme.success.to_string(), 288 border: light_scheme.border.to_string(), 289 link: light_scheme.link.to_string(), 290 highlight: light_scheme.highlight.to_string(), 291 }; 292 293 let dark_palette = ColourPalette { 294 base: dark_scheme.base.to_string(), 295 surface: dark_scheme.surface.to_string(), 296 overlay: dark_scheme.overlay.to_string(), 297 text: dark_scheme.text.to_string(), 298 muted: dark_scheme.muted.to_string(), 299 subtle: dark_scheme.subtle.to_string(), 300 emphasis: dark_scheme.emphasis.to_string(), 301 primary: dark_scheme.primary.to_string(), 302 secondary: dark_scheme.secondary.to_string(), 303 tertiary: dark_scheme.tertiary.to_string(), 304 error: dark_scheme.error.to_string(), 305 warning: dark_scheme.warning.to_string(), 306 success: dark_scheme.success.to_string(), 307 border: dark_scheme.border.to_string(), 308 link: dark_scheme.link.to_string(), 309 highlight: dark_scheme.highlight.to_string(), 310 }; 311 312 Ok(ThemePreviewOutput { 313 light_palette, 314 dark_palette, 315 syntax_css, 316 }) 317} 318 319#[cfg(feature = "server")] 320fn minify_css(css: &str) -> Option<String> { 321 use lightningcss::printer::PrinterOptions; 322 use lightningcss::stylesheet::{MinifyOptions, ParserOptions, StyleSheet}; 323 324 let stylesheet = match StyleSheet::parse(css, ParserOptions::default()) { 325 Ok(s) => s, 326 Err(e) => { 327 tracing::warn!("CSS parse error: {:?}", e); 328 return None; 329 } 330 }; 331 let mut stylesheet = stylesheet; 332 if let Err(e) = stylesheet.minify(MinifyOptions::default()) { 333 tracing::warn!("CSS minify error: {:?}", e); 334 return None; 335 } 336 let result = match stylesheet.to_css(PrinterOptions { 337 minify: true, 338 ..Default::default() 339 }) { 340 Ok(r) => r, 341 Err(e) => { 342 tracing::warn!("CSS print error: {:?}", e); 343 return None; 344 } 345 }; 346 Some(result.code) 347}