-
-
Save janko/e0e10615da1681d4295ab40878d8ccba to your computer and use it in GitHub Desktop.
| 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 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?
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.
Whoops, great catch. Forgot to call locationFromOffset, I just updated the rule in my original comment π
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 π
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.
visitHTMLOpenTagNodeβvisitHTMLAttributeNode: instead of manually iterating attributes withgetAttributes(), the visitor receives each attribute node directly via the visitor pattern (also handlesERBOpenTagNodesautomatically)parserOptionswithaction_view_helpers: true: enables parsing of Rails Action View tag helpers so the rule can also check attributes on helpers liketag.div,link_to,content_tag, etc.parserOptionswithprism_program: true+BootstrapFormHelperDetector(PrismVisitor): gives a single Prism AST on theDocumentNodeand replaces the string-splittingcheckERBNode/visitERBContentNode/visitERBBlockNodeapproach. Walks the Prism AST usingvisitCallNodeto match method names againstBOOTSTRAP3_FORM_HELPERSwhich should be more robust.getTokenListfrom@herb-tools/core: replaces manualvalue.split(/\s+/).filter(Boolean)for splitting class attribute values.getStaticAttributeValue(attribute): called directly on the attribute node instead of doing a second lookup on the parent node with getStaticAttributeValue(node, "class").