ruby_ui_converter 0.1.0

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.
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module RubyUIConverter
6
+ class CLI < Thor
7
+ def self.exit_on_failure?
8
+ true
9
+ end
10
+
11
+ desc "convert PATH", "Convert .erb views/partials under PATH into RubyUI/Phlex .rb files"
12
+ long_desc <<~DESC
13
+ Recursively walks PATH looking for *.erb files and writes an equivalent
14
+ .rb component next to each one (e.g. index.html.erb -> index.rb). Rails
15
+ partials (_form.html.erb) become their own component classes (form.rb).
16
+
17
+ By default, basic HTML elements are mapped onto RubyUI kit components
18
+ (a -> Link, button -> Button, input -> Input, table -> Table, ...).
19
+ Pass --no-ruby-ui to emit plain Phlex elements instead.
20
+ DESC
21
+ option :namespace, default: "Views", desc: "Base module namespace for generated constants"
22
+ option :root, desc: "Directory namespaces are derived from (default: nearest app/views ancestor, else PATH)"
23
+ option :base_class, default: "Phlex::HTML", desc: "Superclass for generated components"
24
+ option :phlex, default: "2", desc: "Target Phlex major version (2 => view_template, 1 => template)"
25
+ option :output, aliases: "-o", desc: "Write into this directory instead of in place (mirrors structure)"
26
+ option :dry_run, type: :boolean, default: false, desc: "Print what would be generated without writing"
27
+ option :force, type: :boolean, default: false, desc: "Overwrite existing .rb files"
28
+ option :ruby_ui, type: :boolean, default: true, desc: "Map basic HTML elements onto RubyUI components (--no-ruby-ui for plain Phlex)"
29
+ option :literal, type: :boolean, default: false, desc: "Emit Literal::Properties props instead of initialize/attr_reader (requires the literal gem)"
30
+ option :verbose, type: :boolean, default: false
31
+ def convert(path)
32
+ unless File.exist?(path)
33
+ say "Path not found: #{path}", :red
34
+ exit 1
35
+ end
36
+
37
+ if options[:root]
38
+ root = File.expand_path(options[:root])
39
+ unless File.directory?(root) && File.expand_path(path).start_with?(root)
40
+ say "Invalid --root: #{options[:root]} (must be an existing ancestor of PATH)", :red
41
+ exit 1
42
+ end
43
+ end
44
+
45
+ config = Configuration.new(
46
+ base_namespace: options[:namespace],
47
+ root: options[:root],
48
+ base_class: options[:base_class],
49
+ phlex_version: options[:phlex],
50
+ output_root: options[:output],
51
+ dry_run: options[:dry_run],
52
+ force: options[:force],
53
+ ruby_ui: options[:ruby_ui],
54
+ literal: options[:literal],
55
+ verbose: options[:verbose]
56
+ )
57
+
58
+ results = Converter.new(path, config: config).run
59
+ report(results, config)
60
+ check_prerequisites(results, config, path)
61
+ end
62
+
63
+ desc "version", "Print the ruby_ui_converter version"
64
+ def version
65
+ say RubyUIConverter::VERSION
66
+ end
67
+
68
+ map %w[--version -v] => :version
69
+
70
+ private
71
+
72
+ def report(results, config)
73
+ if results.empty?
74
+ say "No .erb files found.", :yellow
75
+ return
76
+ end
77
+
78
+ results.each do |result|
79
+ case result.status
80
+ when :written
81
+ say " created #{relative(result.output)}", :green
82
+ preview(result) if config.verbose
83
+ when :previewed
84
+ say " preview #{relative(result.output)}", :cyan
85
+ preview(result)
86
+ when :skipped
87
+ say " skipped #{relative(result.output)} (exists, use --force)", :yellow
88
+ when :error
89
+ say " error #{relative(result.source)}: #{result.error.message}", :red
90
+ end
91
+ end
92
+
93
+ counts = results.group_by(&:status).transform_values(&:size)
94
+ say ""
95
+ say "Done. #{counts.map { |status, n| "#{n} #{status}" }.join(", ")}."
96
+ end
97
+
98
+ def preview(result)
99
+ return unless result.code
100
+
101
+ say "", nil
102
+ say result.code, :white
103
+ say "", nil
104
+ end
105
+
106
+ # Diagnoses the target app for the prerequisites the generated code needs
107
+ # (gems, RubyUI components, Literal::Properties) and offers to install
108
+ # them. Only warns in non-interactive sessions and on --dry-run.
109
+ def check_prerequisites(results, config, path)
110
+ doctor = Doctor.new(results, config: config, start_path: path)
111
+ issues = doctor.issues
112
+ return if issues.empty?
113
+
114
+ say ""
115
+ say "Missing prerequisites detected:", :yellow
116
+ issues.each { |issue| say " - #{issue.description}", :yellow }
117
+
118
+ if config.dry_run || !$stdin.tty?
119
+ pending_commands(issues)
120
+ return
121
+ end
122
+
123
+ # Default to yes: only an explicit "n"/"no" skips; a bare Enter installs.
124
+ if no?("Install now? [Y/n]")
125
+ pending_commands(issues)
126
+ return
127
+ end
128
+
129
+ apply_fixes(issues, doctor.app_root)
130
+
131
+ # Some problems only appear after installing (e.g. ruby_ui:install
132
+ # leaving a broken tw-animate-css import, or the base class to extend
133
+ # Literal::Properties being created by phlex:install). One follow-up
134
+ # diagnosis catches and fixes those under the same consent.
135
+ follow_up = Doctor.new(results, config: config, start_path: path).issues
136
+ return if follow_up.empty?
137
+
138
+ say ""
139
+ say "Applying follow-up fixes:", :yellow
140
+ follow_up.each { |issue| say " - #{issue.description}", :yellow }
141
+ apply_fixes(follow_up, doctor.app_root)
142
+ end
143
+
144
+ def apply_fixes(issues, app_root)
145
+ issues.each { |issue| issue.fixer&.call }
146
+ run_commands(issues.flat_map { |issue| issue.commands || [] }, app_root)
147
+ end
148
+
149
+ def pending_commands(issues)
150
+ say ""
151
+ say "To fix, run:", :yellow
152
+ issues.flat_map { |issue| issue.commands || [] }.each { |cmd| say " #{cmd}", :yellow }
153
+ end
154
+
155
+ def run_commands(commands, app_root)
156
+ commands.reject { |cmd| cmd.start_with?("#") }.each do |cmd|
157
+ say " running #{cmd}", :cyan
158
+ unless run_in_app(cmd, app_root)
159
+ say " command failed: #{cmd} — run it manually.", :red
160
+ break
161
+ end
162
+ end
163
+ end
164
+
165
+ def run_in_app(cmd, app_root)
166
+ if defined?(Bundler)
167
+ Bundler.with_unbundled_env { system(cmd, chdir: app_root) }
168
+ else
169
+ system(cmd, chdir: app_root)
170
+ end
171
+ end
172
+
173
+ def relative(path)
174
+ return path unless path
175
+
176
+ path.delete_prefix("#{Dir.pwd}/")
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIConverter
4
+ # Accumulates indented lines of Ruby source.
5
+ class CodeBuilder
6
+ def initialize(indent: " ", level: 0)
7
+ @indent = indent
8
+ @level = level
9
+ @lines = []
10
+ end
11
+
12
+ attr_reader :level
13
+
14
+ def line(str = nil)
15
+ @lines << (str.nil? || str.empty? ? "" : (@indent * @level) + str)
16
+ self
17
+ end
18
+
19
+ def indent
20
+ @level += 1
21
+ self
22
+ end
23
+
24
+ def dedent
25
+ @level -= 1 if @level.positive?
26
+ self
27
+ end
28
+
29
+ def to_s
30
+ "#{@lines.join("\n")}\n"
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIConverter
4
+ # Maps HTML elements onto RubyUI (or any Phlex) components.
5
+ #
6
+ # Two layers of rules:
7
+ # * user rules, added with #register — always win;
8
+ # * fallback rules — the built-in RubyUI element mapping installed by
9
+ # Configuration when `ruby_ui` is enabled (the default).
10
+ #
11
+ # Each rule has:
12
+ # * matcher: ->(Nodes::Element) { Boolean }
13
+ # * emitter: ->(Nodes::Element, Transformer, CodeBuilder) { ... }
14
+ #
15
+ # The emitter is responsible for writing the component call. It can use the
16
+ # transformer's public helpers (kit_component, wrap_component, emit_children,
17
+ # render_attrs, meaningful).
18
+ #
19
+ # Example of a custom rule (overrides the built-in `button` mapping):
20
+ #
21
+ # config.component_map.register(
22
+ # ->(el) { el.name == "button" && el.static_classes.include?("danger") }
23
+ # ) do |el, transformer, builder|
24
+ # transformer.kit_component("Button", el, builder, extra: "variant: :destructive")
25
+ # end
26
+ class ComponentMap
27
+ Rule = Struct.new(:matcher, :emitter)
28
+
29
+ # Element name -> [RubyUI kit component, void?] for 1:1 mappings.
30
+ ELEMENT_COMPONENTS = {
31
+ "button" => ["Button", false],
32
+ "textarea" => ["Textarea", false],
33
+ "select" => ["NativeSelect", false],
34
+ "option" => ["NativeSelectOption", false],
35
+ "table" => ["Table", false],
36
+ "thead" => ["TableHeader", false],
37
+ "tbody" => ["TableBody", false],
38
+ "tfoot" => ["TableFooter", false],
39
+ "tr" => ["TableRow", false],
40
+ "th" => ["TableHead", false],
41
+ "td" => ["TableCell", false],
42
+ "caption" => ["TableCaption", false],
43
+ "hr" => ["Separator", true]
44
+ }.freeze
45
+
46
+ def initialize
47
+ @rules = []
48
+ @fallback_rules = []
49
+ end
50
+
51
+ def register(matcher, &emitter)
52
+ @rules << Rule.new(matcher, emitter)
53
+ self
54
+ end
55
+
56
+ def register_fallback(matcher, &emitter)
57
+ @fallback_rules << Rule.new(matcher, emitter)
58
+ self
59
+ end
60
+
61
+ # @return [Proc, nil] the emitter for the first matching rule.
62
+ # User rules take precedence over the built-in fallback rules.
63
+ def lookup(node)
64
+ rule = @rules.find { |r| r.matcher.call(node) } ||
65
+ @fallback_rules.find { |r| r.matcher.call(node) }
66
+ rule&.emitter
67
+ end
68
+
69
+ def empty?
70
+ @rules.empty? && @fallback_rules.empty?
71
+ end
72
+
73
+ # Built-in mapping of basic HTML elements onto RubyUI kit components
74
+ # (Link, Button, Input, ...). Installed as fallback rules so user rules
75
+ # registered with #register always win.
76
+ def self.rubyui_rules(map)
77
+ # <a href=...> -> Link(href: ...). Anchors without href stay plain.
78
+ map.register_fallback(->(el) { el.name == "a" && el.attr?("href") }) do |el, t, b|
79
+ t.kit_component("Link", el, b)
80
+ end
81
+
82
+ # <input> -> Checkbox / RadioButton / Input, dispatched on a static
83
+ # type attribute (Checkbox and RadioButton set their own type).
84
+ map.register_fallback(->(el) { el.name == "input" }) do |el, t, b|
85
+ case el.static_attr("type")
86
+ when "checkbox" then t.kit_component("Checkbox", el, b, except: ["type"], void: true)
87
+ when "radio" then t.kit_component("RadioButton", el, b, except: ["type"], void: true)
88
+ else t.kit_component("Input", el, b, void: true)
89
+ end
90
+ end
91
+
92
+ ELEMENT_COMPONENTS.each do |element, (component, void)|
93
+ map.register_fallback(->(el) { el.name == element }) do |el, t, b|
94
+ t.kit_component(component, el, b, void: void)
95
+ end
96
+ end
97
+
98
+ # Rails flash paragraphs (<p id="notice">/<p id="alert">) -> RubyUI Alert.
99
+ # `notice` is a success message; `alert` is an error (destructive).
100
+ map.register_fallback(->(el) { el.name == "p" && %w[notice alert].include?(el.static_attr("id")) }) do |el, t, b|
101
+ kind = el.static_attr("id")
102
+ variant = kind == "alert" ? "destructive" : "success"
103
+ # mb-5 keeps the alert from sitting flush against the content below it
104
+ # (the scaffold's flash <p> had mb-5, dropped when we replace the tag).
105
+ b.line(%(Alert(variant: :#{variant}, class: "mb-5") do))
106
+ b.indent
107
+ b.line(%(AlertTitle { "#{kind.capitalize}" }))
108
+ t.component_block("AlertDescription", el.children, b)
109
+ b.dedent
110
+ b.line("end")
111
+ end
112
+
113
+ # Class-based heuristics for common Bootstrap-ish markup.
114
+ map.register_fallback(->(el) { el.static_classes.include?("badge") }) do |el, t, b|
115
+ t.kit_component("Badge", el, b)
116
+ end
117
+
118
+ map.register_fallback(->(el) { el.static_classes.include?("card") }) do |el, t, b|
119
+ t.kit_component("Card", el, b)
120
+ end
121
+
122
+ map
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIConverter
4
+ class Configuration
5
+ attr_accessor :base_namespace, :base_class, :phlex_version, :indent,
6
+ :output_root, :verbose, :dry_run, :force, :ruby_ui,
7
+ :literal, :root, :component_map
8
+
9
+ def initialize(base_namespace: "Views", base_class: "Phlex::HTML",
10
+ phlex_version: 2, indent: " ", output_root: nil,
11
+ verbose: false, dry_run: false, force: false, ruby_ui: true,
12
+ literal: false, root: nil)
13
+ @base_namespace = base_namespace
14
+ @base_class = base_class
15
+ @phlex_version = phlex_version
16
+ @indent = indent
17
+ @output_root = output_root
18
+ @verbose = verbose
19
+ @dry_run = dry_run
20
+ @force = force
21
+ @ruby_ui = ruby_ui
22
+ @literal = literal
23
+ @root = root
24
+ @component_map = ComponentMap.new
25
+ enable_rubyui_rules! if ruby_ui
26
+ end
27
+
28
+ # Phlex 2 uses `view_template`; Phlex 1 used `template`.
29
+ def template_method
30
+ phlex_version.to_i >= 2 ? "view_template" : "template"
31
+ end
32
+
33
+ # Emit a raw (unescaped) output call for the given Ruby expression. Phlex 2
34
+ # dropped `unsafe_raw` in favor of `raw(safe(...))`; Phlex 1 used
35
+ # `unsafe_raw(...)`.
36
+ def raw_call(expr)
37
+ phlex_version.to_i >= 2 ? "raw(safe(#{expr}))" : "unsafe_raw(#{expr})"
38
+ end
39
+
40
+ def ruby_ui?
41
+ !!@ruby_ui
42
+ end
43
+
44
+ def literal?
45
+ !!@literal
46
+ end
47
+
48
+ def enable_rubyui_rules!
49
+ ComponentMap.rubyui_rules(@component_map)
50
+ self
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module RubyUIConverter
6
+ # Orchestrates the conversion of an entire directory (or single file).
7
+ class Converter
8
+ Result = Struct.new(:source, :output, :status, :error, :code, keyword_init: true)
9
+
10
+ def initialize(path, config: Configuration.new)
11
+ @path = path
12
+ @config = config
13
+ end
14
+
15
+ # The namespace root. Precedence: explicit config.root, then the nearest
16
+ # `app/views` ancestor (Rails convention — keeps generated constants
17
+ # matching the Zeitwerk path mapping no matter which subfolder was
18
+ # converted), then the directory the user pointed at.
19
+ def root
20
+ @root ||=
21
+ if @config.root
22
+ File.expand_path(@config.root)
23
+ else
24
+ base =
25
+ if File.directory?(@path)
26
+ File.expand_path(@path)
27
+ else
28
+ File.dirname(File.expand_path(@path))
29
+ end
30
+ conventional_root(base) || base
31
+ end
32
+ end
33
+
34
+ def run
35
+ FileWalker.new(@path).erb_files.map { |file| convert_file(file) }
36
+ end
37
+
38
+ private
39
+
40
+ # Nearest ancestor (including dir itself) that is a Rails `app/views`
41
+ # directory, or nil when the path is not inside one.
42
+ def conventional_root(dir)
43
+ current = dir
44
+ loop do
45
+ return current if File.basename(current) == "views" &&
46
+ File.basename(File.dirname(current)) == "app"
47
+
48
+ parent = File.dirname(current)
49
+ return nil if parent == current
50
+
51
+ current = parent
52
+ end
53
+ end
54
+
55
+ def convert_file(file)
56
+ file = File.expand_path(file)
57
+ template = Template.new(path: file, root: root, config: @config)
58
+ code = template.render
59
+ output = template.output_path
60
+
61
+ if @config.dry_run
62
+ return Result.new(source: file, output: output, status: :previewed, code: code)
63
+ end
64
+
65
+ if File.exist?(output) && !@config.force
66
+ return Result.new(source: file, output: output, status: :skipped, code: code)
67
+ end
68
+
69
+ FileUtils.mkdir_p(File.dirname(output))
70
+ File.write(output, code)
71
+ Result.new(source: file, output: output, status: :written, code: code)
72
+ rescue StandardError => e
73
+ Result.new(source: file, output: nil, status: :error, error: e)
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIConverter
4
+ # Post-conversion diagnostics: inspects the target app (the nearest Gemfile
5
+ # at or above the converted path) for the prerequisites the generated code
6
+ # needs — phlex-rails, the ruby_ui gem + generated components and, with
7
+ # --literal, the literal gem + Literal::Properties on the base class.
8
+ #
9
+ # The Doctor only diagnoses; executing the fix commands (and prompting the
10
+ # user) is the CLI's responsibility. Commands starting with "#" are manual
11
+ # hints, not executable; issues may also carry a `fixer` proc that applies
12
+ # a file edit (e.g. inserting `extend Literal::Properties`).
13
+ class Doctor
14
+ Issue = Struct.new(:description, :commands, :fixer, keyword_init: true)
15
+
16
+ # Emitted kit component -> `ruby_ui:component` generator family.
17
+ COMPONENT_FAMILIES = {
18
+ "Link" => "Link", "Button" => "Button", "Input" => "Input",
19
+ "Checkbox" => "Checkbox", "RadioButton" => "RadioButton",
20
+ "Textarea" => "Textarea",
21
+ "NativeSelect" => "NativeSelect", "NativeSelectOption" => "NativeSelect",
22
+ "Table" => "Table", "TableHeader" => "Table", "TableBody" => "Table",
23
+ "TableFooter" => "Table", "TableRow" => "Table", "TableHead" => "Table",
24
+ "TableCell" => "Table", "TableCaption" => "Table",
25
+ "Separator" => "Separator", "Badge" => "Badge", "Card" => "Card",
26
+ "FormField" => "Form", "FormFieldLabel" => "Form",
27
+ "FormFieldError" => "Form", "FormFieldHint" => "Form",
28
+ "Alert" => "Alert", "AlertTitle" => "Alert", "AlertDescription" => "Alert"
29
+ }.freeze
30
+
31
+ def initialize(results, config:, start_path:)
32
+ @results = results
33
+ @config = config
34
+ @start_path = File.expand_path(start_path)
35
+ end
36
+
37
+ # The nearest directory at or above start_path containing a Gemfile.
38
+ # nil when the converted path is not inside a bundled app.
39
+ def app_root
40
+ return @app_root if defined?(@app_root)
41
+
42
+ dir = File.directory?(@start_path) ? @start_path : File.dirname(@start_path)
43
+ @app_root = loop do
44
+ break dir if File.exist?(File.join(dir, "Gemfile"))
45
+
46
+ parent = File.dirname(dir)
47
+ break nil if parent == dir
48
+
49
+ dir = parent
50
+ end
51
+ end
52
+
53
+ def issues
54
+ return [] unless app_root
55
+
56
+ [
57
+ phlex_rails_issue,
58
+ literal_gem_issue,
59
+ literal_properties_issue,
60
+ ruby_ui_gem_issue,
61
+ missing_components_issue,
62
+ tw_animate_issue
63
+ ].compact
64
+ end
65
+
66
+ private
67
+
68
+ def gemfile
69
+ @gemfile ||= File.read(File.join(app_root, "Gemfile"))
70
+ end
71
+
72
+ def gem?(name)
73
+ gemfile.match?(/^\s*gem\s+["']#{Regexp.escape(name)}["']/)
74
+ end
75
+
76
+ def phlex_rails_issue
77
+ return if gem?("phlex-rails")
78
+
79
+ Issue.new(
80
+ description: %(gem "phlex-rails" not in Gemfile (required by the generated Phlex classes)),
81
+ commands: ["bundle add phlex-rails", "bin/rails generate phlex:install"]
82
+ )
83
+ end
84
+
85
+ def literal_gem_issue
86
+ return unless @config.literal?
87
+ return if gem?("literal")
88
+
89
+ Issue.new(
90
+ description: %(gem "literal" not in Gemfile (required by --literal props)),
91
+ commands: ["bundle add literal"]
92
+ )
93
+ end
94
+
95
+ def literal_properties_issue
96
+ return unless @config.literal?
97
+
98
+ base = base_component_file
99
+ if base.nil?
100
+ return Issue.new(
101
+ description: "no base component class found to extend Literal::Properties",
102
+ commands: ["# add `extend Literal::Properties` to your base component class"]
103
+ )
104
+ end
105
+ return if File.read(base).include?("Literal::Properties")
106
+
107
+ rel = base.delete_prefix("#{app_root}/")
108
+ Issue.new(
109
+ description: "Literal::Properties not extended in #{rel}",
110
+ commands: ["# add `extend Literal::Properties` to #{rel} (auto-applied on install)"],
111
+ fixer: lambda do
112
+ content = File.read(base)
113
+ updated = content.sub(/^(\s*class\s+\S+\s*<\s*\S+.*)$/) do
114
+ "#{Regexp.last_match(1)}\n extend Literal::Properties"
115
+ end
116
+ File.write(base, updated) unless updated == content
117
+ end
118
+ )
119
+ end
120
+
121
+ def base_component_file
122
+ ["app/components/base.rb", "app/views/base.rb"]
123
+ .map { |rel| File.join(app_root, rel) }
124
+ .find { |path| File.exist?(path) }
125
+ end
126
+
127
+ def ruby_ui_gem_issue
128
+ return unless @config.ruby_ui? && emitted_families.any?
129
+ return if gem?("ruby_ui")
130
+
131
+ Issue.new(
132
+ description: %(gem "ruby_ui" not in Gemfile (the generated code calls RubyUI components)),
133
+ commands: ["bundle add ruby_ui", "bin/rails generate ruby_ui:install"]
134
+ )
135
+ end
136
+
137
+ def missing_components_issue
138
+ return unless @config.ruby_ui?
139
+
140
+ missing = emitted_families.reject { |family| component_installed?(family) }
141
+ return if missing.empty?
142
+
143
+ Issue.new(
144
+ description: "RubyUI components not generated: #{missing.join(", ")}",
145
+ # ruby_ui:component takes a single component per invocation, so emit one
146
+ # command per missing component rather than a single multi-arg call.
147
+ commands: missing.map { |family| "bin/rails generate ruby_ui:component #{family}" }
148
+ )
149
+ end
150
+
151
+ # Which generator families the converted code actually references.
152
+ def emitted_families
153
+ @emitted_families ||= begin
154
+ code = @results.map(&:code).compact.join("\n")
155
+ COMPONENT_FAMILIES.select { |name, _| code.match?(/\b#{name}\(/) }
156
+ .values.uniq
157
+ end
158
+ end
159
+
160
+ def component_installed?(family)
161
+ snake = family.gsub(/([a-z])([A-Z])/, '\1_\2').downcase
162
+ File.directory?(File.join(app_root, "app/components/ruby_ui", snake))
163
+ end
164
+
165
+ TW_ANIMATE_URL = "https://cdn.jsdelivr.net/npm/tw-animate-css/dist/tw-animate.css"
166
+ TW_ANIMATE_IMPORT = /@import\s+["'][^"']*tw-animate-css\.js["'];?/
167
+
168
+ # `ruby_ui:install` pins tw-animate-css via importmap, but the package is
169
+ # CSS-only and the pin fails on jspm — leaving application.css importing a
170
+ # vendor file that was never downloaded (breaks tailwindcss:build/bin/dev).
171
+ # Fix: vendor the real CSS next to application.css and point the import
172
+ # at it.
173
+ def tw_animate_issue
174
+ css_path = File.join(app_root, "app/assets/tailwind/application.css")
175
+ return unless File.exist?(css_path)
176
+ return unless File.read(css_path).match?(TW_ANIMATE_IMPORT)
177
+ return if File.exist?(File.join(app_root, "vendor/javascript/tw-animate-css.js"))
178
+
179
+ Issue.new(
180
+ description: "broken tw-animate-css import in app/assets/tailwind/application.css " \
181
+ "(the importmap pin from ruby_ui:install failed)",
182
+ commands: ["curl -fsSL -o app/assets/tailwind/tw-animate.css #{TW_ANIMATE_URL}"],
183
+ fixer: lambda do
184
+ content = File.read(css_path)
185
+ File.write(css_path, content.sub(TW_ANIMATE_IMPORT, %(@import "./tw-animate.css";)))
186
+ end
187
+ )
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIConverter
4
+ # Finds .erb files under a path (recursively for directories).
5
+ class FileWalker
6
+ ERB_GLOB = "**/*.erb"
7
+
8
+ def initialize(path)
9
+ @path = path
10
+ end
11
+
12
+ def erb_files
13
+ if File.directory?(@path)
14
+ Dir.glob(File.join(@path, ERB_GLOB)).select { |f| File.file?(f) }.sort
15
+ elsif File.file?(@path) && @path.end_with?(".erb")
16
+ [@path]
17
+ else
18
+ []
19
+ end
20
+ end
21
+ end
22
+ end