jsslint_core/
html_output.rs1use crate::catalogue;
20use crate::report::ComplianceReport;
21use std::collections::BTreeMap;
22
23pub(crate) fn escape_html(s: &str) -> String {
28 let mut out = String::with_capacity(s.len());
29 for c in s.chars() {
30 match c {
31 '&' => out.push_str("&"),
32 '<' => out.push_str("<"),
33 '>' => out.push_str(">"),
34 '"' => out.push_str("""),
35 '\'' => out.push_str("'"),
36 other => out.push(other),
37 }
38 }
39 out
40}
41
42const AUTHOR_STYLE: &str = " body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; margin: 2rem; color: #222; }\n h1 { margin-top: 0; }\n h2 { border-bottom: 1px solid #ccc; padding-bottom: 0.25rem; margin-top: 2rem; }\n table { border-collapse: collapse; width: 100%; margin-top: 0.5rem; }\n th, td { border: 1px solid #ddd; padding: 0.4rem 0.6rem; text-align: left; vertical-align: top; font-size: 0.9rem; }\n th { background: #f5f5f5; }\n .sev-error { color: #b00020; font-weight: 600; }\n .sev-warning { color: #b8860b; font-weight: 600; }\n .loc { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; white-space: nowrap; }\n .rid { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }\n .none { color: #888; font-style: italic; }\n .ok { color: #2a7a2a; font-weight: 600; }\n";
43
44pub fn render_author(report: &ComplianceReport) -> String {
46 let journal = escape_html(&report.journal_id);
47 let mut out = String::new();
48 out.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>jss-lint report \u{2014} ");
49 out.push_str(&journal);
50 out.push_str("</title>\n<style>\n");
51 out.push_str(AUTHOR_STYLE);
52 out.push_str("</style>\n</head>\n<body>\n<h1>jss-lint report</h1>\n<p>Journal: <strong>");
53 out.push_str(&journal);
54 out.push_str("</strong> \u{b7} Tool version: <code>");
55 out.push_str(&escape_html(&report.tool_version));
56 out.push_str("</code></p>\n");
57
58 if report.violations.is_empty() {
59 out.push_str("\n<p class=\"ok\">No violations found.</p>\n");
60 out.push('\n');
61 } else {
62 out.push('\n');
63 let mut by_file: BTreeMap<&str, Vec<&crate::report::Violation>> = BTreeMap::new();
64 for v in &report.violations {
65 by_file.entry(v.file.as_str()).or_default().push(v);
66 }
67 for (file, violations) in &by_file {
68 out.push('\n');
69 out.push_str("<h2>");
70 out.push_str(&escape_html(file));
71 out.push_str("</h2>\n<table>\n <thead>\n <tr><th>Line:Col</th><th>Severity</th><th>Rule</th><th>Message</th><th>Section</th><th>Suggestion</th></tr>\n </thead>\n <tbody>\n ");
72 for v in violations {
73 out.push_str("\n \n <tr>\n <td class=\"loc\">");
74 out.push_str(&v.line.to_string());
75 if let Some(col) = v.column {
76 out.push(':');
77 out.push_str(&col.to_string());
78 }
79 out.push_str("</td>\n <td class=\"sev-");
80 out.push_str(v.severity.as_str());
81 out.push_str("\">");
82 out.push_str(v.severity.as_str());
83 out.push_str("</td>\n <td class=\"rid\">");
84 out.push_str(&escape_html(&v.rule_id));
85 out.push_str("</td>\n <td>");
86 out.push_str(&escape_html(&v.message));
87 out.push_str("</td>\n <td>");
88
89 let meta = catalogue::lookup(&v.rule_id);
90 let section = meta.map(|m| m.guide_section).unwrap_or("");
91 let url = meta.and_then(|m| m.guide_url);
92 match url {
93 Some(url) if !section.is_empty() && section != "internal" => {
94 out.push_str("<a href=\"");
95 out.push_str(&escape_html(url));
96 out.push_str("\">");
97 out.push_str(&escape_html(section));
98 out.push_str("</a>");
99 }
100 _ => out.push_str("<span class=\"none\">internal</span>"),
101 }
102 out.push_str("</td>\n <td>");
103 match v.suggestion.as_deref() {
104 Some(s) if !s.is_empty() => out.push_str(&escape_html(s)),
105 _ => out.push_str("<span class=\"none\">\u{2014}</span>"),
106 }
107 out.push_str("</td>\n </tr>\n ");
108 }
109 out.push_str("\n </tbody>\n</table>\n");
110 }
111 out.push('\n');
112 out.push('\n');
113 }
114 out.push_str("</body>\n</html>\n");
115 out
116}
117
118const REVIEWER_STYLE: &str = " body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; margin: 2rem; color: #222; }\n h1 { margin-top: 0; }\n table { border-collapse: collapse; width: 100%; margin-top: 0.5rem; }\n th, td { border: 1px solid #ddd; padding: 0.5rem 0.75rem; font-size: 0.95rem; }\n th { background: #f5f5f5; text-align: left; }\n .num { text-align: right; font-variant-numeric: tabular-nums; }\n .status-PASS { color: #2a7a2a; font-weight: 600; }\n .status-FAIL { color: #b00020; font-weight: 600; }\n .status-SKIPPED { color: #777; font-weight: 600; }\n .overall { font-size: 1.6rem; margin-top: 1rem; }\n .overall.none { color: #777; }\n";
119
120pub fn render_reviewer(report: &ComplianceReport) -> String {
122 let journal = escape_html(&report.journal_id);
123 let mut out = String::new();
124 out.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>jss-lint compliance \u{2014} ");
125 out.push_str(&journal);
126 out.push_str("</title>\n<style>\n");
127 out.push_str(REVIEWER_STYLE);
128 out.push_str("</style>\n</head>\n<body>\n<h1>Journal compliance \u{2014} ");
129 out.push_str(&journal);
130 out.push_str("</h1>\n<p>Tool version: <code>");
131 out.push_str(&escape_html(&report.tool_version));
132 out.push_str("</code></p>\n<table>\n <thead>\n <tr><th>Category</th><th>Status</th><th class=\"num\">Applied</th><th class=\"num\">Passed</th></tr>\n </thead>\n <tbody>\n ");
133
134 for c in &report.categories {
135 out.push_str("\n <tr>\n <td>");
136 out.push_str(&escape_html(&c.title));
137 out.push_str("</td>\n <td class=\"status-");
138 out.push_str(c.status.as_str());
139 out.push_str("\">");
140 out.push_str(c.status.as_str());
141 out.push_str("</td>\n <td class=\"num\">");
142 out.push_str(&c.rules_applied.to_string());
143 out.push_str("</td>\n <td class=\"num\">");
144 out.push_str(&c.rules_passed.to_string());
145 out.push_str("</td>\n </tr>\n ");
146 }
147 out.push_str("\n </tbody>\n</table>\n");
148
149 out.push('\n');
150 match report.compliance_percentage {
151 None => out.push_str("<p class=\"overall none\">Overall: n/a</p>\n"),
152 Some(pct) => out.push_str(&format!(
153 "<p class=\"overall\">Overall: <strong>{pct:.1}%</strong></p>\n"
154 )),
155 }
156 out.push('\n');
157
158 out.push_str("</body>\n</html>\n");
159 out
160}
161
162pub fn render(report: &ComplianceReport, mode: crate::config::Mode) -> String {
166 match mode {
167 crate::config::Mode::Reviewer => render_reviewer(report),
168 crate::config::Mode::Author => render_author(report),
169 }
170}