nextgen 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (102) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +143 -0
  4. data/config/generators.yml +146 -0
  5. data/exe/nextgen +13 -0
  6. data/lib/nextgen/actions/bundler.rb +62 -0
  7. data/lib/nextgen/actions/git.rb +56 -0
  8. data/lib/nextgen/actions/yarn.rb +32 -0
  9. data/lib/nextgen/actions.rb +155 -0
  10. data/lib/nextgen/cli.rb +19 -0
  11. data/lib/nextgen/commands/create.rb +312 -0
  12. data/lib/nextgen/ext/prompt/list.rb +13 -0
  13. data/lib/nextgen/ext/prompt/multilist.rb +16 -0
  14. data/lib/nextgen/generators/action_mailer.rb +26 -0
  15. data/lib/nextgen/generators/annotate.rb +2 -0
  16. data/lib/nextgen/generators/base.rb +60 -0
  17. data/lib/nextgen/generators/basic_auth.rb +5 -0
  18. data/lib/nextgen/generators/brakeman.rb +2 -0
  19. data/lib/nextgen/generators/bundler_audit.rb +2 -0
  20. data/lib/nextgen/generators/capybara_lockstep.rb +4 -0
  21. data/lib/nextgen/generators/clean_gemfile.rb +1 -0
  22. data/lib/nextgen/generators/dotenv.rb +3 -0
  23. data/lib/nextgen/generators/erb_lint.rb +11 -0
  24. data/lib/nextgen/generators/eslint.rb +24 -0
  25. data/lib/nextgen/generators/factory_bot_rails.rb +16 -0
  26. data/lib/nextgen/generators/git_safe.rb +4 -0
  27. data/lib/nextgen/generators/github_actions.rb +2 -0
  28. data/lib/nextgen/generators/good_migrations.rb +1 -0
  29. data/lib/nextgen/generators/home_controller.rb +3 -0
  30. data/lib/nextgen/generators/initial_git_commit.rb +5 -0
  31. data/lib/nextgen/generators/initial_migrations.rb +11 -0
  32. data/lib/nextgen/generators/letter_opener.rb +8 -0
  33. data/lib/nextgen/generators/node.rb +5 -0
  34. data/lib/nextgen/generators/open_browser_on_start.rb +12 -0
  35. data/lib/nextgen/generators/overcommit.rb +1 -0
  36. data/lib/nextgen/generators/pgcli_rails.rb +1 -0
  37. data/lib/nextgen/generators/rack_canonical_host.rb +8 -0
  38. data/lib/nextgen/generators/rack_mini_profiler.rb +2 -0
  39. data/lib/nextgen/generators/rspec_rails.rb +19 -0
  40. data/lib/nextgen/generators/rspec_system_testing.rb +5 -0
  41. data/lib/nextgen/generators/rubocop.rb +32 -0
  42. data/lib/nextgen/generators/shoulda.rb +6 -0
  43. data/lib/nextgen/generators/sidekiq.rb +30 -0
  44. data/lib/nextgen/generators/stylelint.rb +24 -0
  45. data/lib/nextgen/generators/thor.rb +2 -0
  46. data/lib/nextgen/generators/tomo.rb +10 -0
  47. data/lib/nextgen/generators/vite.rb +103 -0
  48. data/lib/nextgen/generators.rb +87 -0
  49. data/lib/nextgen/rails.rb +39 -0
  50. data/lib/nextgen/rails_options.rb +191 -0
  51. data/lib/nextgen/thor_extensions.rb +48 -0
  52. data/lib/nextgen/tidy_gemfile.rb +71 -0
  53. data/lib/nextgen/version.rb +3 -0
  54. data/lib/nextgen.rb +17 -0
  55. data/template/.editorconfig +14 -0
  56. data/template/.env.sample +2 -0
  57. data/template/.erb-lint.yml.tt +25 -0
  58. data/template/.eslintrc.cjs +26 -0
  59. data/template/.github/workflows/ci.yml.tt +142 -0
  60. data/template/.overcommit.yml.tt +86 -0
  61. data/template/.prettierrc.cjs +6 -0
  62. data/template/.rubocop.yml.tt +269 -0
  63. data/template/.stylelintrc.cjs +52 -0
  64. data/template/DEPLOYMENT.md +10 -0
  65. data/template/Procfile.tt +4 -0
  66. data/template/README.md.tt +52 -0
  67. data/template/Thorfile +7 -0
  68. data/template/app/controllers/concerns/basic_auth.rb +20 -0
  69. data/template/app/controllers/home_controller.rb +4 -0
  70. data/template/app/frontend/controllers/index.js +5 -0
  71. data/template/app/frontend/images/example.svg +3 -0
  72. data/template/app/frontend/stylesheets/base.css +8 -0
  73. data/template/app/frontend/stylesheets/index.css +3 -0
  74. data/template/app/frontend/stylesheets/reset.css +36 -0
  75. data/template/app/helpers/inline_svg_helper.rb +9 -0
  76. data/template/app/views/home/index.html.erb.tt +5 -0
  77. data/template/bin/setup +107 -0
  78. data/template/config/initializers/generators.rb +5 -0
  79. data/template/config/initializers/rack_mini_profiler.rb +4 -0
  80. data/template/config/initializers/sidekiq.rb +32 -0
  81. data/template/config/sidekiq.yml +5 -0
  82. data/template/lib/puma/plugin/open.rb +14 -0
  83. data/template/lib/tasks/auto_annotate_models.rake +52 -0
  84. data/template/lib/tasks/erblint.rake +11 -0
  85. data/template/lib/tasks/eslint.rake +11 -0
  86. data/template/lib/tasks/rubocop.rake +4 -0
  87. data/template/lib/tasks/stylelint.rake +11 -0
  88. data/template/lib/templates/rspec/system/system_spec.rb +10 -0
  89. data/template/lib/vite_inline_svg_file_loader.rb +25 -0
  90. data/template/package.json +6 -0
  91. data/template/postcss.config.js +3 -0
  92. data/template/spec/support/factory_bot.rb +3 -0
  93. data/template/spec/support/mailer.rb +5 -0
  94. data/template/spec/support/shoulda.rb +6 -0
  95. data/template/spec/support/system.rb +17 -0
  96. data/template/test/application_system_test_case.rb +18 -0
  97. data/template/test/helpers/inline_svg_helper_test.rb +23 -0
  98. data/template/test/support/factory_bot.rb +3 -0
  99. data/template/test/support/mailer.rb +3 -0
  100. data/template/test/support/shoulda.rb +6 -0
  101. data/template/test/support/vite.rb +5 -0
  102. metadata +220 -0
