# frozen_string_literal: true require "digest" require "erubi" require "fileutils" require "pathname" # Public: Automatically generates JS for Rails routes with { export: true }. # Generates one file per controller, and one function per route. module JsFromRoutes # Internal: Helper class used as a presenter for the routes template. class ControllerRoutes attr_reader :routes def initialize(controller, routes, config) @controller, @config = controller, config @routes = routes .reject { |route| route.requirements[:action] == "update" && route.verb == "PUT" } .group_by { |route| route.requirements.fetch(:action) } .flat_map { |action, routes| routes.each_with_index.map { |route, index| Route.new(route, mappings: config.helper_mappings, index:, controller:) } } end # Public: Used to check whether the file should be generated again, changes # based on the configuration, and route definition. def cache_key routes.map(&:inspect).join + [File.read(@config.template_path), @config.helper_mappings.inspect, @config.client_library].join end # Public: Exposes the preferred import library to the generator. def client_library @config.client_library end # Internal: Name of the JS file with helpers for the the given controller. def filename @config.output_folder.join(basename) end # Public: Name of the JS file with helpers for the the given controller. def import_filename filename.relative_path_from(@config.output_folder).to_s.sub(/\.\w+$/, "") end # Public: Name of the file as a valid JS variable. def js_name @controller.camelize(:lower).tr(":", "") end # Internal: The base name of the JS file to be written. def basename "#{@controller.camelize}#{@config.file_suffix}".tr_s(":", "/") end end # Internal: A presenter for an individual Rails action. class Route def initialize(route, mappings:, controller:, index: 0) @route, @mappings, @controller, @index = route, mappings, controller, index end # Public: The `export` setting specified for the action. def export @route.defaults[:export] end # Public: The HTTP verb for the action. Example: 'patch' def verb @route.verb.split("|").last.downcase end # Public: The path for the action. Example: '/users/:id/edit' def path @route.path.spec.to_s.chomp("(.:format)") end # Public: The name of the JS helper for the action. Example: 'destroyAll' def helper action = @route.requirements.fetch(:action) if @index > 0 action = @route.name&.sub(@controller.tr(":/", "_"), "") || "#{action}#{verb.titleize}" end helper = action.camelize(:lower) @mappings.fetch(helper, helper) end # Internal: Useful as a cache key for the route, and for debugging purposes. def inspect "#{verb} #{helper} #{path}" end end # Internal: Represents a compiled template that can write itself to a file. class Template def initialize(template_path) # NOTE: The compiled ERB template, used to generate JS code. @compiled_template = Erubi::Engine.new(File.read(template_path), filename: template_path).src end # Public: Checks if the cache is fresh, or renders the template with the # specified variables, and writes the updated result to a file. def write_if_changed(object) write_file_if_changed(object.filename, object.cache_key) { render_template(object) } end private # Internal: Returns a String with the generated JS code. def render_template(object) object.instance_eval(@compiled_template) end # Internal: Returns true if the cache key has changed since the last codegen. def stale?(file, cache_key_comment) ENV["JS_FROM_ROUTES_FORCE"] || file.gets != cache_key_comment end # Internal: Writes if the file does not exist or the cache key has changed. # The cache strategy consists of a comment on the first line of the file. # # Yields to receive the rendered file content when it needs to. def write_file_if_changed(name, cache_key) FileUtils.mkdir_p(name.dirname) cache_key_comment = "// JsFromRoutes CacheKey #{Digest::MD5.hexdigest(cache_key)}\n" File.open(name, "a+") { |file| if stale?(file, cache_key_comment) file.truncate(0) file.write(cache_key_comment) file.write(yield) end } end end class Configuration attr_accessor :all_helpers_file, :client_library, :export_if, :file_suffix, :helper_mappings, :output_folder, :template_path, :template_all_path, :template_index_path def initialize(root) dir = %w[frontend packs javascript assets].find { |dir| root.join("app", dir).exist? } @all_helpers_file = true @client_library = "@js-from-routes/client" @export_if = ->(route) { route.defaults.fetch(:export, nil) } @file_suffix = "Api.js" @helper_mappings = {} @output_folder = root.join("app", dir, "api") @template_path = File.expand_path("template.js.erb", __dir__) @template_all_path = File.expand_path("template_all.js.erb", __dir__) @template_index_path = File.expand_path("template_index.js.erb", __dir__) end end class TemplateConfig attr_reader :cache_key, :filename, :helpers def initialize(cache_key:, filename:, helpers: nil) @cache_key = cache_key @filename = filename @helpers = helpers end end class << self # Public: Configuration of the code generator. def config @config ||= Configuration.new(::Rails.root || Pathname.new(Dir.pwd)) yield(@config) if block_given? @config end # Public: Generates code for the specified routes with { export: true }. def generate!(app_or_routes = Rails.application) raise ArgumentError, "A Rails app must be defined, or you must specify a custom `output_folder`" if config.output_folder.to_s.blank? rails_routes = app_or_routes.is_a?(::Rails::Engine) ? app_or_routes.routes.routes : app_or_routes generate_files exported_routes_by_controller(rails_routes) end private def generate_files(exported_routes) template = Template.new(config.template_path) generate_file_for_all exported_routes.filter_map { |controller, routes| next unless controller ControllerRoutes.new(controller, routes, config).tap do |routes| template.write_if_changed routes end } end def generate_file_for_all(routes) return unless config.all_helpers_file && !routes.empty? preferred_extension = File.extname(config.file_suffix) index_file = (config.all_helpers_file == true) ? "index#{preferred_extension}" : config.all_helpers_file Template.new(config.template_all_path).write_if_changed TemplateConfig.new( cache_key: routes.map(&:import_filename).join + File.read(config.template_all_path), filename: config.output_folder.join("all#{preferred_extension}"), helpers: routes, ) Template.new(config.template_index_path).write_if_changed TemplateConfig.new( cache_key: File.read(config.template_index_path), filename: config.output_folder.join(index_file), ) end def namespace_for_route(route) if (export = route.defaults[:export]).is_a?(Hash) export[:namespace] end || route.requirements[:controller] end # Internal: Returns exported routes grouped by controller name. def exported_routes_by_controller(routes) routes .select { |route| config.export_if.call(route) } .group_by { |route| namespace_for_route(route)&.to_s } end end end