Skip to content

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 ​

shell
npm install -g @herb-tools/linter
shell
pnpm add -g @herb-tools/linter
shell
yarn global add @herb-tools/linter
shell
bun add -g @herb-tools/linter

One-time Usage ​

For occasional use without installing:

bash
npx @herb-tools/linter template.html.erb

Preview Releases ​

Want to try unreleased features? Use pkg.pr.new to run the linter from any commit or PR:

bash
npx https://pkg.pr.new/@herb-tools/linter@{commit} template.html.erb

Replace {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 ​

shell
npm add -D @herb-tools/linter
shell
pnpm add -D @herb-tools/linter
shell
yarn add -D @herb-tools/linter
shell
bun add -D @herb-tools/linter

After installing as a dev dependency, initialize the configuration:

shell
npx herb-lint --init
shell
pnpm herb-lint --init
shell
yarn herb-lint --init
shell
bunx herb-lint --init

Then add a lint NPM script to your package.json:

package.json
json
{
  "scripts": {
    "herb:lint": "herb-lint"
  }
}

Then run the scripts:

shell
npm run herb:lint
shell
pnpm herb:lint
shell
yarn herb:lint
shell
bun run herb:lint

Usage ​

Command Line ​

Basic usage:

bash
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:

bash
# Create a .herb.yml configuration file
npx @herb-tools/linter --init

Options ​

Output Format:

bash
# 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 --github

Display Options:

bash
# 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-github

Exit Behavior: v0.8.7+

bash
# Exit with error code when warnings or higher are present
npx @herb-tools/linter template.html.erb --fail-level warning

# Exit with error code when info diagnostics or higher are present
npx @herb-tools/linter template.html.erb --fail-level info

# Exit with error code when any diagnostic (including hints) is present
npx @herb-tools/linter template.html.erb --fail-level hint

By default, the linter exits with code 1 only when errors are present. The --fail-level option allows you to control this behavior for CI/CD pipelines where you want stricter enforcement. Valid values are: error (default), warning, info, hint.

This can also be configured in .herb.yml:

.herb.yml
yaml
linter:
  failLevel: warning

The CLI flag takes precedence over the configuration file.

Autofix:

Automatically fix auto-correctable offenses:

bash
npx @herb-tools/linter --fix

Also apply unsafe auto-fixes (implies --fix):

bash
npx @herb-tools/linter --fix-unsafely

The --fix flag automatically corrects offenses that have safe, deterministic fixes (like formatting issues). The --fix-unsafely flag additionally applies fixes that may change code behavior or require manual review after application.

Safe fixes (--fix):

  • Formatting corrections (whitespace, quotes, trailing newlines)
  • Syntax normalization (tag casing, attribute formatting)

Unsafe fixes (--fix-unsafely):

  • Changes that may alter runtime behavior
  • Insertions that require manual completion (e.g., adding empty strict locals declarations)

WARNING

Always review changes made by --fix-unsafely before committing. These fixes are intentionally separated because they may require additional manual adjustments.

Help and Version:

bash
# Show help
npx @herb-tools/linter --help

# Show version information
npx @herb-tools/linter --version

GitHub 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.

bash
# 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 --github

Example: --github (GitHub annotations + detailed format)

::error file=template.html.erb,line=3,col=3,title=html-img-require-alt • @herb-tools/linter@0.8.10::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.10::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 %0A for newlines) that GitHub renders in PR comments
  • title property 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:

.github/workflows/herb.yml
yaml
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/linter

JSON Output Format ​

The linter supports structured JSON output with the --json flag, useful for programmatic consumption:

bash
npx @herb-tools/linter template.html.erb --json
Example output:
json
{
  "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 severity
  • summary: Statistics about the linting run (null on errors)
  • timing: Timing information with ISO timestamp (null with --no-timing)
  • completed: Whether the linter ran successfully on files
  • clean: Whether there were no offenses (null when completed=false)
  • message: Error or informational message (null on 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:

erb
<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:

erb
<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:

erb
<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:

erb
<%# herb:linter ignore %>

Example:

erb
<%# herb:linter ignore %>

<DIV>
  <SPAN>This entire file will not be linted</SPAN>
</DIV>
erb
<DIV>
Opening tag name `<DIV>` should be lowercase. Use `<div>` instead. (html-tag-name-lowercase)
<SPAN>This entire file will be linted</SPAN>
Closing tag name `</SPAN>` should be lowercase. Use `</span>` instead. (html-tag-name-lowercase)
Opening tag name `<SPAN>` should be lowercase. Use `<span>` instead. (html-tag-name-lowercase)
</DIV>
Closing tag name `</DIV>` should be lowercase. Use `</div>` instead. (html-tag-name-lowercase)

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:

bash
npx @herb-tools/linter --init

Basic Configuration ​

.herb.yml
yaml
linter:
  enabled: true

  # # Exit with error code when diagnostics of this severity or higher are present
  # # Valid values: error (default), warning, info, hint
  # failLevel: warning

  # 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 hint

Rule-Level File Patterns ​

Apply rules to specific files using include, only, and exclude patterns:

.herb.yml
yaml
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:

  1. only - When present, rule applies ONLY to these files (ignores all include)
  2. include - When only is absent, rule applies only to these files (additive)
  3. exclude - Always applied regardless of include or only

Force Flag ​

Process files even when excluded or when linter is disabled:

bash
# Force linting when disabled in config
herb-lint --force

# Force linting on an excluded file
herb-lint --force app/views/excluded-file.html.erb

When 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.

js
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 {
  static ruleName = "no-div-tags"

  check(result, context) {
    const visitor = new NoDivTagsVisitor(this.name, context)
    visitor.visit(result.value)
    return visitor.offenses
  }
}
js
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 {
  static ruleName = "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 default
  • severity: "error" - Offenses are reported as errors
  • exclude: [] - 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"
  • static ruleName - Required, the rule identifier used in configuration and output
  • check() - Required, the method that checks for offenses
  • defaultConfig - Optional, returns the default configuration for the rule
  • isEnabled() - Optional, dynamically determines if the rule should run
  • autofix() - 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.

Released under the MIT License.