pinion 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -0,0 +1,3 @@
1
+ module Pinion
2
+ class Error < RuntimeError; end
3
+ end
@@ -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
@@ -0,0 +1,3 @@
1
+ module Pinion
2
+ VERSION = "0.1.0"
3
+ end
data/lib/pinion.rb ADDED
@@ -0,0 +1,2 @@
1
+ require "pinion/version"
2
+ require "pinion/server"
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: []