Skip to main content

resopt/
report.rs

1use crate::{
2    AnalysisReport,
3    filesystem::{contained_file, replace, write_new},
4    resources::bounded_read,
5};
6use anyhow::{Result, ensure};
7use std::{
8    fs,
9    path::{Path, PathBuf},
10};
11
12/// Refresh presentation only. Does not encode, change JSON, or touch artifacts.
13pub fn refresh_report(directory: impl AsRef<Path>) -> Result<PathBuf> {
14    let directory = fs::canonicalize(directory)?;
15    let data = contained_file(&directory, Path::new("analysis.json"))?;
16    let report: AnalysisReport = serde_json::from_slice(&bounded_read(&data)?)?;
17    ensure!(
18        matches!(report.schema_version, 1 | 2),
19        "unsupported analysis schema version"
20    );
21    let html = render_html(&report)?;
22    let output = directory.join("report.html");
23    if fs::symlink_metadata(&output).is_ok() {
24        contained_file(&directory, Path::new("report.html"))?;
25        replace(&output, html.as_bytes())?;
26    } else {
27        write_new(&output, html.as_bytes())?;
28    }
29    Ok(output)
30}
31
32pub(crate) fn render_html(report: &AnalysisReport) -> Result<String> {
33    let payload = serde_json::json!({
34        "sessionToken": null,
35        "meta": meta(report),
36        "resources": report.resources,
37    });
38    Ok(page(&script_safe_json(&payload)?).into_string())
39}
40
41/// The live application shell: rows arrive over the session API, so the page
42/// stays small no matter how large the project is.
43pub(crate) fn render_live_page(project: &Path, token: &str) -> Result<String> {
44    let payload = serde_json::json!({
45        "sessionToken": token,
46        "meta": {"root": project},
47    });
48    Ok(page(&script_safe_json(&payload)?).into_string())
49}
50
51/// Report-level facts the UI needs besides the resource rows.
52pub(crate) fn meta(report: &AnalysisReport) -> serde_json::Value {
53    serde_json::json!({
54        "root": report.root,
55        "backend": report.backend,
56        "options": report.options,
57        "savings": report.potential_source_bytes_saved,
58        "cancelled": report.cancelled,
59        "similarGroups": report.similar_groups,
60        "performance": report.performance,
61        "projectKinds": report.inventory.project_kinds,
62        "androidMinSdk": report.inventory.android_min_sdk,
63        "diagnostics": report.inventory.diagnostics,
64        "excludedDirectories": report.inventory.excluded_directories,
65    })
66}
67
68// An inert JSON script still ends at a literal </script>. Escape HTML
69// delimiters before embedding data; UI code uses textContent for filenames.
70fn script_safe_json(value: &serde_json::Value) -> Result<String> {
71    Ok(serde_json::to_string(value)?
72        .replace('<', "\\u003c")
73        .replace('>', "\\u003e")
74        .replace('&', "\\u0026")
75        .replace('\u{2028}', "\\u2028")
76        .replace('\u{2029}', "\\u2029"))
77}
78
79use maud::{DOCTYPE, Markup, PreEscaped, html};
80
81const THEME_BOOTSTRAP: &str = "try{const t=localStorage.getItem('resopt-theme')||'system';document.documentElement.dataset.theme=t==='system'?(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'):t}catch{}";
82
83/// The UI sources share one scope; `start()` runs after every file is defined.
84fn script() -> String {
85    [
86        "'use strict';(() => {",
87        include_str!("ui/core.js"),
88        include_str!("ui/i18n.js"),
89        include_str!("ui/app.js"),
90        include_str!("ui/detail.js"),
91        include_str!("ui/batch.js"),
92        "start();})();",
93    ]
94    .join("\n")
95}
96
97fn page(data: &str) -> Markup {
98    html! {
99        (DOCTYPE)
100        html lang="en" {
101            head {
102                meta charset="utf-8";
103                meta name="viewport" content="width=device-width, initial-scale=1";
104                title { "resopt · Resource analysis" }
105                script { (PreEscaped(THEME_BOOTSTRAP)) }
106                style { (PreEscaped(include_str!("ui/style.css"))) }
107            }
108            body {
109                a.skip-link href="#results" data-i18n="colResource" { "Resource" }
110                header {
111                    div.brand {
112                        span.mark aria-hidden="true" { i {} i {} i {} i {} }
113                        strong { "resopt" } span data-i18n="title" { "Resource analysis" }
114                    }
115                    div.header-meta {
116                        span.read-only id="session-mode" {}
117                        select id="language" aria-label="Language" data-i18n-label="language" {
118                            option value="auto" data-i18n="languageAuto" { "Auto" }
119                            option value="en" { "English" }
120                            option value="zh-CN" { "简体中文" }
121                        }
122                        select id="theme" aria-label="Theme" data-i18n-label="theme" {
123                            option value="system" data-i18n="themeSystem" { "System" }
124                            option value="light" data-i18n="themeLight" { "Light" }
125                            option value="dark" data-i18n="themeDark" { "Dark" }
126                        }
127                        a href="analysis.json" target="_blank" rel="noopener" data-i18n="json" { "View JSON ↗" }
128                    }
129                }
130                main {
131                    section.status id="status" role="status" aria-live="polite" {}
132                    (overview())
133                    (toolbar())
134                    div.workspace {
135                        section.list-pane aria-label="Resources" data-i18n-label="statResources" {
136                            div.list-head aria-hidden="true" {
137                                span data-i18n="colResource" {} span data-i18n="colSize" {} span data-i18n="colSavings" {}
138                            }
139                            div.results id="results" role="listbox" tabindex="-1" aria-label="Resources" data-i18n-label="statResources" {}
140                            div.pager {
141                                span id="range" role="status" aria-live="polite" {}
142                                div.pager-controls {
143                                    button.icon-button type="button" id="previous" aria-label="Previous page" data-i18n-label="previous" { "←" }
144                                    span id="page-number" {}
145                                    button.icon-button type="button" id="next" aria-label="Next page" data-i18n-label="next" { "→" }
146                                }
147                            }
148                        }
149                        aside.inspector id="inspector" aria-label="Details" {}
150                    }
151                    footer.footer {
152                        span data-i18n="footerUnits" {}
153                        span data-i18n="footerScope" {}
154                    }
155                }
156                (dialogs())
157                noscript { "Enable JavaScript to filter resources and compare images, or open analysis.json next to this file." }
158                // JSON is escaped for the script context by script_safe_json, not HTML-escaped.
159                script type="application/json" id="report-data" { (PreEscaped(data)) }
160                script { (PreEscaped(script())) }
161            }
162        }
163    }
164}
165
166fn overview() -> Markup {
167    html! {
168        section.summary aria-label="Overview" {
169            @for (id, label, accent) in [
170                ("stat-resources", "statResources", false),
171                ("stat-opportunities", "statOpportunities", false),
172                ("stat-savings", "statSavings", true),
173                ("stat-warnings", "statWarnings", false),
174                ("stat-applied", "statApplied", false),
175            ] {
176                div.stat {
177                    div.stat-label data-i18n=(label) {}
178                    div class={ "stat-value" @if accent { " accent" } } id=(id) { "—" }
179                }
180            }
181            p.summary-note id="scope-note" {}
182        }
183    }
184}
185
186fn toolbar() -> Markup {
187    html! {
188        section.toolbar aria-label="Filters" {
189            div.modes role="group" aria-label="View" {
190                @for mode in ["candidates", "warnings", "duplicates", "applied", "images", "unsupported", "failed", "all"] {
191                    button.mode type="button" data-mode=(mode) aria-pressed="false" {
192                        span data-i18n={ "mode" (mode[..1].to_uppercase()) (mode[1..]) } {}
193                        " " span.mode-count id={ "mode-" (mode) } {}
194                    }
195                }
196            }
197            div.search {
198                svg width="15" height="15" viewBox="0 0 20 20" fill="none" aria-hidden="true" {
199                    circle cx="8.5" cy="8.5" r="5.5" stroke="currentColor" stroke-width="1.5" {}
200                    path d="m13 13 4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" {}
201                }
202                input type="search" id="search" autocomplete="off" aria-label="Search" data-i18n-label="search" data-i18n-placeholder="search";
203            }
204            select id="format-filter" aria-label="Format" data-i18n-label="allFormats" {}
205            select id="sort" aria-label="Sort" {
206                option value="savings" data-i18n="sortSavings" {}
207                option value="size" data-i18n="sortSize" {}
208                option value="name" data-i18n="sortName" {}
209                option value="score" data-i18n="sortScore" {}
210            }
211            button.primary type="button" id="batch-open" hidden data-i18n="batch" {}
212            button type="button" id="restore-all-open" hidden data-i18n="restoreAll" {}
213        }
214    }
215}
216
217fn dialogs() -> Markup {
218    html! {
219        dialog id="apply-dialog" aria-labelledby="apply-title" {
220            h2 id="apply-title" {}
221            p id="apply-description" {}
222            p.hint id="apply-note" {}
223            div.dialog-actions {
224                button type="button" id="apply-cancel" data-i18n="cancelButton" {}
225                button.primary type="button" id="apply-confirm" {}
226            }
227        }
228        dialog.wide id="compare-dialog" aria-labelledby="compare-title" {
229            div.dialog-head {
230                h2 id="compare-title" data-i18n="compareTitle" {}
231                button type="button" id="compare-close" data-i18n="close" {}
232            }
233            p id="compare-caption" {}
234            input type="range" id="compare-slider" min="0" max="100" value="50" aria-label="Comparison position" data-i18n-label="compare";
235            div.compare-stage id="compare-stage" data-background="checker" {}
236            p.hint id="compare-note" {}
237        }
238        dialog id="batch-dialog" aria-labelledby="batch-title" {
239            div.dialog-head {
240                h2 id="batch-title" {}
241                button type="button" id="batch-close" data-i18n="close" {}
242            }
243            div id="batch-policy-view" {
244                p data-i18n="batchIntro" {}
245                @for (id, label, checked) in [
246                    ("batch-lossless", "batchLossless", true),
247                    ("batch-lossy", "batchLossy", false),
248                    ("batch-cross", "batchCross", false),
249                    ("batch-alpha", "batchAlpha", false),
250                    ("batch-quality", "batchQuality", false),
251                ] {
252                    label.check { input type="checkbox" id=(id) checked[checked]; span data-i18n=(label) {} }
253                }
254                label.field { span data-i18n="batchMinScore" {} input type="number" id="batch-min-score" min="0" max="100" step="1" inputmode="decimal"; }
255                label.check { input type="checkbox" id="batch-scope"; span id="batch-scope-label" {} }
256                p.status-warn id="batch-error" role="alert" {}
257                div.dialog-actions { button.primary type="button" id="batch-preview" data-i18n="batchPreview" {} }
258            }
259            div id="batch-plan-view" hidden {
260                p.summary-text id="batch-summary" {}
261                ul.batch-list id="batch-items" {}
262                div.dialog-actions {
263                    button type="button" id="batch-back" data-i18n="cancelButton" {}
264                    button.primary type="button" id="batch-confirm" {}
265                }
266            }
267            div id="batch-progress-view" hidden {
268                p id="batch-progress-text" role="status" aria-live="polite" {}
269                progress id="batch-bar" max="1" value="0" {}
270                p.status-warn id="batch-stopped" hidden data-i18n="batchStopped" {}
271                h3 id="batch-failures-title" hidden data-i18n="batchFailures" {}
272                ul.batch-list id="batch-failures" {}
273                div.dialog-actions {
274                    button type="button" id="batch-stop" data-i18n="batchStop" {}
275                    button.primary type="button" id="batch-done" hidden data-i18n="close" {}
276                }
277            }
278        }
279    }
280}