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
}
}
@marcoroth
Copy link
Copy Markdown

marcoroth commented Mar 25, 2026

This is awesome to see @janko! πŸ™Œ

With the release of Herb v0.9 we get a few more options to utilize that can make it even more accurate and robust.

  1. visitHTMLOpenTagNode β†’ visitHTMLAttributeNode: instead of manually iterating attributes with getAttributes(), the visitor receives each attribute node directly via the visitor pattern (also handles ERBOpenTagNodes automatically)

  2. parserOptions with action_view_helpers: true : enables parsing of Rails Action View tag helpers so the rule can also check attributes on helpers like tag.div, link_to, content_tag, etc.

  3. parserOptions with prism_program: true + BootstrapFormHelperDetector (PrismVisitor): gives a single Prism AST on the DocumentNode and replaces the string-splitting checkERBNode / visitERBContentNode / visitERBBlockNode approach. Walks the Prism AST using visitCallNode to match method names against BOOTSTRAP3_FORM_HELPERS which should be more robust.

  4. getTokenList from @herb-tools/core: replaces manual value.split(/\s+/).filter(Boolean) for splitting class attribute values.

  5. getStaticAttributeValue(attribute): called directly on the attribute node instead of doing a second lookup on the parent node with getStaticAttributeValue(node, "class").

import { BaseRuleVisitor, ParserRule, getAttributeName, getStaticAttributeValue, locationFromOffset } from "@herb-tools/linter"
import { getTokenList, PrismVisitor, Location } from "@herb-tools/core"

// 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 BootstrapFormHelperDetector extends PrismVisitor {
  constructor() {
    super()
    this.offenses = []
  }

  visitCallNode(node) {
    if (BOOTSTRAP3_FORM_HELPERS.has(node.name)) {
      this.offenses.push({
        message: `Bootstrap form helper \`${node.name}\` detected. Use \`ApplicationFormBuilder\` or native Action View form builder instead.`,
        location: node.location,
      })
    }

    super.visitCallNode(node)
  }
}

class NoBootstrap3Visitor extends BaseRuleVisitor {
  visitDocumentNode(document) {
    if (document.prismNode && document.source) {
      const detector = new BootstrapFormHelperDetector()
      detector.visit(document.prismNode)

      for (const offense of detector.offenses) {
        const location = locationFromOffset(document.source, offense.location.startOffset, offense.location.length)
        this.addOffense(offense.message, location)
      }
    }

    super.visitDocumentNode(document)
  }

  visitHTMLAttributeNode(attribute) {
    const name = getAttributeName(attribute)
    if (!name) return

    // 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
      )
      return
    }

    // Check class attribute for Bootstrap 3 CSS classes
    if (name === "class") {
      const value = getStaticAttributeValue(attribute)
      if (!value) return

      const valueLocation = attribute.value?.location
      if (!valueLocation) return

      // Offset into the value text (skip opening quote if quoted)
      const quoted = attribute.value?.quoted
      const quoteOffset = quoted ? 1 : 0

      const classes = getTokenList(value)

      for (const cls of classes) {
        if (isBootstrap3Class(cls)) {
          const classOffset = value.indexOf(cls)
          const startLine = valueLocation.start.line
          const startColumn = valueLocation.start.column + quoteOffset + classOffset
          const location = Location.from(startLine, startColumn, startLine, startColumn + cls.length)

          this.addOffense(
            `Bootstrap 3 class \`${cls}\` detected. Use Tailwind utilities, Cocoon classes or custom CSS instead.`,
            location
          )
        }
      }
    }
  }
}

export default class NoBootstrap3Rule extends ParserRule {
  static ruleName = "no-bootstrap3"

  get parserOptions() {
    return {
      action_view_helpers: true,
      prism_program: true
    }
  }

  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