Herb Linter
Package: @herb-tools/linter
The Herb Linter provides comprehensive HTML+ERB validation with a set of configurable rules to enforce best practices and catch common errors.
Installation
Global Installation
npm install -g @herb-tools/linterpnpm add -g @herb-tools/linteryarn global add @herb-tools/linterbun add -g @herb-tools/linterOne-time Usage
For occasional use without installing:
npx @herb-tools/linter template.html.erbPreview Releases
Want to try unreleased features? Use pkg.pr.new to run the linter from any commit or PR:
npx https://pkg.pr.new/@herb-tools/linter@{commit} template.html.erbReplace {commit} with a commit SHA (e.g., 0d2eabe) or branch name (e.g., main). Find available previews at pkg.pr.new/~/marcoroth/herb.
Project Installation
npm add -D @herb-tools/linterpnpm add -D @herb-tools/linteryarn add -D @herb-tools/linterbun add -D @herb-tools/linterAfter installing as a dev dependency, initialize the configuration:
npx herb-lint --initpnpm herb-lint --inityarn herb-lint --initbunx herb-lint --initThen add a lint NPM script to your package.json:
{
"scripts": {
"herb:lint": "herb-lint"
}
}Then run the scripts:
npm run herb:lintpnpm herb:lintyarn herb:lintbun run herb:lintUsage
Command Line
Basic usage:
npx @herb-tools/linter
npx @herb-tools/linter app/views/
npx @herb-tools/linter template.html.erb
npx @herb-tools/linter "**/*.rhtml"
npx @herb-tools/linter "**/*.xml.erb"Initialize configuration:
# Create a .herb.yml configuration file
npx @herb-tools/linter --initOptions
Output Format:
# Use detailed output (default)
npx @herb-tools/linter template.html.erb --format detailed
# Use simple output format
npx @herb-tools/linter template.html.erb --simple
# or
npx @herb-tools/linter template.html.erb --format simple
# Use JSON output format
npx @herb-tools/linter template.html.erb --json
# or
npx @herb-tools/linter template.html.erb --format json
# Use GitHub Actions output format with detailed preview (default)
# (This is enabled automatically when the GITHUB_ACTIONS environment variable is set)
npx @herb-tools/linter template.html.erb --github
# Combine GitHub Actions output with simple format
npx @herb-tools/linter template.html.erb --format=simple --github
# Combine GitHub Actions output with detailed format (explicit)
npx @herb-tools/linter template.html.erb --format=detailed --githubDisplay Options:
# Disable colored output
npx @herb-tools/linter template.html.erb --no-color
# Set syntax highlighting theme
npx @herb-tools/linter template.html.erb --theme github-dark
# Disable timing information
npx @herb-tools/linter template.html.erb --no-timing
# Disable line wrapping
npx @herb-tools/linter template.html.erb --no-wrap-lines
# Enable line truncation (mutually exclusive with line wrapping)
npx @herb-tools/linter template.html.erb --truncate-lines --no-wrap-lines
# Combine GitHub Actions annotations with different formats
npx @herb-tools/linter template.html.erb --format=simple --github
# Disable GitHub Actions annotations (even in GitHub Actions environment)
npx @herb-tools/linter template.html.erb --no-githubHelp and Version:
# Show help
npx @herb-tools/linter --help
# Show version information
npx @herb-tools/linter --versionGitHub Actions Output Format
The linter supports GitHub Actions annotation format with the --github flag, which can be combined with --format=simple or --format=detailed. The --github flag adds GitHub Actions annotations that GitHub can parse to create inline annotations in pull requests, while also showing the regular format output for local debugging.
Tip: Running in GitHub Actions
When the GITHUB_ACTIONS environment variable is set (as in GitHub Actions), GitHub Actions annotations are enabled by default. You can disable them with --no-github if needed.
# GitHub Actions annotations + detailed format (default)
npx @herb-tools/linter --github
# GitHub Actions annotations + simple format (minimal local output)
npx @herb-tools/linter --format=simple --githubExample: --github (GitHub annotations + detailed format)
::error file=template.html.erb,line=3,col=3,title=html-img-require-alt • @herb-tools/linter@0.8.3::Missing required `alt` attribute on `<img>` tag [html-img-require-alt]%0A%0A%0Atemplate.html.erb:3:3%0A%0A 1 │ <div>%0A 2 │ <span>Test content</span>%0A → 3 │ <img src="test.jpg">%0A │ ~~~%0A 4 │ </div>%0A
[error] Missing required `alt` attribute on `<img>` tag [html-img-require-alt]
template.html.erb:3:3
1 │ <div>
2 │ <span>Test content</span>
→ 3 │ <img src="test.jpg">
│ ~~~
4 │ </div>Example: --format=simple --github (GitHub annotations + simple format)
::error file=template.html.erb,line=3,col=3,title=html-img-require-alt • @herb-tools/linter@0.8.3::Missing required `alt` attribute on `<img>` tag [html-img-require-alt]%0A%0A%0Atemplate.html.erb:3:3%0A%0A 1 │ <div>%0A 2 │ <span>Test content</span>%0A → 3 │ <img src="test.jpg">%0A │ ~~~%0A 4 │ </div>%0A
template.html.erb:
3:3 ✗ Missing required `alt` attribute on `<img>` tag [html-img-require-alt]The GitHub Actions annotations include:
- Embedded plain-text code previews (with
%0Afor newlines) that GitHub renders in PR comments titleproperty showing the rule code and linter version for better traceability- Full error context for debugging directly in GitHub's UI
The regular format output provides colorful syntax highlighting for local terminal debugging.
This approach is ideal for CI/CD workflows as it provides both GitHub integration and local debugging:
name: Herb Lint
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Herb Linter
run: npx @herb-tools/linterJSON Output Format
The linter supports structured JSON output with the --json flag, useful for programmatic consumption:
npx @herb-tools/linter template.html.erb --jsonExample output:
{
"offenses": [
{
"filename": "template.html.erb",
"message": "File must end with trailing newline",
"location": {
"start": { "line": 1, "column": 21 },
"end": { "line": 1, "column": 22 }
},
"severity": "error",
"code": "erb-require-trailing-newline",
"source": "Herb Linter"
}
],
"summary": {
"filesChecked": 1,
"filesWithOffenses": 1,
"totalErrors": 1,
"totalWarnings": 0,
"totalOffenses": 1,
"ruleCount": 21
},
"timing": {
"startTime": "2025-08-14T16:00:48.845Z",
"duration": 27
},
"completed": true,
"clean": false,
"message": null
}JSON output fields:
offenses: Array of linting offenses with location and severitysummary: Statistics about the linting run (nullon errors)timing: Timing information with ISO timestamp (nullwith--no-timing)completed: Whether the linter ran successfully on filesclean: Whether there were no offenses (nullwhencompleted=false)message: Error or informational message (nullon success)
Disabling Rules Inline v0.8.0+
You can disable linting rules for specific lines using inline comments. This is useful when you need to allow certain code that would otherwise trigger a linting offense.
Disabling a Single Rule
Add a comment at the end of the line with herb:disable followed by the rule name:
<DIV>test</DIV> <%# herb:disable html-tag-name-lowercase %>Disabling Multiple Rules
You can disable multiple rules on the same line by separating rule names with commas:
<DIV id='test' class="<%= "hello" %>">test</DIV> <%# herb:disable html-tag-name-lowercase, html-attribute-double-quotes %>Disabling All Rules
To disable all linting rules for a specific line, use all:
<DIV id='test' class="<%= "hello" %>">test</DIV> <%# herb:disable all %>Important
Inline disable comments only affect the line they appear on. Each line that needs linting disabled must have its own disable comment.
Disabling Linting for Entire Files v0.8.2+
You can disable linting for an entire file by adding the ignore directive anywhere in your template:
<%# herb:linter ignore %>Example:
<%# herb:linter ignore %>
<DIV>
<SPAN>This entire file will not be linted</SPAN>
</DIV><DIV> <SPAN>This entire file will be linted</SPAN></DIV>Important
The <%# herb:linter ignore %> directive must be an exact match. Extra text or spacing will prevent it from working.
Configuration
Create a .herb.yml file in your project root to configure the linter:
npx @herb-tools/linter --initBasic Configuration
linter:
enabled: true
# Additional glob patterns to include (additive to defaults)
include:
- '**/*.xml.erb'
# Glob patterns to exclude from linting
exclude:
- 'vendor/**/*'
- 'node_modules/**/*'
rules:
# Disable a rule
erb-no-extra-newline:
enabled: false
# Change rule severity
html-tag-name-lowercase:
severity: warning # error, warning, info, or hintRule-Level File Patterns
Apply rules to specific files using include, only, and exclude patterns:
linter:
rules:
# Apply rule only to component files
html-img-require-alt:
include:
- 'app/components/**/*'
exclude:
- 'app/components/legacy/**/*'
# Restrict rule to specific directory (overrides include)
html-tag-name-lowercase:
only:
- 'app/views/**/*'
exclude:
- 'app/views/admin/**/*'Pattern precedence:
only- When present, rule applies ONLY to these files (ignores allinclude)include- Whenonlyis absent, rule applies only to these files (additive)exclude- Always applied regardless ofincludeoronly
Force Flag
Process files even when excluded or when linter is disabled:
# Force linting when disabled in config
herb-lint --force
# Force linting on an excluded file
herb-lint --force app/views/excluded-file.html.erbWhen using --force on an excluded file, the linter will show a warning but proceed with linting.
Custom Rules
Create custom linter rules for project-specific requirements by placing ES module files (.mjs) in .herb/rules/:
File Extension
Custom rules must use the .mjs extension to avoid Node.js module type warnings. The .mjs extension explicitly marks files as ES modules.
import { BaseRuleVisitor, ParserRule } from "@herb-tools/linter"
class NoDivTagsVisitor extends BaseRuleVisitor {
visitHTMLOpenTagNode(node) {
if (!node.tag_name) return
if (node.tag_name.value !== "div") return
this.addOffense(
`Avoid using \`<div>\` tags. Consider using semantic HTML elements like \`<section>\`, \`<article>\`, \`<nav>\`, \`<main>\`, \`<header>\`, \`<footer>\`, or \`<aside>\` instead.`,
node.tag_name.location
)
}
}
export default class NoDivTagsRule extends ParserRule {
name = "no-div-tags"
check(result, context) {
const visitor = new NoDivTagsVisitor(this.name, context)
visitor.visit(result.value)
return visitor.offenses
}
}import { BaseRuleVisitor, getAttributes, getAttributeName } from "@herb-tools/linter"
class NoInlineStylesVisitor extends BaseRuleVisitor {
visitHTMLOpenTagNode(node) {
const attributes = getAttributes(node)
for (const attribute of attributes) {
const attributeName = getAttributeName(attribute)
if (attributeName === "style") {
this.addOffense(
`Avoid using inline \`style\` attributes. Use CSS classes instead.`,
attribute.location,
"warning"
)
}
}
super.visitHTMLOpenTagNode(node)
}
}
export default class NoInlineStylesRule {
name = "no-inline-styles"
check(parseResult, context) {
const visitor = new NoInlineStylesVisitor(this.name, context)
visitor.visit(parseResult.value)
return visitor.offenses
}
}Rule Configuration:
The defaultConfig getter is optional. If not specified, custom rules default to:
enabled: true- Rule is enabled by defaultseverity: "error"- Offenses are reported as errorsexclude: []- No files are excluded
You can override the defaultConfig getter to customize these defaults, as shown in the example above where severity is set to "warning".
Rule Properties:
static type- Optional, defaults to"parser". Can be"parser","lexer", or"source"name- Required, the rule identifier used in configuration and outputcheck()- Required, the method that checks for offensesdefaultConfig- Optional, returns the default configuration for the ruleisEnabled()- Optional, dynamically determines if the rule should runautofix()- Optional, implements automatic fixing for the rule
Custom rules are loaded automatically by default. Use --no-custom-rules to disable them.
When custom rules are loaded, the linter will display them:
Loaded 2 custom rules:
• no-inline-styles (.herb/rules/no-inline-styles.mjs)
• require-aria-labels (.herb/rules/require-aria-labels.mjs)Rule Name Clashes
If a custom rule has the same name as a built-in rule or another custom rule, you'll see a warning. The custom rule will override the built-in rule.
Hot Reload
Custom rules are automatically reloaded when changed in editors with the Herb Language Server. No need to restart your editor!
Language Server Integration
The linter is automatically integrated into the Herb Language Server, providing real-time validation in supported editors like VS Code, Zed, and Neovim.