@@ -0,0 +1,8 @@
1
+ /*
2
+ This file is for base element styles, like:
3
+
4
+ - Any @font-face declarations needed custom web fonts.
5
+ - Default font-family on the body element.
6
+ - Default foreground, background, and link colors.
7
+ - Global CSS variables (declared on :root), such as color palette.
8
+ */
@@ -0,0 +1,3 @@
1
+ @import "modern-normalize";
2
+ @import "reset";
3
+ @import "base";
@@ -0,0 +1,36 @@
1
+ :root {
2
+ line-height: 1.5;
3
+ -webkit-font-smoothing: antialiased;
4
+ }
5
+
6
+ h1,
7
+ h2,
8
+ h3,
9
+ h4,
10
+ h5,
11
+ figure,
12
+ p,
13
+ ol,
14
+ ul {
15
+ margin: 0;
16
+ }
17
+
18
+ ol,
19
+ ul {
20
+ list-style: none;
21
+ padding-inline: 0;
22
+ }
23
+
24
+ h1,
25
+ h2,
26
+ h3,
27
+ h4,
28
+ h5 {
29
+ font-size: inherit;
30
+ font-weight: inherit;
31
+ }
32
+
33
+ img {
34
+ display: block;
35
+ max-inline-size: 100%;
36
+ }
@@ -0,0 +1,9 @@
1
+ module InlineSvgHelper
2
+ def inline_svg_tag(filename, title: nil)
3
+ svg = ViteInlineSvgFileLoader.named(filename)
4
+ svg = svg.sub(/\A<svg/, '<svg role="img"')
5
+ svg = svg.sub(/\A<svg.*?>/, safe_join(['\0', "\n", tag.title(title)])) if title.present?
6
+
7
+ svg.strip.html_safe # rubocop:disable Rails/OutputSafety
8
+ end
9
+ end
@@ -0,0 +1,5 @@
1
+ <%% provide(:title, "Home") %>
2
+ <p>Find me in app/views/home/index.html.erb</p>
3
+ <% if File.exist?("package.json") && File.read("package.json").match?(%r{@hotwired/stimulus}) -%>
4
+ <p>Stimulus says, <i data-controller=hello></i></p>
5
+ <% end -%>
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # This script is a way to set up or update your development environment automatically.
4
+ # This script is idempotent, so that you can run it at any time and get an expectable outcome.
5
+ # Add necessary setup steps to this method.
6
+ def setup!
7
+ run "bundle install" if bundle_needed?
8
+ run "overcommit --install" if overcommit_installable?
9
+ run "bin/rails db:prepare" if database_present?
10
+ run "yarn install" if yarn_needed?
11
+ run "bin/rails tmp:create" if tmp_missing?
12
+ env ".env", from: ".env.sample"
13
+ run "bin/rails restart"
14
+
15
+ if git_safe_needed?
16
+ say_status :notice,
17
+ "Remember to run #{colorize("mkdir -p .git/safe", :yellow)} to trust the binstubs in this project",
18
+ :magenta
19
+ end
20
+
21
+ say_status :Ready!,
22
+ "Use #{colorize("bin/rails s", :yellow)} to start the app, " \
23
+ "or #{colorize("bin/rake", :yellow)} to run tests"
24
+ end
25
+
26
+ def run(command, echo: true, silent: false, exception: true)
27
+ say_status(:run, command, :blue) if echo
28
+ with_original_bundler_env do
29
+ options = silent ? {out: File::NULL, err: File::NULL} : {}
30
+ system(command, exception: exception, **options)
31
+ end
32
+ end
33
+
34
+ def run?(command)
35
+ run command, silent: true, echo: false, exception: false
36
+ end
37
+
38
+ def bundle_needed?
39
+ !run("bundle check", silent: true, exception: false)
40
+ end
41
+
42
+ def overcommit_installable?
43
+ File.exist?(".overcommit.yml") && !File.exist?(".git/hooks/overcommit-hook") && run?("overcommit -v")
44
+ end
45
+
46
+ def database_present?
47
+ File.exist?("config/database.yml")
48
+ end
49
+
50
+ def yarn_needed?
51
+ File.exist?("yarn.lock") && !run("yarn check --check-files", silent: true, exception: false)
52
+ end
53
+
54
+ def tmp_missing?
55
+ !Dir.exist?("tmp/pids")
56
+ end
57
+
58
+ def git_safe_needed?
59
+ ENV["PATH"].include?(".git/safe/../../bin") && !Dir.exist?(".git/safe")
60
+ end
61
+
62
+ def with_original_bundler_env(&block)
63
+ return yield unless defined?(Bundler)
64
+
65
+ Bundler.with_original_env(&block)
66
+ end
67
+
68
+ def env(env_file, from:)
69
+ return unless File.exist?(from)
70
+
71
+ unless File.exist?(env_file)
72
+ say_status(:copy, "#{from} → #{env_file}", :magenta)
73
+ require "fileutils"
74
+ FileUtils.cp(from, env_file)
75
+ end
76
+
77
+ keys = ->(f) { File.readlines(f).filter_map { |l| l[/^([^#\s][^=\s]*)/, 1] } }
78
+
79
+ missing = keys[from] - keys[env_file]
80
+ return if missing.empty?
81
+
82
+ say_status(:WARNING, "Your #{env_file} file is missing #{missing.join(", ")}. Refer to #{from} for details.", :red)
83
+ end
84
+
85
+ def say_status(label, message, color = :green)
86
+ label = label.to_s.rjust(12)
87
+ puts [colorize(label, color), message].join(" ")
88
+ end
89
+
90
+ def colorize(str, color)
91
+ return str unless color_supported?
92
+
93
+ code = {red: 31, green: 32, yellow: 33, blue: 34, magenta: 35}.fetch(color)
94
+ "\e[0;#{code};49m#{str}\e[0m"
95
+ end
96
+
97
+ def color_supported?
98
+ if ENV["TERM"] == "dumb" || !ENV["NO_COLOR"].to_s.empty?
99
+ false
100
+ else
101
+ [$stdout, $stderr].all? { |io| io.respond_to?(:tty?) && io.tty? }
102
+ end
103
+ end
104
+
105
+ Dir.chdir(File.expand_path("..", __dir__)) do
106
+ setup!
107
+ end
@@ -0,0 +1,5 @@
1
+ Rails.application.config.generators do |g|
2
+ # Disable generators we don't need.
3
+ g.javascripts false
4
+ g.stylesheets false
5
+ end
@@ -0,0 +1,4 @@
1
+ return unless defined?(Rack::MiniProfiler)
2
+
3
+ # https://github.com/MiniProfiler/rack-mini-profiler#configuration-options
4
+ Rack::MiniProfiler.config.enable_hotwire_turbo_drive_support = true
@@ -0,0 +1,32 @@
1
+ return unless defined?(Sidekiq)
2
+
3
+ # Disable SSL certificate verification if using Heroku Redis
4
+ # redis_opts = {
5
+ # ssl_params: {
6
+ # verify_mode: OpenSSL::SSL::VERIFY_NONE
7
+ # }
8
+ # }
9
+ # Sidekiq.configure_server do |config|
10
+ # config.redis = redis_opts
11
+ # end
12
+ # Sidekiq.configure_client do |config|
13
+ # config.redis = redis_opts
14
+ # end
15
+
16
+ # Enable Rails CurrentAttributes to flow transparently through to Sidekiq jobs
17
+ # require "sidekiq/middleware/current_attributes"
18
+ # Sidekiq::CurrentAttributes.persist(Myapp::Current)
19
+
20
+ require "sidekiq/web"
21
+
22
+ Sidekiq::Web.app_url = "/"
23
+
24
+ sidekiq_username = ENV.fetch("SIDEKIQ_WEB_USERNAME", nil)
25
+ sidekiq_password = ENV.fetch("SIDEKIQ_WEB_PASSWORD", nil)
26
+
27
+ Sidekiq::Web.use(Rack::Auth::Basic, "Sidekiq") do |username, password|
28
+ if sidekiq_username.present? && sidekiq_password.present?
29
+ ActiveSupport::SecurityUtils.secure_compare(username, sidekiq_username) &
30
+ ActiveSupport::SecurityUtils.secure_compare(password, sidekiq_password)
31
+ end
32
+ end
@@ -0,0 +1,5 @@
1
+ ---
2
+ :queues:
3
+ - default
4
+
5
+ :concurrency: <%= ENV["SIDEKIQ_CONCURRENCY"] || ENV["RAILS_MAX_THREADS"] || 5 %>
@@ -0,0 +1,14 @@
1
+ require "puma/plugin"
2
+
3
+ Puma::Plugin.create do
4
+ def start(launcher)
5
+ return unless defined?(Rails) && defined?(Launchy)
6
+ return unless Rails.env.development?
7
+
8
+ binding = launcher.options[:binds].grep(/^tcp|ssl/).first
9
+ return if binding.nil?
10
+
11
+ url = binding.sub(/^tcp/, "http").sub(/^ssl/, "https").sub("0.0.0.0", "localhost")
12
+ Launchy.open(url)
13
+ end
14
+ end
@@ -0,0 +1,52 @@
1
+ return unless Rails.env.development?
2
+
3
+ require "annotate"
4
+ task :set_annotation_options do # rubocop:disable Rake/Desc, Metrics/BlockLength
5
+ # You can override any of these by setting an environment variable of the same name.
6
+ Annotate.set_defaults(
7
+ "routes" => "false",
8
+ "models" => "true",
9
+ "position_in_routes" => "before",
10
+ "position_in_class" => "before",
11
+ "position_in_test" => "before",
12
+ "position_in_fixture" => "before",
13
+ "position_in_factory" => "before",
14
+ "position_in_serializer" => "before",
15
+ "show_foreign_keys" => "true",
16
+ "show_complete_foreign_keys" => "false",
17
+ "show_indexes" => "true",
18
+ "simple_indexes" => "false",
19
+ "model_dir" => "app/models",
20
+ "root_dir" => "",
21
+ "include_version" => "false",
22
+ "require" => "",
23
+ "exclude_tests" => "true",
24
+ "exclude_fixtures" => "false",
25
+ "exclude_factories" => "false",
26
+ "exclude_serializers" => "false",
27
+ "exclude_scaffolds" => "true",
28
+ "exclude_controllers" => "true",
29
+ "exclude_helpers" => "true",
30
+ "exclude_sti_subclasses" => "false",
31
+ "ignore_model_sub_dir" => "false",
32
+ "ignore_columns" => nil,
33
+ "ignore_routes" => nil,
34
+ "ignore_unknown_models" => "false",
35
+ "hide_limit_column_types" => "integer,bigint,boolean",
36
+ "hide_default_column_types" => "json,jsonb,hstore",
37
+ "skip_on_db_migrate" => "false",
38
+ "format_bare" => "true",
39
+ "format_rdoc" => "false",
40
+ "format_markdown" => "false",
41
+ "sort" => "false",
42
+ "force" => "false",
43
+ "frozen" => "false",
44
+ "classified_sort" => "true",
45
+ "trace" => "false",
46
+ "wrapper_open" => nil,
47
+ "wrapper_close" => nil,
48
+ "with_comment" => "true"
49
+ )
50
+ end
51
+
52
+ Annotate.load_tasks
@@ -0,0 +1,11 @@
1
+ desc "Run erblint"
2
+ task :erblint do
3
+ sh "bin/erblint --lint-all"
4
+ end
5
+
6
+ namespace :erblint do
7
+ desc "Autocorrect erblint offenses"
8
+ task :autocorrect do
9
+ sh "bin/erblint --lint-all -a"
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ desc "Run ESLint"
2
+ task :eslint do
3
+ sh "yarn lint:js"
4
+ end
5
+
6
+ namespace :eslint do
7
+ desc "Autocorrect ESLint offenses"
8
+ task :autocorrect do
9
+ sh "yarn fix:js"
10
+ end
11
+ end
@@ -0,0 +1,4 @@
1
+ return unless Gem.loaded_specs.key?("rubocop")
2
+
3
+ require "rubocop/rake_task"
4
+ RuboCop::RakeTask.new
@@ -0,0 +1,11 @@
1
+ desc "Run Stylelint"
2
+ task :stylelint do
3
+ sh "yarn lint:css"
4
+ end
5
+
6
+ namespace :stylelint do
7
+ desc "Autocorrect Stylelint offenses"
8
+ task :autocorrect do
9
+ sh "yarn fix:css"
10
+ end
11
+ end
@@ -0,0 +1,10 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe "<%= class_name %>" do
4
+ pending "add some scenarios (or delete) #{__FILE__}"
5
+
6
+ # it "renders a message on the home page" do
7
+ # visit "/"
8
+ # expect(page).to have_content("Hello, world!")
9
+ # end
10
+ end
@@ -0,0 +1,25 @@
1
+ module ViteInlineSvgFileLoader
2
+ class << self
3
+ def named(filename)
4
+ vite = ViteRuby.instance
5
+ vite_asset_path = vite.manifest.path_for(filename)
6
+
7
+ if vite.dev_server_running?
8
+ fetch_from_dev_server(vite_asset_path)
9
+ else
10
+ Rails.public_path.join(vite_asset_path.sub(%r{^/}, "")).read
11
+ end
12
+ end
13
+
14
+ private
15
+
16
+ def fetch_from_dev_server(path)
17
+ config = ViteRuby.config
18
+ dev_server_uri = URI("#{config.protocol}://#{config.host_with_port}#{path}")
19
+ response = Net::HTTP.get_response(dev_server_uri)
20
+ raise "Failed to load inline SVG from #{dev_server_uri}" unless response.is_a?(Net::HTTPSuccess)
21
+
22
+ response.body
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,6 @@
1
+ {
2
+ "private": true,
3
+ "engines": {
4
+ "node": ">=18.0.0"
5
+ }
6
+ }
@@ -0,0 +1,3 @@
1
+ module.exports = {
2
+ plugins: [require("autoprefixer")],
3
+ };
@@ -0,0 +1,3 @@
1
+ RSpec.configure do |config|
2
+ config.include FactoryBot::Syntax::Methods
3
+ end
@@ -0,0 +1,5 @@
1
+ RSpec.configure do |config|
2
+ config.before(:each) do
3
+ ActionMailer::Base.deliveries.clear
4
+ end
5
+ end
@@ -0,0 +1,6 @@
1
+ Shoulda::Matchers.configure do |config|
2
+ config.integrate do |with|
3
+ with.test_framework :rspec
4
+ with.library :rails
5
+ end
6
+ end
@@ -0,0 +1,17 @@
1
+ require "capybara/rails"
2
+ require "capybara/rspec"
3
+
4
+ Capybara.default_max_wait_time = 2
5
+ Capybara.disable_animation = true
6
+
7
+ RSpec.configure do |config|
8
+ config.before(:each, type: :system) do
9
+ driven_by :selenium,
10
+ using: (ENV["SHOW_BROWSER"] ? :chrome : :headless_chrome),
11
+ screen_size: [1400, 1400] do |options|
12
+ # Allows running in Docker
13
+ options.add_argument("--disable-dev-shm-usage")
14
+ options.add_argument("--no-sandbox")
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,18 @@
1
+ require "test_helper"
2
+ require "capybara/rails"
3
+
4
+ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
5
+ driven_by :selenium,
6
+ using: (ENV["SHOW_BROWSER"] ? :chrome : :headless_chrome),
7
+ screen_size: [1400, 1400] do |options|
8
+ # Allows running in Docker
9
+ options.add_argument("--disable-dev-shm-usage")
10
+ options.add_argument("--no-sandbox")
11
+ end
12
+
13
+ setup do
14
+ Capybara.default_max_wait_time = 2
15
+ Capybara.disable_animation = true
16
+ Capybara.server = :puma, {Silent: true}
17
+ end
18
+ end
@@ -0,0 +1,23 @@
1
+ require "test_helper"
2
+
3
+ class InlineSvgHelperTest < ActionView::TestCase
4
+ test "inline_svg_tag returns contents of svg file as html_safe string" do
5
+ svg_result = inline_svg_tag("images/example.svg")
6
+ assert_predicate(svg_result, :html_safe?)
7
+ assert_equal(<<~EXPECTED_SVG.strip, svg_result)
8
+ <svg role="img" width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
9
+ <rect x="2" y="2" width="176" height="176" rx="6" fill="white" stroke="#CCCCCC" stroke-width="4" stroke-miterlimit="0" stroke-linecap="round"/>
10
+ </svg>
11
+ EXPECTED_SVG
12
+ end
13
+
14
+ test "inline_svg_tag adds a <title> to the svg if specified" do
15
+ svg_result = inline_svg_tag("images/example.svg", title: "rounded box")
16
+ assert_equal(<<~EXPECTED_SVG.strip, svg_result)
17
+ <svg role="img" width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
18
+ <title>rounded box</title>
19
+ <rect x="2" y="2" width="176" height="176" rx="6" fill="white" stroke="#CCCCCC" stroke-width="4" stroke-miterlimit="0" stroke-linecap="round"/>
20
+ </svg>
21
+ EXPECTED_SVG
22
+ end
23
+ end
@@ -0,0 +1,3 @@
1
+ ActiveSupport.on_load(:active_support_test_case) do
2
+ include FactoryBot::Syntax::Methods
3
+ end
@@ -0,0 +1,3 @@
1
+ ActiveSupport.on_load(:active_support_test_case) do
2
+ setup { ActionMailer::Base.deliveries.clear }
3
+ end
@@ -0,0 +1,6 @@
1
+ Shoulda::Matchers.configure do |config|
2
+ config.integrate do |with|
3
+ with.test_framework :minitest
4
+ with.library :rails
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ return if ViteRuby.config.auto_build
2
+
3
+ # Compile assets once at the start of testing
4
+ millis = Benchmark.ms { ViteRuby.commands.build }
5
+ puts format("Built Vite assets (%.1fms)", millis)