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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +73 -0
- data/LICENSE.txt +21 -0
- data/README.md +487 -0
- data/exe/ruby_ui_converter +7 -0
- data/lib/ruby_ui_converter/cli.rb +179 -0
- data/lib/ruby_ui_converter/code_builder.rb +33 -0
- data/lib/ruby_ui_converter/component_map.rb +125 -0
- data/lib/ruby_ui_converter/configuration.rb +53 -0
- data/lib/ruby_ui_converter/converter.rb +76 -0
- data/lib/ruby_ui_converter/doctor.rb +190 -0
- data/lib/ruby_ui_converter/file_walker.rb +22 -0
- data/lib/ruby_ui_converter/form_builder.rb +252 -0
- data/lib/ruby_ui_converter/html_tokenizer.rb +109 -0
- data/lib/ruby_ui_converter/lexer.rb +58 -0
- data/lib/ruby_ui_converter/locals_detector.rb +111 -0
- data/lib/ruby_ui_converter/naming.rb +45 -0
- data/lib/ruby_ui_converter/nodes.rb +128 -0
- data/lib/ruby_ui_converter/parser.rb +179 -0
- data/lib/ruby_ui_converter/rails_helpers.rb +230 -0
- data/lib/ruby_ui_converter/template.rb +170 -0
- data/lib/ruby_ui_converter/transformer.rb +401 -0
- data/lib/ruby_ui_converter/version.rb +5 -0
- data/lib/ruby_ui_converter.rb +54 -0
- metadata +114 -0
|
@@ -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
|