twitter-bootstrap-rails 5.5.0 → 5.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e749f38d103c9081159cd686251b083119950e45ac05a07ffbec1fdc9998c718
4
- data.tar.gz: b68354a498fbccd9f3332c3c28c1e34984d236cad0aaaaf24b521e32361700d2
3
+ metadata.gz: 875edfcbadfdb5b579da81a68c3a5dc44d7b14d208bcf7de6dc89a8dabe969d3
4
+ data.tar.gz: bc3f64fe1f6c45217d043887a9fbcab629e7161738b2c47c3ea46a0a830b1cb1
5
5
  SHA512:
6
- metadata.gz: d89262fa8b657f75f13f3be3f0550f0999e06e14a2de6bc6d76fdab6171639b5785cd7167118d90aef0dae928ecff7f526d3c3daf1e2b31cb568c252ca391c4d
7
- data.tar.gz: deb6bc95b028221c973918da2f69df302b862552e12f5744c7735fabf32c00eab231c82855e4beb2f52dcb15239c7a9325583bff128df349f7a7bcd235fe7fb4
6
+ metadata.gz: 18bd87131c776f3da869462fb6af504614dd708a91434881a9e46955a3e7817cdbcd8071e3d490a5bd18841b3601016e632e73163071786c026b5a2fb52cfc74
7
+ data.tar.gz: 75630ca82664a7956d088fbc90b48d2eb1f11e89087488dcb7a5b209221aff58ccabc2296546a4be9fc83595109b6b31fc41db9be8e931a23818ac8f6dea8841
data/CHANGELOG.md CHANGED
@@ -1,3 +1,26 @@
1
+ ## 5.5.1
2
+
3
+ ### Added
4
+
5
+ - **Bootstrap 6 migration support.** `rails g bootstrap:doctor --v6` adds a
6
+ `v6_migration` check and list: Bootstrap 5 classes, data attributes, Sass
7
+ `@import`s and JavaScript calls that change in Bootstrap 6, with file, line and
8
+ replacement. It follows the upstream `bootstrap-v5-v6-migration` skill from
9
+ `twbs/bootstrap`, scans views, helpers, components, `app/javascript` and
10
+ stylesheets, skips the Bootstrap files this gem vendored, and only warns.
11
+ - The `bootstrap:agent` guide has a Bootstrap 6 section. It tells agents not to write
12
+ Bootstrap 6 markup on the Bootstrap 5 this gem ships, and gives the steps to migrate:
13
+ install the upstream skill with
14
+ `npx skills add twbs/bootstrap --skill bootstrap-v5-v6-migration`, run
15
+ `bootstrap:doctor --v6 --json`, and switch assets before markup.
16
+ - The upstream migration skill is installed in this repository
17
+ (`.claude/skills/bootstrap-v5-v6-migration`, pinned in `skills-lock.json`) for work
18
+ on the gem itself. It is not packaged into the gem.
19
+
20
+ ### Changed
21
+
22
+ - `bootstrap:doctor` prints a heading above each list of findings.
23
+
1
24
  ## 5.5.0
2
25
 
3
26
  ### Added
data/README.md CHANGED
@@ -156,6 +156,7 @@ knows instead. The gem doesn't call any AI service and needs no API key.
156
156
  rails g bootstrap:agent # write a guide tailored to this app
157
157
  rails g bootstrap:doctor # check how Bootstrap is set up
158
158
  rails g bootstrap:doctor --json # the same report, for agents and CI
159
+ rails g bootstrap:doctor --v6 # also list what changes in Bootstrap 6
159
160
  rails g bootstrap:header --json # list a snippet's variants as JSON
160
161
  ```
161
162
 
@@ -179,6 +180,37 @@ In an existing app, `legacy_markup` works as a migration to-do list. Plain names
179
180
  If Bootstrap comes from `package.json` or your importmap, the doctor warns that it
180
181
  can't verify it rather than reporting an error.
181
182
 
183
+ ### Preparing for Bootstrap 6
184
+
185
+ This gem ships Bootstrap 5.3.8. Bootstrap 6 is in alpha (`6.0.0-alpha.1` on npm),
186
+ and the Bootstrap team publishes an agent skill for the move from 5 to 6. Install it
187
+ into your app alongside the gem's own guide:
188
+
189
+ ```bash
190
+ npx skills add twbs/bootstrap --skill bootstrap-v5-v6-migration
191
+ rails g bootstrap:agent # the guide tells agents to stay on Bootstrap 5 markup until you migrate
192
+ rails g bootstrap:doctor --v6 # the migration worklist
193
+ rails g bootstrap:doctor --v6 --json
194
+ ```
195
+
196
+ `--v6` adds a `v6_migration` check and list: every Bootstrap 5 class, data attribute,
197
+ Sass `@import` and JavaScript call that changes in Bootstrap 6, with file, line and
198
+ replacement. For example `modal-body` becomes `dialog-body`, `col-md-6` becomes
199
+ `md:col-6`, `btn-primary` becomes `btn-solid theme-primary`, and
200
+ `data-bs-toggle="dropdown"` becomes `data-bs-toggle="menu"`. It scans `app/views`,
201
+ `app/helpers`, `app/components`, `app/javascript` and your stylesheets, and skips the
202
+ Bootstrap files this gem vendored. It reports a warning, never an error, and is off
203
+ unless you ask for it.
204
+
205
+ Things the scan can't do for you:
206
+
207
+ - The vendored assets, `bootstrap:install` and every generator are still Bootstrap 5.
208
+ Switch the assets to Bootstrap 6 before changing markup, and don't re-run the
209
+ generators afterwards.
210
+ - The view helpers (`nav_bar`, `modal_dialog`, `badge` and the rest) render Bootstrap 5
211
+ markup at runtime, so their output isn't scanned.
212
+ - Bootstrap 6 changes the spacing scale, so check `p-*`/`m-*` utilities by eye.
213
+
182
214
  ## Quick Start
183
215
 
184
216
  ```bash
