diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31f4cc5..bd480de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest services: postgres: - image: postgres:14 + image: postgres:latest env: POSTGRES_DB: test_app_production POSTGRES_USER: test_app @@ -37,17 +37,18 @@ jobs: options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 steps: - - uses: actions/checkout@master + - uses: actions/checkout@v4 - name: Setup Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: '3.0' + ruby-version: '3.2' + bundler-cache: true - name: Setup Node - uses: actions/setup-node@v1 + uses: actions/setup-node@v3 with: - node-version: '16.x' + node-version: 'lts/*' - name: Install dependencies run: | diff --git a/Procfile.dev b/Procfile.dev index 68a69c6..d44b54d 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,4 +1,4 @@ web: bin/rails server -p 3000 worker: bundle exec sidekiq -js: yarn build --watch +js: yarn build --reload css: yarn build:css --watch diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index eb0ef89..ce537e9 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,6 +1,6 @@ class ApplicationController < ActionController::Base impersonates :user - include Pundit + include Pundit::Authorization protect_from_forgery with: :exception diff --git a/app/views/shared/_head.html.erb b/app/views/shared/_head.html.erb index da9c5e9..1bc68f4 100644 --- a/app/views/shared/_head.html.erb +++ b/app/views/shared/_head.html.erb @@ -6,5 +6,5 @@ <%= csp_meta_tag %> -<%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %> +<%= stylesheet_link_tag "application", media: "all", "data-turbo-track": "reload" %> <%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %> diff --git a/esbuild.config.js b/esbuild.config.js deleted file mode 100644 index ba42641..0000000 --- a/esbuild.config.js +++ /dev/null @@ -1,18 +0,0 @@ -const path = require('path') -const rails = require('esbuild-rails') - -const watch = process.argv.includes("--watch") && { - onRebuild(error) { - if (error) console.error("[watch] build failed", error); - else console.log("[watch] build finished"); - }, -}; - -require("esbuild").build({ - entryPoints: ["application.js"], - bundle: true, - outdir: path.join(process.cwd(), "app/assets/builds"), - absWorkingDir: path.join(process.cwd(), "app/javascript"), - watch: watch, - plugins: [rails()], -}).catch(() => process.exit(1)); diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..2fe760b --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,96 @@ +#!/usr/bin/env node + +// Esbuild is configured with 3 modes: +// +// `yarn build` - Build JavaScript and exit +// `yarn build --watch` - Rebuild JavaScript on change +// `yarn build --reload` - Reloads page when views, JavaScript, or stylesheets change +// +// Minify is enabled when "RAILS_ENV=production" +// Sourcemaps are enabled in non-production environments + +import * as esbuild from "esbuild" +import path from "path" +import rails from "esbuild-rails" +import chokidar from "chokidar" +import http from "http" +import { setTimeout } from "timers/promises" + +const clients = [] +const entryPoints = [ + "application.js" +] +const watchDirectories = [ + "./app/javascript/**/*.js", + "./app/views/**/*.erb", + "./app/assets/builds/**/*.css", // Wait for cssbundling changes +] +const config = { + absWorkingDir: path.join(process.cwd(), "app/javascript"), + bundle: true, + entryPoints: entryPoints, + minify: process.env.RAILS_ENV == "production", + outdir: path.join(process.cwd(), "app/assets/builds"), + plugins: [rails()], + sourcemap: process.env.RAILS_ENV != "production" +} + +async function buildAndReload() { + // Foreman & Overmind assign a separate PORT for each process + const port = parseInt(process.env.PORT) + const context = await esbuild.context({ + ...config, + banner: { + js: ` (() => new EventSource("http://localhost:${port}").onmessage = () => location.reload())();`, + } + }) + + // Reload uses an HTTP server as an even stream to reload the browser + http + .createServer((req, res) => { + return clients.push( + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Access-Control-Allow-Origin": "*", + Connection: "keep-alive", + }) + ) + }) + .listen(port) + + await context.rebuild() + console.log("[reload] initial build succeeded") + + let ready = false + chokidar + .watch(watchDirectories) + .on("ready", () => { + console.log("[reload] ready") + ready = true + }) + .on("all", async (event, path) => { + if (ready === false) return + + if (path.includes("javascript")) { + try { + await setTimeout(20) + await context.rebuild() + console.log("[reload] build succeeded") + } catch (error) { + console.error("[reload] build failed", error) + } + } + clients.forEach((res) => res.write("data: update\n\n")) + clients.length = 0 + }) +} + +if (process.argv.includes("--reload")) { + buildAndReload() +} else if (process.argv.includes("--watch")) { + let context = await esbuild.context({...config, logLevel: 'info'}) + context.watch() +} else { + esbuild.build(config) +} diff --git a/template.rb b/template.rb index dc5f094..b42ded8 100644 --- a/template.rb +++ b/template.rb @@ -25,17 +25,29 @@ def add_template_repository_to_source_path end end +def read_gemfile? + File.open("Gemfile").each_line do |line| + return true if line.strip.start_with?("rails") && line.include?("6.") + end +end + def rails_version - @rails_version ||= Gem::Version.new(Rails::VERSION::STRING) + @rails_version ||= Gem::Version.new(Rails::VERSION::STRING) || read_gemfile? +end + +def rails_7_or_newer? + Gem::Requirement.new(">= 7.0.0.alpha").satisfied_by? rails_version end -def rails_6_or_newer? - Gem::Requirement.new(">= 6.0.0.alpha").satisfied_by? rails_version +unless rails_7_or_newer? + say "\nJumpstart requires Rails 7 or newer. You are using #{rails_version}.", :green + say "Please remove partially installed Jumpstart files #{original_app_name} and try again.", :green + exit 1 end def add_gems add_gem 'cssbundling-rails' - add_gem 'devise', '~> 4.8', '>= 4.8.0' + add_gem 'devise', '~> 4.9' add_gem 'friendly_id', '~> 5.4' add_gem 'jsbundling-rails' add_gem 'madmin' @@ -64,45 +76,16 @@ def add_users route "root to: 'home#index'" generate "devise:install" - # Configure Devise to handle TURBO_STREAM requests like HTML requests - inject_into_file "config/initializers/devise.rb", " config.navigational_formats = ['/', :html, :turbo_stream]", after: "Devise.setup do |config|\n" - - inject_into_file 'config/initializers/devise.rb', after: "# frozen_string_literal: true\n" do <<~EOF - class TurboFailureApp < Devise::FailureApp - def respond - if request_format == :turbo_stream - redirect - else - super - end - end - - def skip_format? - %w(html turbo_stream */*).include? request_format.to_s - end - end - EOF - end - - inject_into_file 'config/initializers/devise.rb', after: "# ==> Warden configuration\n" do <<-EOF - config.warden do |manager| - manager.failure_app = TurboFailureApp - end - EOF - end - environment "config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }", env: 'development' generate :devise, "User", "first_name", "last_name", "announcements_last_read_at:datetime", "admin:boolean" # Set admin default to false in_root do - migration = Dir.glob("db/migrate/*").max_by{ |f| File.mtime(f) } + migration = Dir.glob("db/migrate/*").max_by { |f| File.mtime(f) } gsub_file migration, /:admin/, ":admin, default: false" end - if Gem::Requirement.new("> 5.2").satisfied_by? rails_version - gsub_file "config/initializers/devise.rb", / # config.secret_key = .+/, " config.secret_key = Rails.application.credentials.secret_key_base" - end + gsub_file "config/initializers/devise.rb", / # config.secret_key = .+/, " config.secret_key = Rails.application.credentials.secret_key_base" inject_into_file("app/models/user.rb", "omniauthable, :", after: "devise :") end @@ -111,12 +94,15 @@ def add_authorization generate 'pundit:install' end -def add_jsbundling - rails_command "javascript:install:esbuild" +def default_to_esbuild + return if options[:javascript] == "esbuild" + unless options[:skip_javascript] + @options = options.merge(javascript: "esbuild") + end end def add_javascript - run "yarn add local-time esbuild-rails trix @hotwired/stimulus @hotwired/turbo-rails @rails/activestorage @rails/ujs @rails/request.js" + run "yarn add local-time esbuild-rails trix @hotwired/stimulus @hotwired/turbo-rails @rails/activestorage @rails/ujs @rails/request.js chokidar" end def copy_templates @@ -128,7 +114,7 @@ def copy_templates copy_file "Procfile" copy_file "Procfile.dev" copy_file ".foreman" - copy_file "esbuild.config.js" + copy_file "esbuild.config.mjs" copy_file "app/javascript/application.js" copy_file "app/javascript/controllers/index.js" @@ -144,21 +130,21 @@ def add_sidekiq environment "config.active_job.queue_adapter = :sidekiq" insert_into_file "config/routes.rb", - "require 'sidekiq/web'\n\n", - before: "Rails.application.routes.draw do" + "require 'sidekiq/web'\n\n", + before: "Rails.application.routes.draw do" content = <<~RUBY - authenticate :user, lambda { |u| u.admin? } do - mount Sidekiq::Web => '/sidekiq' - - namespace :madmin do - resources :impersonates do - post :impersonate, on: :member - post :stop_impersonating, on: :collection - end - end - end - RUBY + authenticate :user, lambda { |u| u.admin? } do + mount Sidekiq::Web => '/sidekiq' + + namespace :madmin do + resources :impersonates do + post :impersonate, on: :member + post :stop_impersonating, on: :collection + end + end + end + RUBY insert_into_file "config/routes.rb", "#{content}\n", after: "Rails.application.routes.draw do\n" end @@ -194,7 +180,7 @@ def add_whenever def add_friendly_id generate "friendly_id" - insert_into_file( Dir["db/migrate/**/*friendly_id_slugs.rb"].first, "[5.2]", after: "ActiveRecord::Migration") + insert_into_file(Dir["db/migrate/**/*friendly_id_slugs.rb"].first, "[5.2]", after: "ActiveRecord::Migration") end def add_sitemap @@ -210,12 +196,17 @@ def add_announcements_css end def add_esbuild_script - build_script = "node esbuild.config.js" + build_script = "node esbuild.config.mjs" - if (`npx -v`.to_f < 7.1 rescue "Missing") - say %(Add "scripts": { "build": "#{build_script}" } to your package.json), :green - else + case `npx -v`.to_f + when 7.1...8.0 run %(npm set-script build "#{build_script}") + run %(yarn build) + when (8.0..) + run %(npm pkg set scripts.build="#{build_script}") + run %(yarn build) + else + say %(Add "scripts": { "build": "#{build_script}" } to your package.json), :green end end @@ -241,20 +232,15 @@ def gem_exists?(name) IO.read("Gemfile") =~ /^\s*gem ['"]#{name}['"]/ end -unless rails_6_or_newer? - puts "Please use Rails 6.0 or newer to create a Jumpstart application" -end - # Main setup add_template_repository_to_source_path - +default_to_esbuild add_gems after_bundle do set_application_name add_users add_authorization - add_jsbundling add_javascript add_announcements add_notifications @@ -265,7 +251,6 @@ def gem_exists?(name) add_whenever add_sitemap add_announcements_css - add_esbuild_script add_github_actions_ci rails_command "active_storage:install" @@ -274,6 +259,8 @@ def gem_exists?(name) copy_templates + add_esbuild_script + # Commit everything to git unless ENV["SKIP_GIT"] git :init @@ -294,8 +281,9 @@ def gem_exists?(name) say say " # Update config/database.yml with your database credentials" say - say " rails db:create db:migrate" + say " rails db:create" say " rails g noticed:model" + say " rails db:migrate" say " rails g madmin:install # Generate admin dashboards" say " gem install foreman" say " bin/dev" diff --git a/test/template_test.rb b/test/template_test.rb index a71004a..234dd1b 100644 --- a/test/template_test.rb +++ b/test/template_test.rb @@ -10,10 +10,15 @@ def teardown end def test_generator_succeeds - output, err = capture_subprocess_io do + output, _err = capture_subprocess_io do system("DISABLE_SPRING=1 SKIP_GIT=1 rails new test_app -m template.rb") end assert_includes output, "Jumpstart app successfully created!" + + output, _err = capture_subprocess_io do + system("cd test_app && yarn build") + end + assert_includes output, "Done in " end # TODO: Fix these tests on CI so they don't fail on db:create