pinion 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/README.md +78 -0
- data/lib/pinion/conversion.rb +78 -0
- data/lib/pinion/error.rb +3 -0
- data/lib/pinion/server.rb +171 -0
- data/lib/pinion/version.rb +3 -0
- data/lib/pinion.rb +2 -0
- metadata +65 -0
data/README.md
ADDED
@@ -0,0 +1,78 @@
|
|
1
|
+
Pinion
|
2
|
+
======
|
3
|
+
|
4
|
+
Pinion is a Rack application that serves assets, possibly transforming them in the process. It is generally
|
5
|
+
useful for serving Javascript and CSS, and things that compile to Javascript and CSS such as Coffeescript and
|
6
|
+
Sass.
|
7
|
+
|
8
|
+
Goals
|
9
|
+
=====
|
10
|
+
|
11
|
+
There are a lot of tools that accomplish very similar things in this space. Pinion is meant to be a very
|
12
|
+
simple and lightweight solution. It is driven by these core goals (bold goals are implemented):
|
13
|
+
|
14
|
+
* **Simple configuration and usage.**
|
15
|
+
* **No added syntax to your assets (e.g. no `//= require my_other_asset`)**
|
16
|
+
* **Recompile all compiled assets when they change (or dependencies change) in development and set mtimes**
|
17
|
+
* Recompile asynchronously from requests (no polling allowed)
|
18
|
+
* Compile assets one time in production
|
19
|
+
|
20
|
+
Installation
|
21
|
+
============
|
22
|
+
|
23
|
+
$ gem install pinion
|
24
|
+
|
25
|
+
Usage
|
26
|
+
=====
|
27
|
+
|
28
|
+
The easiest way to use Pinion is to map your desired asset mount point to a `Pinion::Server` instance in your
|
29
|
+
`config.ru`.
|
30
|
+
|
31
|
+
``` ruby
|
32
|
+
require "pinion"
|
33
|
+
require "your_app.rb"
|
34
|
+
|
35
|
+
map "/assets" do
|
36
|
+
server = Pinion::Server.new
|
37
|
+
# Tell Pinion each type of conversion it should perform
|
38
|
+
server.convert :scss => :css # Sass and Coffeescript will just work if you have the gems installed
|
39
|
+
server.convert :coffee => :js # Conversion types correspond to file extensions. .coffee -> .js
|
40
|
+
server.convert :styl => :css do |file_contents|
|
41
|
+
Stylus.compile file_contents # Requires the stylus gem
|
42
|
+
end
|
43
|
+
# Tell Pinion the paths to watch
|
44
|
+
server.watch "public/javascripts"
|
45
|
+
server.watch "public/scss"
|
46
|
+
server.watch "public/stylus"
|
47
|
+
# Boom
|
48
|
+
run server
|
49
|
+
end
|
50
|
+
|
51
|
+
map "/" do
|
52
|
+
run Your::App.new
|
53
|
+
end
|
54
|
+
```
|
55
|
+
|
56
|
+
Notes
|
57
|
+
-----
|
58
|
+
|
59
|
+
* Everything in `/assets` (in the example) will be served as-is. No conversions will be performed on those
|
60
|
+
files (unless you add `/assets` as a watch path).
|
61
|
+
* Currently, Pinion sidesteps the dependency question by invalidating its cache of each file of a particular
|
62
|
+
type (say, all `.scss` files) when any such source file is changed.
|
63
|
+
* The order that paths are added to the watch list is a priority order in case of conflicting assets. (For
|
64
|
+
intance, if `foo/bar` and `foo/baz` are both on the watch list, and both of the files `foo/bar/style.scss`
|
65
|
+
and `foo/baz/style.scss` exist, then `foo/bar/style.scss` will be used if a request occurs for `/style.css`.
|
66
|
+
|
67
|
+
You can see an example app using Pinion and Sinatra in the `example/` directory.
|
68
|
+
|
69
|
+
Authors
|
70
|
+
=======
|
71
|
+
|
72
|
+
Pinion was written by Caleb Spare ([cespare](https://github.com/cespare)). Inspiration from
|
73
|
+
[sprockets](https://github.com/sstephenson/sprockets) and [rerun](https://github.com/alexch/rerun).
|
74
|
+
|
75
|
+
License
|
76
|
+
=======
|
77
|
+
|
78
|
+
Pinion is released under [the MIT License](http://www.opensource.org/licenses/mit-license.php).
|
@@ -0,0 +1,78 @@
|
|
1
|
+
require "pinion/error"
|
2
|
+
|
3
|
+
module Pinion
|
4
|
+
# A conversion describes how to convert certain types of files and create asset links for them.
|
5
|
+
# Conversions.create() provides a tiny DSL for defining new conversions
|
6
|
+
class Conversion
|
7
|
+
@@conversions = {}
|
8
|
+
def self.[](from_and_to) @@conversions[from_and_to] end
|
9
|
+
def self.conversions_for(to) @@conversions.values.select { |c| c.to_type == to } end
|
10
|
+
def self.create(from_and_to, &block)
|
11
|
+
unless from_and_to.is_a?(Hash) && from_and_to.size == 1
|
12
|
+
raise Error, "Unexpected argument to Conversion.create: #{from_and_to.inspect}"
|
13
|
+
end
|
14
|
+
conversion = Conversion.new *from_and_to.to_a[0]
|
15
|
+
conversion.instance_eval &block
|
16
|
+
conversion.verify
|
17
|
+
@@conversions[conversion.signature] = conversion
|
18
|
+
end
|
19
|
+
|
20
|
+
attr_reader :from_type, :to_type, :gem_required
|
21
|
+
|
22
|
+
def initialize(from_type, to_type)
|
23
|
+
@from_type = from_type
|
24
|
+
@to_type = to_type
|
25
|
+
@gem_required = nil
|
26
|
+
@conversion_fn = nil
|
27
|
+
end
|
28
|
+
|
29
|
+
# DSL methods
|
30
|
+
def require_gem(gem_name) @gem_required = gem_name end
|
31
|
+
def render(&block) @conversion_fn = block end
|
32
|
+
|
33
|
+
# Instance methods
|
34
|
+
def signature() { @from_type => @to_type } end
|
35
|
+
def content_type
|
36
|
+
case @to_type
|
37
|
+
when :css then "text/css"
|
38
|
+
when :js then "application/javascript"
|
39
|
+
else
|
40
|
+
raise Error, "No known content-type for #{@to_type}."
|
41
|
+
end
|
42
|
+
end
|
43
|
+
def convert(file_contents) @conversion_fn.call(file_contents) end
|
44
|
+
def require_dependency
|
45
|
+
return unless @gem_required
|
46
|
+
begin
|
47
|
+
require @gem_required
|
48
|
+
rescue LoadError => e
|
49
|
+
raise "Tried to load conversion for #{signature.inspect}, but failed to load the #{@gem_required} gem"
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def verify
|
54
|
+
unless [@from_type, @to_type].all? { |s| s.is_a? Symbol }
|
55
|
+
raise Error, "Expecting symbol key/value but got #{from_and_to.inspect}"
|
56
|
+
end
|
57
|
+
unless @conversion_fn
|
58
|
+
raise Error, "Must provide a conversion function with convert { |file_contents| ... }."
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
# Define built-in conversions
|
64
|
+
Conversion.create :scss => :css do
|
65
|
+
require_gem "sass"
|
66
|
+
render { |file_contents| Sass::Engine.new(file_contents, :syntax => :scss).render }
|
67
|
+
end
|
68
|
+
|
69
|
+
Conversion.create :sass => :css do
|
70
|
+
require_gem "sass"
|
71
|
+
render { |file_contents| Sass::Engine.new(file_contents, :syntax => :sass).render }
|
72
|
+
end
|
73
|
+
|
74
|
+
Conversion.create :coffee => :js do
|
75
|
+
require_gem "coffee-script"
|
76
|
+
render { |file_contents| CoffeeScript.compile(file_contents) }
|
77
|
+
end
|
78
|
+
end
|
data/lib/pinion/error.rb
ADDED
@@ -0,0 +1,171 @@
|
|
1
|
+
#require "fssm"
|
2
|
+
|
3
|
+
require "pinion/error"
|
4
|
+
require "pinion/conversion"
|
5
|
+
require "time"
|
6
|
+
require "set"
|
7
|
+
|
8
|
+
module Pinion
|
9
|
+
class Server
|
10
|
+
#CachedFile = Struct.new :from_path, :to_path, :compiled_contents, :mtime
|
11
|
+
Asset = Struct.new :from_path, :to_path, :from_type, :to_type, :compiled_contents, :length, :mtime,
|
12
|
+
:content_type
|
13
|
+
Watch = Struct.new :path, :from_type, :to_type, :conversion
|
14
|
+
|
15
|
+
def initialize
|
16
|
+
@running = false
|
17
|
+
@watch_directories = []
|
18
|
+
@watches = []
|
19
|
+
@cached_assets = {}
|
20
|
+
@conversions_used = Set.new
|
21
|
+
end
|
22
|
+
|
23
|
+
def convert(from_and_to, &block)
|
24
|
+
unless from_and_to.is_a?(Hash) && from_and_to.size == 1
|
25
|
+
raise Error, "Unexpected argument to convert: #{from_and_to.inspect}"
|
26
|
+
end
|
27
|
+
from, to = from_and_to.to_a[0]
|
28
|
+
unless [from, to].all? { |s| s.is_a? Symbol }
|
29
|
+
raise Error, "Expecting symbols in this hash #{from_and_to.inspect}"
|
30
|
+
end
|
31
|
+
if block_given?
|
32
|
+
# Save new conversion type (this might overwrite an implicit or previously defined conversion)
|
33
|
+
Conversion.create(from_and_to) do
|
34
|
+
render { |file_contents| block.call(file_contents) }
|
35
|
+
end
|
36
|
+
else
|
37
|
+
unless Conversion[from_and_to]
|
38
|
+
raise Error, "No immplicit conversion for #{from_and_to.inspect}. Must provide a conversion block."
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def watch(path)
|
44
|
+
raise Error, "#{path} is not a directory." unless File.directory? path
|
45
|
+
@watch_directories << path
|
46
|
+
end
|
47
|
+
|
48
|
+
# Boilerplate mostly stolen from sprockets
|
49
|
+
# https://github.com/sstephenson/sprockets/blob/master/lib/sprockets/server.rb
|
50
|
+
def call(env)
|
51
|
+
puts "********************** CALLING CALL"
|
52
|
+
puts "\033[01;34m>>>> env['PATH_INFO']: #{env["PATH_INFO"].inspect}\e[m"
|
53
|
+
puts "************************************"
|
54
|
+
start unless @running
|
55
|
+
|
56
|
+
# Avoid modifying the session state, don't set cookies, etc
|
57
|
+
env["rack.session.options"] ||= {}
|
58
|
+
env["rack.session.options"].merge! :defer => true, :skip => true
|
59
|
+
|
60
|
+
path = env["PATH_INFO"].to_s.sub(%r[^/], "")
|
61
|
+
|
62
|
+
if path.include? ".."
|
63
|
+
return with_content_length([403, { "Content-Type" => "text/plain" }, ["Forbidden"]])
|
64
|
+
end
|
65
|
+
|
66
|
+
asset = get_asset(path)
|
67
|
+
if asset
|
68
|
+
headers = {
|
69
|
+
"Content-Type" => asset.content_type,
|
70
|
+
"Content-Length" => asset.length.to_s,
|
71
|
+
# TODO: set a long cache in prod mode when implemented
|
72
|
+
"Cache-Control" => "public, must-revalidate",
|
73
|
+
"Last-Modified" => asset.mtime.httpdate,
|
74
|
+
}
|
75
|
+
return [200, headers, []] if env["REQUEST_METHOD"] == "HEAD"
|
76
|
+
[200, headers, asset.compiled_contents]
|
77
|
+
else
|
78
|
+
with_content_length([404, { "Content-Type" => "text/plain" }, ["Not found"]])
|
79
|
+
end
|
80
|
+
rescue Exception => e
|
81
|
+
# TODO: logging
|
82
|
+
STDERR.puts "Error compiling #{path}:"
|
83
|
+
STDERR.puts "#{e.class.name}: #{e.message}"
|
84
|
+
# TODO: render nice custom errors in the browser
|
85
|
+
raise
|
86
|
+
end
|
87
|
+
|
88
|
+
private
|
89
|
+
|
90
|
+
def get_asset(to_path)
|
91
|
+
asset = @cached_assets[to_path]
|
92
|
+
if asset
|
93
|
+
mtime = asset.mtime
|
94
|
+
latest = latest_mtime_of_type(asset.from_type)
|
95
|
+
if latest > mtime
|
96
|
+
invalidate_all_assets_of_type(asset.from_type)
|
97
|
+
return get_asset(to_path)
|
98
|
+
end
|
99
|
+
puts "\033[01;34m>>>> Using cached asset at to_path: #{to_path.inspect}\e[m"
|
100
|
+
else
|
101
|
+
begin
|
102
|
+
asset = get_uncached_asset(to_path)
|
103
|
+
rescue Error
|
104
|
+
return nil
|
105
|
+
end
|
106
|
+
@cached_assets[to_path] = asset
|
107
|
+
end
|
108
|
+
asset
|
109
|
+
end
|
110
|
+
|
111
|
+
def latest_mtime_of_type(type)
|
112
|
+
latest = Time.at(0)
|
113
|
+
@watch_directories.each do |directory|
|
114
|
+
Dir[File.join(directory, "**/*.#{type}")].each do |file|
|
115
|
+
mtime = File.stat(file).mtime
|
116
|
+
latest = mtime if mtime > latest
|
117
|
+
end
|
118
|
+
end
|
119
|
+
latest
|
120
|
+
end
|
121
|
+
|
122
|
+
def get_uncached_asset(to_path)
|
123
|
+
from_path, conversion = find_source_file_and_conversion(to_path)
|
124
|
+
# If we reach this point we've found the asset we're going to compile
|
125
|
+
conversion.require_dependency unless @conversions_used.include? conversion
|
126
|
+
@conversions_used << conversion
|
127
|
+
# TODO: log at info: compiling asset ...
|
128
|
+
contents = conversion.convert(File.read(from_path))
|
129
|
+
length = File.stat(from_path).size
|
130
|
+
mtime = latest_mtime_of_type(conversion.from_type)
|
131
|
+
content_type = conversion.content_type
|
132
|
+
return Asset.new from_path, to_path, conversion.from_type, conversion.to_type,
|
133
|
+
[contents], contents.length, mtime, content_type
|
134
|
+
end
|
135
|
+
|
136
|
+
def find_source_file_and_conversion(to_path)
|
137
|
+
path, dot, suffix = to_path.rpartition(".")
|
138
|
+
conversions = Conversion.conversions_for(suffix.to_sym)
|
139
|
+
raise Error, "No conversion for for #{to_path}" if conversions.empty?
|
140
|
+
@watch_directories.each do |directory|
|
141
|
+
conversions.each do |conversion|
|
142
|
+
Dir[File.join(directory, "#{path}.#{conversion.from_type}")].each do |from_path|
|
143
|
+
return [from_path, conversion]
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
147
|
+
raise Error, "No source file found for #{to_path}"
|
148
|
+
end
|
149
|
+
|
150
|
+
def invalidate_all_assets_of_type(type)
|
151
|
+
@cached_assets.delete_if { |to_path, asset| asset.from_type == type }
|
152
|
+
end
|
153
|
+
|
154
|
+
def with_content_length(response)
|
155
|
+
status, headers, body = response
|
156
|
+
[status, headers.merge({ "Content-Length" => Rack::Utils.bytesize(body).to_s }), body]
|
157
|
+
end
|
158
|
+
|
159
|
+
def update_asset(asset)
|
160
|
+
end
|
161
|
+
|
162
|
+
def start
|
163
|
+
@running = true
|
164
|
+
# TODO: mad threadz
|
165
|
+
# Start a thread with an FSSM watch on each directory. Upon detecting a change to a compiled file that
|
166
|
+
# is a dependency of any asset in @required_assets, call update_asset for each affected asset.
|
167
|
+
#
|
168
|
+
# There are some tricky threading issues here.
|
169
|
+
end
|
170
|
+
end
|
171
|
+
end
|
data/lib/pinion.rb
ADDED
metadata
ADDED
@@ -0,0 +1,65 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: pinion
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Caleb Spare
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2012-02-27 00:00:00.000000000Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: rack
|
16
|
+
requirement: &22405020 !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ~>
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '1.0'
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: *22405020
|
25
|
+
description: ! 'Pinion is a Rack application that you can use to compile and serve
|
26
|
+
assets (such as Javascript and CSS).
|
27
|
+
|
28
|
+
'
|
29
|
+
email:
|
30
|
+
- cespare@gmail.com
|
31
|
+
executables: []
|
32
|
+
extensions: []
|
33
|
+
extra_rdoc_files: []
|
34
|
+
files:
|
35
|
+
- README.md
|
36
|
+
- lib/pinion/conversion.rb
|
37
|
+
- lib/pinion/error.rb
|
38
|
+
- lib/pinion/server.rb
|
39
|
+
- lib/pinion/version.rb
|
40
|
+
- lib/pinion.rb
|
41
|
+
homepage: https://github.com/ooyala/pinion
|
42
|
+
licenses: []
|
43
|
+
post_install_message:
|
44
|
+
rdoc_options: []
|
45
|
+
require_paths:
|
46
|
+
- lib
|
47
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
48
|
+
none: false
|
49
|
+
requirements:
|
50
|
+
- - ! '>='
|
51
|
+
- !ruby/object:Gem::Version
|
52
|
+
version: '0'
|
53
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
54
|
+
none: false
|
55
|
+
requirements:
|
56
|
+
- - ! '>='
|
57
|
+
- !ruby/object:Gem::Version
|
58
|
+
version: '0'
|
59
|
+
requirements: []
|
60
|
+
rubyforge_project: pinion
|
61
|
+
rubygems_version: 1.8.10
|
62
|
+
signing_key:
|
63
|
+
specification_version: 3
|
64
|
+
summary: Pinion compiles and serves your assets
|
65
|
+
test_files: []
|