@@ -1,6 +1,7 @@
1
1
  require 'erb'
2
2
  require 'rails/generators'
3
3
  require 'twitter/bootstrap/rails/doctor'
4
+ require 'twitter/bootstrap/rails/v6_migration'
4
5
  require 'generators/bootstrap/snippets/snippet_catalog'
5
6
 
6
7
  module Bootstrap
@@ -66,6 +67,10 @@ module Bootstrap
66
67
  Twitter::Bootstrap::Rails::LegacyMarkup
67
68
  end
68
69
 
70
+ def v6
71
+ Twitter::Bootstrap::Rails::V6Migration
72
+ end
73
+
69
74
  def pipeline_note
70
75
  case facts[:asset_pipeline]
71
76
  when "propshaft"
@@ -45,6 +45,18 @@ Snippets are always written as ERB partials. They render from any template engin
45
45
  - Modals: `modal_dialog`, `modal_toggle`, `modal_cancel_button`
46
46
  - Other: `badge`, `tag_label`, `render_breadcrumbs`, `add_breadcrumb`, and `error_span` on form builders
47
47
 
48
+ ## Bootstrap 6
49
+
50
+ This app runs Bootstrap <%= facts[:bootstrap_version] %>. Don't write Bootstrap 6 markup (`dialog`, `drawer`, `menu`, `md:col-6`, `btn-solid theme-primary`) unless the app is being moved to Bootstrap 6: none of it works on <%= facts[:bootstrap_version] %>. The gem's generators and helpers produce Bootstrap 5 markup.
51
+
52
+ To move the app to Bootstrap 6:
53
+
54
+ 1. Install the upstream migration skill: `<%= v6::SKILL_COMMAND %>`. Follow it alongside this guide.
55
+ 2. Run `rails g bootstrap:doctor --v6 --json`. It lists every Bootstrap 5 class, data attribute, Sass import and JavaScript call that changes, with file, line and replacement, under `v6_migration`.
56
+ 3. Switch the Bootstrap assets before changing markup, because the vendored files and `bootstrap:install` are Bootstrap 5. Re-running `bootstrap:layout`, `bootstrap:themed` or a snippet generator writes Bootstrap 5 markup again.
57
+ 4. The helpers in "View helpers" render Bootstrap 5 markup at runtime, so the scan can't see their output. Replace them with Bootstrap 6 markup in the views that use them.
58
+ 5. v6 changes the spacing scale (`p-3` is smaller), so check spacing utilities by eye; the scan doesn't flag them.
59
+
48
60
  ## Markup to avoid
49
61
 
50
62
  These come from older Bootstrap versions and do nothing on this one. `bootstrap:doctor` reports them, with file and line, under `legacy_markup`. Plain names like `close` or `well` may be the app's own CSS classes, so check those before changing them.
@@ -11,9 +11,11 @@ module Bootstrap
11
11
 
12
12
  class_option :json, :type => :boolean, :default => false,
13
13
  :desc => 'Print the report as JSON'
14
+ class_option :v6, :type => :boolean, :default => false,
15
+ :desc => 'Also list Bootstrap 5 code that changes in Bootstrap 6'
14
16
 
15
17
  def diagnose
16
- report = Twitter::Bootstrap::Rails::Doctor.new(destination_root).report
18
+ report = Twitter::Bootstrap::Rails::Doctor.new(destination_root).report(:v6 => options[:v6])
17
19
 
18
20
  options[:json] ? say(JSON.pretty_generate(report)) : print_report(report)
19
21
  exit(1) if report[:status] == "error"
@@ -30,7 +32,15 @@ module Bootstrap
30
32
  say " #{' ' * 20} Fix: #{check[:fix]}" if check[:fix]
31
33
  end
32
34
 
