pinion 0.1.2 → 0.1.3
Sign up to get free protection for your applications and to get access to all the features.
- data/README.md +33 -18
- data/lib/pinion/asset.rb +23 -0
- data/lib/pinion/conversion.rb +18 -9
- data/lib/pinion/directory_watcher.rb +37 -0
- data/lib/pinion/server.rb +57 -71
- data/lib/pinion/version.rb +1 -1
- metadata +8 -12
data/README.md
CHANGED
@@ -15,13 +15,15 @@ simple and lightweight solution. It is driven by these core goals (bold goals ar
|
|
15
15
|
* **No added syntax to your assets (e.g. no `//= require my_other_asset`)**
|
16
16
|
* **Recompile all compiled assets when they change (or dependencies change) in development and set mtimes**
|
17
17
|
* Recompile asynchronously from requests (no polling allowed)
|
18
|
-
* Compile assets one time in production
|
18
|
+
* **Compile assets one time in production**
|
19
19
|
|
20
20
|
Installation
|
21
21
|
============
|
22
22
|
|
23
23
|
$ gem install pinion
|
24
24
|
|
25
|
+
You should add pinion to your project's Gemfile.
|
26
|
+
|
25
27
|
Usage
|
26
28
|
=====
|
27
29
|
|
@@ -32,37 +34,50 @@ The easiest way to use Pinion is to map your desired asset mount point to a `Pin
|
|
32
34
|
require "pinion"
|
33
35
|
require "your_app.rb"
|
34
36
|
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
37
|
+
MOUNT_POINT = "/assets"
|
38
|
+
pinion = Pinion::Server.new(MOUNT_POINT)
|
39
|
+
# Tell Pinion each type of conversion it should perform
|
40
|
+
pinion.convert :scss => :css # Sass and Coffeescript will just work if you have the gems installed
|
41
|
+
pinion.convert :coffee => :js # Conversion types correspond to file extensions. .coffee -> .js
|
42
|
+
pinion.convert :styl => :css do |file_contents|
|
43
|
+
Stylus.compile file_contents # Requires the stylus gem
|
44
|
+
end
|
45
|
+
# Tell Pinion the paths to watch
|
46
|
+
pinion.watch "public/javascripts"
|
47
|
+
pinion.watch "public/scss"
|
48
|
+
pinion.watch "public/stylus"
|
49
|
+
|
50
|
+
map MOUNT_POINT do
|
47
51
|
# Boom
|
48
|
-
run
|
52
|
+
run pinion
|
49
53
|
end
|
50
54
|
|
51
55
|
map "/" do
|
52
|
-
|
56
|
+
# You should pass pinion into your app in order to use its helper methods.
|
57
|
+
run Your::App.new(pinion)
|
53
58
|
end
|
54
59
|
```
|
55
60
|
|
61
|
+
In your app, you will use pinion's helper methods to construct urls:
|
62
|
+
|
63
|
+
``` erb
|
64
|
+
<head>
|
65
|
+
<title>My App</title>
|
66
|
+
<link type="text/css" rel="stylesheet" href="<%= pinion.asset_url("/assets/style.css") %>" />
|
67
|
+
<!-- Shorthand equivalent -->
|
68
|
+
<%= pinion.css_url("style.css") %>
|
69
|
+
</head>
|
70
|
+
```
|
71
|
+
|
56
72
|
Notes
|
57
73
|
-----
|
58
74
|
|
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
75
|
* Currently, Pinion sidesteps the dependency question by invalidating its cache of each file of a particular
|
62
76
|
type (say, all `.scss` files) when any such source file is changed.
|
63
77
|
* The order that paths are added to the watch list is a priority order in case of conflicting assets. (For
|
64
78
|
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
|
79
|
+
and `foo/baz/style.scss` exist, then `foo/bar/style.scss` will be used if a request occurs for
|
80
|
+
`/style.css`.)
|
66
81
|
|
67
82
|
You can see an example app using Pinion and Sinatra in the `example/` directory.
|
68
83
|
|
data/lib/pinion/asset.rb
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
require "digest/md5"
|
2
|
+
|
3
|
+
module Pinion
|
4
|
+
class Asset
|
5
|
+
attr_reader :uncompiled_path, :compiled_path, :from_type, :to_type, :compiled_contents, :length, :mtime,
|
6
|
+
:content_type, :checksum
|
7
|
+
|
8
|
+
def initialize(uncompiled_path, compiled_path, conversion, mtime)
|
9
|
+
@uncompiled_path = uncompiled_path
|
10
|
+
@compiled_path = compiled_path
|
11
|
+
@from_type = conversion.from_type
|
12
|
+
@to_type = conversion.to_type
|
13
|
+
@compiled_contents = conversion.convert(File.read(uncompiled_path))
|
14
|
+
@length = Rack::Utils.bytesize(@compiled_contents)
|
15
|
+
@mtime = mtime
|
16
|
+
@content_type = conversion.content_type
|
17
|
+
@checksum = Digest::MD5.hexdigest(@compiled_contents)
|
18
|
+
end
|
19
|
+
|
20
|
+
# Allow the Asset to be served as a rack response body
|
21
|
+
def each() yield @compiled_contents end
|
22
|
+
end
|
23
|
+
end
|
data/lib/pinion/conversion.rb
CHANGED
@@ -22,6 +22,7 @@ module Pinion
|
|
22
22
|
attr_reader :from_type, :to_type, :gem_required
|
23
23
|
|
24
24
|
def initialize(from_type, to_type)
|
25
|
+
@loaded = false
|
25
26
|
@from_type = from_type
|
26
27
|
@to_type = to_type
|
27
28
|
@gem_required = nil
|
@@ -45,16 +46,11 @@ module Pinion
|
|
45
46
|
raise Error, "No known content-type for #{@to_type}."
|
46
47
|
end
|
47
48
|
end
|
48
|
-
def convert(file_contents)
|
49
|
-
|
50
|
-
|
51
|
-
return unless @gem_required
|
52
|
-
begin
|
53
|
-
require @gem_required
|
54
|
-
rescue LoadError => e
|
55
|
-
raise "Tried to load conversion for #{signature.inspect}, but failed to load the #{@gem_required} gem"
|
56
|
-
end
|
49
|
+
def convert(file_contents)
|
50
|
+
require_dependency
|
51
|
+
@conversion_fn.call(file_contents, @environment)
|
57
52
|
end
|
53
|
+
def add_watch_directory(path) @watch_fn.call(path, @environment) end
|
58
54
|
|
59
55
|
def verify
|
60
56
|
unless [@from_type, @to_type].all? { |s| s.is_a? Symbol }
|
@@ -64,6 +60,19 @@ module Pinion
|
|
64
60
|
raise Error, "Must provide a conversion function with convert { |file_contents| ... }."
|
65
61
|
end
|
66
62
|
end
|
63
|
+
|
64
|
+
private
|
65
|
+
|
66
|
+
def require_dependency
|
67
|
+
return if @loaded
|
68
|
+
@loaded = true
|
69
|
+
return unless @gem_required
|
70
|
+
begin
|
71
|
+
require @gem_required
|
72
|
+
rescue LoadError => e
|
73
|
+
raise "Tried to load conversion for #{signature.inspect}, but failed to load the #{@gem_required} gem"
|
74
|
+
end
|
75
|
+
end
|
67
76
|
end
|
68
77
|
|
69
78
|
# Define built-in conversions
|
@@ -0,0 +1,37 @@
|
|
1
|
+
require "set"
|
2
|
+
require "time"
|
3
|
+
|
4
|
+
module Pinion
|
5
|
+
class DirectoryWatcher
|
6
|
+
def initialize(root = ".")
|
7
|
+
@root = root
|
8
|
+
@watch_directories = Set.new
|
9
|
+
end
|
10
|
+
|
11
|
+
def <<(directory) @watch_directories << File.join(@root, directory) end
|
12
|
+
|
13
|
+
def glob(pattern, &block)
|
14
|
+
enumerator = Enumerator.new do |yielder|
|
15
|
+
@watch_directories.each do |directory|
|
16
|
+
Dir.glob(File.join(directory, pattern)) { |filename| yielder.yield filename }
|
17
|
+
end
|
18
|
+
end
|
19
|
+
enumerator.each(&block)
|
20
|
+
end
|
21
|
+
|
22
|
+
def find(path)
|
23
|
+
@watch_directories.each do |directory|
|
24
|
+
result = File.join(directory, path)
|
25
|
+
return result if File.file? result
|
26
|
+
end
|
27
|
+
nil
|
28
|
+
end
|
29
|
+
|
30
|
+
def latest_mtime_with_suffix(suffix)
|
31
|
+
pattern = "**/*#{DirectoryWatcher.sanitize_for_glob(suffix)}"
|
32
|
+
glob(pattern).reduce(Time.at(0)) { |latest, path| [latest, File.stat(path).mtime].max }
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.sanitize_for_glob(pattern) pattern.gsub(/[\*\?\[\]\{\}]/) { |match| "\\#{match}" } end
|
36
|
+
end
|
37
|
+
end
|
data/lib/pinion/server.rb
CHANGED
@@ -1,23 +1,19 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
require "pinion/error"
|
1
|
+
require "pinion/asset"
|
4
2
|
require "pinion/conversion"
|
5
|
-
require "
|
6
|
-
require "
|
3
|
+
require "pinion/directory_watcher"
|
4
|
+
require "pinion/error"
|
7
5
|
|
8
6
|
module Pinion
|
9
7
|
class Server
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
@
|
16
|
-
@watch_directories = []
|
17
|
-
@watches = []
|
8
|
+
# TODO: is there a way to figure out the mount point ourselves? The only way I can find would be to wait
|
9
|
+
# for a request and compare REQUEST_PATH to PATH_INFO, but that's super hacky and won't work anyway
|
10
|
+
# because we need that information before requests are handled due to #asset_url
|
11
|
+
def initialize(mount_point)
|
12
|
+
@mount_point = mount_point
|
13
|
+
@watcher = DirectoryWatcher.new
|
18
14
|
@cached_assets = {}
|
19
|
-
@conversions_used = Set.new
|
20
15
|
@file_server = Rack::File.new(Dir.pwd)
|
16
|
+
@environment = (defined?(RACK_ENV) && RACK_ENV) || ENV["RACK_ENV"] || "development"
|
21
17
|
end
|
22
18
|
|
23
19
|
def convert(from_and_to, &block)
|
@@ -42,15 +38,13 @@ module Pinion
|
|
42
38
|
|
43
39
|
def watch(path)
|
44
40
|
raise Error, "#{path} is not a directory." unless File.directory? path
|
45
|
-
@
|
41
|
+
@watcher << path
|
46
42
|
Conversion.add_watch_directory path
|
47
43
|
end
|
48
44
|
|
49
45
|
# Boilerplate mostly stolen from sprockets
|
50
46
|
# https://github.com/sstephenson/sprockets/blob/master/lib/sprockets/server.rb
|
51
47
|
def call(env)
|
52
|
-
start unless @running
|
53
|
-
|
54
48
|
# Avoid modifying the session state, don't set cookies, etc
|
55
49
|
env["rack.session.options"] ||= {}
|
56
50
|
env["rack.session.options"].merge! :defer => true, :skip => true
|
@@ -62,7 +56,12 @@ module Pinion
|
|
62
56
|
return [403, { "Content-Type" => "text/plain", "Content-Length" => "9" }, ["Forbidden"]]
|
63
57
|
end
|
64
58
|
|
65
|
-
|
59
|
+
# Pull out the md5sum if it's part of the given path
|
60
|
+
# e.g. foo/bar-a95c53a7a0f5f492a74499e70578d150.js -> a95c53a7a0f5f492a74499e70578d150
|
61
|
+
checksum_tag = path[/-([\da-f]{32})\..+$/, 1]
|
62
|
+
path.sub!("-#{checksum_tag}", "") if checksum_tag
|
63
|
+
|
64
|
+
real_file = @watcher.find path
|
66
65
|
if real_file
|
67
66
|
# Total hack; this is probably a big misuse of Rack::File but I don't want to have to reproduce a lot
|
68
67
|
# of its logic
|
@@ -71,17 +70,23 @@ module Pinion
|
|
71
70
|
return @file_server.call(env)
|
72
71
|
end
|
73
72
|
|
74
|
-
asset =
|
73
|
+
asset = find_asset(path)
|
74
|
+
|
75
|
+
# If the ETag matches, give a 304
|
76
|
+
return [304, {}, []] if env["HTTP_IF_NONE_MATCH"] == %Q["#{asset.checksum}"]
|
77
|
+
|
75
78
|
if asset
|
79
|
+
# Cache for a year in production; don't cache in dev
|
80
|
+
cache_policy = checksum_tag ? "max-age=31536000" : "must-revalidate"
|
76
81
|
headers = {
|
77
82
|
"Content-Type" => asset.content_type,
|
78
83
|
"Content-Length" => asset.length.to_s,
|
79
|
-
|
80
|
-
"Cache-Control" => "public,
|
84
|
+
"ETag" => %Q["#{asset.checksum}"],
|
85
|
+
"Cache-Control" => "public, #{cache_policy}",
|
81
86
|
"Last-Modified" => asset.mtime.httpdate,
|
82
87
|
}
|
83
88
|
return [200, headers, []] if env["REQUEST_METHOD"] == "HEAD"
|
84
|
-
[200, headers, asset
|
89
|
+
[200, headers, asset]
|
85
90
|
else
|
86
91
|
[404, { "Content-Type" => "text/plain", "Content-Length" => "9" }, ["Not found"]]
|
87
92
|
end
|
@@ -93,28 +98,40 @@ module Pinion
|
|
93
98
|
raise
|
94
99
|
end
|
95
100
|
|
96
|
-
|
101
|
+
# Helper methods for an application to generate urls (with fingerprints in production)
|
102
|
+
def asset_url(path)
|
103
|
+
path.sub!(%r[^(#{@mount_point})?/?], "")
|
104
|
+
mounted_path = "#{@mount_point}/#{path}"
|
97
105
|
|
98
|
-
|
99
|
-
@
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
106
|
+
# TODO: Change the real file behavior if I replace the use of Rack::File above
|
107
|
+
return mounted_path if @watcher.find(path)
|
108
|
+
|
109
|
+
return mounted_path unless @environment == "production"
|
110
|
+
|
111
|
+
# Add on a checksum tag in production
|
112
|
+
asset = find_asset(path)
|
113
|
+
raise "Error: no such asset available: #{path}" unless asset
|
114
|
+
mounted_path, dot, extension = mounted_path.rpartition(".")
|
115
|
+
return mounted_path if dot.empty?
|
116
|
+
"#{mounted_path}-#{asset.checksum}.#{extension}"
|
104
117
|
end
|
118
|
+
def css_url(path) %Q{<link type="text/css" rel="stylesheet" href="#{asset_url(path)}" />} end
|
119
|
+
def js_url(path) %Q{<script type="application/javascript" src="#{asset_url(path)}"></script>} end
|
105
120
|
|
106
|
-
|
121
|
+
private
|
122
|
+
|
123
|
+
def find_asset(to_path)
|
107
124
|
asset = @cached_assets[to_path]
|
108
125
|
if asset
|
109
126
|
mtime = asset.mtime
|
110
|
-
latest =
|
127
|
+
latest = @watcher.latest_mtime_with_suffix(asset.from_type.to_s)
|
111
128
|
if latest > mtime
|
112
129
|
invalidate_all_assets_of_type(asset.from_type)
|
113
|
-
return
|
130
|
+
return find_asset(to_path)
|
114
131
|
end
|
115
132
|
else
|
116
133
|
begin
|
117
|
-
asset =
|
134
|
+
asset = find_uncached_asset(to_path)
|
118
135
|
rescue Error
|
119
136
|
return nil
|
120
137
|
end
|
@@ -123,41 +140,22 @@ module Pinion
|
|
123
140
|
asset
|
124
141
|
end
|
125
142
|
|
126
|
-
def
|
127
|
-
latest = Time.at(0)
|
128
|
-
@watch_directories.each do |directory|
|
129
|
-
Dir[File.join(directory, "**/*.#{type}")].each do |file|
|
130
|
-
mtime = File.stat(file).mtime
|
131
|
-
latest = mtime if mtime > latest
|
132
|
-
end
|
133
|
-
end
|
134
|
-
latest
|
135
|
-
end
|
136
|
-
|
137
|
-
def get_uncached_asset(to_path)
|
143
|
+
def find_uncached_asset(to_path)
|
138
144
|
from_path, conversion = find_source_file_and_conversion(to_path)
|
139
145
|
# If we reach this point we've found the asset we're going to compile
|
140
|
-
conversion.require_dependency unless @conversions_used.include? conversion
|
141
|
-
@conversions_used << conversion
|
142
146
|
# TODO: log at info: compiling asset ...
|
143
|
-
|
144
|
-
|
145
|
-
mtime = latest_mtime_of_type(conversion.from_type)
|
146
|
-
content_type = conversion.content_type
|
147
|
-
return Asset.new from_path, to_path, conversion.from_type, conversion.to_type,
|
148
|
-
[contents], contents.length, mtime, content_type
|
147
|
+
mtime = @watcher.latest_mtime_with_suffix(conversion.to_type.to_s)
|
148
|
+
Asset.new from_path, to_path, conversion, mtime
|
149
149
|
end
|
150
150
|
|
151
151
|
def find_source_file_and_conversion(to_path)
|
152
152
|
path, dot, suffix = to_path.rpartition(".")
|
153
153
|
conversions = Conversion.conversions_for(suffix.to_sym)
|
154
154
|
raise Error, "No conversion for for #{to_path}" if conversions.empty?
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
end
|
160
|
-
end
|
155
|
+
conversions.each do |conversion|
|
156
|
+
filename = "#{path}.#{conversion.from_type}"
|
157
|
+
from_path = @watcher.find filename
|
158
|
+
return [from_path, conversion] if from_path
|
161
159
|
end
|
162
160
|
raise Error, "No source file found for #{to_path}"
|
163
161
|
end
|
@@ -170,17 +168,5 @@ module Pinion
|
|
170
168
|
status, headers, body = response
|
171
169
|
[status, headers.merge({ "Content-Length" => Rack::Utils.bytesize(body).to_s }), body]
|
172
170
|
end
|
173
|
-
|
174
|
-
def update_asset(asset)
|
175
|
-
end
|
176
|
-
|
177
|
-
def start
|
178
|
-
@running = true
|
179
|
-
# TODO: mad threadz
|
180
|
-
# Start a thread with an FSSM watch on each directory. Upon detecting a change to a compiled file that
|
181
|
-
# is a dependency of any asset in @required_assets, call update_asset for each affected asset.
|
182
|
-
#
|
183
|
-
# There are some tricky threading issues here.
|
184
|
-
end
|
185
171
|
end
|
186
172
|
end
|
data/lib/pinion/version.rb
CHANGED
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: pinion
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.3
|
5
5
|
prerelease:
|
6
6
|
platform: ruby
|
7
7
|
authors:
|
@@ -9,11 +9,11 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
|
-
date: 2012-
|
12
|
+
date: 2012-04-27 00:00:00.000000000Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: rack
|
16
|
-
requirement: &
|
16
|
+
requirement: &23433260 !ruby/object:Gem::Requirement
|
17
17
|
none: false
|
18
18
|
requirements:
|
19
19
|
- - ~>
|
@@ -21,10 +21,10 @@ dependencies:
|
|
21
21
|
version: '1.0'
|
22
22
|
type: :runtime
|
23
23
|
prerelease: false
|
24
|
-
version_requirements: *
|
24
|
+
version_requirements: *23433260
|
25
25
|
- !ruby/object:Gem::Dependency
|
26
26
|
name: rake
|
27
|
-
requirement: &
|
27
|
+
requirement: &23432840 !ruby/object:Gem::Requirement
|
28
28
|
none: false
|
29
29
|
requirements:
|
30
30
|
- - ! '>='
|
@@ -32,7 +32,7 @@ dependencies:
|
|
32
32
|
version: '0'
|
33
33
|
type: :development
|
34
34
|
prerelease: false
|
35
|
-
version_requirements: *
|
35
|
+
version_requirements: *23432840
|
36
36
|
description: ! 'Pinion is a Rack application that you can use to compile and serve
|
37
37
|
assets (such as Javascript and CSS).
|
38
38
|
|
@@ -44,9 +44,11 @@ extensions: []
|
|
44
44
|
extra_rdoc_files: []
|
45
45
|
files:
|
46
46
|
- README.md
|
47
|
+
- lib/pinion/asset.rb
|
47
48
|
- lib/pinion/conversion.rb
|
48
49
|
- lib/pinion/error.rb
|
49
50
|
- lib/pinion/server.rb
|
51
|
+
- lib/pinion/directory_watcher.rb
|
50
52
|
- lib/pinion/version.rb
|
51
53
|
- lib/pinion.rb
|
52
54
|
homepage: https://github.com/ooyala/pinion
|
@@ -61,18 +63,12 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
61
63
|
- - ! '>='
|
62
64
|
- !ruby/object:Gem::Version
|
63
65
|
version: '0'
|
64
|
-
segments:
|
65
|
-
- 0
|
66
|
-
hash: -3405067065911020567
|
67
66
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
68
67
|
none: false
|
69
68
|
requirements:
|
70
69
|
- - ! '>='
|
71
70
|
- !ruby/object:Gem::Version
|
72
71
|
version: '0'
|
73
|
-
segments:
|
74
|
-
- 0
|
75
|
-
hash: -3405067065911020567
|
76
72
|
requirements: []
|
77
73
|
rubyforge_project: pinion
|
78
74
|
rubygems_version: 1.8.10
|