pinion 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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: []
|