33
- report[:legacy_markup].each do |finding|
35
+ print_findings("Older Bootstrap markup", report[:legacy_markup])
36
+ print_findings("Changes in Bootstrap 6", report[:v6_migration])
37
+ end
38
+
39
+ def print_findings(title, findings)
40
+ return if findings.nil? || findings.empty?
41
+
42
+ say "\n#{title}:"
43
+ findings.each do |finding|
34
44
  say " #{finding[:file]}:#{finding[:line]} #{finding[:found]} -> #{finding[:replacement]}"
35
45
  end
36
46
  end
@@ -1,6 +1,7 @@
1
1
  require 'twitter/bootstrap/rails/version'
2
2
  require 'twitter/bootstrap/rails/config'
3
3
  require 'twitter/bootstrap/rails/legacy_markup'
4
+ require 'twitter/bootstrap/rails/v6_migration'
4
5
 
5
6
  module Twitter
6
7
  module Bootstrap
@@ -14,19 +15,27 @@ module Twitter
14
15
  SCANNED_EXTENSIONS = %w[.erb .haml .slim .rb].freeze
15
16
  PLACEHOLDER_IMAGES = ["bootstrap-docs.png", "bootstrap-themes.png", "/docs/5.3/assets/", "unsplash-photo-"].freeze
16
17
  SEVERITY = %w[ok warn error].freeze
18
+ # What `--v6` scans on top of views and helpers. The gem's own vendored
19
+ # Bootstrap files are skipped: replacing them is the first migration step.
20
+ V6_EXTRA_FILES = "app/{javascript,assets/javascripts,assets/stylesheets}/**/*".freeze
21
+ V6_EXTENSIONS = %w[.js .mjs .ts .css .scss .sass].freeze
22
+ VENDORED_DIR = %r{/twitter/bootstrap/}
17
23
 
18
24
  def initialize(root)
19
25
  @root = root
20
26
  end
21
27
 
22
- def report
28
+ # v6: true adds a v6_migration check and list for apps moving to Bootstrap 6.
29
+ def report(v6: false)
23
30
  checks = [css_check, js_check, version_check, sprite_check, placeholder_check, legacy_check]
31
+ checks << v6_check if v6
24
32
 
25
- facts.merge(
33
+ result = facts.merge(
26
34
  :status => checks.map { |c| c[:status] }.max_by { |s| SEVERITY.index(s) },
27
35
  :checks => checks,
28
36
  :legacy_markup => legacy_markup
29
37
  )
38
+ v6 ? result.merge(:v6_migration => v6_migration) : result
30
39
  end
31
40
 
32
41
  def facts
@@ -169,6 +178,28 @@ module Twitter
169
178
  end
170
179
  end
171
180
 
181
+ def v6_check
182
+ count = v6_migration.size
183
+ return check("v6_migration", "ok", "Nothing found that changes in Bootstrap 6.") if count.zero?
184
+
185
+ check("v6_migration", "warn",
186
+ "#{count} use(s) of Bootstrap 5 markup, Sass or JavaScript that change in Bootstrap 6, listed in v6_migration. " \
187
+ "This gem still ships Bootstrap #{BOOTSTRAP_VERSION}, so only change them as part of moving the app to Bootstrap 6.",
188
+ "Install the upstream migration skill (`#{V6Migration::SKILL_COMMAND}`) and follow it, " \
189
+ "or read #{V6Migration::GUIDE_URL}. Switch the Bootstrap assets first.")
190
+ end
191
+
192
+ def v6_migration
193
+ @v6_migration ||= v6_files.flat_map do |file|
194
+ V6Migration.scan(read(file), File.extname(file)).map { |finding| { :file => file }.merge(finding) }
195
+ end
196
+ end
197
+
198
+ def v6_files
199
+ extra = relative_files(V6_EXTRA_FILES, V6_EXTENSIONS).reject { |file| file.match?(VENDORED_DIR) }
200
+ (scanned_files + extra).uniq.sort
201
+ end
202
+
172
203
  def check(id, status, message, fix = nil)
173
204
  result = { :id => id, :status => status, :message => message }
174
205
  result[:fix] = fix if fix
@@ -187,9 +218,9 @@ module Twitter
187
218
  relative_files("app/{views,helpers,components}/**/*")
188
219
  end
189
220
 
190
- def relative_files(pattern)
221
+ def relative_files(pattern, extensions = SCANNED_EXTENSIONS)
191
222
  Dir[File.join(@root, pattern)]
192
- .select { |file| File.file?(file) && SCANNED_EXTENSIONS.include?(File.extname(file)) }
223
+ .select { |file| File.file?(file) && extensions.include?(File.extname(file)) }
193
224
  .map { |file| file.delete_prefix("#{@root}/") }
194
225
  .sort
195
226
  end
@@ -0,0 +1,156 @@
1
+ require 'twitter/bootstrap/rails/legacy_markup'
2
+
3
+ module Twitter
4
+ module Bootstrap
5
+ module Rails
6
+ # Finds Bootstrap 5 markup, Sass and JavaScript that changes in Bootstrap 6,
7
+ # following the upstream migration skill
8
+ # (npx skills add twbs/bootstrap --skill bootstrap-v5-v6-migration).
9
+ # The gem still ships Bootstrap 5, so this is a worklist for an app that is
10
+ # moving to 6, not markup to avoid today. `bootstrap:doctor --v6` reports it.
11
+ module V6Migration
12
+ SKILL_COMMAND = "npx skills add twbs/bootstrap --skill bootstrap-v5-v6-migration".freeze
13
+ GUIDE_URL = "https://getbootstrap.com/docs/6.0/guides/migration/".freeze
14
+
15
+ BREAKPOINTS = { "sm" => "sm", "md" => "md", "lg" => "lg", "xl" => "xl", "xxl" => "2xl" }.freeze
16
+ THEMES = %w[primary secondary success danger warning info].freeze
17
+ FONT_SIZES = { "1" => "4xl", "2" => "3xl", "3" => "2xl", "4" => "xl", "5" => "lg", "6" => "md" }.freeze
18
+ RENAMED_COMPONENTS = { "modal" => "dialog", "offcanvas" => "drawer", "dropdown" => "menu" }.freeze
19
+ # Classes that take a breakpoint as a suffix in v5 (navbar-expand-lg).
20
+ SUFFIXED = %w[container navbar-expand offcanvas table-responsive list-group-horizontal vstack hstack col].freeze
21
+
22
+ CLASSES = {
23
+ "modal" => "dialog on a <dialog> element",
24
+ "modal-dialog" => "nothing (remove the wrapper; use one <dialog class=\"dialog\">)",
25
+ "modal-content" => "nothing (remove the wrapper; use one <dialog class=\"dialog\">)",
26
+ "offcanvas" => "drawer on a <dialog> element",
27
+ "dropdown" => "nothing (remove the wrapper; the toggle and .menu are siblings)",
28
+ "dropdown-menu" => "menu",
29
+ "dropdown-item" => "menu-item",
30
+ "dropdown-divider" => "menu-divider",
31
+ "dropdown-header" => "menu-header",
32
+ "dropdown-toggle" => "nothing (the menu toggle needs no class)",
33
+ "dropdown-toggle-split" => "nothing (remove it)",
34
+ "text-muted" => "fg-secondary",
35
+ "form-select" => "form-control (on the select)",
36
+ "form-check" => "form-field",
37
+ "form-check-input" => "check or radio (on the input)",
38
+ "form-check-label" => "nothing (a plain label)",
39
+ "form-switch" => "a switch wrapper around the input",
40
+ "needs-validation" => "data-bs-validate on the form",
41
+ "was-validated" => "nothing (:user-invalid handles it)",
42
+ "has-validation" => "nothing (remove it)",
43
+ "clearfix" => "d-flow-root",
44
+ "lh-base" => "lh-md",
45
+ "lead" => "fs-* and fw-* utilities",
46
+ "placeholder-glow" => "placeholder-pulse (or placeholder-wave)",
47
+ "btn-close-white" => "nothing (set the text colour instead)",
48
+ "card-link" => "a plain link",
49
+ "accordion-button" => "nothing (put the title in summary.accordion-header)",
50
+ "accordion-collapse" => "nothing (use details.accordion-item)",
51
+ "navbar-light" => "nothing (remove it)",
52
+ "navbar-dark" => "nothing (use data-bs-theme=\"dark\")",
53
+ "carousel-control-prev" => "btn-icon with data-bs-slide=\"prev\"",
54
+ "carousel-control-next" => "btn-icon with data-bs-slide=\"next\"",
55
+ "carousel-control-prev-icon" => "carousel-icon-prev",
56
+ "carousel-control-next-icon" => "carousel-icon-next",
57
+ "carousel-caption" => "your own markup",
58
+ "carousel-dark" => "data-bs-theme=\"dark\""
59
+ }.freeze
60
+
61
+ # [pattern, block turning the match into its replacement, example for docs]
62
+ CLASS_PATTERNS = [
63
+ [/\Amodal-(header|body|footer|title|sm|lg|xl|fullscreen)\z/, ->(m) { "dialog-#{m[1]}" }, "modal-body becomes dialog-body"],
64
+ [/\Aoffcanvas-(header|body|title|start|end|top|bottom)\z/, ->(m) { "drawer-#{m[1]}" }, "offcanvas-start becomes drawer-start"],
65
+ [/\Adropdown-menu-(start|end)\z/, ->(m) { "menu-#{m[1]}" }, "dropdown-menu-end becomes menu-end"],
66
+ [/\Amodal-fullscreen-(sm|md|lg|xl|xxl)-down\z/,
67
+ ->(m) { "#{BREAKPOINTS[m[1]]}-down:dialog-fullscreen" }, "modal-fullscreen-sm-down becomes sm-down:dialog-fullscreen"],
68
+ [/\A([a-z][\w-]*)-hover\z/,
69
+ ->(m) { "hover:#{V6Migration.class_replacement(m[1])}" }, "opacity-50-hover becomes hover:opacity-50"],
70
+ [/\Abtn-(outline-)?(#{THEMES.join('|')})\z/,
71
+ ->(m) { "btn-#{m[1] ? 'outline' : 'solid'} theme-#{m[2]}" }, "btn-primary and btn-outline-primary become btn-solid theme-primary and btn-outline theme-primary"],
72
+ [/\Aalert-(#{THEMES.join('|')})\z/, ->(m) { "alert theme-#{m[1]}" }, "alert-info becomes alert theme-info"],
73
+ [/\Atext-bg-(#{THEMES.join('|')})\z/, ->(m) { "bg-#{m[1]} fg-contrast-#{m[1]}" }, "text-bg-warning becomes bg-warning fg-contrast-warning"],
74
+ [/\Atext-(#{THEMES.join('|')})\z/, ->(m) { "fg-#{m[1]}" }, "text-danger becomes fg-danger"],
75
+ [/\A(?:btn|btn-outline|alert|text|text-bg)-(light|dark)\z/,
76
+ ->(_) { "a neutral surface, a colour mode or theme-inverse (v6 has no theme-light or theme-dark)" },
77
+ "btn-light and text-dark become a neutral surface, a colour mode or theme-inverse"],
78
+ [/\Afs-([1-6])\z/, ->(m) { "fs-#{FONT_SIZES[m[1]]}" }, "fs-1 and fs-6 become fs-4xl and fs-md"],
79
+ [/\Adisplay-[1-6]\z/, ->(_) { "fs-* and fw-* utilities" }, "display-1 becomes fs-* and fw-* utilities"],
80
+ [/\Am(h|w)-(.+)\z/, ->(m) { "max-#{m[1]}-#{m[2]}" }, "mw-100 becomes max-w-100"],
81
+ [/\Alink-(offset|underline)(-.+)?\z/,
82
+ ->(m) { "underline#{m[1] == 'offset' ? '-offset' : ''}#{m[2]}" }, "link-offset-2 becomes underline-offset-2"],
83
+ [/\Am[tbsexy]?-n\d\z/, ->(_) { "ms--1, ms--2, me--1, me--2 or your own style" }, "mt-n1 becomes ms--1 / me--1 or your own style"],
84
+ [/\A(#{SUFFIXED.join('|')})-(sm|md|lg|xl|xxl)\z/,
85
+ ->(m) { "#{BREAKPOINTS[m[2]]}:#{V6Migration.class_replacement(m[1])}" }, "navbar-expand-lg becomes lg:navbar-expand"],
86
+ [/\A([a-z][\w-]*?)-print-([\w-]+)\z/,
87
+ ->(m) { "print:#{V6Migration.class_replacement("#{m[1]}-#{m[2]}")}" }, "d-print-none becomes print:d-none"],
88
+ [/\A([a-z][\w-]*?)-(sm|md|lg|xl|xxl)-([\w-]+)\z/,
89
+ ->(m) { "#{BREAKPOINTS[m[2]]}:#{V6Migration.class_replacement("#{m[1]}-#{m[3]}")}" },
90
+ "col-md-6 and d-xxl-none become md:col-6 and 2xl:d-none"]
91
+ ].freeze
92
+
93
+ TOGGLE = /(?:data-bs-|\bbs[_-])(toggle|dismiss)["']?\s*(?:=>|=|:)\s*(["'])(#{RENAMED_COMPONENTS.keys.join('|')})\2/
94
+
95
+ # [pattern, replacement, example for docs]. Applied to every scanned file.
96
+ LINE_PATTERNS = [
97
+ [/(?:data-bs-|\bbs[_-])ride(?![\w-])/, %{data-bs-autoplay="true" (carousels no longer start on their own)}],
98
+ [/@import\s+(["'])bootstrap/, %{@use "bootstrap/scss/bootstrap" (add `as *` to keep v5 names)}],
99
+ [/\bbootstrap\.(?:Modal|Offcanvas|Dropdown)\b/, "import { Dialog, Drawer, Menu } from Bootstrap's module build"],
100
+ [/\bpopperConfig\b/, "floatingConfig"],
101
+ [/@popperjs\/core/, "@floating-ui/dom"],
102
+ [/--bs-[\w-]+-rgb\b/, "color-mix(in oklab, var(--bs-…), transparent N%)"]
103
+ ].freeze
104
+
105
+ # Files whose class attributes and shorthand are read for class names.
106
+ MARKUP_EXTENSIONS = %w[.erb .haml .slim .rb .html].freeze
107
+
108
+ module_function
109
+
110
+ # Returns [{ :line, :found, :replacement }, ...] for a file's contents.
111
+ def scan(text, extension)
112
+ text.each_line.with_index(1).flat_map do |line, number|
113
+ scan_line(line, extension).map { |found, replacement| { :line => number, :found => found, :replacement => replacement } }
114
+ end
115
+ end
116
+
117
+ def scan_line(line, extension)
118
+ classes = MARKUP_EXTENSIONS.include?(extension) ? LegacyMarkup.class_names(line, extension) : []
119
+ renamed = classes.filter_map do |name|
120
+ replacement = replacement_for(name)
121
+ [name, replacement] if replacement
122
+ end
123
+
124
+ toggles = line.scan(TOGGLE).map do |attribute, _quote, value|
125
+ [%{data-bs-#{attribute}="#{value}"}, %{data-bs-#{attribute}="#{RENAMED_COMPONENTS[value]}"}]
126
+ end
127
+
128
+ other = LINE_PATTERNS.flat_map do |pattern, replacement|
129
+ matches = []
130
+ line.scan(pattern) { matches << [Regexp.last_match(0).sub(/\Abs[_-]/, "data-bs-"), replacement] }
131
+ matches
132
+ end
133
+
134
+ renamed + toggles + other
135
+ end
136
+
137
+ def replacement_for(name)
138
+ return CLASSES[name] if CLASSES.key?(name)
139
+
140
+ CLASS_PATTERNS.each do |pattern, replacement, _example|
141
+ match = pattern.match(name)
142
+ return replacement.call(match) if match
143
+ end
144
+ nil
145
+ end
146
+
147
+ # The v6 name for a class when it is a plain rename, otherwise the class itself.
148
+ # Used under a breakpoint prefix, where a sentence would make no sense.
149
+ def class_replacement(name)
150
+ replacement = RENAMED_COMPONENTS[name] || replacement_for(name)
151
+ replacement && replacement.match?(/\A[\w:-]+\z/) ? replacement : name
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
@@ -1,7 +1,7 @@
1
1
  module Twitter
2
2
  module Bootstrap
3
3
  module Rails
4
- VERSION = "5.5.0"
4
+ VERSION = "5.5.1"
5
5
 
6
6
  # The Bootstrap release this gem vendors and links to. The gem version no
7
7
  # longer tracks it; this constant is the authoritative statement.
@@ -107,5 +107,14 @@ describe Bootstrap::Generators::AgentGenerator do
107
107
 
108
108
  expect(skill).to include("rails g bootstrap:doctor --json")
109
109
  end
110
+
111
+ it "keeps agents on the shipped Bootstrap and points them at the upstream skill for Bootstrap 6" do
112
+ run_generator(described_class)
113
+ section = skill[/^## Bootstrap 6\n.*?(?=^## |\z)/m]
114
+
115
+ expect(section).to include("Don't write Bootstrap 6 markup")
116
+ expect(section).to include(Twitter::Bootstrap::Rails::V6Migration::SKILL_COMMAND)
117
+ expect(section).to include("rails g bootstrap:doctor --v6 --json")
118
+ end
110
119
  end
111
120
  end
@@ -166,6 +166,55 @@ describe Bootstrap::Generators::DoctorGenerator do
166
166
  end
167
167
  end
168
168
 
169
+ describe "Bootstrap 6 migration" do
170
+ def v6_report
171
+ rails_mod::Doctor.new(destination_root).report(:v6 => true)
172
+ end
173
+
174
+ it "is left out unless asked for" do
175
+ write_destination("app/views/tasks/index.html.erb", %{<div class="modal-body"></div>})
176
+
177
+ expect(report).not_to have_key(:v6_migration)
178
+ expect(report[:checks].map { |c| c[:id] }).not_to include("v6_migration")
179
+ end
180
+
181
+ it "lists what changes in Bootstrap 6, with the location" do
182
+ write_destination("app/views/tasks/index.html.erb", %{<p>ok</p>\n<button data-bs-toggle="modal" class="btn btn-primary">x</button>})
183
+
184
+ expect(v6_report[:v6_migration]).to contain_exactly(
185
+ a_hash_including(:file => "app/views/tasks/index.html.erb", :line => 2, :found => "btn-primary"),
186
+ a_hash_including(:file => "app/views/tasks/index.html.erb", :line => 2, :found => %{data-bs-toggle="modal"})
187
+ )
188
+ check = v6_report[:checks].find { |c| c[:id] == "v6_migration" }
189
+ expect(check).to include(:status => "warn", :fix => a_string_including("bootstrap-v5-v6-migration"))
190
+ end
191
+
192
+ it "scans the app's JavaScript and stylesheets" do
193
+ write_destination("app/javascript/controllers/menu_controller.js", "new bootstrap.Dropdown(this.element)")
194
+ write_destination("app/assets/stylesheets/custom.scss", %{@import "bootstrap/scss/functions";})
195
+
196
+ expect(v6_report[:v6_migration].map { |f| f[:file] })
197
+ .to eq(%w[app/assets/stylesheets/custom.scss app/javascript/controllers/menu_controller.js])
198
+ end
199
+
200
+ it "skips the Bootstrap files the gem vendored" do
201
+ install_static_on_propshaft
202
+ layout_findings = v6_report[:v6_migration].reject { |f| f[:file].start_with?("app/views/") }
203
+
204
+ expect(layout_findings).to be_empty
205
+ end
206
+
207
+ it "finds the Bootstrap 5 markup in a layout the generators wrote" do
208
+ install_static_on_propshaft
209
+
210
+ expect(v6_report[:v6_migration].map { |f| f[:found] }).to include("navbar-expand-lg")
211
+ end
212
+
213
+ it "is happy when nothing needs to change" do
214
+ expect(v6_report[:checks].find { |c| c[:id] == "v6_migration" }[:status]).to eq("ok")
215
+ end
216
+ end
217
+
169
218
  describe "facts" do
170
219
  it "states the versions and template engine" do
171
220
  expect(report).to include(
@@ -207,6 +256,13 @@ describe Bootstrap::Generators::DoctorGenerator do
207
256
  .to raise_error(SystemExit) { |e| expect(e.status).to eq(1) }
208
257
  end
209
258
 
259
+ it "adds the Bootstrap 6 worklist with --v6" do
260
+ parsed = JSON.parse(run_generator(described_class, %w[--v6 --json]))
261
+
262
+ expect(parsed["v6_migration"]).to include(a_hash_including("found" => "navbar-expand-lg", "replacement" => "lg:navbar-expand"))
263
+ expect(run_generator(described_class, %w[--v6])).to match(%r{\[warn\]\s+v6_migration.*\n(.*\n)*\s+app/views/layouts/application\.html\.erb:\d+\s+navbar-expand-lg -> lg:navbar-expand})
264
+ end
265
+
210
266
  it "exits normally on warnings" do
211
267
  write_destination("app/views/tasks/index.html.erb", %{<a class="btn-default"></a>})
212
268
 
@@ -0,0 +1,98 @@
1
+ require 'spec_helper'
2
+ require 'twitter/bootstrap/rails/v6_migration'
3
+
4
+ describe Twitter::Bootstrap::Rails::V6Migration do
5
+ def scan(text, extension = ".erb")
6
+ described_class.scan(text, extension)
7
+ end
8
+
9
+ def found(text, extension = ".erb")
10
+ scan(text, extension).map { |f| f[:found] }
11
+ end
12
+
13
+ def replacements(text, extension = ".erb")
14
+ scan(text, extension).map { |f| f[:replacement] }
15
+ end
16
+
17
+ it "reports the line and the replacement" do
18
+ finding = scan(%{<p>ok</p>\n<div class="modal-body">x</div>}).first
19
+
20
+ expect(finding).to include(:line => 2, :found => "modal-body", :replacement => "dialog-body")
21
+ end
22
+
23
+ it "renames modal, offcanvas and dropdown classes" do
24
+ expect(replacements(%{<div class="offcanvas offcanvas-start dropdown-menu dropdown-item"></div>}))
25
+ .to eq(["drawer on a <dialog> element", "drawer-start", "menu", "menu-item"])
26
+ end
27
+
28
+ it "renames components under a breakpoint prefix" do
29
+ expect(replacements(%{<div class="offcanvas-lg dropdown-menu-md-end"></div>})).to eq(%w[lg:drawer md:menu-end])
30
+ end
31
+
32
+ it "moves breakpoints to a prefix" do
33
+ expect(replacements(%{<div class="col-md-6 d-lg-none p-sm-3 offset-xxl-2 sticky-md-top"></div>}))
34
+ .to eq(%w[md:col-6 lg:d-none sm:p-3 2xl:offset-2 md:sticky-top])
35
+ end
36
+
37
+ it "moves breakpoint suffixes to a prefix" do
38
+ expect(replacements(%{<nav class="navbar navbar-expand-lg"><div class="container-xxl table-responsive-sm"></div></nav>}))
39
+ .to eq(%w[lg:navbar-expand 2xl:container sm:table-responsive])
40
+ end
41
+
42
+ it "moves hover and print states to a prefix" do
43
+ expect(replacements(%{<a class="opacity-50-hover link-underline-opacity-75-hover d-print-none"></a>}))
44
+ .to eq(%w[hover:opacity-50 hover:underline-opacity-75 print:d-none])
45
+ end
46
+
47
+ it "replaces colour variants with theme classes" do
48
+ expect(replacements(%{<a class="btn btn-primary btn-outline-danger alert-info text-muted text-success"></a>}))
49
+ .to eq(["btn-solid theme-primary", "btn-outline theme-danger", "alert theme-info", "fg-secondary", "fg-success"])
50
+ end
51
+
52
+ it "maps font sizes to the t-shirt scale" do
53
+ expect(replacements(%{<p class="fs-1 fs-6"></p>})).to eq(%w[fs-4xl fs-md])
54
+ end
55
+
56
+ it "finds renamed toggle values in HTML and Ruby" do
57
+ expect(replacements(%{<button data-bs-toggle="modal" data-bs-dismiss='offcanvas'></button>}))
58
+ .to eq([%{data-bs-toggle="dialog"}, %{data-bs-dismiss="drawer"}])
59
+ expect(replacements(%{<%= link_to "x", "#", data: { bs_toggle: "dropdown" } %>}))
60
+ .to eq([%{data-bs-toggle="menu"}])
61
+ expect(replacements(%{<%= button_tag "x", data: { "bs-dismiss" => "modal" } %>}))
62
+ .to eq([%{data-bs-dismiss="dialog"}])
63
+ end
64
+
65
+ it "finds carousel autoplay" do
66
+ expect(found(%{<div class="carousel" data-bs-ride="carousel"></div>})).to eq(%w[data-bs-ride])
67
+ end
68
+
69
+ it "finds Sass and JavaScript that changed" do
70
+ expect(found(%{@import "bootstrap/scss/bootstrap";}, ".scss")).to eq([%{@import "bootstrap}])
71
+ expect(found("new bootstrap.Modal(el, { popperConfig: {} })", ".js")).to eq(%w[bootstrap.Modal popperConfig])
72
+ expect(found("color: rgba(var(--bs-primary-rgb), .5);", ".css")).to eq(%w[--bs-primary-rgb])
73
+ end
74
+
75
+ it "only reads class names in markup and Ruby files" do
76
+ expect(found(%{const html = '<div class="modal-body">'}, ".js")).to be_empty
77
+ end
78
+
79
+ it "finds class shorthand in Haml and Slim" do
80
+ expect(found("%button.btn.btn-primary Save", ".haml")).to eq(%w[btn-primary])
81
+ expect(found(".modal-dialog\n .modal-content", ".slim")).to eq(%w[modal-dialog modal-content])
82
+ end
83
+
84
+ it "leaves Bootstrap 6 markup alone" do
85
+ v6 = %{<dialog class="dialog"><div class="dialog-body md:col-6 btn-solid theme-primary fg-secondary fs-4xl"></div></dialog>}
86
+
87
+ expect(found(v6)).to be_empty
88
+ expect(found(%{<button data-bs-toggle="dialog"></button>})).to be_empty
89
+ end
90
+
91
+ it "matches whole class names only" do
92
+ expect(found(%{<div class="my-modal modal-bodywork"></div>})).to be_empty
93
+ end
94
+
95
+ it "has an example for every pattern, for the agent guide" do
96
+ expect(described_class::CLASS_PATTERNS.map(&:last)).to all(match(/ becomes? /))
97
+ end
98
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: twitter-bootstrap-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.5.0
4
+ version: 5.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Seyhun Akyurek
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-11-06 00:00:00.000000000 Z
11
+ date: 2026-11-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: railties
@@ -196,6 +196,7 @@ files:
196
196
  - lib/twitter/bootstrap/rails/doctor.rb
197
197
  - lib/twitter/bootstrap/rails/engine.rb
198
198
  - lib/twitter/bootstrap/rails/legacy_markup.rb
199
+ - lib/twitter/bootstrap/rails/v6_migration.rb
199
200
  - lib/twitter/bootstrap/rails/version.rb
200
201
  - spec/lib/breadcrumbs_spec.rb
201
202
  - spec/lib/generators/agent_generator_spec.rb
@@ -215,6 +216,7 @@ files:
215
216
  - spec/lib/twitter_bootstrap_rails/modal_helper_spec.rb
216
217
  - spec/lib/twitter_bootstrap_rails/navbar_helper_spec.rb
217
218
  - spec/lib/twitter_bootstrap_rails/uri_state_spec.rb
219
+ - spec/lib/twitter_bootstrap_rails/v6_migration_spec.rb
218
220
  - spec/spec_helper.rb
219
221
  - spec/support/generator_spec_helper.rb
220
222
  - vendor/assets/javascripts/twitter/bootstrap/bootstrap.bundle.min.js
@@ -261,5 +263,6 @@ test_files:
261
263
  - spec/lib/twitter_bootstrap_rails/modal_helper_spec.rb
262
264
  - spec/lib/twitter_bootstrap_rails/navbar_helper_spec.rb
263
265
  - spec/lib/twitter_bootstrap_rails/uri_state_spec.rb
266
+ - spec/lib/twitter_bootstrap_rails/v6_migration_spec.rb
264
267
  - spec/spec_helper.rb
265
268
  - spec/support/generator_spec_helper.rb