Created
March 25, 2026 09:05
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | |
| } | |
| } |
Author
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
Whoops, great catch. Forgot to call
locationFromOffset, I just updated the rule in my original comment π