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
12pub 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
41pub(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
51pub(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
68fn 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
83fn 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/compare.js"),
91 include_str!("ui/detail.js"),
92 include_str!("ui/batch.js"),
93 "start();})();",
94 ]
95 .join("\n")
96}
97
98fn page(data: &str) -> Markup {
99 html! {
100 (DOCTYPE)
101 html lang="en" {
102 head {
103 meta charset="utf-8";
104 meta name="viewport" content="width=device-width, initial-scale=1";
105 title { "resopt · Resource analysis" }
106 script { (PreEscaped(THEME_BOOTSTRAP)) }
107 style { (PreEscaped(include_str!("ui/style.css"))) }
108 }
109 body {
110 a.skip-link href="#results" data-i18n="colResource" { "Resource" }
111 header {
112 div.brand {
113 span.mark aria-hidden="true" { i {} i {} i {} i {} }
114 strong { "resopt" } span data-i18n="title" { "Resource analysis" }
115 }
116 div.header-meta {
117 span.read-only id="session-mode" {}
118 select id="language" aria-label="Language" data-i18n-label="language" {
119 option value="auto" data-i18n="languageAuto" { "Auto" }
120 option value="en" { "English" }
121 option value="zh-CN" { "简体中文" }
122 }
123 select id="theme" aria-label="Theme" data-i18n-label="theme" {
124 option value="system" data-i18n="themeSystem" { "System" }
125 option value="light" data-i18n="themeLight" { "Light" }
126 option value="dark" data-i18n="themeDark" { "Dark" }
127 }
128 a href="analysis.json" target="_blank" rel="noopener" data-i18n="json" { "View JSON ↗" }
129 }
130 }
131 main {
132 section.status id="status" role="status" aria-live="polite" {}
133 (overview())
134 (toolbar())
135 div.workspace {
136 section.list-pane aria-label="Resources" data-i18n-label="statResources" {
137 div.list-head aria-hidden="true" {
138 span data-i18n="colResource" {} span data-i18n="colSize" {} span data-i18n="colSavings" {}
139 }
140 div.results id="results" role="listbox" tabindex="-1" aria-label="Resources" data-i18n-label="statResources" {}
141 div.pager {
142 span id="range" role="status" aria-live="polite" {}
143 div.pager-controls {
144 button.icon-button type="button" id="previous" aria-label="Previous page" data-i18n-label="previous" { "←" }
145 span id="page-number" {}
146 button.icon-button type="button" id="next" aria-label="Next page" data-i18n-label="next" { "→" }
147 }
148 }
149 }
150 aside.inspector id="inspector" aria-label="Details" {}
151 }
152 footer.footer {
153 span data-i18n="footerUnits" {}
154 span data-i18n="footerScope" {}
155 }
156 }
157 (dialogs())
158 noscript { "Enable JavaScript to filter resources and compare images, or open analysis.json next to this file." }
159 script type="application/json" id="report-data" { (PreEscaped(data)) }
161 script { (PreEscaped(script())) }
162 }
163 }
164 }
165}
166
167fn overview() -> Markup {
168 html! {
169 section.summary aria-label="Overview" {
170 @for (id, label, accent) in [
171 ("stat-resources", "statResources", false),
172 ("stat-opportunities", "statOpportunities", false),
173 ("stat-savings", "statSavings", true),
174 ("stat-warnings", "statWarnings", false),
175 ("stat-applied", "statApplied", false),
176 ] {
177 div.stat {
178 div.stat-label data-i18n=(label) {}
179 div class={ "stat-value" @if accent { " accent" } } id=(id) { "—" }
180 }
181 }
182 p.summary-note id="scope-note" {}
183 }
184 }
185}
186
187fn toolbar() -> Markup {
188 html! {
189 section.toolbar aria-label="Filters" {
190 div.modes role="group" aria-label="View" {
191 @for mode in ["candidates", "warnings", "duplicates", "applied", "images", "unsupported", "failed", "all"] {
192 button.mode type="button" data-mode=(mode) aria-pressed="false" {
193 span data-i18n={ "mode" (mode[..1].to_uppercase()) (mode[1..]) } {}
194 " " span.mode-count id={ "mode-" (mode) } {}
195 }
196 }
197 }
198 div.search {
199 svg width="15" height="15" viewBox="0 0 20 20" fill="none" aria-hidden="true" {
200 circle cx="8.5" cy="8.5" r="5.5" stroke="currentColor" stroke-width="1.5" {}
201 path d="m13 13 4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" {}
202 }
203 input type="search" id="search" autocomplete="off" aria-label="Search" data-i18n-label="search" data-i18n-placeholder="search";
204 }
205 select id="format-filter" aria-label="Format" data-i18n-label="allFormats" {}
206 select id="sort" aria-label="Sort" {
207 option value="savings" data-i18n="sortSavings" {}
208 option value="size" data-i18n="sortSize" {}
209 option value="name" data-i18n="sortName" {}
210 option value="score" data-i18n="sortScore" {}
211 }
212 button.primary type="button" id="batch-open" hidden data-i18n="batch" {}
213 button type="button" id="restore-selected-open" hidden data-i18n="restoreSelected" {}
214 button type="button" id="restore-all-open" hidden data-i18n="restoreAll" {}
215 }
216 }
217}
218
219fn dialogs() -> Markup {
220 html! {
221 dialog id="apply-dialog" aria-labelledby="apply-title" {
222 h2 id="apply-title" {}
223 p id="apply-description" {}
224 p.hint id="apply-note" {}
225 div.dialog-actions {
226 button type="button" id="apply-cancel" data-i18n="cancelButton" {}
227 button.primary type="button" id="apply-confirm" {}
228 }
229 }
230 dialog.wide id="compare-dialog" aria-labelledby="compare-title" {
231 div.dialog-head {
232 h2 id="compare-title" data-i18n="compareTitle" {}
233 button type="button" id="compare-close" data-i18n="close" {}
234 }
235 p id="compare-caption" {}
236 div.compare-stage id="compare-stage" data-background="checker" {}
237 p.hint id="compare-note" {}
238 }
239 dialog id="batch-dialog" aria-labelledby="batch-title" {
240 div.dialog-head {
241 h2 id="batch-title" {}
242 button type="button" id="batch-close" data-i18n="close" {}
243 }
244 div id="batch-policy-view" {
245 p data-i18n="batchIntro" {}
246 @for (id, label, checked) in [
247 ("batch-lossless", "batchLossless", true),
248 ("batch-lossy", "batchLossy", false),
249 ("batch-cross", "batchCross", false),
250 ("batch-alpha", "batchAlpha", false),
251 ("batch-quality", "batchQuality", false),
252 ] {
253 label.check { input type="checkbox" id=(id) checked[checked]; span data-i18n=(label) {} }
254 }
255 label.field { span data-i18n="batchMinScore" {} input type="number" id="batch-min-score" min="0" max="100" step="1" inputmode="decimal"; }
256 label.check { input type="checkbox" id="batch-scope"; span id="batch-scope-label" {} }
257 p.status-warn id="batch-error" role="alert" {}
258 div.dialog-actions { button.primary type="button" id="batch-preview" data-i18n="batchPreview" {} }
259 }
260 div id="batch-plan-view" hidden {
261 p.summary-text id="batch-summary" {}
262 ul.batch-list id="batch-items" {}
263 h3 id="batch-excluded-title" hidden {}
264 ul.batch-list.batch-excluded id="batch-excluded-items" hidden {}
265 div.dialog-actions {
266 button type="button" id="batch-back" data-i18n="cancelButton" {}
267 button.primary type="button" id="batch-confirm" {}
268 }
269 }
270 div id="batch-progress-view" hidden {
271 p id="batch-progress-text" role="status" aria-live="polite" {}
272 progress id="batch-bar" max="1" value="0" {}
273 p.status-warn id="batch-stopped" hidden data-i18n="batchStopped" {}
274 h3 id="batch-failures-title" hidden data-i18n="batchFailures" {}
275 ul.batch-list id="batch-failures" {}
276 div.dialog-actions {
277 button type="button" id="batch-stop" data-i18n="batchStop" {}
278 button.primary type="button" id="batch-done" hidden data-i18n="close" {}
279 }
280 }
281 }
282 }
283}