@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.
Files changed (175) hide show
  1. package/README.md +34 -0
  2. package/bin/herb-lint +3 -0
  3. package/dist/herb-lint.js +16505 -0
  4. package/dist/herb-lint.js.map +1 -0
  5. package/dist/index.cjs +834 -0
  6. package/dist/index.cjs.map +1 -0
  7. package/dist/index.js +820 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/package.json +49 -0
  10. package/dist/src/cli/argument-parser.js +96 -0
  11. package/dist/src/cli/argument-parser.js.map +1 -0
  12. package/dist/src/cli/file-processor.js +58 -0
  13. package/dist/src/cli/file-processor.js.map +1 -0
  14. package/dist/src/cli/formatters/base-formatter.js +3 -0
  15. package/dist/src/cli/formatters/base-formatter.js.map +1 -0
  16. package/dist/src/cli/formatters/detailed-formatter.js +62 -0
  17. package/dist/src/cli/formatters/detailed-formatter.js.map +1 -0
  18. package/dist/src/cli/formatters/index.js +4 -0
  19. package/dist/src/cli/formatters/index.js.map +1 -0
  20. package/dist/src/cli/formatters/simple-formatter.js +31 -0
  21. package/dist/src/cli/formatters/simple-formatter.js.map +1 -0
  22. package/dist/src/cli/index.js +5 -0
  23. package/dist/src/cli/index.js.map +1 -0
  24. package/dist/src/cli/summary-reporter.js +96 -0
  25. package/dist/src/cli/summary-reporter.js.map +1 -0
  26. package/dist/src/cli.js +50 -0
  27. package/dist/src/cli.js.map +1 -0
  28. package/dist/src/default-rules.js +31 -0
  29. package/dist/src/default-rules.js.map +1 -0
  30. package/dist/src/herb-lint.js +5 -0
  31. package/dist/src/herb-lint.js.map +1 -0
  32. package/dist/src/index.js +4 -0
  33. package/dist/src/index.js.map +1 -0
  34. package/dist/src/linter.js +39 -0
  35. package/dist/src/linter.js.map +1 -0
  36. package/dist/src/rules/erb-no-empty-tags.js +23 -0
  37. package/dist/src/rules/erb-no-empty-tags.js.map +1 -0
  38. package/dist/src/rules/erb-no-output-control-flow.js +47 -0
  39. package/dist/src/rules/erb-no-output-control-flow.js.map +1 -0
  40. package/dist/src/rules/erb-require-whitespace-inside-tags.js +43 -0
  41. package/dist/src/rules/erb-require-whitespace-inside-tags.js.map +1 -0
  42. package/dist/src/rules/html-anchor-require-href.js +25 -0
  43. package/dist/src/rules/html-anchor-require-href.js.map +1 -0
  44. package/dist/src/rules/html-aria-role-heading-requires-level.js +26 -0
  45. package/dist/src/rules/html-aria-role-heading-requires-level.js.map +1 -0
  46. package/dist/src/rules/html-attribute-double-quotes.js +21 -0
  47. package/dist/src/rules/html-attribute-double-quotes.js.map +1 -0
  48. package/dist/src/rules/html-attribute-values-require-quotes.js +22 -0
  49. package/dist/src/rules/html-attribute-values-require-quotes.js.map +1 -0
  50. package/dist/src/rules/html-boolean-attributes-no-value.js +19 -0
  51. package/dist/src/rules/html-boolean-attributes-no-value.js.map +1 -0
  52. package/dist/src/rules/html-img-require-alt.js +29 -0
  53. package/dist/src/rules/html-img-require-alt.js.map +1 -0
  54. package/dist/src/rules/html-no-block-inside-inline.js +59 -0
  55. package/dist/src/rules/html-no-block-inside-inline.js.map +1 -0
  56. package/dist/src/rules/html-no-duplicate-attributes.js +43 -0
  57. package/dist/src/rules/html-no-duplicate-attributes.js.map +1 -0
  58. package/dist/src/rules/html-no-empty-headings.js +148 -0
  59. package/dist/src/rules/html-no-empty-headings.js.map +1 -0
  60. package/dist/src/rules/html-no-nested-links.js +45 -0
  61. package/dist/src/rules/html-no-nested-links.js.map +1 -0
  62. package/dist/src/rules/html-tag-name-lowercase.js +39 -0
  63. package/dist/src/rules/html-tag-name-lowercase.js.map +1 -0
  64. package/dist/src/rules/index.js +13 -0
  65. package/dist/src/rules/index.js.map +1 -0
  66. package/dist/src/rules/rule-utils.js +198 -0
  67. package/dist/src/rules/rule-utils.js.map +1 -0
  68. package/dist/src/types.js +2 -0
  69. package/dist/src/types.js.map +1 -0
  70. package/dist/tsconfig.tsbuildinfo +1 -0
  71. package/dist/types/cli/argument-parser.d.ts +14 -0
  72. package/dist/types/cli/file-processor.d.ts +21 -0
  73. package/dist/types/cli/formatters/base-formatter.d.ts +6 -0
  74. package/dist/types/cli/formatters/detailed-formatter.d.ts +13 -0
  75. package/dist/types/cli/formatters/index.d.ts +3 -0
  76. package/dist/types/cli/formatters/simple-formatter.d.ts +7 -0
  77. package/dist/types/cli/summary-reporter.d.ts +22 -0
  78. package/dist/types/cli.d.ts +6 -0
  79. package/dist/types/default-rules.d.ts +2 -0
  80. package/dist/types/herb-lint.d.ts +2 -0
  81. package/dist/types/index.d.ts +3 -0
  82. package/dist/types/linter.d.ts +18 -0
  83. package/dist/types/rules/erb-no-empty-tags.d.ts +6 -0
  84. package/dist/types/rules/erb-no-output-control-flow.d.ts +6 -0
  85. package/dist/types/rules/erb-require-whitespace-inside-tags.d.ts +6 -0
  86. package/dist/types/rules/html-anchor-require-href.d.ts +6 -0
  87. package/dist/types/rules/html-aria-role-heading-requires-level.d.ts +6 -0
  88. package/dist/types/rules/html-attribute-double-quotes.d.ts +6 -0
  89. package/dist/types/rules/html-attribute-values-require-quotes.d.ts +6 -0
  90. package/dist/types/rules/html-boolean-attributes-no-value.d.ts +6 -0
  91. package/dist/types/rules/html-img-require-alt.d.ts +6 -0
  92. package/dist/types/rules/html-no-block-inside-inline.d.ts +6 -0
  93. package/dist/types/rules/html-no-duplicate-attributes.d.ts +6 -0
  94. package/dist/types/rules/html-no-empty-headings.d.ts +6 -0
  95. package/dist/types/rules/html-no-nested-links.d.ts +6 -0
  96. package/dist/types/rules/html-tag-name-lowercase.d.ts +6 -0
  97. package/dist/types/rules/index.d.ts +12 -0
  98. package/dist/types/rules/rule-utils.d.ts +89 -0
  99. package/dist/types/src/cli/argument-parser.d.ts +14 -0
  100. package/dist/types/src/cli/file-processor.d.ts +21 -0
  101. package/dist/types/src/cli/formatters/base-formatter.d.ts +6 -0
  102. package/dist/types/src/cli/formatters/detailed-formatter.d.ts +13 -0
  103. package/dist/types/src/cli/formatters/index.d.ts +3 -0
  104. package/dist/types/src/cli/formatters/simple-formatter.d.ts +7 -0
  105. package/dist/types/src/cli/index.d.ts +4 -0
  106. package/dist/types/src/cli/summary-reporter.d.ts +22 -0
  107. package/dist/types/src/cli.d.ts +6 -0
  108. package/dist/types/src/default-rules.d.ts +2 -0
  109. package/dist/types/src/herb-lint.d.ts +2 -0
  110. package/dist/types/src/index.d.ts +3 -0
  111. package/dist/types/src/linter.d.ts +18 -0
  112. package/dist/types/src/rules/erb-no-empty-tags.d.ts +6 -0
  113. package/dist/types/src/rules/erb-no-output-control-flow.d.ts +6 -0
  114. package/dist/types/src/rules/erb-require-whitespace-inside-tags.d.ts +6 -0
  115. package/dist/types/src/rules/html-anchor-require-href.d.ts +6 -0
  116. package/dist/types/src/rules/html-aria-role-heading-requires-level.d.ts +6 -0
  117. package/dist/types/src/rules/html-attribute-double-quotes.d.ts +6 -0
  118. package/dist/types/src/rules/html-attribute-values-require-quotes.d.ts +6 -0
  119. package/dist/types/src/rules/html-boolean-attributes-no-value.d.ts +6 -0
  120. package/dist/types/src/rules/html-img-require-alt.d.ts +6 -0
  121. package/dist/types/src/rules/html-no-block-inside-inline.d.ts +6 -0
  122. package/dist/types/src/rules/html-no-duplicate-attributes.d.ts +6 -0
  123. package/dist/types/src/rules/html-no-empty-headings.d.ts +6 -0
  124. package/dist/types/src/rules/html-no-nested-links.d.ts +6 -0
  125. package/dist/types/src/rules/html-tag-name-lowercase.d.ts +6 -0
  126. package/dist/types/src/rules/index.d.ts +12 -0
  127. package/dist/types/src/rules/rule-utils.d.ts +89 -0
  128. package/dist/types/src/types.d.ts +26 -0
  129. package/dist/types/types.d.ts +26 -0
  130. package/docs/rules/README.md +39 -0
  131. package/docs/rules/erb-no-empty-tags.md +38 -0
  132. package/docs/rules/erb-no-output-control-flow.md +45 -0
  133. package/docs/rules/erb-require-whitespace-inside-tags.md +43 -0
  134. package/docs/rules/html-anchor-require-href.md +32 -0
  135. package/docs/rules/html-aria-role-heading-requires-level.md +34 -0
  136. package/docs/rules/html-attribute-double-quotes.md +43 -0
  137. package/docs/rules/html-attribute-values-require-quotes.md +43 -0
  138. package/docs/rules/html-boolean-attributes-no-value.md +39 -0
  139. package/docs/rules/html-img-require-alt.md +44 -0
  140. package/docs/rules/html-no-block-inside-inline.md +66 -0
  141. package/docs/rules/html-no-duplicate-attributes.md +35 -0
  142. package/docs/rules/html-no-empty-headings.md +78 -0
  143. package/docs/rules/html-no-nested-links.md +44 -0
  144. package/docs/rules/html-tag-name-lowercase.md +44 -0
  145. package/package.json +49 -0
  146. package/src/cli/argument-parser.ts +125 -0
  147. package/src/cli/file-processor.ts +86 -0
  148. package/src/cli/formatters/base-formatter.ts +11 -0
  149. package/src/cli/formatters/detailed-formatter.ts +74 -0
  150. package/src/cli/formatters/index.ts +3 -0
  151. package/src/cli/formatters/simple-formatter.ts +40 -0
  152. package/src/cli/index.ts +4 -0
  153. package/src/cli/summary-reporter.ts +127 -0
  154. package/src/cli.ts +60 -0
  155. package/src/default-rules.ts +33 -0
  156. package/src/herb-lint.ts +6 -0
  157. package/src/index.ts +3 -0
  158. package/src/linter.ts +50 -0
  159. package/src/rules/erb-no-empty-tags.ts +34 -0
  160. package/src/rules/erb-no-output-control-flow.ts +61 -0
  161. package/src/rules/erb-require-whitespace-inside-tags.ts +61 -0
  162. package/src/rules/html-anchor-require-href.ts +39 -0
  163. package/src/rules/html-aria-role-heading-requires-level.ts +44 -0
  164. package/src/rules/html-attribute-double-quotes.ts +28 -0
  165. package/src/rules/html-attribute-values-require-quotes.ts +30 -0
  166. package/src/rules/html-boolean-attributes-no-value.ts +27 -0
  167. package/src/rules/html-img-require-alt.ts +42 -0
  168. package/src/rules/html-no-block-inside-inline.ts +84 -0
  169. package/src/rules/html-no-duplicate-attributes.ts +59 -0
  170. package/src/rules/html-no-empty-headings.ts +185 -0
  171. package/src/rules/html-no-nested-links.ts +65 -0
  172. package/src/rules/html-tag-name-lowercase.ts +50 -0
  173. package/src/rules/index.ts +12 -0
  174. package/src/rules/rule-utils.ts +257 -0
  175. 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,3 @@
1
+ export { BaseFormatter } from "./base-formatter.js"
2
+ export { SimpleFormatter } from "./simple-formatter.js"
3
+ export { DetailedFormatter } from "./detailed-formatter.js"
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ export { ArgumentParser } from "./argument-parser.js"
2
+ export { FileProcessor } from "./file-processor.js"
3
+ export { SummaryReporter } from "./summary-reporter.js"
4
+ export * from "./formatters/index.js"
@@ -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
+ ]
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { CLI } from "./cli.js"
4
+
5
+ const cli = new CLI()
6
+ cli.run()
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./linter.js"
2
+ export * from "./rules/index.js"
3
+ export * from "./types.js"
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
+ }