Skip to content

Commit 50f344f

Browse files
committed
feat: Add hmtl linter output
1 parent b78e7ad commit 50f344f

File tree

6 files changed

+1195
-2
lines changed

6 files changed

+1195
-2
lines changed

README.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
- [`--details`](#--details)
2222
- [`--format`](#--format)
2323
- [`--fix`](#--fix)
24+
- [Dry Run Mode](#dry-run-mode)
2425
- [`--ignore-pattern`](#--ignore-pattern)
2526
- [`--config`](#--config)
2627
- [`--ui5-config`](#--ui5-config)
@@ -150,7 +151,7 @@ ui5lint --details
150151

151152
#### `--format`
152153

153-
Choose the output format. Currently, `stylish` (default), `json` and `markdown` are supported.
154+
Choose the output format. Currently, `stylish` (default), `json`, `markdown` and `html` are supported.
154155

155156
**Example:**
156157
```sh

src/cli/base.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {Argv, ArgumentsCamelCase, CommandModule, MiddlewareFunction} from "yargs
22
import {Text} from "../formatter/text.js";
33
import {Json} from "../formatter/json.js";
44
import {Markdown} from "../formatter/markdown.js";
5+
import {Html} from "../formatter/html.js";
56
import {Coverage} from "../formatter/coverage.js";
67
import {writeFile} from "node:fs/promises";
78
import baseMiddleware from "./middlewares/base.js";
@@ -104,7 +105,7 @@ const lintCommand: FixedCommandModule<object, LinterArg> = {
104105
describe: "Set the output format for the linter result",
105106
default: "stylish",
106107
type: "string",
107-
choices: ["stylish", "json", "markdown"],
108+
choices: ["stylish", "json", "markdown", "html"],
108109
})
109110
.option("ui5-config", {
110111
describe: "Set a custom path for the UI5 Config (default: './ui5.yaml' if that file exists)",
@@ -183,6 +184,10 @@ async function handleLint(argv: ArgumentsCamelCase<LinterArg>) {
183184
const markdownFormatter = new Markdown();
184185
process.stdout.write(markdownFormatter.format(res, details, getVersion(), fix));
185186
process.stdout.write("\n");
187+
} else if (format === "html") {
188+
const htmlFormatter = new Html();
189+
process.stdout.write(htmlFormatter.format(res, details, getVersion(), fix));
190+
process.stdout.write("\n");
186191
} else if (format === "" || format === "stylish") {
187192
const textFormatter = new Text(rootDir);
188193
process.stderr.write(textFormatter.format(res, details, fix));

src/formatter/html.ts

+282
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
import {LintResult, LintMessage} from "../linter/LinterContext.js";
2+
import {LintMessageSeverity} from "../linter/messages.js";
3+
4+
export class Html {
5+
format(lintResults: LintResult[], showDetails: boolean, version: string, autofix: boolean): string {
6+
let totalErrorCount = 0;
7+
let totalWarningCount = 0;
8+
let totalFatalErrorCount = 0;
9+
10+
// Build the HTML content
11+
let resultsHtml = "";
12+
lintResults.forEach(({filePath, messages, errorCount, warningCount, fatalErrorCount}) => {
13+
if (!errorCount && !warningCount) {
14+
// Skip files without errors or warnings
15+
return;
16+
}
17+
// Accumulate totals
18+
totalErrorCount += errorCount;
19+
totalWarningCount += warningCount;
20+
totalFatalErrorCount += fatalErrorCount;
21+
22+
// Add the file path as a section header
23+
resultsHtml += `<div class="file">
24+
<h3>${filePath}</h3>
25+
<table>
26+
<thead>
27+
<tr>
28+
<th>Severity</th>
29+
<th>Rule</th>
30+
<th>Location</th>
31+
<th>Message</th>
32+
${showDetails ? "<th>Details</th>" : ""}
33+
</tr>
34+
</thead>
35+
<tbody>`;
36+
37+
// Sort messages by severity (fatal errors first, then errors, then warnings)
38+
messages.sort((a, b) => {
39+
// Handle fatal errors first to push them to the bottom
40+
if (a.fatal !== b.fatal) {
41+
return a.fatal ? -1 : 1; // Fatal errors go to the top
42+
}
43+
// Then, compare by severity
44+
if (a.severity !== b.severity) {
45+
return b.severity - a.severity;
46+
}
47+
// If severity is the same, compare by line number
48+
if ((a.line ?? 0) !== (b.line ?? 0)) {
49+
return (a.line ?? 0) - (b.line ?? 0);
50+
}
51+
// If both severity and line number are the same, compare by column number
52+
return (a.column ?? 0) - (b.column ?? 0);
53+
});
54+
55+
// Format each message
56+
messages.forEach((msg) => {
57+
const severityClass = this.getSeverityClass(msg.severity, msg.fatal);
58+
const severityText = this.formatSeverity(msg.severity, msg.fatal);
59+
const location = this.formatLocation(msg.line, msg.column);
60+
const rule = this.formatRuleId(msg.ruleId, version);
61+
62+
resultsHtml += `<tr class="${severityClass}">`;
63+
resultsHtml += `<td>${severityText}</td>`;
64+
resultsHtml += `<td>${rule}</td>`;
65+
resultsHtml += `<td><code>${location}</code></td>`;
66+
resultsHtml += `<td>${msg.message}</td>`;
67+
if (showDetails && msg.messageDetails) {
68+
resultsHtml += `<td>${this.formatMessageDetails(msg)}</td>`;
69+
} else if (showDetails) {
70+
resultsHtml += `<td></td>`;
71+
}
72+
resultsHtml += `</tr>`;
73+
});
74+
75+
resultsHtml += `</tbody></table></div>`;
76+
});
77+
78+
// Build summary
79+
const summary = `<div class="summary">
80+
<h2>Summary</h2>
81+
<p>
82+
${totalErrorCount + totalWarningCount} problems
83+
(${totalErrorCount} errors, ${totalWarningCount} warnings)
84+
</p>
85+
${totalFatalErrorCount ? `<p><strong>${totalFatalErrorCount} fatal errors</strong></p>` : ""}
86+
${!autofix && (totalErrorCount + totalWarningCount > 0) ?
87+
"<p>Run <code>ui5lint --fix</code> to resolve all auto-fixable problems</p>" :
88+
""}
89+
</div>`;
90+
91+
// Full HTML document with some basic styling
92+
const html = `<!DOCTYPE html>
93+
<html lang="en">
94+
<head>
95+
<meta charset="UTF-8">
96+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
97+
<title>UI5 Linter Report</title>
98+
<style>
99+
body {
100+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
101+
Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
102+
line-height: 1.5;
103+
max-width: 1200px;
104+
margin: 0 auto;
105+
padding: 20px;
106+
color: #333;
107+
}
108+
h1, h2 {
109+
border-bottom: 1px solid #eaecef;
110+
padding-bottom: 0.3em;
111+
color: #24292e;
112+
}
113+
h3 {
114+
padding: 10px;
115+
margin: 0;
116+
background-color: #f6f8fa;
117+
border-top-left-radius: 4px;
118+
border-top-right-radius: 4px;
119+
border: 1px solid #eaecef;
120+
border-bottom: none;
121+
}
122+
table {
123+
width: 100%;
124+
border-collapse: collapse;
125+
margin-bottom: 20px;
126+
border: 1px solid #eaecef;
127+
border-radius: 4px;
128+
}
129+
th, td {
130+
text-align: left;
131+
padding: 8px 12px;
132+
border-bottom: 1px solid #eaecef;
133+
}
134+
th {
135+
background-color: #f6f8fa;
136+
font-weight: 600;
137+
}
138+
tr.error {
139+
background-color: #fff5f5;
140+
}
141+
tr.error td:first-child {
142+
color: #d73a49;
143+
font-weight: 600;
144+
}
145+
tr.warning {
146+
background-color: #fffbea;
147+
}
148+
tr.warning td:first-child {
149+
color: #e36209;
150+
font-weight: 600;
151+
}
152+
tr.fatal-error {
153+
background-color: #ffdce0;
154+
}
155+
tr.fatal-error td:first-child {
156+
color: #b31d28;
157+
font-weight: 600;
158+
}
159+
code {
160+
background-color: #f6f8fa;
161+
padding: 0.2em 0.4em;
162+
border-radius: 3px;
163+
font-family: SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
164+
}
165+
.summary {
166+
margin-bottom: 30px;
167+
}
168+
.file {
169+
margin-bottom: 30px;
170+
}
171+
.note {
172+
margin-top: 20px;
173+
padding: 10px;
174+
background-color: #f6f8fa;
175+
border-radius: 4px;
176+
font-size: 14px;
177+
}
178+
a {
179+
color: #0366d6;
180+
text-decoration: none;
181+
}
182+
a:hover {
183+
text-decoration: underline;
184+
}
185+
@media (max-width: 768px) {
186+
body {
187+
padding: 10px;
188+
}
189+
table {
190+
display: block;
191+
overflow-x: auto;
192+
}
193+
}
194+
</style>
195+
</head>
196+
<body>
197+
<h1>UI5 Linter Report</h1>
198+
<p>Generated on ${new Date().toLocaleString()} with UI5 Linter v${version}</p>
199+
200+
${summary}
201+
202+
${resultsHtml ? `<h2>Findings</h2>${resultsHtml}` : "<p>No issues found. Your code looks great!</p>"}
203+
204+
${!showDetails && (totalErrorCount + totalWarningCount) > 0 ?
205+
"<div class=\"note\"><strong>Note:</strong> Use <code>ui5lint --details</code> " +
206+
"to show more information about the findings.</div>" :
207+
""}
208+
</body>
209+
</html>`;
210+
211+
return html;
212+
}
213+
214+
// Formats the severity of the lint message
215+
private formatSeverity(severity: LintMessageSeverity, fatal: LintMessage["fatal"]): string {
216+
if (fatal === true) {
217+
return "Fatal Error";
218+
} else if (severity === LintMessageSeverity.Warning) {
219+
return "Warning";
220+
} else if (severity === LintMessageSeverity.Error) {
221+
return "Error";
222+
} else {
223+
throw new Error(`Unknown severity: ${LintMessageSeverity[severity]}`);
224+
}
225+
}
226+
227+
// Returns CSS class name based on severity
228+
private getSeverityClass(severity: LintMessageSeverity, fatal: LintMessage["fatal"]): string {
229+
if (fatal === true) {
230+
return "fatal-error";
231+
} else if (severity === LintMessageSeverity.Warning) {
232+
return "warning";
233+
} else if (severity === LintMessageSeverity.Error) {
234+
return "error";
235+
} else {
236+
return "";
237+
}
238+
}
239+
240+
// Formats the location of the lint message (line and column numbers)
241+
private formatLocation(line?: number, column?: number): string {
242+
// Default to 0 if line or column are not provided
243+
return `${line ?? 0}:${column ?? 0}`;
244+
}
245+
246+
// Formats additional message details if available
247+
private formatMessageDetails(msg: LintMessage): string {
248+
if (!msg.messageDetails) {
249+
return "";
250+
}
251+
// Replace multiple spaces, tabs, or newlines with a single space for clean output
252+
// This more comprehensive regex handles all whitespace characters
253+
const cleanedDetails = msg.messageDetails.replace(/[\s\t\r\n]+/g, " ");
254+
255+
// Convert URLs to hyperlinks
256+
// This regex matches http/https URLs and also patterns like ui5.sap.com/... with or without protocol
257+
return cleanedDetails.replace(
258+
/(https?:\/\/[^\s)]+)|(\([^(]*?)(https?:\/\/[^\s)]+)([^)]*?\))|(\b(?:www\.|ui5\.sap\.com)[^\s)]+)/g,
259+
(match, directUrl, beforeParen, urlInParen, afterParen, domainUrl) => {
260+
if (directUrl) {
261+
// Direct URL without parentheses
262+
return `<a href="${directUrl}" target="_blank">${directUrl}</a>`;
263+
} else if (urlInParen) {
264+
// URL inside parentheses - keep the parentheses as text but make the URL a link
265+
return `${beforeParen}<a href="${urlInParen}" target="_blank">${urlInParen}</a>${afterParen}`;
266+
} else if (domainUrl) {
267+
// Domain starting with www. or ui5.sap.com without http(s)://
268+
const fullUrl = typeof domainUrl === "string" && domainUrl.startsWith("www.") ?
269+
`http://${domainUrl}` :
270+
`https://${domainUrl}`;
271+
return `<a href="${fullUrl}" target="_blank">${domainUrl}</a>`;
272+
}
273+
return match;
274+
}
275+
);
276+
}
277+
278+
// Formats the rule of the lint message (ruleId and link to rules.md)
279+
private formatRuleId(ruleId: string, version: string): string {
280+
return `<a href="https://github.com/SAP/ui5-linter/blob/v${version}/docs/Rules.md#${ruleId}" target="_blank">${ruleId}</a>`;
281+
}
282+
}

0 commit comments

Comments
 (0)