require 'rack/scriptstacker/version'
require 'rack'
class ::Hash
def recursive_merge other
merger = proc do |key, v1, v2|
Hash === v1 && Hash === v2 ? v1.merge(v2, &merger) : v2
end
self.merge(other, &merger)
end
end
module Rack
class ScriptStacker
DEFAULT_CONFIG = {
configure_static: true,
stackers: {
css: {
template: '',
glob: '*.css',
slot: 'CSS'
},
javascript: {
template: '',
glob: '*.js',
slot: 'JAVASCRIPT'
}
}
}
def initialize app, config={}, &stack_spec
@config = DEFAULT_CONFIG.recursive_merge config
@path_specs = ScriptStackerUtils::SpecSolidifier.new.call stack_spec
@runner = ScriptStackerUtils::Runner.new @config[:stackers]
@app = @config[:configure_static] ? configure_static(app) : app
end
def call env
response = @app.call env
if response[1]['Content-Type'] != 'text/html'
response
else
[
response[0],
response[1],
@runner.replace_in_body(response[2], @path_specs)
]
end
end
private
def configure_static app
Rack::Static.new app, {
urls: @path_specs
.values
.reduce([]) { |memo, specs| memo + specs }
.select { |spec| spec.paths_identical? }
.map { |spec| spec.serve_path }
}
end
end
module ScriptStackerUtils
class SpecSolidifier < BasicObject
def initialize
@specs = ::Hash.new { |hash, key| hash[key] = [] }
end
def call stack_spec
instance_eval &stack_spec
@specs
end
def method_missing name, *args
if args.size != 1
raise ::ArgumentError.new(
"Expected a path spec like 'static/css' => 'stylesheets', " +
"but got #{args.inspect} instead."
)
end
@specs[name].push ::Rack::ScriptStackerUtils::PathSpec.new(args[0])
end
end
class PathSpec
def initialize paths
if paths.respond_to? :key
# this is just for pretty method calls, eg.
# css 'stylesheets' => 'static/css'
@source_path, @serve_path = paths.to_a.flatten
else
# if only one path is given, use the same for both;
# this is just like how Rack::Static works
@source_path = @serve_path = paths
end
end
def source_path
normalize_end_slash @source_path
end
def serve_path
normalize_end_slash normalize_begin_slash(@serve_path)
end
def paths_identical?
# Paths are normalized differently, so this check isn't doable from
# outside the instance; but we still want to know if they're basically
# the same so we can easily configure Rack::Static to match.
@source_path == @serve_path
end
private
def normalize_end_slash path
path.end_with?('/') ? path : path + '/'
end
def normalize_begin_slash path
path.start_with?('/') ? path : '/' + path
end
end
class Runner
def initialize stacker_configs
@stackers = stacker_configs.map do |name, config|
[name, Stacker.new(config)]
end.to_h
end
def replace_in_body body, path_specs
path_specs.each do |name, specs|
specs.each do |spec|
@stackers[name].find_files spec.source_path, spec.serve_path
end
end
body.map do |chunk|
@stackers.values.reduce chunk do |memo, stacker|
stacker.replace_slot memo
end
end
end
end
class Stacker
def initialize config
@template = config[:template]
@glob = config[:glob]
@slot = config[:slot]
@files = []
end
def find_files source_path, serve_path
@files = @files + files_for(source_path).map do |filename|
sprintf @template, serve_path + filename
end
end
def replace_slot chunk
chunk.gsub /^(\s*)#{slot}/ do
indent = $1
@files.map do |line|
indent + line
end.join "\n"
end
end
private
def slot
""
end
def files_for source_path
Dir[source_path + @glob]
.map { |file| ::File.basename(file) }
end
end
end
end