Skip to main content

jsslint_core/
html_output.rs

1//! Author/reviewer HTML renderer — hand-translated from
2//! `output/html_output.py`'s `author.html.j2`/`reviewer.html.j2`
3//! (no templating-engine dependency, matching this crate's existing
4//! `terminal.rs`/`sarif.rs`/`conformance.rs` approach of building
5//! output strings directly). Byte-exact output was derived by reading
6//! the templates' Jinja2 whitespace-control semantics (`{%- -%}` trim
7//! markers around the guide-link `<td>`; every other `{% %}` tag is
8//! *not* trimmed, since the templates' `Environment` doesn't set
9//! `trim_blocks`/`lstrip_blocks`, so untrimmed tags leave their
10//! adjacent source newlines/indentation in the output) and cross-
11//! checked against real `jss-lint --output html` renders.
12//!
13//! `report --format html` (spec 015's `conformance.html.j2`) is a
14//! separate template, rendered by `conformance.rs::render_html`
15//! (which reuses this module's `escape_html`) — this module only
16//! covers the bare-lint `--output html` path
17//! (`author.html.j2`/`reviewer.html.j2`).
18
19use crate::catalogue;
20use crate::report::ComplianceReport;
21use std::collections::BTreeMap;
22
23/// Mirrors `markupsafe.escape()`, which Jinja2's `select_autoescape`
24/// uses for every `{{ }}` interpolation in these `.html.j2` templates.
25/// `pub(crate)`: also reused by `conformance.rs` for
26/// `conformance.html.j2`, which autoescapes with the same rules.
27pub(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("&amp;"),
32            '<' => out.push_str("&lt;"),
33            '>' => out.push_str("&gt;"),
34            '"' => out.push_str("&#34;"),
35            '\'' => out.push_str("&#39;"),
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
44/// Mirrors `author.html.j2` (individual violations grouped by file).
45pub 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
120/// Mirrors `reviewer.html.j2` (per-category compliance table).
121pub 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
162/// Renders exactly as `jss-lint --output html` does: `author.html.j2`
163/// in author mode, `reviewer.html.j2` in reviewer mode. Mirrors
164/// `html_output.py::render`.
165pub 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}