├── 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 | [](https://travis-ci.org/ujifgc/rack-pipeline)
2 | [](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 |
--------------------------------------------------------------------------------