1#![cfg_attr(feature = "fail-on-warnings", deny(warnings))]
2#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
3#![allow(clippy::multiple_crate_versions)]
4
5use std::{collections::HashMap, io::Write};
6
7use async_trait::async_trait;
8use flume::Receiver;
9use html::{
10 element_classes_to_html, element_style_to_html, number_to_html_string, write_css_attr_important,
11};
12use hyperchad_renderer::{
13 Color, HtmlTagRenderer, PartialView, RenderRunner, Renderer, ToRenderRunner, View,
14 canvas::CanvasUpdate,
15};
16use hyperchad_router::Container;
17use hyperchad_transformer::{
18 OverrideCondition, OverrideItem, ResponsiveTrigger,
19 models::{AlignItems, LayoutDirection, TextAlign, Visibility},
20};
21use maud::{DOCTYPE, PreEscaped, html};
22use tokio::runtime::Handle;
23
24#[cfg(feature = "actix")]
25pub use actix::router_to_actix;
26
27#[cfg(feature = "lambda")]
28pub use lambda::router_to_lambda;
29
30pub mod html;
31pub mod stub;
32
33#[cfg(feature = "actix")]
34pub mod actix;
35
36#[cfg(feature = "lambda")]
37pub mod lambda;
38
39#[cfg(feature = "extend")]
40pub mod extend;
41
42#[derive(Default, Clone)]
43pub struct DefaultHtmlTagRenderer {
44 pub responsive_triggers: HashMap<String, ResponsiveTrigger>,
45}
46
47impl DefaultHtmlTagRenderer {
48 #[must_use]
49 pub fn with_responsive_trigger(
50 mut self,
51 name: impl Into<String>,
52 trigger: ResponsiveTrigger,
53 ) -> Self {
54 self.add_responsive_trigger(name.into(), trigger);
55 self
56 }
57}
58
59impl HtmlTagRenderer for DefaultHtmlTagRenderer {
60 fn add_responsive_trigger(&mut self, name: String, trigger: ResponsiveTrigger) {
61 self.responsive_triggers.insert(name, trigger);
62 }
63
64 fn element_attrs_to_html(
68 &self,
69 f: &mut dyn Write,
70 container: &Container,
71 is_flex_child: bool,
72 ) -> Result<(), std::io::Error> {
73 if let Some(id) = &container.str_id {
74 f.write_all(b" id=\"")?;
75 f.write_all(id.as_bytes())?;
76 f.write_all(b"\"")?;
77 }
78
79 element_style_to_html(f, container, is_flex_child)?;
80 element_classes_to_html(f, container)?;
81
82 for (key, value) in &container.data {
83 f.write_all(b" data-")?;
84 f.write_all(key.as_bytes())?;
85 f.write_all(b"=\"")?;
86 f.write_all(html_escape::encode_quoted_attribute(value).as_bytes())?;
87 f.write_all(b"\"")?;
88 }
89
90 Ok(())
91 }
92
93 #[allow(clippy::too_many_lines)]
97 fn reactive_conditions_to_css(
98 &self,
99 f: &mut dyn Write,
100 container: &Container,
101 ) -> Result<(), std::io::Error> {
102 f.write_all(b"<style>")?;
103
104 for (container, config) in container.iter_overrides(true) {
105 let Some(id) = &container.str_id else {
106 continue;
107 };
108
109 let Some(trigger) = (match &config.condition {
110 OverrideCondition::ResponsiveTarget { name } => self.responsive_triggers.get(name),
111 }) else {
112 continue;
113 };
114
115 f.write_all(b"@media(")?;
116
117 match trigger {
118 ResponsiveTrigger::MaxWidth(number) => {
119 f.write_all(b"max-width:")?;
120 f.write_all(number_to_html_string(number, true).as_bytes())?;
121 }
122 ResponsiveTrigger::MaxHeight(number) => {
123 f.write_all(b"max-height:")?;
124 f.write_all(number_to_html_string(number, true).as_bytes())?;
125 }
126 }
127
128 f.write_all(b"){")?;
129
130 f.write_all(b"#")?;
131 f.write_all(id.as_bytes())?;
132 f.write_all(b"{")?;
133
134 for o in &config.overrides {
135 match o {
136 OverrideItem::Direction(x) => {
137 write_css_attr_important(
138 f,
139 override_item_to_css_name(o),
140 match x {
141 LayoutDirection::Row => b"row",
142 LayoutDirection::Column => b"column",
143 },
144 )?;
145 }
146 OverrideItem::Visibility(x) => {
147 write_css_attr_important(
148 f,
149 override_item_to_css_name(o),
150 match x {
151 Visibility::Visible => b"visible",
152 Visibility::Hidden => b"hidden",
153 },
154 )?;
155 }
156 OverrideItem::Hidden(x) => {
157 write_css_attr_important(
158 f,
159 override_item_to_css_name(o),
160 if *x { b"none" } else { b"initial" },
161 )?;
162 }
163 OverrideItem::AlignItems(x) => {
164 write_css_attr_important(
165 f,
166 override_item_to_css_name(o),
167 match x {
168 AlignItems::Start => b"start",
169 AlignItems::Center => b"center",
170 AlignItems::End => b"end",
171 },
172 )?;
173 }
174 OverrideItem::TextAlign(x) => {
175 write_css_attr_important(
176 f,
177 override_item_to_css_name(o),
178 match x {
179 TextAlign::Start => b"start",
180 TextAlign::Center => b"center",
181 TextAlign::End => b"end",
182 TextAlign::Justify => b"justify",
183 },
184 )?;
185 }
186 OverrideItem::MarginLeft(x)
187 | OverrideItem::MarginRight(x)
188 | OverrideItem::MarginTop(x)
189 | OverrideItem::MarginBottom(x)
190 | OverrideItem::Width(x)
191 | OverrideItem::MinWidth(x)
192 | OverrideItem::MaxWidth(x)
193 | OverrideItem::Height(x)
194 | OverrideItem::MinHeight(x)
195 | OverrideItem::MaxHeight(x)
196 | OverrideItem::Left(x)
197 | OverrideItem::Right(x)
198 | OverrideItem::Top(x)
199 | OverrideItem::Bottom(x)
200 | OverrideItem::ColumnGap(x)
201 | OverrideItem::RowGap(x)
202 | OverrideItem::BorderTopLeftRadius(x)
203 | OverrideItem::BorderTopRightRadius(x)
204 | OverrideItem::BorderBottomLeftRadius(x)
205 | OverrideItem::BorderBottomRightRadius(x)
206 | OverrideItem::PaddingLeft(x)
207 | OverrideItem::PaddingRight(x)
208 | OverrideItem::PaddingTop(x)
209 | OverrideItem::PaddingBottom(x)
210 | OverrideItem::Opacity(x)
211 | OverrideItem::TranslateX(x)
212 | OverrideItem::TranslateY(x)
213 | OverrideItem::FontSize(x)
214 | OverrideItem::GridCellSize(x) => {
215 write_css_attr_important(
216 f,
217 override_item_to_css_name(o),
218 number_to_html_string(x, true).as_bytes(),
219 )?;
220 }
221 OverrideItem::StrId(..)
222 | OverrideItem::Classes(..)
223 | OverrideItem::OverflowX(..)
224 | OverrideItem::OverflowY(..)
225 | OverrideItem::JustifyContent(..)
226 | OverrideItem::TextDecoration(..)
227 | OverrideItem::FontFamily(..)
228 | OverrideItem::Flex(..)
229 | OverrideItem::Cursor(..)
230 | OverrideItem::Position(..)
231 | OverrideItem::Background(..)
232 | OverrideItem::BorderTop(..)
233 | OverrideItem::BorderRight(..)
234 | OverrideItem::BorderBottom(..)
235 | OverrideItem::BorderLeft(..)
236 | OverrideItem::Color(..) => {}
237 }
238 }
239
240 f.write_all(b"}")?; f.write_all(b"}")?; }
243
244 f.write_all(b"</style>")?;
245
246 Ok(())
247 }
248
249 fn partial_html(
250 &self,
251 _headers: &HashMap<String, String>,
252 _container: &Container,
253 content: String,
254 _viewport: Option<&str>,
255 _background: Option<Color>,
256 ) -> String {
257 content
258 }
259
260 fn root_html(
261 &self,
262 _headers: &HashMap<String, String>,
263 container: &Container,
264 content: String,
265 viewport: Option<&str>,
266 background: Option<Color>,
267 title: Option<&str>,
268 description: Option<&str>,
269 ) -> String {
270 let background = background.map(|x| format!("background:rgb({},{},{})", x.r, x.g, x.b));
271 let background = background.as_deref().unwrap_or("");
272
273 let mut responsive_css = vec![];
274 self.reactive_conditions_to_css(&mut responsive_css, container)
275 .unwrap();
276 let responsive_css = std::str::from_utf8(&responsive_css).unwrap();
277
278 html! {
279 (DOCTYPE)
280 html style="height:100%" lang="en" {
281 head {
282 @if let Some(title) = title {
283 title { (title) }
284 }
285 @if let Some(description) = description {
286 meta name="description" content=(description);
287 }
288 style {(format!(r"
289 body {{
290 margin: 0;{background};
291 overflow: hidden;
292 }}
293 .remove-button-styles {{
294 background: none;
295 color: inherit;
296 border: none;
297 padding: 0;
298 font: inherit;
299 cursor: pointer;
300 outline: inherit;
301 }}
302 "))}
303 (PreEscaped(responsive_css))
304 @if let Some(content) = viewport {
305 meta name="viewport" content=(content);
306 }
307 }
308 body style="height:100%" {
309 (PreEscaped(content))
310 }
311 }
312 }
313 .into_string()
314 }
315}
316
317const fn override_item_to_css_name(item: &OverrideItem) -> &'static [u8] {
318 match item {
319 OverrideItem::StrId(..) => b"id",
320 OverrideItem::Classes(..) => b"classes",
321 OverrideItem::Direction(..) => b"flex-direction",
322 OverrideItem::OverflowX(..) => b"overflow-x",
323 OverrideItem::OverflowY(..) => b"overflow-y",
324 OverrideItem::GridCellSize(..) => b"grid-template-columns",
325 OverrideItem::JustifyContent(..) => b"justify-content",
326 OverrideItem::AlignItems(..) => b"align-items",
327 OverrideItem::TextAlign(..) => b"text-align",
328 OverrideItem::TextDecoration(..) => b"text-decoration",
329 OverrideItem::FontFamily(..) => b"font-family",
330 OverrideItem::Width(..) => b"width",
331 OverrideItem::MinWidth(..) => b"min-width",
332 OverrideItem::MaxWidth(..) => b"max-width",
333 OverrideItem::Height(..) => b"height",
334 OverrideItem::MinHeight(..) => b"min-height",
335 OverrideItem::MaxHeight(..) => b"max-height",
336 OverrideItem::Flex(..) => b"flex",
337 OverrideItem::ColumnGap(..) => b"column-gap",
338 OverrideItem::RowGap(..) => b"row-gap",
339 OverrideItem::Opacity(..) => b"opacity",
340 OverrideItem::Left(..) => b"left",
341 OverrideItem::Right(..) => b"right",
342 OverrideItem::Top(..) => b"top",
343 OverrideItem::Bottom(..) => b"bottom",
344 OverrideItem::TranslateX(..) | OverrideItem::TranslateY(..) => b"transform",
345 OverrideItem::Cursor(..) => b"cursor",
346 OverrideItem::Position(..) => b"position",
347 OverrideItem::Background(..) => b"background",
348 OverrideItem::BorderTop(..) => b"border-top",
349 OverrideItem::BorderRight(..) => b"border-right",
350 OverrideItem::BorderBottom(..) => b"border-bottom",
351 OverrideItem::BorderLeft(..) => b"border-left",
352 OverrideItem::BorderTopLeftRadius(..) => b"border-top-left-radius",
353 OverrideItem::BorderTopRightRadius(..) => b"border-top-right-radius",
354 OverrideItem::BorderBottomLeftRadius(..) => b"border-bottom-left-radius",
355 OverrideItem::BorderBottomRightRadius(..) => b"border-bottom-right-radius",
356 OverrideItem::MarginLeft(..) => b"margin-left",
357 OverrideItem::MarginRight(..) => b"margin-right",
358 OverrideItem::MarginTop(..) => b"margin-top",
359 OverrideItem::MarginBottom(..) => b"margin-bottom",
360 OverrideItem::PaddingLeft(..) => b"padding-left",
361 OverrideItem::PaddingRight(..) => b"padding-right",
362 OverrideItem::PaddingTop(..) => b"padding-top",
363 OverrideItem::PaddingBottom(..) => b"padding-bottom",
364 OverrideItem::FontSize(..) => b"font-size",
365 OverrideItem::Color(..) => b"color",
366 OverrideItem::Hidden(..) => b"display",
367 OverrideItem::Visibility(..) => b"visibility",
368 }
369}
370
371pub trait HtmlApp {
372 #[must_use]
373 fn with_responsive_trigger(self, _name: String, _trigger: ResponsiveTrigger) -> Self;
374 fn add_responsive_trigger(&mut self, _name: String, _trigger: ResponsiveTrigger);
375
376 #[cfg(feature = "assets")]
377 #[must_use]
378 fn with_static_asset_routes(
379 self,
380 paths: impl Into<Vec<hyperchad_renderer::assets::StaticAssetRoute>>,
381 ) -> Self;
382
383 #[must_use]
384 fn with_viewport(self, viewport: Option<String>) -> Self;
385 fn set_viewport(&mut self, viewport: Option<String>);
386
387 #[must_use]
388 fn with_title(self, title: Option<String>) -> Self;
389 fn set_title(&mut self, title: Option<String>);
390
391 #[must_use]
392 fn with_description(self, description: Option<String>) -> Self;
393 fn set_description(&mut self, description: Option<String>);
394
395 #[must_use]
396 fn with_background(self, background: Option<Color>) -> Self;
397 fn set_background(&mut self, background: Option<Color>);
398
399 #[cfg(feature = "extend")]
400 #[must_use]
401 fn with_html_renderer_event_rx(self, rx: Receiver<hyperchad_renderer::RendererEvent>) -> Self;
402 #[cfg(feature = "extend")]
403 fn set_html_renderer_event_rx(&mut self, rx: Receiver<hyperchad_renderer::RendererEvent>);
404}
405
406#[derive(Clone)]
407pub struct HtmlRenderer<T: HtmlApp + ToRenderRunner + Send + Sync> {
408 width: Option<f32>,
409 height: Option<f32>,
410 x: Option<i32>,
411 y: Option<i32>,
412 pub app: T,
413 receiver: Receiver<String>,
414 #[cfg(feature = "extend")]
415 extend: Option<std::sync::Arc<Box<dyn extend::ExtendHtmlRenderer + Send + Sync>>>,
416 #[cfg(feature = "extend")]
417 publisher: Option<extend::HtmlRendererEventPub>,
418}
419
420impl<T: HtmlApp + ToRenderRunner + Send + Sync> HtmlRenderer<T> {
421 #[must_use]
422 pub fn new(app: T) -> Self {
423 let (_tx, rx) = flume::unbounded();
424
425 Self {
426 width: None,
427 height: None,
428 x: None,
429 y: None,
430 app,
431 receiver: rx,
432 #[cfg(feature = "extend")]
433 extend: None,
434 #[cfg(feature = "extend")]
435 publisher: None,
436 }
437 }
438
439 #[must_use]
440 pub fn with_background(mut self, background: Option<Color>) -> Self {
441 self.app = self.app.with_background(background);
442 self
443 }
444
445 #[must_use]
446 pub fn with_title(mut self, title: Option<String>) -> Self {
447 self.app = self.app.with_title(title);
448 self
449 }
450
451 #[must_use]
452 pub fn with_description(mut self, description: Option<String>) -> Self {
453 self.app = self.app.with_description(description);
454 self
455 }
456
457 #[must_use]
458 pub async fn wait_for_navigation(&self) -> Option<String> {
459 self.receiver.recv_async().await.ok()
460 }
461
462 #[cfg(feature = "assets")]
463 #[must_use]
464 pub fn with_static_asset_routes(
465 mut self,
466 paths: impl Into<Vec<hyperchad_renderer::assets::StaticAssetRoute>>,
467 ) -> Self {
468 self.app = self.app.with_static_asset_routes(paths);
469 self
470 }
471
472 #[cfg(feature = "extend")]
473 #[must_use]
474 pub fn with_extend_html_renderer(
475 mut self,
476 renderer: impl extend::ExtendHtmlRenderer + Send + Sync + 'static,
477 ) -> Self {
478 self.extend = Some(std::sync::Arc::new(Box::new(renderer)));
479 self
480 }
481
482 #[cfg(feature = "extend")]
483 #[must_use]
484 pub fn with_html_renderer_event_pub(mut self, publisher: extend::HtmlRendererEventPub) -> Self {
485 self.publisher = Some(publisher);
486 self
487 }
488}
489
490impl<T: HtmlApp + ToRenderRunner + Send + Sync> ToRenderRunner for HtmlRenderer<T> {
491 fn to_runner(
495 self,
496 handle: Handle,
497 ) -> Result<Box<dyn RenderRunner>, Box<dyn std::error::Error + Send>> {
498 self.app.to_runner(handle)
499 }
500}
501
502#[async_trait]
503impl<T: HtmlApp + ToRenderRunner + Send + Sync> Renderer for HtmlRenderer<T> {
504 fn add_responsive_trigger(&mut self, name: String, trigger: ResponsiveTrigger) {
505 self.app.add_responsive_trigger(name, trigger);
506 }
507
508 async fn init(
512 &mut self,
513 width: f32,
514 height: f32,
515 x: Option<i32>,
516 y: Option<i32>,
517 background: Option<Color>,
518 title: Option<&str>,
519 description: Option<&str>,
520 viewport: Option<&str>,
521 ) -> Result<(), Box<dyn std::error::Error + Send + 'static>> {
522 self.width = Some(width);
523 self.height = Some(height);
524 self.x = x;
525 self.y = y;
526 self.app.set_background(background);
527 self.app.set_title(title.map(ToString::to_string));
528 self.app
529 .set_description(description.map(ToString::to_string));
530 self.app.set_viewport(viewport.map(ToString::to_string));
531
532 Ok(())
533 }
534
535 async fn emit_event(
539 &self,
540 event_name: String,
541 event_value: Option<String>,
542 ) -> Result<(), Box<dyn std::error::Error + Send + 'static>> {
543 log::trace!("emit_event: event_name={event_name} event_value={event_value:?}");
544
545 #[cfg(feature = "extend")]
546 if let (Some(extend), Some(publisher)) = (self.extend.as_ref(), self.publisher.as_ref()) {
547 extend
548 .emit_event(publisher.clone(), event_name, event_value)
549 .await?;
550 }
551
552 Ok(())
553 }
554
555 async fn render(
559 &self,
560 elements: View,
561 ) -> Result<(), Box<dyn std::error::Error + Send + 'static>> {
562 moosicbox_logging::debug_or_trace!(
563 ("render: start"),
564 ("render: start {:?}", elements.immediate)
565 );
566
567 #[cfg(feature = "extend")]
568 if let (Some(extend), Some(publisher)) = (self.extend.as_ref(), self.publisher.as_ref()) {
569 extend.render(publisher.clone(), elements).await?;
570 }
571
572 log::debug!("render: finished");
573
574 Ok(())
575 }
576
577 async fn render_partial(
585 &self,
586 view: PartialView,
587 ) -> Result<(), Box<dyn std::error::Error + Send + 'static>> {
588 moosicbox_logging::debug_or_trace!(
589 ("render_partial: start"),
590 ("render_partial: start {:?}", view)
591 );
592
593 #[cfg(feature = "extend")]
594 if let (Some(extend), Some(publisher)) = (self.extend.as_ref(), self.publisher.as_ref()) {
595 extend.render_partial(publisher.clone(), view).await?;
596 }
597
598 log::debug!("render_partial: finished");
599
600 Ok(())
601 }
602
603 #[allow(unused_variables)]
611 async fn render_canvas(
612 &self,
613 update: CanvasUpdate,
614 ) -> Result<(), Box<dyn std::error::Error + Send + 'static>> {
615 log::trace!("render_canvas");
616
617 #[cfg(feature = "extend")]
618 if let (Some(extend), Some(publisher)) = (self.extend.as_ref(), self.publisher.as_ref()) {
619 extend.render_canvas(publisher.clone(), update).await?;
620 }
621
622 log::debug!("render_canvas: finished");
623
624 Ok(())
625 }
626}