Skip to main content

forge/report/
mod.rs

1// #t(file: rust_hardcoded_url) report chrome embeds brand URLs by design.
2
3//! Reusable HTML report shell shared by every forge subcommand.
4
5use base64::{Engine as _, engine::general_purpose::STANDARD};
6use maud::{DOCTYPE, Markup, PreEscaped, html};
7
8const SITE_URL: &str = "https://wowlab.gg";
9
10const LOGO_PNG: &[u8] = include_bytes!("logo.png");
11const VENDOR_BULMA_CSS: &str = include_str!("vendor/bulma.min.css");
12const VENDOR_ECHARTS_JS: &str = include_str!("vendor/echarts.min.js");
13const SHARED_CSS: &str = include_str!("report.css");
14const SHARED_JS: &str = include_str!("report.js");
15
16#[derive(Debug)]
17pub(crate) struct Tab {
18    pub id: &'static str,
19    pub label: &'static str,
20}
21
22#[derive(Debug)]
23pub(crate) struct Section {
24    pub id: &'static str,
25    pub body: Markup,
26}
27
28#[derive(Debug)]
29pub(crate) struct DataScript {
30    pub id: String,
31    pub json: String,
32}
33
34#[derive(Debug)]
35pub(crate) struct Page {
36    title: String,
37    subtitle: String,
38    footer_command: String,
39    header_extras: Option<Markup>,
40    tabs: Vec<Tab>,
41    sections: Vec<Section>,
42    data_scripts: Vec<DataScript>,
43    extra_js: Vec<Box<str>>,
44    logo_data_uri: String,
45}
46
47impl Page {
48    pub(crate) fn new(title: impl Into<String>, subtitle: impl Into<String>) -> Self {
49        Self {
50            title: title.into(),
51            subtitle: subtitle.into(),
52            footer_command: String::new(),
53            header_extras: None,
54            tabs: Vec::new(),
55            sections: Vec::new(),
56            data_scripts: Vec::new(),
57            extra_js: Vec::new(),
58            logo_data_uri: format!("data:image/png;base64,{}", STANDARD.encode(LOGO_PNG)),
59        }
60    }
61
62    pub(crate) fn footer_command(mut self, cmd: impl Into<String>) -> Self {
63        self.footer_command = cmd.into();
64
65        self
66    }
67
68    pub(crate) fn header_extras(mut self, markup: Markup) -> Self {
69        self.header_extras = Some(markup);
70
71        self
72    }
73
74    pub(crate) fn tab(mut self, id: &'static str, label: &'static str) -> Self {
75        self.tabs.push(Tab { id, label });
76
77        self
78    }
79
80    pub(crate) fn section(mut self, id: &'static str, body: Markup) -> Self {
81        self.sections.push(Section { id, body });
82
83        self
84    }
85
86    pub(crate) fn data_script(mut self, id: impl Into<String>, json: impl Into<String>) -> Self {
87        self.data_scripts.push(DataScript {
88            id: id.into(),
89            json: json.into(),
90        });
91
92        self
93    }
94
95    pub(crate) fn extra_js(mut self, js: impl Into<String>) -> Self {
96        self.extra_js.push(js.into().into_boxed_str());
97
98        self
99    }
100
101    pub(crate) fn render(&self) -> String {
102        self.render_markup().into_string()
103    }
104
105    fn render_markup(&self) -> Markup {
106        let first_tab = self.tabs.first().map_or("", |t| t.id);
107
108        html! {
109            (DOCTYPE)
110            html lang="en" data-theme="dark" {
111                head {
112                    meta charset="utf-8";
113                    meta name="viewport" content="width=device-width, initial-scale=1";
114                    title { (self.title) }
115                    style { (PreEscaped(VENDOR_BULMA_CSS)) }
116                    style { (PreEscaped(SHARED_CSS)) }
117                    script { (PreEscaped(VENDOR_ECHARTS_JS)) }
118                }
119                body {
120                    header {
121                        section class="section pb-3" {
122                            div class="container is-max-widescreen" {
123                                (self.render_brand())
124                                @if let Some(extras) = &self.header_extras {
125                                    (extras)
126                                }
127                                (self.render_tabs(first_tab))
128                            }
129                        }
130                    }
131
132                    main {
133                        section class="section pt-0 pb-3" {
134                            div class="container is-max-widescreen" {
135                                @for section in &self.sections {
136                                    section #(format!("section-{}", section.id))
137                                        class="report-section"
138                                        role="tabpanel"
139                                        aria-labelledby=(format!("tab-{}", section.id))
140                                        tabindex="0"
141                                        hidden[section.id != first_tab] {
142                                        (section.body)
143                                    }
144                                }
145                            }
146                        }
147                    }
148
149                    footer class="footer py-3" {
150                        div class="content has-text-centered is-size-7 has-text-weak" {
151                            "Generated by " code { (self.footer_command) }
152                        }
153                    }
154
155                    @for ds in &self.data_scripts {
156                        script #(ds.id.as_str()) type="application/json" {
157                            (PreEscaped(ds.json.as_str()))
158                        }
159                    }
160
161                    script { (PreEscaped(SHARED_JS)) }
162                    @for js in &self.extra_js {
163                        script { (PreEscaped(js.as_ref())) }
164                    }
165                }
166            }
167        }
168    }
169
170    fn render_brand(&self) -> Markup {
171        html! {
172            div class="is-flex is-align-items-center mb-4" {
173                a href=(SITE_URL)
174                    target="_blank"
175                    rel="noopener noreferrer"
176                    aria-label="WoW Lab — open homepage"
177                    class="mr-3" {
178                    img src=(self.logo_data_uri.as_str()) alt="WoW Lab logo" width="32" height="32";
179                }
180                div {
181                    h1 class="title is-4 mb-0" { (self.title) }
182                    p class="is-size-7 has-text-weak" { (self.subtitle) }
183                }
184            }
185        }
186    }
187
188    fn render_tabs(&self, active: &str) -> Markup {
189        html! {
190            nav aria-label="Report sections" {
191                div #report-tabs class="buttons has-addons is-small mb-3" role="tablist" {
192                    @for t in &self.tabs {
193                        @let is_active = t.id == active;
194                        button type="button"
195                            id=(format!("tab-{}", t.id))
196                            role="tab"
197                            aria-selected=(if is_active { "true" } else { "false" })
198                            aria-controls=(format!("section-{}", t.id))
199                            tabindex=(if is_active { "0" } else { "-1" })
200                            class=[Some(if is_active { "button is-small is-active" } else { "button is-small" })]
201                            data-tab=(t.id)
202                            onclick=(format!("switchSection('{}')", t.id)) {
203                            (t.label)
204                        }
205                    }
206                }
207            }
208        }
209    }
210}
211
212pub(crate) fn chart_box(label: &str, css_class: &str, data_chart: &str) -> Markup {
213    html! {
214        div class="box" {
215            h2 class="chart-label" { (label) }
216            div class=(css_class)
217                data-chart=(data_chart)
218                role="img"
219                aria-label=(label) {}
220        }
221    }
222}