├── test ├── assets │ ├── javascripts │ │ ├── coffee.coffee │ │ ├── a.js │ │ └── b.js │ └── stylesheets │ │ ├── a.css │ │ └── b.css ├── minitest_helper.rb ├── rack-pipeline_test.rb └── rack-pipeline_base_test.rb ├── Gemfile ├── .travis.yml ├── lib ├── rack-pipeline │ ├── version.rb │ ├── compressing │ │ └── uglifier.rb │ ├── compiling │ │ └── coffee-script.rb │ ├── compiling.rb │ ├── compressing.rb │ ├── processing.rb │ ├── caching.rb │ ├── sinatra.rb │ └── base.rb └── rack-pipeline.rb ├── Rakefile ├── .gitignore ├── README.md ├── LICENSE.txt └── rack-pipeline.gemspec /test/assets/javascripts/coffee.coffee: -------------------------------------------------------------------------------- 1 | a = (x) -> x * x -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /test/assets/javascripts/a.js: -------------------------------------------------------------------------------- 1 | a = function() { 2 | }; 3 | -------------------------------------------------------------------------------- /test/assets/javascripts/b.js: -------------------------------------------------------------------------------- 1 | b = function() { 2 | }; 3 | -------------------------------------------------------------------------------- /test/assets/stylesheets/a.css: -------------------------------------------------------------------------------- 1 | a { 2 | color: white; 3 | } 4 | -------------------------------------------------------------------------------- /test/assets/stylesheets/b.css: -------------------------------------------------------------------------------- 1 | b { 2 | color: black; 3 | } 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 1.9.3 4 | - 2.0.0 5 | -------------------------------------------------------------------------------- /lib/rack-pipeline/version.rb: -------------------------------------------------------------------------------- 1 | module RackPipeline 2 | VERSION = "0.0.11" 3 | end 4 | -------------------------------------------------------------------------------- /lib/rack-pipeline.rb: -------------------------------------------------------------------------------- 1 | require 'rack-pipeline/base' 2 | require 'rack-pipeline/compiling' 3 | require 'rack-pipeline/compressing' 4 | -------------------------------------------------------------------------------- /test/minitest_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | 3 | require 'minitest/autorun' 4 | require 'awesome_print' 5 | -------------------------------------------------------------------------------- /test/rack-pipeline_test.rb: -------------------------------------------------------------------------------- 1 | require 'minitest_helper' 2 | require 'rack-pipeline' 3 | 4 | describe RackPipeline do 5 | it 'should have a version' do 6 | RackPipeline::VERSION.must_match /.+/ 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | 3 | require 'rake/testtask' 4 | 5 | Rake::TestTask.new do |t| 6 | t.libs << "test" 7 | t.test_files = FileList['test/**/*_test.rb'] 8 | t.verbose = true 9 | end 10 | 11 | task :default => :test 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | .rbx 7 | Gemfile.lock 8 | InstalledFiles 9 | _yardoc 10 | coverage 11 | doc/ 12 | lib/bundler/man 13 | pkg 14 | rdoc 15 | spec/reports 16 | test/tmp 17 | test/version_tmp 18 | tmp 19 | -------------------------------------------------------------------------------- /lib/rack-pipeline/compressing/uglifier.rb: -------------------------------------------------------------------------------- 1 | require 'uglifier' 2 | 3 | module RackPipeline 4 | module Compressing 5 | module Uglifier 6 | def self.process(source, target) 7 | compiled = ::Uglifier.compile File.read(source) 8 | File.write(target, compiled) 9 | target 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/rack-pipeline/compiling/coffee-script.rb: -------------------------------------------------------------------------------- 1 | require 'coffee-script' 2 | 3 | module RackPipeline 4 | module Compiling 5 | module CoffeeScript 6 | def self.process(source, target) 7 | compiled = ::CoffeeScript.compile File.read(source) 8 | File.write(target, compiled) 9 | target 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/rack-pipeline/compiling.rb: -------------------------------------------------------------------------------- 1 | module RackPipeline 2 | module Compiling 3 | def self.process(source, target) 4 | ext = File.extname source 5 | if compiler = compilers[ext] 6 | require compiler[1] 7 | Compiling.const_get(compiler[0]).process(source, target) 8 | else 9 | fail LoadError, "no compiler for #{source} => #{target}" 10 | end 11 | end 12 | 13 | def self.register(ext, klass, feature) 14 | compilers[ext] = [klass, feature] 15 | end 16 | 17 | def self.compilers 18 | @compilers ||= {} 19 | end 20 | end 21 | end 22 | 23 | RackPipeline::Compiling.register '.coffee', 'CoffeeScript', 'rack-pipeline/compiling/coffee-script' 24 | -------------------------------------------------------------------------------- /lib/rack-pipeline/compressing.rb: -------------------------------------------------------------------------------- 1 | require 'fileutils' 2 | 3 | module RackPipeline 4 | module Compressing 5 | def self.process(source, target) 6 | ext = File.extname source 7 | if compressor = compressors[ext] 8 | require compressor[1] 9 | Compressing.const_get(compressor[0]).process(source, target) 10 | else 11 | warn "no compressor found for #{source}" 12 | FileUtils.cp source, target 13 | target 14 | end 15 | end 16 | 17 | def self.register(ext, klass, feature) 18 | compressors[ext] = [klass, feature] 19 | end 20 | 21 | def self.compressors 22 | @compressors ||= {} 23 | end 24 | end 25 | end 26 | 27 | RackPipeline::Compressing.register '.js', 'Uglifier', 'rack-pipeline/compressing/uglifier' 28 | -------------------------------------------------------------------------------- /lib/rack-pipeline/processing.rb: -------------------------------------------------------------------------------- 1 | require 'fileutils' 2 | 3 | module RackPipeline 4 | module Processing 5 | def combine(sources, target) 6 | cache_target(sources, target) do |target_path| 7 | body = sources.inject('') do |all,(source,kind)| 8 | all << "/*!\n * #{source}\n */\n\n" + File.read(prepare_file(source, static_type(target))).encode('utf-8') + "\n\n" 9 | end 10 | File.write(target_path, body) 11 | target_path 12 | end 13 | end 14 | 15 | def compress(source, target) 16 | return source unless settings[:compress] 17 | cache_target(source, target) do |target_path| 18 | Compressing.process(source, target_path) 19 | end 20 | end 21 | 22 | def compile(source, target) 23 | cache_target(source, target) do |target_path| 24 | Compiling.process(source, target_path) 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/ujifgc/rack-pipeline.png)](https://travis-ci.org/ujifgc/rack-pipeline) 2 | [![Code Climate](https://codeclimate.com/github/ujifgc/rack-pipeline.png)](https://codeclimate.com/github/ujifgc/rack-pipeline) 3 | 4 | # RackPipeline 5 | 6 | A rack middleware to serve javascript and stylesheet assets for ruby web applications 7 | 8 | ## Installation 9 | 10 | Add this line to your application's Gemfile: 11 | 12 | gem 'rack-pipeline' 13 | 14 | And then execute: 15 | 16 | $ bundle 17 | 18 | Or install it yourself as: 19 | 20 | $ gem install rack-pipeline 21 | 22 | ## Usage 23 | 24 | TODO: Write usage instructions here 25 | 26 | ## Contributing 27 | 28 | 1. Fork it 29 | 2. Create your feature branch (`git checkout -b my-new-feature`) 30 | 3. Commit your changes (`git commit -am 'Add some feature'`) 31 | 4. Push to the branch (`git push origin my-new-feature`) 32 | 5. Create new Pull Request 33 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Igor Bochkariov 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /rack-pipeline.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | $LOAD_PATH << File.expand_path('../lib', __FILE__) 3 | require 'rack-pipeline/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'rack-pipeline' 7 | spec.version = RackPipeline::VERSION 8 | spec.description = 'Asset pipeline for ruby Rack' 9 | spec.summary = 'A Rack middleware to serve javascript and stylesheet assets for ruby web applications' 10 | 11 | spec.authors = ['Igor Bochkariov'] 12 | spec.email = ['ujifgc@gmail.com'] 13 | spec.homepage = 'https://github.com/ujifgc/rack-pipeline' 14 | spec.license = 'MIT' 15 | 16 | spec.require_paths = ['lib'] 17 | spec.files = `git ls-files`.split($/) 18 | spec.test_files = spec.files.grep(%r{^test/}) 19 | 20 | spec.add_development_dependency 'bundler', '>= 1.3' 21 | spec.add_development_dependency 'rake' 22 | spec.add_development_dependency 'minitest' 23 | spec.add_development_dependency 'rack-test' 24 | spec.add_development_dependency 'awesome_print' 25 | 26 | # compiling 27 | spec.add_development_dependency 'coffee-script' 28 | 29 | # compressing 30 | spec.add_development_dependency 'uglifier' 31 | end 32 | -------------------------------------------------------------------------------- /lib/rack-pipeline/caching.rb: -------------------------------------------------------------------------------- 1 | require 'digest/md5' 2 | require 'fileutils' 3 | 4 | module RackPipeline 5 | module Caching 6 | def cache_target(source, target) 7 | ensure_temp_directory 8 | caller_method = caller.first[/`([^']*)'/, 1] 9 | extname = File.extname(target) 10 | target_filename = File.basename(target).sub(/[0-9a-f]{32}\./,'').chomp(extname) << '.' << caller_method 11 | target_path = File.join(settings[:temp], target_filename + '.' << calculate_hash(source) << extname) 12 | if File.file?(target_path) 13 | target_path 14 | else 15 | cleanup_cache(target_filename << '.*' << extname) 16 | yield target_path 17 | end 18 | end 19 | 20 | def ensure_temp_directory 21 | temp = settings[:temp] 22 | return temp if temp.kind_of?(String) && File.directory?(temp) 23 | unless temp 24 | require 'tmpdir' 25 | temp = File.join(Dir.tmpdir, 'RackPipeline') 26 | end 27 | FileUtils.mkdir_p temp 28 | settings[:temp] = temp 29 | end 30 | 31 | def cleanup_cache(target) 32 | @busted = true 33 | FileUtils.rm Dir.glob(File.join(settings[:temp], target)) 34 | end 35 | 36 | def calculate_hash(sources) 37 | Digest::MD5.hexdigest(Array(sources).inject(''){ |all,(file,_)| all << file << File.mtime(file).to_i.to_s }) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/rack-pipeline/sinatra.rb: -------------------------------------------------------------------------------- 1 | module RackPipeline 2 | module Sinatra 3 | def self.registered(app) 4 | app.use RackPipeline::Base, app.respond_to?(:pipeline) ? app.pipeline : {} 5 | app.helpers Helpers 6 | end 7 | 8 | module Helpers 9 | def pipeline(pipes = [ :app ], types = [ :css, :js ], options = {}) 10 | bust_cache = respond_to?(:settings) && settings.respond_to?(:pipeline) && settings.pipeline[:bust_cache] 11 | @pipeline_object = env['rack-pipeline'] 12 | Array(types).map do |type| 13 | assets = @pipeline_object.assets_for(pipes, type, options) 14 | assets.map do |asset| 15 | pipe_tag(type, asset + options[:postfix].to_s, bust_cache) 16 | end.join("\n") 17 | end.join("\n") 18 | end 19 | 20 | def pipe_tag(type, asset, bust_cache=nil) 21 | asset += cache_buster(asset) if bust_cache 22 | case type.to_sym 23 | when :css 24 | %() 25 | when :js 26 | %() 27 | end 28 | end 29 | 30 | def cache_buster(file) 31 | compress = respond_to?(:settings) && settings.respond_to?(:pipeline) && settings.pipeline[:compress] 32 | if !compress && File.file?(file) 33 | "?#{File.mtime(file).to_i}" 34 | else 35 | temp = @pipeline_object.ensure_temp_directory 36 | max_mtime = 0 37 | Dir.glob(File.join(temp, File.basename(file,'.*') << '.*' << File.extname(file))).each do |cached_file| 38 | mtime = File.mtime(cached_file).to_i 39 | max_mtime = mtime if mtime > max_mtime 40 | end 41 | max_mtime = Time.now.to_i if max_mtime == 0 42 | "?#{max_mtime}" 43 | end 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/rack-pipeline_base_test.rb: -------------------------------------------------------------------------------- 1 | require 'minitest_helper' 2 | require 'rack/test' 3 | require 'rack-pipeline/base' 4 | 5 | describe RackPipeline::Base do 6 | SETTINGS_1 = {} 7 | SETTINGS_2 = { 8 | :compress => true, 9 | :js => { 10 | :coffee => 'assets/**/*.coffee', 11 | } 12 | } 13 | 14 | before do 15 | Dir.chdir File.dirname(__FILE__) 16 | @mockapp = MiniTest::Mock.new 17 | @mockapp.expect(:call, [200, {}, ['UNDERLYING APP RESPONSE']], [Hash]) 18 | @r1 = Rack::MockRequest.new(RackPipeline::Base.new(@mockapp, SETTINGS_1.dup)) 19 | @r2 = Rack::MockRequest.new(RackPipeline::Base.new(@mockapp, SETTINGS_2.dup)) 20 | end 21 | 22 | it 'should pass to underlying app' do 23 | response = @r1.get('/') 24 | @mockapp.verify 25 | end 26 | 27 | it 'should pass to underlying app' do 28 | response = @r1.get('/assets/stylesheets/non-existing.css') 29 | @mockapp.verify 30 | end 31 | 32 | it 'should respond with combined css' do 33 | response = @r1.get('/app.css') 34 | response.body.must_include 'color: black' 35 | response.body.must_include 'color: white' 36 | end 37 | 38 | it 'should respond with combined js' do 39 | response = @r1.get('/app.js') 40 | response.body.must_include 'a = function' 41 | response.body.must_include 'b = function' 42 | end 43 | 44 | it 'should respond with single css' do 45 | response = @r1.get('/assets/javascripts/a.js') 46 | response.body.must_include 'a = function' 47 | end 48 | 49 | it 'should respond with single js' do 50 | response = @r1.get('/assets/stylesheets/a.css') 51 | response.body.must_include 'color: white' 52 | end 53 | 54 | it 'should have proper css content_type' do 55 | response = @r1.get('/assets/stylesheets/a.css') 56 | response.headers['Content-Type'].must_include 'text/css' 57 | end 58 | 59 | it 'should have proper js content_type' do 60 | response = @r1.get('/app.js') 61 | response.headers['Content-Type'].must_include 'application/javascript' 62 | end 63 | 64 | it 'should properly compile and compress coffeescript' do 65 | response = @r2.get('/coffee.js') 66 | response.body.must_include '=function(' 67 | end 68 | 69 | after do 70 | FileUtils.rm_r( File.join( Dir.tmpdir, 'RackPipeline' ) ) 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/rack-pipeline/base.rb: -------------------------------------------------------------------------------- 1 | require 'time' 2 | 3 | require 'rack-pipeline/version' 4 | require 'rack-pipeline/caching' 5 | require 'rack-pipeline/processing' 6 | 7 | module RackPipeline 8 | class MustRepopulate < Exception; end 9 | class Base 10 | include Caching 11 | include Processing 12 | 13 | attr_accessor :assets, :settings 14 | 15 | STATIC_TYPES = { '.js' => :js, '.css' => :css }.freeze 16 | CONTENT_TYPES = { '.js' => 'application/javascript', '.css' => 'text/css' }.freeze 17 | 18 | def assets_for(pipes, type, opts = {}) 19 | Array(pipes).inject([]) do |all,pipe| 20 | all += Array(settings[:combine] ? "#{pipe}.#{type}" : assets[type][pipe].keys) 21 | end.compact.uniq 22 | end 23 | 24 | def initialize(app, *args) 25 | @generations = 0 26 | @assets = {} 27 | @settings = { 28 | :temp => nil, 29 | :compress => false, 30 | :combine => false, 31 | :bust_cache => false, 32 | :css => { 33 | :app => 'assets/**/*.css', 34 | }, 35 | :js => { 36 | :app => 'assets/**/*.js', 37 | }, 38 | } 39 | @settings.merge!(args.pop) if args.last.kind_of?(Hash) 40 | ensure_temp_directory 41 | populate_pipelines 42 | @app = app 43 | end 44 | 45 | def inspect 46 | { :settings => settings, :assets => assets } 47 | end 48 | 49 | def call(env) 50 | @env = env 51 | env['rack-pipeline'] = self 52 | if file_path = prepare_pipe(env['PATH_INFO']) 53 | serve_file(file_path, env['HTTP_IF_MODIFIED_SINCE']) 54 | else 55 | @app.call(env) 56 | end 57 | rescue MustRepopulate 58 | populate_pipelines 59 | retry 60 | end 61 | 62 | private 63 | 64 | def busted? 65 | result = settings[:bust_cache] && @busted 66 | @busted = false 67 | result 68 | end 69 | 70 | def serve_file(file, mtime) 71 | headers = { 'Last-Modified' => File.mtime(file).httpdate } 72 | if mtime == headers['Last-Modified'] 73 | [304, headers, []] 74 | else 75 | if busted? 76 | headers['Location'] = "#{@env['PATH_INFO']}?#{File.mtime(file).to_i}" 77 | [302, headers, []] 78 | else 79 | body = File.read file 80 | headers['Content-Type'] = "#{content_type(file)}; charset=#{body.encoding.to_s}" 81 | headers['Content-Length'] = File.size(file).to_s 82 | [200, headers, [body]] 83 | end 84 | end 85 | rescue Errno::ENOENT 86 | raise MustRepopulate 87 | end 88 | 89 | def static_type(file) 90 | if file.kind_of? String 91 | STATIC_TYPES[file] || STATIC_TYPES[File.extname(file)] 92 | else 93 | STATIC_TYPES.values.include?(file) && file 94 | end 95 | end 96 | 97 | def content_type(file) 98 | CONTENT_TYPES[File.extname(file)] || 'text' 99 | end 100 | 101 | def prepare_pipe(path_info) 102 | file = path_info.start_with?('/') ? path_info[1..-1] : path_info 103 | type = static_type(file) or return nil 104 | unless ready_file = prepare_file(file, type) 105 | pipename = File.basename(file, '.*').to_sym 106 | if assets[type] && assets[type][pipename] 107 | ready_file = combine(assets[type][pipename], File.basename(file)) 108 | end 109 | end 110 | compress(ready_file, File.basename(ready_file)) if ready_file 111 | rescue Errno::ENOENT 112 | raise MustRepopulate 113 | end 114 | 115 | def prepare_file(source, type) 116 | assets[type].each do |pipe,files| 117 | case files[source] 118 | when :raw 119 | return source 120 | when :source 121 | return compile(source, File.basename(source, '.*') + ".#{type}") 122 | end 123 | end 124 | nil 125 | end 126 | 127 | def file_kind(file) 128 | static_type(file) ? :raw : :source 129 | end 130 | 131 | def glob_files(globs) 132 | Array(globs).each_with_object({}) do |glob,all| 133 | Dir.glob(glob).sort.each do |file| 134 | all[file] = file_kind(file) 135 | end 136 | end 137 | end 138 | 139 | def populate_pipelines 140 | fail SystemStackError, 'too many RackPipeline generations' if @generations > 5 141 | @generations += 1 142 | STATIC_TYPES.each do |extname,type| 143 | pipes = settings[type] 144 | assets[type] = {} 145 | pipes.each do |pipe, dirs| 146 | assets[type][pipe] = glob_files(dirs) 147 | end 148 | end 149 | end 150 | end 151 | end 152 | --------------------------------------------------------------------------------