Skip to content

Instantly share code, notes, and snippets.

@janko
Created March 25, 2026 09:05
Show Gist options
  • Select an option

  • Save janko/e0e10615da1681d4295ab40878d8ccba to your computer and use it in GitHub Desktop.

Select an option

Save janko/e0e10615da1681d4295ab40878d8ccba to your computer and use it in GitHub Desktop.
Custom Herb Linter rule that disallows usage of Bootstrap 3 classes, `data-*` attributes or `bootstrap_form_*` helpers
import { BaseRuleVisitor, ParserRule, getAttributes, getAttributeName, getStaticAttributeValue } from "@herb-tools/linter"
// Bootstrap 3 CSS classes that were removed or renamed in Bootstrap 4/5
const BOOTSTRAP3_CLASSES = new Set([
// Grid: col-xs-* prefix (renamed to col- in BS4)
// Detected via regex below
// Panels (replaced by Cards in BS4)
"panel", "panel-default", "panel-primary", "panel-success", "panel-info",
"panel-warning", "panel-danger", "panel-body", "panel-heading", "panel-title",
"panel-footer", "panel-group", "panel-collapse",
// Wells (removed in BS4)
"well", "well-sm", "well-lg",
// Thumbnails (removed in BS4)
"thumbnail",
// Glyphicons (removed in BS4)
// Detected via regex below
// Labels (renamed to Badge in BS4)
"label", "label-default", "label-primary", "label-success", "label-info",
"label-warning", "label-danger",
// Page header (removed in BS4)
"page-header",
// Responsive utilities (changed in BS4)
"hidden-xs", "hidden-sm", "hidden-md", "hidden-lg",
"hidden-xs-up", "hidden-sm-up", "hidden-md-up", "hidden-lg-up",
"hidden-xs-down", "hidden-sm-down", "hidden-md-down", "hidden-lg-down",
"visible-xs", "visible-sm", "visible-md", "visible-lg",
"visible-xs-block", "visible-xs-inline", "visible-xs-inline-block",
"visible-sm-block", "visible-sm-inline", "visible-sm-inline-block",
"visible-md-block", "visible-md-inline", "visible-md-inline-block",
"visible-lg-block", "visible-lg-inline", "visible-lg-inline-block",
"visible-print-block", "visible-print-inline", "visible-print-inline-block",
"hidden-print",
// Navbar
"navbar-default", "navbar-inverse",
"navbar-toggle", "navbar-header",
// Nav
"nav-stacked",
// Input sizes (changed in BS4)
"input-lg", "input-sm",
// Tables
"table-condensed",
// Progress bars (contextual variants renamed in BS4)
"progress-bar-success", "progress-bar-info", "progress-bar-warning", "progress-bar-danger",
// Media object (removed in BS4)
"media", "media-body", "media-heading", "media-left", "media-right", "media-middle", "media-bottom",
// Pager (removed in BS4)
"pager", "pager-next", "pager-prev",
// List group
"list-group-item-heading", "list-group-item-text",
// Forms
"form-control", "form-group", "form-horizontal", "form-inline",
"control-label", "has-error", "has-warning", "has-success",
"help-block", "input-group", "input-group-addon",
// Alerts
"alert", "alert-success", "alert-info", "alert-warning", "alert-danger",
"alert-dismissable", "alert-dismissible", "alert-link",
// Modals
"modal", "modal-dialog", "modal-content",
"modal-header", "modal-title", "modal-body", "modal-footer",
"modal-backdrop", "modal-open", "modal-sm", "modal-lg",
// Jumbotron (removed in BS5)
"jumbotron",
// Close button (replaced by btn-close in BS5)
"close",
// Visibility / screen readers (renamed in BS5)
"sr-only", "sr-only-focusable",
// Dropdowns
"divider",
// Carousel
"carousel-control",
// Misc
"affix", "dl-horizontal", "text-hide",
"center-block", "pull-right", "pull-left",
"img-responsive", "img-circle", "img-rounded",
"initialism",
])
// Bootstrap 3 regex patterns for class names
const BOOTSTRAP3_CLASS_PATTERNS = [
/^col-xs-\d+$/, // col-xs-* (renamed to col- in BS4)
/^col-xs-(offset|push|pull)-\d+$/,
/^glyphicon(-\S+)?$/, // glyphicon and glyphicon-* (removed in BS4)
/^btn-\S+$/, // btn-* (Bootstrap button modifiers, use Tailwind instead)
]
// Bootstrap 3 data-* attributes (replaced by data-bs-* in BS5)
const BOOTSTRAP3_DATA_ATTRS = new Set([
"data-toggle",
"data-dismiss",
"data-target",
"data-parent",
"data-ride",
"data-slide",
"data-slide-to",
"data-spy",
"data-offset",
"data-content",
"data-placement",
"data-container",
"data-trigger",
"data-original-title",
])
const BOOTSTRAP3_FORM_HELPERS = new Set([
"bootstrap_form_for",
"bootstrap_form_tag",
"bootstrap_form_with",
])
function isBootstrap3Class(cls) {
if (BOOTSTRAP3_CLASSES.has(cls)) return true
return BOOTSTRAP3_CLASS_PATTERNS.some((pattern) => pattern.test(cls))
}
class NoBootstrap3Visitor extends BaseRuleVisitor {
checkERBNode(node) {
if (node.tag_opening?.value !== "<%=") return
const content = node.content?.value
if (!content) return
const helper = content.trim().split(/[\s(]/)[0]
if (BOOTSTRAP3_FORM_HELPERS.has(helper)) {
this.addOffense(
`Bootstrap form helper \`${helper}\` detected. Use \`ApplicationFormBuilder\` or native Action View form builder instead.`,
node.location
)
}
}
visitERBContentNode(node) {
this.checkERBNode(node)
}
visitERBBlockNode(node) {
this.checkERBNode(node)
this.visitChildNodes(node)
}
visitHTMLOpenTagNode(node) {
const attributes = getAttributes(node)
for (const attribute of attributes) {
const name = getAttributeName(attribute)
if (!name) continue
// Check for Bootstrap 3 data-* attributes
if (BOOTSTRAP3_DATA_ATTRS.has(name)) {
this.addOffense(
`Bootstrap 3 attribute \`${name}\` detected. Move this behavior to Stimulus for encapsulation.`,
attribute.location
)
continue
}
// Check class attribute for Bootstrap 3 CSS classes
if (name === "class") {
const value = getStaticAttributeValue(node, "class")
if (!value) continue
const classes = value.split(/\s+/).filter(Boolean)
for (const cls of classes) {
if (isBootstrap3Class(cls)) {
this.addOffense(
`Bootstrap 3 class \`${cls}\` detected. Use Tailwind utilities, Cocoon classes or custom CSS instead.`,
attribute.location
)
break // one offense per attribute node is enough
}
}
}
}
super.visitHTMLOpenTagNode(node)
}
}
export default class NoBootstrap3Rule extends ParserRule {
static ruleName = "no-bootstrap3"
check(result, context) {
const visitor = new NoBootstrap3Visitor(this.ruleName, context)
visitor.visit(result.value)
return visitor.offenses
}
}
@janko
Copy link
Copy Markdown
Author

janko commented Mar 25, 2026

@marcoroth That's great, thanks! I updated the rule, and it caught additional Bootstrap classes in Action View helpers.

However, the BootstrapFormHelperDetector visitor isn't quite working, I'm getting the following error (no backtrace):

Error: Error: Worker error: Cannot read properties of undefined (reading 'line')

When I inspect the node, it's passing CallNode.location, which contains { startOffset: 4, length: 2213 }, and it's possible that BaseRuleVisitor.addOffense requires the location to have a line somewhere?

@janko
Copy link
Copy Markdown
Author

janko commented Mar 25, 2026

Yep, HTMLAttributeNode.location returns the following object:

Location {
  start: Position { line: 26, column: 91 },
  end: Position { line: 26, column: 98 }
}

So, this.addOffense receives an incompatible location object from Prism.

@marcoroth
Copy link
Copy Markdown

Whoops, great catch. Forgot to call locationFromOffset, I just updated the rule in my original comment πŸ™Œ

@janko
Copy link
Copy Markdown
Author

janko commented Mar 25, 2026

Perfect, that fixed things πŸ‘ Very cool that individual classes are now highlighted!

The ruby-based class highlighting is now more accurate, but still not perfectly aligned:

[error] Bootstrap 3 class btn-primary detected. Use Tailwind utilities, Cocoon classes or custom CSS instead. (no-bootstrap3)

app/views/two_factor_authentications/_required_options.html.erb:4:73

      2 β”‚   <div class="flex flex-col lg:flex-row space-y-4 space-x-4">
      3 β”‚     <div class="lg:w-2/5 xl:w-1/4">
  β†’   4 β”‚       <%= link_to t(".change"), new_two_factor_authentication_path, class: "btn btn-primary", data: { turbo: false } %>
        β”‚                                                                          ~~~~~~~~~~~
      5 β”‚     </div>
      6 β”‚     <div class="lg:w-3/5 xl:w-3/4">

The bootstrap form helper call also doesn't get fully highlighted, only first two characters:

[error] Bootstrap form helper bootstrap_form_for detected. Use ApplicationFormBuilder or native Action View form builder instead. (no-bootstrap3)

app/views/building_admin/csv_import/pending_users/_form.html.erb:1:4

  β†’   1 β”‚ <%= bootstrap_form_for model: pending_user, url: url do |f| %>
        β”‚     ~~
      2 β”‚   <%= f.text_field :first_name, disabled: pending_user.missing?(:first_name) %>
      3 β”‚   <%= f.text_field :last_name, disabled: pending_user.missing?(:last_name) %>

But that's just cosmetic, I'm really happy with how this is working now πŸ™Œ

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment