@herb-tools/linter 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -0
- package/bin/herb-lint +3 -0
- package/dist/herb-lint.js +16505 -0
- package/dist/herb-lint.js.map +1 -0
- package/dist/index.cjs +834 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.js +820 -0
- package/dist/index.js.map +1 -0
- package/dist/package.json +49 -0
- package/dist/src/cli/argument-parser.js +96 -0
- package/dist/src/cli/argument-parser.js.map +1 -0
- package/dist/src/cli/file-processor.js +58 -0
- package/dist/src/cli/file-processor.js.map +1 -0
- package/dist/src/cli/formatters/base-formatter.js +3 -0
- package/dist/src/cli/formatters/base-formatter.js.map +1 -0
- package/dist/src/cli/formatters/detailed-formatter.js +62 -0
- package/dist/src/cli/formatters/detailed-formatter.js.map +1 -0
- package/dist/src/cli/formatters/index.js +4 -0
- package/dist/src/cli/formatters/index.js.map +1 -0
- package/dist/src/cli/formatters/simple-formatter.js +31 -0
- package/dist/src/cli/formatters/simple-formatter.js.map +1 -0
- package/dist/src/cli/index.js +5 -0
- package/dist/src/cli/index.js.map +1 -0
- package/dist/src/cli/summary-reporter.js +96 -0
- package/dist/src/cli/summary-reporter.js.map +1 -0
- package/dist/src/cli.js +50 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/default-rules.js +31 -0
- package/dist/src/default-rules.js.map +1 -0
- package/dist/src/herb-lint.js +5 -0
- package/dist/src/herb-lint.js.map +1 -0
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/linter.js +39 -0
- package/dist/src/linter.js.map +1 -0
- package/dist/src/rules/erb-no-empty-tags.js +23 -0
- package/dist/src/rules/erb-no-empty-tags.js.map +1 -0
- package/dist/src/rules/erb-no-output-control-flow.js +47 -0
- package/dist/src/rules/erb-no-output-control-flow.js.map +1 -0
- package/dist/src/rules/erb-require-whitespace-inside-tags.js +43 -0
- package/dist/src/rules/erb-require-whitespace-inside-tags.js.map +1 -0
- package/dist/src/rules/html-anchor-require-href.js +25 -0
- package/dist/src/rules/html-anchor-require-href.js.map +1 -0
- package/dist/src/rules/html-aria-role-heading-requires-level.js +26 -0
- package/dist/src/rules/html-aria-role-heading-requires-level.js.map +1 -0
- package/dist/src/rules/html-attribute-double-quotes.js +21 -0
- package/dist/src/rules/html-attribute-double-quotes.js.map +1 -0
- package/dist/src/rules/html-attribute-values-require-quotes.js +22 -0
- package/dist/src/rules/html-attribute-values-require-quotes.js.map +1 -0
- package/dist/src/rules/html-boolean-attributes-no-value.js +19 -0
- package/dist/src/rules/html-boolean-attributes-no-value.js.map +1 -0
- package/dist/src/rules/html-img-require-alt.js +29 -0
- package/dist/src/rules/html-img-require-alt.js.map +1 -0
- package/dist/src/rules/html-no-block-inside-inline.js +59 -0
- package/dist/src/rules/html-no-block-inside-inline.js.map +1 -0
- package/dist/src/rules/html-no-duplicate-attributes.js +43 -0
- package/dist/src/rules/html-no-duplicate-attributes.js.map +1 -0
- package/dist/src/rules/html-no-empty-headings.js +148 -0
- package/dist/src/rules/html-no-empty-headings.js.map +1 -0
- package/dist/src/rules/html-no-nested-links.js +45 -0
- package/dist/src/rules/html-no-nested-links.js.map +1 -0
- package/dist/src/rules/html-tag-name-lowercase.js +39 -0
- package/dist/src/rules/html-tag-name-lowercase.js.map +1 -0
- package/dist/src/rules/index.js +13 -0
- package/dist/src/rules/index.js.map +1 -0
- package/dist/src/rules/rule-utils.js +198 -0
- package/dist/src/rules/rule-utils.js.map +1 -0
- package/dist/src/types.js +2 -0
- package/dist/src/types.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/dist/types/cli/argument-parser.d.ts +14 -0
- package/dist/types/cli/file-processor.d.ts +21 -0
- package/dist/types/cli/formatters/base-formatter.d.ts +6 -0
- package/dist/types/cli/formatters/detailed-formatter.d.ts +13 -0
- package/dist/types/cli/formatters/index.d.ts +3 -0
- package/dist/types/cli/formatters/simple-formatter.d.ts +7 -0
- package/dist/types/cli/summary-reporter.d.ts +22 -0
- package/dist/types/cli.d.ts +6 -0
- package/dist/types/default-rules.d.ts +2 -0
- package/dist/types/herb-lint.d.ts +2 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/linter.d.ts +18 -0
- package/dist/types/rules/erb-no-empty-tags.d.ts +6 -0
- package/dist/types/rules/erb-no-output-control-flow.d.ts +6 -0
- package/dist/types/rules/erb-require-whitespace-inside-tags.d.ts +6 -0
- package/dist/types/rules/html-anchor-require-href.d.ts +6 -0
- package/dist/types/rules/html-aria-role-heading-requires-level.d.ts +6 -0
- package/dist/types/rules/html-attribute-double-quotes.d.ts +6 -0
- package/dist/types/rules/html-attribute-values-require-quotes.d.ts +6 -0
- package/dist/types/rules/html-boolean-attributes-no-value.d.ts +6 -0
- package/dist/types/rules/html-img-require-alt.d.ts +6 -0
- package/dist/types/rules/html-no-block-inside-inline.d.ts +6 -0
- package/dist/types/rules/html-no-duplicate-attributes.d.ts +6 -0
- package/dist/types/rules/html-no-empty-headings.d.ts +6 -0
- package/dist/types/rules/html-no-nested-links.d.ts +6 -0
- package/dist/types/rules/html-tag-name-lowercase.d.ts +6 -0
- package/dist/types/rules/index.d.ts +12 -0
- package/dist/types/rules/rule-utils.d.ts +89 -0
- package/dist/types/src/cli/argument-parser.d.ts +14 -0
- package/dist/types/src/cli/file-processor.d.ts +21 -0
- package/dist/types/src/cli/formatters/base-formatter.d.ts +6 -0
- package/dist/types/src/cli/formatters/detailed-formatter.d.ts +13 -0
- package/dist/types/src/cli/formatters/index.d.ts +3 -0
- package/dist/types/src/cli/formatters/simple-formatter.d.ts +7 -0
- package/dist/types/src/cli/index.d.ts +4 -0
- package/dist/types/src/cli/summary-reporter.d.ts +22 -0
- package/dist/types/src/cli.d.ts +6 -0
- package/dist/types/src/default-rules.d.ts +2 -0
- package/dist/types/src/herb-lint.d.ts +2 -0
- package/dist/types/src/index.d.ts +3 -0
- package/dist/types/src/linter.d.ts +18 -0
- package/dist/types/src/rules/erb-no-empty-tags.d.ts +6 -0
- package/dist/types/src/rules/erb-no-output-control-flow.d.ts +6 -0
- package/dist/types/src/rules/erb-require-whitespace-inside-tags.d.ts +6 -0
- package/dist/types/src/rules/html-anchor-require-href.d.ts +6 -0
- package/dist/types/src/rules/html-aria-role-heading-requires-level.d.ts +6 -0
- package/dist/types/src/rules/html-attribute-double-quotes.d.ts +6 -0
- package/dist/types/src/rules/html-attribute-values-require-quotes.d.ts +6 -0
- package/dist/types/src/rules/html-boolean-attributes-no-value.d.ts +6 -0
- package/dist/types/src/rules/html-img-require-alt.d.ts +6 -0
- package/dist/types/src/rules/html-no-block-inside-inline.d.ts +6 -0
- package/dist/types/src/rules/html-no-duplicate-attributes.d.ts +6 -0
- package/dist/types/src/rules/html-no-empty-headings.d.ts +6 -0
- package/dist/types/src/rules/html-no-nested-links.d.ts +6 -0
- package/dist/types/src/rules/html-tag-name-lowercase.d.ts +6 -0
- package/dist/types/src/rules/index.d.ts +12 -0
- package/dist/types/src/rules/rule-utils.d.ts +89 -0
- package/dist/types/src/types.d.ts +26 -0
- package/dist/types/types.d.ts +26 -0
- package/docs/rules/README.md +39 -0
- package/docs/rules/erb-no-empty-tags.md +38 -0
- package/docs/rules/erb-no-output-control-flow.md +45 -0
- package/docs/rules/erb-require-whitespace-inside-tags.md +43 -0
- package/docs/rules/html-anchor-require-href.md +32 -0
- package/docs/rules/html-aria-role-heading-requires-level.md +34 -0
- package/docs/rules/html-attribute-double-quotes.md +43 -0
- package/docs/rules/html-attribute-values-require-quotes.md +43 -0
- package/docs/rules/html-boolean-attributes-no-value.md +39 -0
- package/docs/rules/html-img-require-alt.md +44 -0
- package/docs/rules/html-no-block-inside-inline.md +66 -0
- package/docs/rules/html-no-duplicate-attributes.md +35 -0
- package/docs/rules/html-no-empty-headings.md +78 -0
- package/docs/rules/html-no-nested-links.md +44 -0
- package/docs/rules/html-tag-name-lowercase.md +44 -0
- package/package.json +49 -0
- package/src/cli/argument-parser.ts +125 -0
- package/src/cli/file-processor.ts +86 -0
- package/src/cli/formatters/base-formatter.ts +11 -0
- package/src/cli/formatters/detailed-formatter.ts +74 -0
- package/src/cli/formatters/index.ts +3 -0
- package/src/cli/formatters/simple-formatter.ts +40 -0
- package/src/cli/index.ts +4 -0
- package/src/cli/summary-reporter.ts +127 -0
- package/src/cli.ts +60 -0
- package/src/default-rules.ts +33 -0
- package/src/herb-lint.ts +6 -0
- package/src/index.ts +3 -0
- package/src/linter.ts +50 -0
- package/src/rules/erb-no-empty-tags.ts +34 -0
- package/src/rules/erb-no-output-control-flow.ts +61 -0
- package/src/rules/erb-require-whitespace-inside-tags.ts +61 -0
- package/src/rules/html-anchor-require-href.ts +39 -0
- package/src/rules/html-aria-role-heading-requires-level.ts +44 -0
- package/src/rules/html-attribute-double-quotes.ts +28 -0
- package/src/rules/html-attribute-values-require-quotes.ts +30 -0
- package/src/rules/html-boolean-attributes-no-value.ts +27 -0
- package/src/rules/html-img-require-alt.ts +42 -0
- package/src/rules/html-no-block-inside-inline.ts +84 -0
- package/src/rules/html-no-duplicate-attributes.ts +59 -0
- package/src/rules/html-no-empty-headings.ts +185 -0
- package/src/rules/html-no-nested-links.ts +65 -0
- package/src/rules/html-tag-name-lowercase.ts +50 -0
- package/src/rules/index.ts +12 -0
- package/src/rules/rule-utils.ts +257 -0
- package/src/types.ts +32 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import dedent from "dedent"
|
|
2
|
+
|
|
3
|
+
import { parseArgs } from "util"
|
|
4
|
+
import { statSync } from "fs"
|
|
5
|
+
import { join } from "path"
|
|
6
|
+
|
|
7
|
+
import { Herb } from "@herb-tools/node-wasm"
|
|
8
|
+
|
|
9
|
+
import { THEME_NAMES, DEFAULT_THEME } from "@herb-tools/highlighter"
|
|
10
|
+
import type { ThemeInput } from "@herb-tools/highlighter"
|
|
11
|
+
|
|
12
|
+
import { name, version } from "../../package.json"
|
|
13
|
+
|
|
14
|
+
export interface ParsedArguments {
|
|
15
|
+
pattern: string
|
|
16
|
+
formatOption: 'simple' | 'detailed'
|
|
17
|
+
showTiming: boolean
|
|
18
|
+
theme: ThemeInput
|
|
19
|
+
wrapLines: boolean
|
|
20
|
+
truncateLines: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class ArgumentParser {
|
|
24
|
+
private readonly usage = dedent`
|
|
25
|
+
Usage: herb-lint [file|glob-pattern|directory] [options]
|
|
26
|
+
|
|
27
|
+
Arguments:
|
|
28
|
+
file Single file to lint
|
|
29
|
+
glob-pattern Files to lint (defaults to **/*.html.erb)
|
|
30
|
+
directory Directory to lint (automatically appends **/*.html.erb)
|
|
31
|
+
|
|
32
|
+
Options:
|
|
33
|
+
-h, --help show help
|
|
34
|
+
-v, --version show version
|
|
35
|
+
--format output format (simple|detailed) [default: detailed]
|
|
36
|
+
--simple use simple output format (shortcut for --format simple)
|
|
37
|
+
--theme syntax highlighting theme (${THEME_NAMES.join('|')}) or path to custom theme file [default: ${DEFAULT_THEME}]
|
|
38
|
+
--no-color disable colored output
|
|
39
|
+
--no-timing hide timing information
|
|
40
|
+
--no-wrap-lines disable line wrapping
|
|
41
|
+
--truncate-lines enable line truncation (mutually exclusive with line wrapping)
|
|
42
|
+
`
|
|
43
|
+
|
|
44
|
+
parse(argv: string[]): ParsedArguments {
|
|
45
|
+
const { values, positionals } = parseArgs({
|
|
46
|
+
args: argv.slice(2),
|
|
47
|
+
options: {
|
|
48
|
+
help: { type: 'boolean', short: 'h' },
|
|
49
|
+
version: { type: 'boolean', short: 'v' },
|
|
50
|
+
format: { type: 'string' },
|
|
51
|
+
simple: { type: 'boolean' },
|
|
52
|
+
theme: { type: 'string' },
|
|
53
|
+
'no-color': { type: 'boolean' },
|
|
54
|
+
'no-timing': { type: 'boolean' },
|
|
55
|
+
'no-wrap-lines': { type: 'boolean' },
|
|
56
|
+
'truncate-lines': { type: 'boolean' }
|
|
57
|
+
},
|
|
58
|
+
allowPositionals: true
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
if (values.help) {
|
|
62
|
+
console.log(this.usage)
|
|
63
|
+
process.exit(0)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (values.version) {
|
|
67
|
+
console.log("Versions:")
|
|
68
|
+
console.log(` ${name}@${version}, ${Herb.version}`.split(", ").join("\n "))
|
|
69
|
+
process.exit(0)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let formatOption: 'simple' | 'detailed' = 'detailed'
|
|
73
|
+
if (values.format && (values.format === "detailed" || values.format === "simple")) {
|
|
74
|
+
formatOption = values.format
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (values.simple) {
|
|
78
|
+
formatOption = "simple"
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (values['no-color']) {
|
|
82
|
+
process.env.NO_COLOR = "1"
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const showTiming = !values['no-timing']
|
|
86
|
+
|
|
87
|
+
let wrapLines = !values['no-wrap-lines']
|
|
88
|
+
let truncateLines = false
|
|
89
|
+
|
|
90
|
+
if (values['truncate-lines']) {
|
|
91
|
+
truncateLines = true
|
|
92
|
+
wrapLines = false
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!values['no-wrap-lines'] && values['truncate-lines']) {
|
|
96
|
+
console.error("Error: Line wrapping and --truncate-lines cannot be used together. Use --no-wrap-lines with --truncate-lines.")
|
|
97
|
+
process.exit(1)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const theme = values.theme || DEFAULT_THEME
|
|
101
|
+
const pattern = this.getFilePattern(positionals)
|
|
102
|
+
|
|
103
|
+
if (positionals.length === 0) {
|
|
104
|
+
console.error("Please specify input file.")
|
|
105
|
+
process.exit(1)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { pattern, formatOption, showTiming, theme, wrapLines, truncateLines }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private getFilePattern(positionals: string[]): string {
|
|
112
|
+
let pattern = positionals.length > 0 ? positionals[0] : "**/*.html.erb"
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const stat = statSync(pattern)
|
|
116
|
+
if (stat.isDirectory()) {
|
|
117
|
+
pattern = join(pattern, "**/*.html.erb")
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
// Not a file/directory, treat as glob pattern
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return pattern
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { readFileSync } from "fs"
|
|
2
|
+
import { resolve } from "path"
|
|
3
|
+
import { Herb } from "@herb-tools/node-wasm"
|
|
4
|
+
import { Linter } from "../linter.js"
|
|
5
|
+
import { colorize } from "@herb-tools/highlighter"
|
|
6
|
+
import type { Diagnostic } from "@herb-tools/core"
|
|
7
|
+
|
|
8
|
+
export interface ProcessedFile {
|
|
9
|
+
filename: string
|
|
10
|
+
diagnostic: Diagnostic
|
|
11
|
+
content: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ProcessingResult {
|
|
15
|
+
totalErrors: number
|
|
16
|
+
totalWarnings: number
|
|
17
|
+
filesWithIssues: number
|
|
18
|
+
ruleCount: number
|
|
19
|
+
allDiagnostics: ProcessedFile[]
|
|
20
|
+
ruleViolations: Map<string, { count: number, files: Set<string> }>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class FileProcessor {
|
|
24
|
+
private linter: Linter | null = null
|
|
25
|
+
|
|
26
|
+
async processFiles(files: string[]): Promise<ProcessingResult> {
|
|
27
|
+
let totalErrors = 0
|
|
28
|
+
let totalWarnings = 0
|
|
29
|
+
let filesWithIssues = 0
|
|
30
|
+
let ruleCount = 0
|
|
31
|
+
const allDiagnostics: ProcessedFile[] = []
|
|
32
|
+
const ruleViolations = new Map<string, { count: number, files: Set<string> }>()
|
|
33
|
+
|
|
34
|
+
for (const filename of files) {
|
|
35
|
+
const filePath = resolve(filename)
|
|
36
|
+
const content = readFileSync(filePath, "utf-8")
|
|
37
|
+
|
|
38
|
+
const parseResult = Herb.parse(content)
|
|
39
|
+
|
|
40
|
+
if (parseResult.errors.length > 0) {
|
|
41
|
+
console.error(`${colorize(filename, "cyan")} - ${colorize("Parse errors:", "brightRed")}`)
|
|
42
|
+
|
|
43
|
+
for (const error of parseResult.errors) {
|
|
44
|
+
console.error(` ${colorize("✗", "brightRed")} ${error.message}`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
totalErrors++
|
|
48
|
+
filesWithIssues++
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!this.linter) {
|
|
53
|
+
this.linter = new Linter()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const lintResult = this.linter.lint(parseResult.value)
|
|
57
|
+
|
|
58
|
+
// Get rule count on first file
|
|
59
|
+
if (ruleCount === 0) {
|
|
60
|
+
ruleCount = this.linter.getRuleCount()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (lintResult.offenses.length === 0) {
|
|
64
|
+
if (files.length === 1) {
|
|
65
|
+
console.log(`${colorize("✓", "brightGreen")} ${colorize(filename, "cyan")} - ${colorize("No issues found", "green")}`)
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
// Collect messages for later display
|
|
69
|
+
for (const offense of lintResult.offenses) {
|
|
70
|
+
allDiagnostics.push({ filename, diagnostic: offense, content })
|
|
71
|
+
|
|
72
|
+
const ruleData = ruleViolations.get(offense.rule) || { count: 0, files: new Set() }
|
|
73
|
+
ruleData.count++
|
|
74
|
+
ruleData.files.add(filename)
|
|
75
|
+
ruleViolations.set(offense.rule, ruleData)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
totalErrors += lintResult.errors
|
|
79
|
+
totalWarnings += lintResult.warnings
|
|
80
|
+
filesWithIssues++
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { totalErrors, totalWarnings, filesWithIssues, ruleCount, allDiagnostics, ruleViolations }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Diagnostic } from "@herb-tools/core"
|
|
2
|
+
import type { ProcessedFile } from "../file-processor.js"
|
|
3
|
+
|
|
4
|
+
export abstract class BaseFormatter {
|
|
5
|
+
abstract format(
|
|
6
|
+
allDiagnostics: ProcessedFile[],
|
|
7
|
+
isSingleFile?: boolean
|
|
8
|
+
): Promise<void>
|
|
9
|
+
|
|
10
|
+
abstract formatFile(filename: string, diagnostics: Diagnostic[]): void
|
|
11
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { colorize, Highlighter, type ThemeInput, DEFAULT_THEME } from "@herb-tools/highlighter"
|
|
2
|
+
|
|
3
|
+
import { BaseFormatter } from "./base-formatter.js"
|
|
4
|
+
|
|
5
|
+
import type { Diagnostic } from "@herb-tools/core"
|
|
6
|
+
import type { ProcessedFile } from "../file-processor.js"
|
|
7
|
+
|
|
8
|
+
export class DetailedFormatter extends BaseFormatter {
|
|
9
|
+
private highlighter: Highlighter | null = null
|
|
10
|
+
private theme: ThemeInput
|
|
11
|
+
private wrapLines: boolean
|
|
12
|
+
private truncateLines: boolean
|
|
13
|
+
|
|
14
|
+
constructor(theme: ThemeInput = DEFAULT_THEME, wrapLines: boolean = true, truncateLines: boolean = false) {
|
|
15
|
+
super()
|
|
16
|
+
this.theme = theme
|
|
17
|
+
this.wrapLines = wrapLines
|
|
18
|
+
this.truncateLines = truncateLines
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async format(allDiagnostics: ProcessedFile[], isSingleFile: boolean = false): Promise<void> {
|
|
22
|
+
if (allDiagnostics.length === 0) return
|
|
23
|
+
|
|
24
|
+
if (!this.highlighter) {
|
|
25
|
+
this.highlighter = new Highlighter(this.theme)
|
|
26
|
+
await this.highlighter.initialize()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (isSingleFile) {
|
|
30
|
+
// For single file, use inline diagnostics with syntax highlighting
|
|
31
|
+
const { filename, content } = allDiagnostics[0]
|
|
32
|
+
const diagnostics = allDiagnostics.map(item => item.diagnostic)
|
|
33
|
+
|
|
34
|
+
const highlighted = this.highlighter.highlight(filename, content, {
|
|
35
|
+
diagnostics: diagnostics,
|
|
36
|
+
splitDiagnostics: true, // Use split mode to show each diagnostic separately
|
|
37
|
+
contextLines: 2,
|
|
38
|
+
wrapLines: this.wrapLines,
|
|
39
|
+
truncateLines: this.truncateLines
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
console.log(`\n${highlighted}`)
|
|
43
|
+
} else {
|
|
44
|
+
// For multiple files, show individual diagnostics with syntax highlighting
|
|
45
|
+
const totalMessageCount = allDiagnostics.length
|
|
46
|
+
|
|
47
|
+
for (let i = 0; i < allDiagnostics.length; i++) {
|
|
48
|
+
const { filename, diagnostic, content } = allDiagnostics[i]
|
|
49
|
+
const formatted = this.highlighter.highlightDiagnostic(filename, diagnostic, content, {
|
|
50
|
+
contextLines: 2,
|
|
51
|
+
wrapLines: this.wrapLines,
|
|
52
|
+
truncateLines: this.truncateLines
|
|
53
|
+
})
|
|
54
|
+
console.log(`\n${formatted}`)
|
|
55
|
+
|
|
56
|
+
const width = process.stdout.columns || 80
|
|
57
|
+
const progressText = `[${i + 1}/${totalMessageCount}]`
|
|
58
|
+
const rightPadding = 16
|
|
59
|
+
const separatorLength = Math.max(0, width - progressText.length - 1 - rightPadding)
|
|
60
|
+
const separator = '⎯'
|
|
61
|
+
const leftSeparator = colorize(separator.repeat(separatorLength), "gray")
|
|
62
|
+
const rightSeparator = colorize(separator.repeat(4), "gray")
|
|
63
|
+
const progress = colorize(progressText, "gray")
|
|
64
|
+
|
|
65
|
+
console.log(colorize(`${leftSeparator} ${progress}`, "dim") + colorize(` ${rightSeparator}\n`, "dim"))
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
formatFile(_filename: string, _diagnostics: Diagnostic[]): void {
|
|
71
|
+
// Not used in detailed formatter
|
|
72
|
+
throw new Error("formatFile is not implemented for DetailedFormatter")
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { colorize } from "@herb-tools/highlighter"
|
|
2
|
+
|
|
3
|
+
import { BaseFormatter } from "./base-formatter.js"
|
|
4
|
+
|
|
5
|
+
import type { Diagnostic } from "@herb-tools/core"
|
|
6
|
+
import type { ProcessedFile } from "../file-processor.js"
|
|
7
|
+
|
|
8
|
+
export class SimpleFormatter extends BaseFormatter {
|
|
9
|
+
async format(allDiagnostics: ProcessedFile[]): Promise<void> {
|
|
10
|
+
if (allDiagnostics.length === 0) return
|
|
11
|
+
|
|
12
|
+
const groupedDiagnostics = new Map<string, Diagnostic[]>()
|
|
13
|
+
|
|
14
|
+
for (const { filename, diagnostic } of allDiagnostics) {
|
|
15
|
+
const diagnostics = groupedDiagnostics.get(filename) || []
|
|
16
|
+
diagnostics.push(diagnostic)
|
|
17
|
+
groupedDiagnostics.set(filename, diagnostics)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
for (const [filename, diagnostics] of groupedDiagnostics) {
|
|
21
|
+
console.log("")
|
|
22
|
+
this.formatFile(filename, diagnostics)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
formatFile(filename: string, diagnostics: Diagnostic[]): void {
|
|
27
|
+
console.log(`${colorize(filename, "cyan")}:`)
|
|
28
|
+
|
|
29
|
+
for (const diagnostic of diagnostics) {
|
|
30
|
+
const isError = diagnostic.severity === "error"
|
|
31
|
+
const severity = isError ? colorize("✗", "brightRed") : colorize("⚠", "brightYellow")
|
|
32
|
+
const rule = colorize(`(${diagnostic.code})`, "blue")
|
|
33
|
+
const locationString = `${diagnostic.location.start.line}:${diagnostic.location.start.column}`
|
|
34
|
+
const paddedLocation = locationString.padEnd(4) // Pad to 4 characters for alignment
|
|
35
|
+
|
|
36
|
+
console.log(` ${colorize(paddedLocation, "gray")} ${severity} ${diagnostic.message} ${rule}`)
|
|
37
|
+
}
|
|
38
|
+
console.log("")
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { colorize } from "@herb-tools/highlighter"
|
|
2
|
+
|
|
3
|
+
export interface SummaryData {
|
|
4
|
+
files: string[]
|
|
5
|
+
totalErrors: number
|
|
6
|
+
totalWarnings: number
|
|
7
|
+
filesWithViolations: number
|
|
8
|
+
ruleCount: number
|
|
9
|
+
startTime: number
|
|
10
|
+
startDate: Date
|
|
11
|
+
showTiming: boolean
|
|
12
|
+
ruleViolations: Map<string, { count: number, files: Set<string> }>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class SummaryReporter {
|
|
16
|
+
private pluralize(count: number, singular: string, plural?: string): string {
|
|
17
|
+
return count === 1 ? singular : (plural || `${singular}s`)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
displaySummary(data: SummaryData): void {
|
|
21
|
+
const { files, totalErrors, totalWarnings, filesWithViolations, ruleCount, startTime, startDate, showTiming } = data
|
|
22
|
+
|
|
23
|
+
console.log("\n")
|
|
24
|
+
console.log(` ${colorize("Summary:", "bold")}`)
|
|
25
|
+
|
|
26
|
+
// Calculate padding for alignment
|
|
27
|
+
const labelWidth = 12 // Width for the longest label "Violations"
|
|
28
|
+
const pad = (label: string) => label.padEnd(labelWidth)
|
|
29
|
+
|
|
30
|
+
// Checked summary
|
|
31
|
+
console.log(` ${colorize(pad("Checked"), "gray")} ${colorize(`${files.length} ${this.pluralize(files.length, "file")}`, "cyan")}`)
|
|
32
|
+
|
|
33
|
+
// Files summary (for multiple files)
|
|
34
|
+
if (files.length > 1) {
|
|
35
|
+
const filesChecked = files.length
|
|
36
|
+
const filesClean = filesChecked - filesWithViolations
|
|
37
|
+
|
|
38
|
+
let filesSummary = ""
|
|
39
|
+
let shouldDim = false
|
|
40
|
+
|
|
41
|
+
if (filesWithViolations > 0) {
|
|
42
|
+
filesSummary = `${colorize(colorize(`${filesWithViolations} with violations`, "brightRed"), "bold")} | ${colorize(colorize(`${filesClean} clean`, "green"), "bold")} ${colorize(colorize(`(${filesChecked} total)`, "gray"), "dim")}`
|
|
43
|
+
} else {
|
|
44
|
+
filesSummary = `${colorize(colorize(`${filesChecked} clean`, "green"), "bold")} ${colorize(colorize(`(${filesChecked} total)`, "gray"), "dim")}`
|
|
45
|
+
shouldDim = true
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (shouldDim) {
|
|
49
|
+
console.log(colorize(` ${colorize(pad("Files"), "gray")} ${filesSummary}`, "dim"))
|
|
50
|
+
} else {
|
|
51
|
+
console.log(` ${colorize(pad("Files"), "gray")} ${filesSummary}`)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Violations summary with file count
|
|
56
|
+
let violationsSummary = ""
|
|
57
|
+
const parts = []
|
|
58
|
+
|
|
59
|
+
// Build the main part with errors and warnings
|
|
60
|
+
if (totalErrors > 0) {
|
|
61
|
+
parts.push(colorize(colorize(`${totalErrors} ${this.pluralize(totalErrors, "error")}`, "brightRed"), "bold"))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (totalWarnings > 0) {
|
|
65
|
+
parts.push(colorize(colorize(`${totalWarnings} ${this.pluralize(totalWarnings, "warning")}`, "brightYellow"), "bold"))
|
|
66
|
+
} else if (totalErrors > 0) {
|
|
67
|
+
// Show 0 warnings when there are errors but no warnings
|
|
68
|
+
parts.push(colorize(colorize(`${totalWarnings} ${this.pluralize(totalWarnings, "warning")}`, "green"), "bold"))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (parts.length === 0) {
|
|
72
|
+
violationsSummary = colorize(colorize("0 violations", "green"), "bold")
|
|
73
|
+
} else {
|
|
74
|
+
violationsSummary = parts.join(" | ")
|
|
75
|
+
// Add total count and file count
|
|
76
|
+
let detailText = ""
|
|
77
|
+
|
|
78
|
+
const totalViolations = totalErrors + totalWarnings
|
|
79
|
+
|
|
80
|
+
if (filesWithViolations > 0) {
|
|
81
|
+
detailText = `${totalViolations} ${this.pluralize(totalViolations, "violation")} across ${filesWithViolations} ${this.pluralize(filesWithViolations, "file")}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
violationsSummary += ` ${colorize(colorize(`(${detailText})`, "gray"), "dim")}`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
console.log(` ${colorize(pad("Violations"), "gray")} ${violationsSummary}`)
|
|
88
|
+
|
|
89
|
+
// Timing information (if enabled)
|
|
90
|
+
if (showTiming) {
|
|
91
|
+
const duration = Date.now() - startTime
|
|
92
|
+
const timeString = startDate.toTimeString().split(' ')[0] // HH:MM:SS format
|
|
93
|
+
|
|
94
|
+
console.log(` ${colorize(pad("Start at"), "gray")} ${colorize(timeString, "cyan")}`)
|
|
95
|
+
console.log(` ${colorize(pad("Duration"), "gray")} ${colorize(`${duration}ms`, "cyan")} ${colorize(colorize(`(${ruleCount} ${this.pluralize(ruleCount, "rule")})`, "gray"), "dim")}`)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Success message for all files clean
|
|
99
|
+
if (filesWithViolations === 0 && files.length > 1) {
|
|
100
|
+
console.log("")
|
|
101
|
+
console.log(` ${colorize("✓", "brightGreen")} ${colorize("All files are clean!", "green")}`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
displayMostViolatedRules(ruleViolations: Map<string, { count: number, files: Set<string> }>, limit: number = 5): void {
|
|
106
|
+
if (ruleViolations.size === 0) return
|
|
107
|
+
|
|
108
|
+
const allRules = Array.from(ruleViolations.entries()).sort((a, b) => b[1].count - a[1].count)
|
|
109
|
+
const displayedRules = allRules.slice(0, limit)
|
|
110
|
+
const remainingRules = allRules.slice(limit)
|
|
111
|
+
|
|
112
|
+
const title = ruleViolations.size <= limit ? "Rule violations:" : "Most violated rules:"
|
|
113
|
+
console.log(` ${colorize(title, "bold")}`)
|
|
114
|
+
|
|
115
|
+
for (const [rule, data] of displayedRules) {
|
|
116
|
+
const fileCount = data.files.size
|
|
117
|
+
const countText = `(${data.count} ${this.pluralize(data.count, "violation")} in ${fileCount} ${this.pluralize(fileCount, "file")})`
|
|
118
|
+
console.log(` ${colorize(rule, "gray")} ${colorize(colorize(countText, "gray"), "dim")}`)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (remainingRules.length > 0) {
|
|
122
|
+
const remainingViolationCount = remainingRules.reduce((sum, [_, data]) => sum + data.count, 0)
|
|
123
|
+
const remainingRuleCount = remainingRules.length
|
|
124
|
+
console.log(colorize(colorize(`\n ...and ${remainingRuleCount} more ${this.pluralize(remainingRuleCount, "rule")} with ${remainingViolationCount} ${this.pluralize(remainingViolationCount, "violation")}`, "gray"), "dim"))
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { glob } from "glob"
|
|
2
|
+
import { Herb } from "@herb-tools/node-wasm"
|
|
3
|
+
import { ArgumentParser } from "./cli/argument-parser.js"
|
|
4
|
+
import { FileProcessor } from "./cli/file-processor.js"
|
|
5
|
+
import { SimpleFormatter, DetailedFormatter } from "./cli/formatters/index.js"
|
|
6
|
+
import { SummaryReporter } from "./cli/summary-reporter.js"
|
|
7
|
+
|
|
8
|
+
export class CLI {
|
|
9
|
+
private argumentParser = new ArgumentParser()
|
|
10
|
+
private fileProcessor = new FileProcessor()
|
|
11
|
+
private summaryReporter = new SummaryReporter()
|
|
12
|
+
|
|
13
|
+
async run() {
|
|
14
|
+
const startTime = Date.now()
|
|
15
|
+
const startDate = new Date()
|
|
16
|
+
|
|
17
|
+
const { pattern, formatOption, showTiming, theme, wrapLines, truncateLines } = this.argumentParser.parse(process.argv)
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
await Herb.load()
|
|
21
|
+
|
|
22
|
+
const files = await glob(pattern)
|
|
23
|
+
|
|
24
|
+
if (files.length === 0) {
|
|
25
|
+
console.log(`No files found matching pattern: ${pattern}`)
|
|
26
|
+
process.exit(0)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const results = await this.fileProcessor.processFiles(files)
|
|
30
|
+
const { totalErrors, totalWarnings, filesWithIssues, ruleCount, allDiagnostics, ruleViolations } = results
|
|
31
|
+
|
|
32
|
+
const formatter = formatOption === 'simple'
|
|
33
|
+
? new SimpleFormatter()
|
|
34
|
+
: new DetailedFormatter(theme, wrapLines, truncateLines)
|
|
35
|
+
|
|
36
|
+
await formatter.format(allDiagnostics, files.length === 1)
|
|
37
|
+
|
|
38
|
+
this.summaryReporter.displayMostViolatedRules(ruleViolations)
|
|
39
|
+
this.summaryReporter.displaySummary({
|
|
40
|
+
files,
|
|
41
|
+
totalErrors,
|
|
42
|
+
totalWarnings,
|
|
43
|
+
filesWithViolations: filesWithIssues,
|
|
44
|
+
ruleCount,
|
|
45
|
+
startTime,
|
|
46
|
+
startDate,
|
|
47
|
+
showTiming,
|
|
48
|
+
ruleViolations
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
if (totalErrors > 0) {
|
|
52
|
+
process.exit(1)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
} catch (error) {
|
|
56
|
+
console.error(`Error:`, error)
|
|
57
|
+
process.exit(1)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { RuleClass } from "./types.js"
|
|
2
|
+
|
|
3
|
+
import { ERBNoEmptyTagsRule } from "./rules/erb-no-empty-tags.js"
|
|
4
|
+
import { ERBNoOutputControlFlowRule } from "./rules/erb-no-output-control-flow.js"
|
|
5
|
+
import { ERBRequireWhitespaceRule } from "./rules/erb-require-whitespace-inside-tags.js"
|
|
6
|
+
import { HTMLAnchorRequireHrefRule } from "./rules/html-anchor-require-href.js"
|
|
7
|
+
import { HTMLAriaRoleHeadingRequiresLevelRule } from "./rules/html-aria-role-heading-requires-level.js"
|
|
8
|
+
import { HTMLAttributeDoubleQuotesRule } from "./rules/html-attribute-double-quotes.js"
|
|
9
|
+
import { HTMLAttributeValuesRequireQuotesRule } from "./rules/html-attribute-values-require-quotes.js"
|
|
10
|
+
import { HTMLBooleanAttributesNoValueRule } from "./rules/html-boolean-attributes-no-value.js"
|
|
11
|
+
import { HTMLImgRequireAltRule } from "./rules/html-img-require-alt.js"
|
|
12
|
+
import { HTMLNoBlockInsideInlineRule } from "./rules/html-no-block-inside-inline.js"
|
|
13
|
+
import { HTMLNoDuplicateAttributesRule } from "./rules/html-no-duplicate-attributes.js"
|
|
14
|
+
import { HTMLNoEmptyHeadingsRule } from "./rules/html-no-empty-headings.js"
|
|
15
|
+
import { HTMLNoNestedLinksRule } from "./rules/html-no-nested-links.js"
|
|
16
|
+
import { HTMLTagNameLowercaseRule } from "./rules/html-tag-name-lowercase.js"
|
|
17
|
+
|
|
18
|
+
export const defaultRules: RuleClass[] = [
|
|
19
|
+
ERBNoEmptyTagsRule,
|
|
20
|
+
ERBNoOutputControlFlowRule,
|
|
21
|
+
ERBRequireWhitespaceRule,
|
|
22
|
+
HTMLAnchorRequireHrefRule,
|
|
23
|
+
HTMLAriaRoleHeadingRequiresLevelRule,
|
|
24
|
+
HTMLAttributeDoubleQuotesRule,
|
|
25
|
+
HTMLAttributeValuesRequireQuotesRule,
|
|
26
|
+
HTMLBooleanAttributesNoValueRule,
|
|
27
|
+
HTMLImgRequireAltRule,
|
|
28
|
+
HTMLNoBlockInsideInlineRule,
|
|
29
|
+
HTMLNoDuplicateAttributesRule,
|
|
30
|
+
HTMLNoEmptyHeadingsRule,
|
|
31
|
+
HTMLNoNestedLinksRule,
|
|
32
|
+
HTMLTagNameLowercaseRule,
|
|
33
|
+
]
|
package/src/herb-lint.ts
ADDED
package/src/index.ts
ADDED
package/src/linter.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { defaultRules } from "./default-rules.js"
|
|
2
|
+
|
|
3
|
+
import type { RuleClass, LintResult, LintOffense } from "./types.js"
|
|
4
|
+
import type { DocumentNode } from "@herb-tools/core"
|
|
5
|
+
|
|
6
|
+
export class Linter {
|
|
7
|
+
private rules: RuleClass[]
|
|
8
|
+
private offenses: LintOffense[]
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Creates a new Linter instance.
|
|
12
|
+
* @param rules - Array of rule classes (not instances) to use. If not provided, uses default rules.
|
|
13
|
+
*/
|
|
14
|
+
constructor(rules?: RuleClass[]) {
|
|
15
|
+
this.rules = rules !== undefined ? rules : this.getDefaultRules()
|
|
16
|
+
this.offenses = []
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Returns the default set of rule classes used by the linter.
|
|
21
|
+
* @returns Array of rule classes
|
|
22
|
+
*/
|
|
23
|
+
private getDefaultRules(): RuleClass[] {
|
|
24
|
+
return defaultRules
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
getRuleCount(): number {
|
|
28
|
+
return this.rules.length
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
lint(document: DocumentNode): LintResult {
|
|
32
|
+
this.offenses = []
|
|
33
|
+
|
|
34
|
+
for (const Rule of this.rules) {
|
|
35
|
+
const rule = new Rule()
|
|
36
|
+
const ruleOffenses = rule.check(document)
|
|
37
|
+
|
|
38
|
+
this.offenses.push(...ruleOffenses)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const errors = this.offenses.filter(offense => offense.severity === "error").length
|
|
42
|
+
const warnings = this.offenses.filter(offense => offense.severity === "warning").length
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
offenses: this.offenses,
|
|
46
|
+
errors,
|
|
47
|
+
warnings
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { BaseRuleVisitor } from "./rule-utils.js"
|
|
2
|
+
|
|
3
|
+
import type { Rule, LintOffense } from "../types.js"
|
|
4
|
+
import type { Node, ERBContentNode } from "@herb-tools/core"
|
|
5
|
+
|
|
6
|
+
class ERBNoEmptyTagsVisitor extends BaseRuleVisitor {
|
|
7
|
+
visitERBContentNode(node: ERBContentNode): void {
|
|
8
|
+
this.visitChildNodes(node)
|
|
9
|
+
|
|
10
|
+
const { content, tag_closing } = node
|
|
11
|
+
|
|
12
|
+
if (!content) return
|
|
13
|
+
if (tag_closing?.value === "") return
|
|
14
|
+
if (content.value.trim().length > 0) return
|
|
15
|
+
|
|
16
|
+
this.addOffense(
|
|
17
|
+
"ERB tag should not be empty. Remove empty ERB tags or add content.",
|
|
18
|
+
node.location,
|
|
19
|
+
"error"
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class ERBNoEmptyTagsRule implements Rule {
|
|
25
|
+
name = "erb-no-empty-tags"
|
|
26
|
+
|
|
27
|
+
check(node: Node): LintOffense[] {
|
|
28
|
+
const visitor = new ERBNoEmptyTagsVisitor(this.name)
|
|
29
|
+
|
|
30
|
+
visitor.visit(node)
|
|
31
|
+
|
|
32
|
+
return visitor.offenses
|
|
33
|
+
}
|
|
34
|
+
}
|