Skip to content

feat: Add html linter output #627

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
- [`--details`](#--details)
- [`--format`](#--format)
- [`--fix`](#--fix)
- [Dry Run Mode](#dry-run-mode)
- [`--ignore-pattern`](#--ignore-pattern)
- [`--config`](#--config)
- [`--ui5-config`](#--ui5-config)
Expand Down Expand Up @@ -150,7 +151,7 @@ ui5lint --details

#### `--format`

Choose the output format. Currently, `stylish` (default), `json` and `markdown` are supported.
Choose the output format. Currently, `stylish` (default), `json`, `markdown` and `html` are supported.

**Example:**
```sh
Expand Down
7 changes: 6 additions & 1 deletion src/cli/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {Argv, ArgumentsCamelCase, CommandModule, MiddlewareFunction} from "yargs
import {Text} from "../formatter/text.js";
import {Json} from "../formatter/json.js";
import {Markdown} from "../formatter/markdown.js";
import {Html} from "../formatter/html.js";
import {Coverage} from "../formatter/coverage.js";
import {writeFile} from "node:fs/promises";
import baseMiddleware from "./middlewares/base.js";
Expand Down Expand Up @@ -104,7 +105,7 @@ const lintCommand: FixedCommandModule<object, LinterArg> = {
describe: "Set the output format for the linter result",
default: "stylish",
type: "string",
choices: ["stylish", "json", "markdown"],
choices: ["stylish", "json", "markdown", "html"],
})
.option("ui5-config", {
describe: "Set a custom path for the UI5 Config (default: './ui5.yaml' if that file exists)",
Expand Down Expand Up @@ -183,6 +184,10 @@ async function handleLint(argv: ArgumentsCamelCase<LinterArg>) {
const markdownFormatter = new Markdown();
process.stdout.write(markdownFormatter.format(res, details, getVersion(), fix));
process.stdout.write("\n");
} else if (format === "html") {
const htmlFormatter = new Html();
process.stdout.write(htmlFormatter.format(res, details, getVersion(), fix));
process.stdout.write("\n");
} else if (format === "" || format === "stylish") {
const textFormatter = new Text(rootDir);
process.stderr.write(textFormatter.format(res, details, fix));
Expand Down
282 changes: 282 additions & 0 deletions src/formatter/html.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
import {LintResult, LintMessage} from "../linter/LinterContext.js";
import {LintMessageSeverity} from "../linter/messages.js";

export class Html {
format(lintResults: LintResult[], showDetails: boolean, version: string, autofix: boolean): string {
let totalErrorCount = 0;
let totalWarningCount = 0;
let totalFatalErrorCount = 0;

// Build the HTML content
let resultsHtml = "";
lintResults.forEach(({filePath, messages, errorCount, warningCount, fatalErrorCount}) => {
if (!errorCount && !warningCount) {
// Skip files without errors or warnings
return;
}
// Accumulate totals
totalErrorCount += errorCount;
totalWarningCount += warningCount;
totalFatalErrorCount += fatalErrorCount;

// Add the file path as a section header
resultsHtml += `<div class="file">
<h3>${filePath}</h3>
<table>
<thead>
<tr>
<th>Severity</th>
<th>Rule</th>
<th>Location</th>
<th>Message</th>
${showDetails ? "<th>Details</th>" : ""}
</tr>
</thead>
<tbody>`;

// Sort messages by severity (fatal errors first, then errors, then warnings)
messages.sort((a, b) => {
// Handle fatal errors first to push them to the bottom
if (a.fatal !== b.fatal) {
return a.fatal ? -1 : 1; // Fatal errors go to the top
}
// Then, compare by severity
if (a.severity !== b.severity) {
return b.severity - a.severity;
}
// If severity is the same, compare by line number
if ((a.line ?? 0) !== (b.line ?? 0)) {
return (a.line ?? 0) - (b.line ?? 0);
}
// If both severity and line number are the same, compare by column number
return (a.column ?? 0) - (b.column ?? 0);
});

// Format each message
messages.forEach((msg) => {
const severityClass = this.getSeverityClass(msg.severity, msg.fatal);
const severityText = this.formatSeverity(msg.severity, msg.fatal);
const location = this.formatLocation(msg.line, msg.column);
const rule = this.formatRuleId(msg.ruleId, version);

resultsHtml += `<tr class="${severityClass}">`;
resultsHtml += `<td>${severityText}</td>`;
resultsHtml += `<td>${rule}</td>`;
resultsHtml += `<td><code>${location}</code></td>`;
resultsHtml += `<td>${msg.message}</td>`;
if (showDetails && msg.messageDetails) {
resultsHtml += `<td>${this.formatMessageDetails(msg)}</td>`;
} else if (showDetails) {
resultsHtml += `<td></td>`;
}
resultsHtml += `</tr>`;
});

resultsHtml += `</tbody></table></div>`;
});

// Build summary
const summary = `<div class="summary">
<h2>Summary</h2>
<p>
${totalErrorCount + totalWarningCount} problems
(${totalErrorCount} errors, ${totalWarningCount} warnings)
</p>
${totalFatalErrorCount ? `<p><strong>${totalFatalErrorCount} fatal errors</strong></p>` : ""}
${!autofix && (totalErrorCount + totalWarningCount > 0) ?
"<p>Run <code>ui5lint --fix</code> to resolve all auto-fixable problems</p>" :
""}
</div>`;

// Full HTML document with some basic styling
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>UI5 Linter Report</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
line-height: 1.5;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
color: #333;
}
h1, h2 {
border-bottom: 1px solid #eaecef;
padding-bottom: 0.3em;
color: #24292e;
}
h3 {
padding: 10px;
margin: 0;
background-color: #f6f8fa;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
border: 1px solid #eaecef;
border-bottom: none;
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
border: 1px solid #eaecef;
border-radius: 4px;
}
th, td {
text-align: left;
padding: 8px 12px;
border-bottom: 1px solid #eaecef;
}
th {
background-color: #f6f8fa;
font-weight: 600;
}
tr.error {
background-color: #fff5f5;
}
tr.error td:first-child {
color: #d73a49;
font-weight: 600;
}
tr.warning {
background-color: #fffbea;
}
tr.warning td:first-child {
color: #e36209;
font-weight: 600;
}
tr.fatal-error {
background-color: #ffdce0;
}
tr.fatal-error td:first-child {
color: #b31d28;
font-weight: 600;
}
code {
background-color: #f6f8fa;
padding: 0.2em 0.4em;
border-radius: 3px;
font-family: SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
}
.summary {
margin-bottom: 30px;
}
.file {
margin-bottom: 30px;
}
.note {
margin-top: 20px;
padding: 10px;
background-color: #f6f8fa;
border-radius: 4px;
font-size: 14px;
}
a {
color: #0366d6;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
@media (max-width: 768px) {
body {
padding: 10px;
}
table {
display: block;
overflow-x: auto;
}
}
</style>
</head>
<body>
<h1>UI5 Linter Report</h1>
<p>Generated on ${new Date().toLocaleString()} with UI5 Linter v${version}</p>

${summary}

${resultsHtml ? `<h2>Findings</h2>${resultsHtml}` : "<p>No issues found. Your code looks great!</p>"}

${!showDetails && (totalErrorCount + totalWarningCount) > 0 ?
"<div class=\"note\"><strong>Note:</strong> Use <code>ui5lint --details</code> " +
"to show more information about the findings.</div>" :
""}
</body>
</html>`;

return html;
}

// Formats the severity of the lint message
private formatSeverity(severity: LintMessageSeverity, fatal: LintMessage["fatal"]): string {
if (fatal === true) {
return "Fatal Error";
} else if (severity === LintMessageSeverity.Warning) {
return "Warning";
} else if (severity === LintMessageSeverity.Error) {
return "Error";
} else {
throw new Error(`Unknown severity: ${LintMessageSeverity[severity]}`);
}
}

// Returns CSS class name based on severity
private getSeverityClass(severity: LintMessageSeverity, fatal: LintMessage["fatal"]): string {
if (fatal === true) {
return "fatal-error";
} else if (severity === LintMessageSeverity.Warning) {
return "warning";
} else if (severity === LintMessageSeverity.Error) {
return "error";
} else {
return "";
}
}

// Formats the location of the lint message (line and column numbers)
private formatLocation(line?: number, column?: number): string {
// Default to 0 if line or column are not provided
return `${line ?? 0}:${column ?? 0}`;
}

// Formats additional message details if available
private formatMessageDetails(msg: LintMessage): string {
if (!msg.messageDetails) {
return "";
}
// Replace multiple spaces, tabs, or newlines with a single space for clean output
// This more comprehensive regex handles all whitespace characters
const cleanedDetails = msg.messageDetails.replace(/[\s\t\r\n]+/g, " ");

// Convert URLs to hyperlinks
// This regex matches http/https URLs and also patterns like ui5.sap.com/... with or without protocol
return cleanedDetails.replace(
/(https?:\/\/[^\s)]+)|(\([^(]*?)(https?:\/\/[^\s)]+)([^)]*?\))|(\b(?:www\.|ui5\.sap\.com)[^\s)]+)/g,
(match, directUrl, beforeParen, urlInParen, afterParen, domainUrl) => {
if (directUrl) {
// Direct URL without parentheses
return `<a href="${directUrl}" target="_blank">${directUrl}</a>`;
} else if (urlInParen) {
// URL inside parentheses - keep the parentheses as text but make the URL a link
return `${beforeParen}<a href="${urlInParen}" target="_blank">${urlInParen}</a>${afterParen}`;
} else if (domainUrl) {
// Domain starting with www. or ui5.sap.com without http(s)://
const fullUrl = typeof domainUrl === "string" && domainUrl.startsWith("www.") ?
`http://${domainUrl}` :
`https://${domainUrl}`;
return `<a href="${fullUrl}" target="_blank">${domainUrl}</a>`;
}
return match;
}
);
}

// Formats the rule of the lint message (ruleId and link to rules.md)
private formatRuleId(ruleId: string, version: string): string {
return `<a href="https://github.com/SAP/ui5-linter/blob/v${version}/docs/Rules.md#${ruleId}" target="_blank">${ruleId}</a>`;
}
}
Loading
Loading