atproto blogging
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(¬ebook.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(¬ebook.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}