Lints ERB templates with Herb's actual rule set — 100 built-in rules spanning accessibility, Action View, ERB, HTML, SVG, and Turbo markup, plus autofix — as a plain Ruby dependency. Nothing shells out to Node while it runs.
Herb itself is JS-first: herb lint shells out to npx @herb-tools/linter under the hood,
and the other Ruby-side integrations either do the same (pronto-herb) or aren't implemented
yet (rubocop-herb). That's a dead end for a Rails app that has deliberately kept Node off
its stack — importmap-rails plus the precompiled tailwindcss-ruby binary being the usual
reason. herb-embedded gets around it by vendoring Herb's real TypeScript rule engine and
running it inside an embedded JS engine (V8, via mini_racer) from within your Ruby process —
so you get the actual upstream rules, not a Ruby reimplementation of them, without installing
Node anywhere. Full rationale and scope in CHARTER.md.
# Gemfile
gem "herb-embedded"bundle installOr without Bundler:
gem install herb-embeddedTo try an unreleased change from a clone instead of the published gem:
git clone https://github.com/defmethodinc/herb-embedded.git
cd herb-embedded
bundle install
bundle exec rake build
gem install pkg/herb-embedded-*.gemLint every file your config (or the defaults) picks up:
bundle exec herb-lint-rbWith no arguments, herb-lint-rb discovers files itself, from .herb.yml's
files.include/files.exclude globs (default: **/*.html.erb and **/*.herb under the
current directory) — it does not accept a directory as an argument. Pass explicit file paths
instead to lint a subset:
bundle exec herb-lint-rb app/views/widgets/_card.html.erb app/views/widgets/_footer.html.erbExit codes: 0 clean, 1 offenses found at or above the fail level, 2 a config/flag error.
| Flag | Effect |
|---|---|
--fix |
Apply safe autocorrections |
--fix-unsafely |
Apply unsafe autocorrections too (implies --fix) |
--format FORMAT |
detailed | simple | json | github (default: detailed) |
--fail-level LEVEL |
error | warning | info | hint — minimum severity that fails the run (default from .herb.yml's linter.fail_level, itself defaulting to error) |
--only RULE |
Run only this rule (repeatable) |
--init |
Write a starter .herb.yml pinning the currently bundled linter version |
--version |
Print the herb-embedded and bundled @herb-tools/linter versions |
-h, --help |
Show help |
bundle exec herb-lint-rb --init writes a starter file:
version: "0.10.3"
files:
include:
- "**/*.html.erb"
- "**/*.herb"
exclude: []
linter:
fail_level: error
rules: {}-
versionrecords which bundled linter version the file was generated against; it's informational, not enforced. -
files.include/files.excludeare glob lists (relative to the directoryherb-lint-rbruns from) that control which files a no-argument run discovers. -
linter.fail_levelsets the default minimum severity that produces a non-zero exit code (overridable per-run with--fail-level). -
linter.rules.<rule-name>.enabled: falsedisables a built-in rule:linter: rules: html-no-self-closing: enabled: false
Only these keys are read; any other key from upstream @herb-tools/config's schema is parsed
but ignored, not an error.
Drop .mjs files under .herb/rules/. The only supported import is named imports from
@herb-tools/linter (rewritten at load time to pull from the already-loaded bundle — nothing
else can be imported, and an unsupported import fails loudly instead of producing a runtime
ReferenceError):
// .herb/rules/my_custom_rule.mjs
import { ParserRule } from "@herb-tools/linter";
export default class MyCustomRule extends ParserRule {
static ruleName = "my-custom-rule";
check(result, context) {
return [this.createOffense("Custom offense!", result.value.location)];
}
}A custom rule whose ruleName matches a built-in rule replaces it for that run (and prints a
warning to stderr).
CHARTER.md covers what this gem is, who it's for, what "good" means here, and what's explicitly out of scope — read that before proposing a change to its shape. CONTRIBUTING.md covers development setup.