lib/roda/plugins/assets.rb



# frozen-string-literal: true

#
class Roda
  module RodaPlugins
    # The assets plugin adds support for rendering your CSS and javascript
    # asset files on the fly in development, and compiling them
    # to a single, compressed file in production.
    #
    # This uses the render plugin for rendering the assets, and the render
    # plugin uses tilt internally, so you can use any template engine
    # supported by tilt for your assets.  Tilt ships with support for
    # the following asset template engines, assuming the necessary libraries
    # are installed:
    #
    # css :: Less, Sass, Scss
    # js :: CoffeeScript
    #
    # You can also use opal as a javascript template engine, assuming it is
    # installed.
    #
    # == Usage
    #
    # When loading the plugin, use the :css and :js options
    # to set the source file(s) to use for CSS and javascript assets:
    #
    #   plugin :assets, css: 'some_file.scss', js: 'some_file.coffee'
    #
    # This will look for the following files:
    #
    #   assets/css/some_file.scss
    #   assets/js/some_file.coffee
    #
    # The values for the :css and :js options can be arrays to load multiple
    # files. If you want to change the paths where asset files are stored, see the
    # Options section below.
    #
    # === Serving
    #
    # In your routes, call the +r.assets+ method to add a route to your assets,
    # which will make your app serve the rendered assets:
    #
    #   route do |r|
    #     r.assets
    #   end
    #
    # You should generally call +r.assets+ inside the route block itself, and not
    # under any branches of the routing tree.
    #
    # === Views
    #
    # In your layout view, use the assets method to add links to your CSS and
    # javascript assets:
    #
    #   <%= assets(:css) %>
    #   <%= assets(:js) %>
    #
    # You can add attributes to the tags by using an options hash:
    #
    #   <%= assets(:css, media: 'print') %>
    #
    # The assets method will respect the application's +:add_script_name+ option,
    # if it set it will automatically prefix the path with the +SCRIPT_NAME+ for
    # the request.
    #
    # == Asset Paths
    #
    # If you just want the paths rather than the full tags, you can use
    # assets_paths instead. This will return an array of the sources that
    # the assets function would have put into tags:
    #
    #   assets_paths(:css)
    #   # => ["/assets/css/foo.css", "/assets/css/app.css"]
    #
    # If compilation is turned on, it will return the path to the compiled
    # asset:
    #
    #   assets_paths(:css)
    #   # => ["/assets/app.5e7b06baa1a514d8473b0eca514b806c201073b9.css"]
    #
    # == Asset Groups
    #
    # The asset plugin supports groups for the cases where you have different
    # css/js files for your front end and back end.  To use asset groups, you
    # pass a hash for the :css and/or :js options:
    #
    #   plugin :assets, css: {frontend: 'some_frontend_file.scss',
    #                         backend: 'some_backend_file.scss'}
    #
    # This expects the following directory structure for your assets:
    #
    #   assets/css/frontend/some_frontend_file.scss
    #   assets/css/backend/some_backend_file.scss
    #
    # If you do not want to force that directory structure when using
    # asset groups, you can use the <tt>group_subdirs: false</tt> option.
    #
    # In your view code use an array argument in your call to assets:
    #
    #   <%= assets([:css, :frontend]) %>
    #
    # === Nesting
    #
    # Asset groups also support nesting, though that should only be needed
    # in fairly large applications.  You can use a nested hash when loading
    # the plugin:
    #
    #   plugin :assets,
    #     css: {frontend: {dashboard: 'some_frontend_file.scss'}}
    #
    # and an extra entry per nesting level when creating the tags.
    #
    #   <%= assets([:css, :frontend, :dashboard]) %>
    #
    # == Caching
    #
    # The assets plugin uses the caching plugin internally, and will set the
    # Last-Modified header to the modified timestamp of the asset source file
    # when rendering the asset.
    #
    # If you have assets that include other asset files, such as using @import
    # in a sass file, you need to specify the dependencies for your assets so
    # that the assets plugin will correctly pick up changes.  You can do this
    # using the :dependencies option to the plugin, which takes a hash where
    # the keys are paths to asset files, and values are arrays of paths to
    # dependencies of those asset files:
    #
    #   app.plugin :assets,
    #     dependencies: {'assets/css/bootstrap.scss'=>Dir['assets/css/bootstrap/' '**/*.scss']}
    #
    # == Asset Compilation
    #
    # In production, you are generally going to want to compile your assets
    # into a single file, with you can do by calling compile_assets after
    # loading the plugin:
    #
    #   plugin :assets, css: 'some_file.scss', js: 'some_file.coffee'
    #   compile_assets
    #
    # After calling compile_assets, calls to assets in your views will default
    # to a using a single link each to your CSS and javascript compiled asset
    # files.  By default the compiled files are written to the public directory,
    # so that they can be served by the webserver.
    #
    # === Asset Compression
    #
    # If you have the yuicompressor gem installed and working, it will be used
    # automatically to compress your javascript and css assets.  For javascript
    # assets, if yuicompressor is not available, the plugin will check for
    # closure-compiler, uglifier, and minjs and use the first one that works.
    # If no compressors are available, the assets will just be concatenated
    # together and not compressed during compilation.  You can use the
    # :css_compressor and :js_compressor options to specify the compressor to use.
    #
    # It is also possible to use the built-in compression options in the CSS or JS
    # compiler, assuming the compiler supports such options.  For example, with
    # sass/sassc, you can use:
    #
    #   plugin :assets,
    #     css_opts: {style: :compressed}
    #
    # === Source Maps (CSS)
    #
    # The assets plugin does not have direct support for source maps, so it is
    # recommended you use embedded source maps if supported by the CSS compiler.
    # For sass/sassc, you can use:
    #
    #   plugin :assets,
    #     css_opts: {:source_map_embed=>true, source_map_contents: true, source_map_file: "."}
    #
    # === With Asset Groups
    #
    # When using asset groups, a separate compiled file will be produced per
    # asset group.
    #
    # === Unique Asset Names
    #
    # When compiling assets, a unique name is given to each asset file, using the
    # a SHA1 hash of the content of the file.  This is done so that clients do
    # not attempt to use cached versions of the assets if the asset has changed.
    #
    # === Serving
    #
    # When compiling assets, +r.assets+ will serve the compiled asset
    # files.  However, it is recommended to have the main webserver (e.g. nginx)
    # serve the compiled files, instead of relying on the application.
    #
    # Assuming you are using compiled assets in production mode that are served
    # by the webserver, you can remove the serving of them by the application:
    #
    #   route do |r|
    #     r.assets unless ENV['RACK_ENV'] == 'production'
    #   end
    #
    # If you do have the application serve the compiled assets, it will use the
    # Last-Modified header to make sure that clients do not redownload compiled
    # assets that haven't changed.
    #
    # === Asset Precompilation
    #
    # If you want to precompile your assets, so they do not need to be compiled
    # every time you boot the application, you can provide a :precompiled option
    # when loading the plugin.  The value of this option should be the filename
    # where the compiled asset metadata is stored.  
    #
    # If the compiled asset metadata file does not exist when the assets plugin
    # is loaded, the plugin will run in non-compiled mode.  However, when you call
    # compile_assets, it will write the compiled asset metadata file after
    # compiling the assets.
    #
    # If the compiled asset metadata file already exists when the assets plugin
    # is loaded, the plugin will read the file to get the compiled asset metadata,
    # and it will run in compiled mode, assuming that the compiled asset files
    # already exist.
    #
    # ==== On Heroku
    #
    # Heroku supports precompiling the assets when using Roda.  You just need to
    # add an assets:precompile task, similar to this:
    #
    #   namespace :assets do
    #     desc "Precompile the assets"
    #     task :precompile do
    #       require './app'
    #       App.compile_assets
    #     end
    #   end
    #
    # == Postprocessing
    #
    # If you pass a callable object to the :postprocessor option, it will be called
    # before an asset is served.
    # If the assets are to be compiled, the object will be called at compilation time.
    #
    # It is passed three arguments; the name of the asset file, the type of the
    # asset file (which is a symbol, either :css or :js), and the asset contents.
    #
    # It should return the new content for the asset.
    #
    # You can use this to call Autoprefixer on your CSS:
    #
    #   plugin :assets, {
    #     css: [ 'style.scss' ],
    #     postprocessor: lambda do |file, type, content|
    #       type == :css ? AutoprefixerRails.process(content).css : content
    #     end
    #   }
    #
    # == External Assets/Assets from Gems
    #
    # The assets plugin only supports loading assets files underneath the assets
    # path.  You cannot pass an absolute path to an asset file and have it
    # work.  If you would like to reference asset files that are outside the assets
    # path, you have the following options:
    #
    # * Copy, hard link, or symlink the external assets files into the assets path.
    # * Use tilt-indirect or another method of indirection (such as an erb template that loads
    #   the external asset file) so that a file inside the assets path can reference files
    #   outside the assets path.
    #
    # == Plugin Options
    #
    # :add_suffix :: Whether to append a .css or .js extension to asset routes in non-compiled mode
    #                (default: false)
    # :compiled_asset_host :: The asset host to use for compiled assets.  Should include the protocol
    #                         as well as the host (e.g. "https://cdn.example.com", "//cdn.example.com")
    # :compiled_css_dir :: Directory name in which to store the compiled css file,
    #                      inside :compiled_path (default: nil)
    # :compiled_css_route :: Route under :prefix for compiled css assets (default: :compiled_css_dir)
    # :compiled_js_dir :: Directory name in which to store the compiled javascript file,
    #                     inside :compiled_path (default: nil)
    # :compiled_js_route :: Route under :prefix for compiled javscript assets (default: :compiled_js_dir)
    # :compiled_name :: Compiled file name prefix (default: 'app')
    # :compiled_path:: Path inside public folder in which compiled files are stored (default: :prefix)
    # :concat_only :: Whether to just concatenate instead of concatenating
    #                 and compressing files (default: false)
    # :css_compressor :: Compressor to use for compressing CSS, either :yui, :none, or nil (the default, which will try
    #                    :yui if available, but not fail if it is not available)
    # :css_dir :: Directory name containing your css source, inside :path (default: 'css')
    # :css_headers :: A hash of additional headers for your rendered css files
    # :css_opts :: Template options to pass to the render plugin (via :template_opts) when rendering css assets
    # :css_route :: Route under :prefix for css assets (default: :css_dir)
    # :dependencies :: A hash of dependencies for your asset files.  Keys should be paths to asset files,
    #                  values should be arrays of paths your asset files depends on.  This is used to
    #                  detect changes in your asset files.
    # :early_hints :: Automatically send early hints for all assets.  Requires the early_hints plugin.
    # :group_subdirs :: Whether a hash used in :css and :js options requires the assets for the
    #                   related group are contained in a subdirectory with the same name (default: true)
    # :gzip :: Store gzipped compiled assets files, and serve those to clients who accept gzip encoding.
    # :headers :: A hash of additional headers for both js and css rendered files
    # :js_compressor :: Compressor to use for compressing javascript, either :yui, :closure, :uglifier, :minjs,
    #                   :none, or nil (the default, which will try :yui, :closure, :uglifier, then :minjs, but
    #                   not fail if any of them is not available)
    # :js_dir :: Directory name containing your javascript source, inside :path (default: 'js')
    # :js_headers :: A hash of additional headers for your rendered javascript files
    # :js_opts :: Template options to pass to the render plugin (via :template_opts) when rendering javascript assets
    # :js_route :: Route under :prefix for javascript assets (default: :js_dir)
    # :path :: Path to your asset source directory (default: 'assets').   Relative
    #          paths will be considered relative to the application's :root option.
    # :postprocessor :: A block which should accept three arguments (asset name, asset type,
    #                   content). This block can be used to hook into the asset system and
    #                   make your own modifications before the asset is served. If the asset
    #                   is to be compiled, the block is called at compile time.
    # :prefix :: Prefix for assets path in your URL/routes (default: 'assets')
    # :precompiled :: Path to the compiled asset metadata file.  If the file exists, will use compiled
    #                 mode using the metadata in the file.  If the file does not exist, will use
    #                 non-compiled mode, but will write the metadata to the file if compile_assets is called.
    # :public :: Path to your public folder, in which compiled files are placed (default: 'public').  Relative
    #            paths will be considered relative to the application's :root option.
    # :relative_paths :: Use relative paths instead of absolute paths when setting up link and script tags for
    #                    assets.
    # :sri :: Enables subresource integrity when setting up references to compiled assets. The value should be
    #         :sha256, :sha384, or :sha512 depending on which hash algorithm you want to use.  This changes the
    #         hash algorithm that Roda will use when naming compiled asset files. The default is :sha256, you
    #         can use nil to disable subresource integrity.
    # :timestamp_paths :: Include the timestamp of assets in asset paths in non-compiled mode. Doing this can
    #                     slow down development requests due to additional requests to get last modified times,
    #                     but it will make sure the paths change in development when there are modifications,
    #                     which can fix issues when using a caching proxy in non-compiled mode. This can also
    #                     be specified as a string to use that string to separate the timestamp from the asset.
    #                     By default, <tt>/</tt> is used as the separator if timestamp paths are enabled.
    module Assets
      DEFAULTS = {
        :compiled_name    => 'app'.freeze,
        :js_dir           => 'js'.freeze,
        :css_dir          => 'css'.freeze,
        :prefix           => 'assets'.freeze,
        :concat_only      => false,
        :compiled         => false,
        :add_suffix       => false,
        :early_hints      => false,
        :timestamp_paths  => false,
        :group_subdirs    => true,
        :compiled_css_dir => nil,
        :compiled_js_dir  => nil,
        :sri              => :sha256
      }.freeze

      # Internal exception raised when a compressor cannot be found
      CompressorNotFound = Class.new(RodaError)

      # Load the render, caching, and h plugins, since the assets plugin
      # depends on them.
      def self.load_dependencies(app, opts = OPTS)
        app.plugin :render
        app.plugin :caching
        app.plugin :h

        if opts[:relative_paths]
          app.plugin :relative_path
        end
      end

      # Setup the options for the plugin.  See the Assets module RDoc
      # for a description of the supported options.
      def self.configure(app, opts = {})
        if app.assets_opts
          prev_opts = app.assets_opts[:orig_opts]
          orig_opts = app.assets_opts[:orig_opts].merge(opts)
          [:headers, :css_headers, :js_headers, :css_opts, :js_opts, :dependencies].each do |s|
            if prev_opts[s]
              if opts[s]
                orig_opts[s] = prev_opts[s].merge(opts[s])
              else
                orig_opts[s] = prev_opts[s].dup
              end
            end
          end
          app.opts[:assets] = orig_opts.dup
          app.opts[:assets][:orig_opts] = orig_opts
        else
          app.opts[:assets] = opts.dup
          app.opts[:assets][:orig_opts] = opts
        end
        opts = app.opts[:assets]
        opts[:path] = app.expand_path(opts[:path]||"assets").freeze
        opts[:public] = app.expand_path(opts[:public]||"public").freeze

        # Combine multiple values into a path, ignoring trailing slashes
        j = lambda do |*v|
          opts.values_at(*v).
            reject{|s| s.to_s.empty?}.
            map{|s| s.chomp('/')}.
            join('/').freeze
        end

        # Same as j, but add a trailing slash if not empty
        sj = lambda do |*v|
          s = j.call(*v)
          s.empty? ? s : (s + '/').freeze
        end

        if opts[:precompiled] && !opts[:compiled] && ::File.exist?(opts[:precompiled])
          require 'json'
          opts[:compiled] = app.send(:_precompiled_asset_metadata, opts[:precompiled])
        end

        if opts[:early_hints]
          app.plugin :early_hints
        end

        if opts[:timestamp_paths] && !opts[:timestamp_paths].is_a?(String)
          opts[:timestamp_paths] = '/'
        end

        DEFAULTS.each do |k, v|
          opts[k] = v unless opts.has_key?(k)
        end

        [
         [:compiled_path, :prefix],
         [:js_route, :js_dir],
         [:css_route, :css_dir],
         [:compiled_js_route, :compiled_js_dir],
         [:compiled_css_route, :compiled_css_dir]
        ].each do |k, v|
          opts[k]  = opts[v] unless opts.has_key?(k)
        end

        [:css_headers, :js_headers, :css_opts, :js_opts, :dependencies].each do |s|
          opts[s] ||= {} 
        end

        expanded_deps = opts[:expanded_dependencies] = {}
        opts[:dependencies].each do |file, deps|
          expanded_deps[File.expand_path(file)] = Array(deps)
        end

        if headers = opts[:headers]
          opts[:css_headers] = headers.merge(opts[:css_headers])
          opts[:js_headers]  = headers.merge(opts[:js_headers])
        end
        opts[:css_headers][RodaResponseHeaders::CONTENT_TYPE] ||= "text/css; charset=UTF-8".freeze
        opts[:js_headers][RodaResponseHeaders::CONTENT_TYPE]  ||= "application/javascript; charset=UTF-8".freeze

        [:css_headers, :js_headers, :css_opts, :js_opts, :dependencies, :expanded_dependencies].each do |s|
          opts[s].freeze
        end
        [:headers, :css, :js].each do |s|
          opts[s].freeze if opts[s]
        end

        # Used for reading/writing files
        opts[:js_path]           = sj.call(:path, :js_dir)
        opts[:css_path]          = sj.call(:path, :css_dir)
        opts[:compiled_js_path]  = j.call(:public, :compiled_path, :compiled_js_dir, :compiled_name)
        opts[:compiled_css_path] = j.call(:public, :compiled_path, :compiled_css_dir, :compiled_name)

        # Used for URLs/routes
        opts[:js_prefix]           = sj.call(:prefix, :js_route)
        opts[:css_prefix]          = sj.call(:prefix, :css_route)
        opts[:compiled_js_prefix]  = j.call(:prefix, :compiled_js_route, :compiled_name)
        opts[:compiled_css_prefix] = j.call(:prefix, :compiled_css_route, :compiled_name)
        opts[:js_suffix]           = (opts[:add_suffix] ? '.js' : '').freeze
        opts[:css_suffix]          = (opts[:add_suffix] ? '.css' : '').freeze

        opts.freeze
      end

      module ClassMethods
        # Return the assets options for this class.
        def assets_opts
          opts[:assets]
        end

        # Compile options for the given asset type.  If no asset_type
        # is given, compile both the :css and :js asset types.  You
        # can specify an array of types (e.g. [:css, :frontend]) to
        # compile assets for the given asset group.
        def compile_assets(type=nil)
          require 'fileutils'

          unless assets_opts[:compiled]
            opts[:assets] = assets_opts.merge(:compiled => _compiled_assets_initial_hash).freeze
          end

          if type == nil
            _compile_assets(:css)
            _compile_assets(:js)
          else
            _compile_assets(type)
          end

          if precompile_file = assets_opts[:precompiled]
            require 'json'
            ::FileUtils.mkdir_p(File.dirname(precompile_file))
            tmp_file = "#{precompile_file}.tmp"
            ::File.open(tmp_file, 'wb'){|f| f.write((opts[:json_serializer] || :to_json.to_proc).call(assets_opts[:compiled]))}
            ::File.rename(tmp_file, precompile_file)
          end

          assets_opts[:compiled]
        end

        private

        # The initial hash to use to store compiled asset metadata.
        def _compiled_assets_initial_hash
          {}
        end

        # Internals of compile_assets, handling recursive calls for loading
        # all asset groups under the given type.
        def _compile_assets(type)
          type, *dirs = type if type.is_a?(Array)
          dirs ||= []
          files = assets_opts[type]
          dirs.each{|d| files = files[d]}

          case files
          when Hash
            files.each_key{|dir| _compile_assets([type] + dirs + [dir])}
          else
            files = Array(files)
            compile_assets_files(files, type, dirs) unless files.empty?
          end
        end

        # The precompiled asset metadata stored in the given file
        def _precompiled_asset_metadata(file)
          (opts[:json_parser] || ::JSON.method(:parse)).call(::File.read(file))
        end

        # Compile each array of files for the given type into a single
        # file.  Dirs should be an array of asset group names, if these
        # are files in an asset group.
        def compile_assets_files(files, type, dirs)
          dirs = nil if dirs && dirs.empty?
          o = assets_opts
          app = allocate

          content = files.map do |file|
            file = "#{dirs.join('/')}/#{file}" if dirs && o[:group_subdirs]
            file = "#{o[:"#{type}_path"]}#{file}"
            app.read_asset_file(file, type)
          end.join("\n")

          unless o[:concat_only]
            content = compress_asset(content, type)
          end

          suffix = ".#{dirs.join('.')}" if dirs
          key = "#{type}#{suffix}"
          unique_id = o[:compiled][key] = asset_digest(content)
          path = "#{o[:"compiled_#{type}_path"]}#{suffix}.#{unique_id}.#{type}"
          ::FileUtils.mkdir_p(File.dirname(path))
          ::File.open(path, 'wb'){|f| f.write(content)}

          if o[:gzip]
            require 'zlib'
            Zlib::GzipWriter.open("#{path}.gz") do |gz|
              gz.write(content)
            end
          end

          nil
        end

        # Compress the given content for the given type by using the
        # configured compressor, or trying the supported compressors.
        def compress_asset(content, type)
          case compressor = assets_opts[:"#{type}_compressor"]
          when :none
            return content
          when nil
            # default, try different compressors
          else
            # Allow calling private compress methods
            return send("compress_#{type}_#{compressor}", content)
          end

          compressors = if type == :js
            [:yui, :closure, :uglifier, :minjs]
          else
            [:yui]
          end

          compressors.each do |comp|
            begin
            # Allow calling private compress methods
              if c = send("compress_#{type}_#{comp}", content)
                return c
              end
            rescue LoadError, CompressorNotFound
            end
          end

          content
        end

        # Compress the CSS using YUI Compressor, requires java runtime
        def compress_css_yui(content)
          compress_yui(content, :compress_css)
        end

        # Compress the JS using Google Closure Compiler, requires java runtime
        def compress_js_closure(content)
          require 'closure-compiler'

          begin
            ::Closure::Compiler.new.compile(content)
          rescue ::Closure::Error => e
            raise CompressorNotFound, "#{e.class}: #{e.message}", e.backtrace
          end
        end

        # Compress the JS using MinJS, a pure ruby compressor
        def compress_js_minjs(content)
          require 'minjs'
          Minjs::Compressor::Compressor.new(:debug => false).compress(content).to_js
        end

        # Compress the JS using Uglifier, requires javascript runtime
        def compress_js_uglifier(content)
          begin
            require 'uglifier'
          rescue => e
            # :nocov:
            raise CompressorNotFound, "#{e.class}: #{e.message}", e.backtrace
            # :nocov:
          end

          Uglifier.compile(content)
        end

        # Compress the CSS using YUI Compressor, requires java runtime
        def compress_js_yui(content)
          compress_yui(content, :compress_js)
        end

        # Compress the CSS/JS using YUI Compressor, requires java runtime
        def compress_yui(content, meth)
          require 'yuicompressor'
          ::YUICompressor.public_send(meth, content, :munge => true)
        rescue ::Errno::ENOENT => e
          raise CompressorNotFound, "#{e.class}: #{e.message}", e.backtrace
        end

        # Return a unique id for the given content.  By default, uses the
        # SHA256 hash of the content.  This method can be overridden to use
        # a different digest type or to return a static string if you don't
        # want to use a unique value.
        def asset_digest(content)
          algo = assets_opts[:sri] || :sha256
          digest = begin
            require 'openssl'
            ::OpenSSL::Digest
          # :nocov:
          rescue LoadError
            require 'digest/sha2'
            ::Digest
          # :nocov:
          end
          digest.const_get(algo.to_s.upcase).hexdigest(content)
        end
      end

      module InstanceMethods
        # Return an array of paths for the given asset type and optionally
        # asset group. See the assets function documentation for details.
        def assets_paths(type)
          o = self.class.assets_opts
          if type.is_a?(Array)
            ltype, *dirs = type
          else
            ltype = type
          end
          stype = ltype.to_s

          url_prefix = request.script_name if self.class.opts[:add_script_name]
          relative_paths = o[:relative_paths]

          paths = if o[:compiled]
            relative_paths = false if o[:compiled_asset_host]
            if ukey = _compiled_assets_hash(type, true)
              ["#{o[:compiled_asset_host]}#{url_prefix}/#{o[:"compiled_#{stype}_prefix"]}.#{ukey}.#{stype}"]
            else
              []
            end
          else
            asset_dir = o[ltype]
            if dirs && !dirs.empty?
              dirs.each{|f| asset_dir = asset_dir[f]}
              prefix = "#{dirs.join('/')}/" if o[:group_subdirs]
            end
            Array(asset_dir).map do |f|
              if ts = o[:timestamp_paths]
                mtime = asset_last_modified(File.join(o[:"#{stype}_path"], *[prefix, f].compact))
                mtime = "#{sprintf("%i%06i", mtime.to_i, mtime.usec)}#{ts}"
              end
              "#{url_prefix}/#{o[:"#{stype}_prefix"]}#{mtime}#{prefix}#{f}#{o[:"#{stype}_suffix"]}"
            end
          end

          if relative_paths
            paths.map! do |path|
              "#{relative_prefix}#{path}"
            end
          end

          paths
        end

        # Return a string containing html tags for the given asset type.
        # This will use a script tag for the :js type and a link tag for
        # the :css type.
        #
        # To return the tags for a specific asset group, use an array for
        # the type, such as [:css, :frontend].
        #
        # You can specify custom attributes for the tag by passing a hash
        # as the attrs argument.
        #
        # When the assets are not compiled, this will result in a separate
        # tag for each asset file.  When the assets are compiled, this will
        # result in a single tag to the compiled asset file.
        def assets(type, attrs = OPTS)
          ltype = type.is_a?(Array) ? type[0] : type

          o = self.class.assets_opts
          if o[:compiled] && (algo = o[:sri]) && (hash = _compiled_assets_hash(type))
            attrs = Hash[attrs]
            attrs[:integrity] = "#{algo}-#{h([[hash].pack('H*')].pack('m').tr("\n", ''))}"
          end

          attributes = attrs.map{|k,v| "#{k}=\"#{h(v)}\""}.join(' ')

          if ltype == :js
            tag_start = "<script#{' type="text/javascript"' unless attrs[:type]} #{attributes} src=\""
            tag_end = "\"></script>"
          else
            tag_start = "<link rel=\"stylesheet\" #{attributes} href=\""
            tag_end = "\" />"
          end

          paths = assets_paths(type)
          if o[:early_hints]
            early_hint_as = ltype == :js ? 'script' : 'style'
            early_hints = paths.map{|p| "<#{p}>; rel=preload; as=#{early_hint_as}"}
            early_hints = early_hints.join("\n") if Rack.release < '3'
            send_early_hints(RodaResponseHeaders::LINK=>early_hints)
          end
          paths.map{|p| "#{tag_start}#{h(p)}#{tag_end}"}.join("\n")
        end

        # Render the asset with the given filename.  When assets are compiled,
        # or when the file is already of the given type (no rendering necessary),
        # this returns the contents of the compiled file.
        # When assets are not compiled and the file is not already in the same format,
        # this will render the asset using the render plugin.
        # In both cases, if the file has not been modified since the last request,
        # this will return a 304 response.
        def render_asset(file, type)
          o = self.class.assets_opts
          if o[:compiled]
            file = "#{o[:"compiled_#{type}_path"]}#{file}"

            if o[:gzip] && env['HTTP_ACCEPT_ENCODING'] =~ /\bgzip\b/
              @_response[RodaResponseHeaders::CONTENT_ENCODING] = 'gzip'
              file += '.gz'
            end

            check_asset_request(file, type, ::File.stat(file).mtime)
            ::File.read(file)
          else
            file = "#{o[:"#{type}_path"]}#{file}"
            check_asset_request(file, type, asset_last_modified(file))
            read_asset_file(file, type)
          end
        end

        # Return the content of the file if it is already of the correct type.
        # Otherwise, render the file using the render plugin.  +file+ should be
        # the relative path to the file from the current directory.
        def read_asset_file(file, type)
          o = self.class.assets_opts

          content = if file.end_with?(".#{type}")
            ::File.read(file)
          else
            render_asset_file(file, :template_opts=>o[:"#{type}_opts"], :dependencies=>o[:expanded_dependencies][file])
          end

          o[:postprocessor] ? o[:postprocessor].call(file, type, content) : content
        end

        private

        def _compiled_assets_hash(type, return_ukey=false)
          compiled = self.class.assets_opts[:compiled]
          type, *dirs = type if type.is_a?(Array)
          stype = type.to_s

          if dirs && !dirs.empty?
            key = dirs.join('.')
            ckey = "#{stype}.#{key}"
            if hash = ukey = compiled[ckey]
              ukey = "#{key}.#{ukey}"
            end
          else
            hash = ukey = compiled[stype]
          end

          return_ukey ? ukey : hash
        end

        # Return when the file was last modified.  If the file depends on any
        # other files, check the modification times of all dependencies and
        # return the maximum.
        def asset_last_modified(file)
          if deps = self.class.assets_opts[:expanded_dependencies][file]
            ([file] + Array(deps)).map{|f| ::File.stat(f).mtime}.max
          else
            ::File.stat(file).mtime
          end
        end

        # If the asset hasn't been modified since the last request, return
        # a 304 response immediately.  Otherwise, add the appropriate
        # type-specific headers.
        def check_asset_request(file, type, mtime)
          @_request.last_modified(mtime)
          @_response.headers.merge!(self.class.assets_opts[:"#{type}_headers"])
        end

        # Render the given asset file using the render plugin, with the given options.
        # +file+ should be the relative path to the file from the current directory.
        def render_asset_file(file, options)
          render_template({:path => file}, options)
        end
      end

      module RequestClassMethods
        # An array of asset type strings and regexps for that type, for all asset types
        # handled.
        def assets_matchers
          @assets_matchers ||= [:css, :js].map do |t|
            if regexp = assets_regexp(t)
              [t, regexp].freeze
            end
          end.compact.freeze
        end

        private

        # A string for the asset filename for the asset type, key, and digest.
        def _asset_regexp(type, key, digest)
          "#{key.sub(/\A#{type}/, '')}.#{digest}.#{type}"
        end

        # The regexp matcher to use for the given type.  This handles any asset groups
        # for the asset types.
        def assets_regexp(type)
          o = roda_class.assets_opts
          if compiled = o[:compiled]
            assets = compiled.
              select{|k,_| k =~ /\A#{type}/}.
              map{|k, md| _asset_regexp(type, k, md)}
            return if assets.empty?
            /#{o[:"compiled_#{type}_prefix"]}(#{Regexp.union(assets)})/
          else
            return unless assets = o[type]
            assets = unnest_assets_hash(assets)
            ts = o[:timestamp_paths]
            /#{o[:"#{type}_prefix"]}#{"\\d+#{ts}" if ts}(#{Regexp.union(assets.uniq)})#{o[:"#{type}_suffix"]}/
          end
        end

        # Recursively unnested the given assets hash, returning a single array of asset
        # files for the given.
        def unnest_assets_hash(h)
          case h
          when Hash
            h.map do |k,v|
              assets = unnest_assets_hash(v)
              assets = assets.map{|x| "#{k}/#{x}"} if roda_class.assets_opts[:group_subdirs]
              assets
            end.flatten(1)
          else
            Array(h)
          end
        end
      end

      module RequestMethods
        # Render the matching asset if this is a GET request for a supported asset.
        def assets
          if is_get?
            self.class.assets_matchers.each do |type, matcher|
              is matcher do |file|
                scope.render_asset(file, type)
              end
            end
            nil
          end
        end
      end
    end

    register_plugin(:assets, Assets)
  end
end