slinky 0.2.1 → 0.4.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.
@@ -0,0 +1,371 @@
1
+ # -*- coding: utf-8 -*-
2
+ require 'pathname'
3
+
4
+ module Slinky
5
+ # extensions of files that can contain build directives
6
+ DIRECTIVE_FILES = %w{js css html haml sass scss coffee}
7
+ REQUIRE_DIRECTIVE = /^\W*(slinky_require)\((".*"|'.+'|)\)\W*$/
8
+ SCRIPTS_DIRECTIVE = /^\W*(slinky_scripts)\W*$/
9
+ STYLES_DIRECTIVE = /^\W*(slinky_styles)\W*$/
10
+ BUILD_DIRECTIVES = Regexp.union(REQUIRE_DIRECTIVE, SCRIPTS_DIRECTIVE, STYLES_DIRECTIVE)
11
+ CSS_URL_MATCHER = /url\(['"]?([^'"\/][^\s)]+\.[a-z]+)(\?\d+)?['"]?\)/
12
+
13
+ # Raised when a compilation fails for any reason
14
+ class BuildFailedError < StandardError; end
15
+ # Raised when a required file is not found.
16
+ class FileNotFoundError < StandardError; end
17
+ # Raised when there is a cycle in the dependency graph (i.e., file A
18
+ # requires file B which requires C which requires A)
19
+ class DependencyError < StandardError; end
20
+
21
+ class Manifest
22
+ attr_accessor :manifest_dir, :dir
23
+
24
+ def initialize dir, options = {}
25
+ @dir = dir
26
+ @build_to = if d = options[:build_to]
27
+ File.absolute_path(d)
28
+ else
29
+ dir
30
+ end
31
+ @manifest_dir = ManifestDir.new dir, @build_to, self
32
+ @devel = (options[:devel].nil?) ? true : options[:devel]
33
+ end
34
+
35
+ # Returns a list of all files contained in this manifest
36
+ #
37
+ # @return [ManifestFile] a list of manifest files
38
+ def files
39
+ @files = []
40
+ files_rec @manifest_dir
41
+ @files
42
+ end
43
+
44
+ # Finds the file at the given path in the manifest if one exists,
45
+ # otherwise nil.
46
+ #
47
+ # @param String path the path of the file relative to the manifest
48
+ #
49
+ # @return ManifestFile the manifest file at that path if one exists
50
+ def find_by_path path
51
+ @manifest_dir.find_by_path path
52
+ end
53
+
54
+ def scripts_string
55
+ if @devel
56
+ dependency_list.reject{|x| x.output_path.extname != ".js"}.collect{|d|
57
+ %Q\<script type="text/javascript" src="#{d.relative_output_path}"></script>\
58
+ }.join("")
59
+ else
60
+ '<script type="text/javscript" src="/scripts.js"></script>'
61
+ end
62
+ end
63
+
64
+ def compress ext, output, compressor
65
+ scripts = dependency_list.reject{|x| x.output_path.extname != ext}
66
+
67
+ s = scripts.collect{|s|
68
+ f = File.open(s.build_to.to_s, 'rb'){|f| f.read}
69
+ (block_given?) ? (yield s, f) : f
70
+ }.join("\n")
71
+
72
+ File.open(output, "w+"){|f|
73
+ f.write(compressor.compress(s))
74
+ }
75
+ scripts.collect{|s| FileUtils.rm(s.build_to)}
76
+ end
77
+
78
+ def compress_scripts
79
+ compressor = YUI::JavaScriptCompressor.new(:munge => true)
80
+ compress(".js", "#{@build_to}/scripts.js", compressor)
81
+ end
82
+
83
+ def compress_styles
84
+ compressor = YUI::CssCompressor.new()
85
+
86
+ compress(".css", "#{@build_to}/styles.css", compressor){|s, css|
87
+ css.gsub(CSS_URL_MATCHER){|url|
88
+ p = s.relative_output_path.dirname.to_s + "/#{$1}"
89
+ "url('#{p}')"
90
+ }
91
+ }
92
+ end
93
+
94
+ def styles_string
95
+ if @devel
96
+ dependency_list.reject{|x| x.output_path.extname != ".css"}.collect{|d|
97
+ %Q\<link rel="stylesheet" href="#{d.relative_output_path}" />\
98
+ }.join("")
99
+ else
100
+ '<link rel="stylesheet" href="/styles.css" />'
101
+ end
102
+ end
103
+
104
+ # Builds the directed graph representing the dependencies of all
105
+ # files in the manifest that contain a slinky_require
106
+ # declaration. The graph is represented as a list of pairs
107
+ # (required, by), each of which describes an edge.
108
+ #
109
+ # @return [[ManifestFile, ManifestFile]] the graph
110
+ def build_dependency_graph
111
+ graph = []
112
+ files.each{|mf|
113
+ mf.directives[:slinky_require].each{|rf|
114
+ required = mf.parent.find_by_path(rf)
115
+ if required
116
+ graph << [required, mf]
117
+ else
118
+ error = "Could not find file #{rf} required by #{mf.source}"
119
+ $stderr.puts error.foreground(:red)
120
+ raise FileNotFoundError.new(error)
121
+ end
122
+ } if mf.directives[:slinky_require]
123
+ }
124
+ @dependency_graph = graph
125
+ end
126
+
127
+ # Builds a list of files in topological order, so that when
128
+ # required in this order all dependencies are met. See
129
+ # http://en.wikipedia.org/wiki/Topological_sorting for more
130
+ # information.
131
+ def dependency_list
132
+ build_dependency_graph unless @dependency_graph
133
+ graph = @dependency_graph.clone
134
+ # will contain sorted elements
135
+ l = []
136
+ # start nodes, those with no incoming edges
137
+ s = @files.reject{|mf| mf.directives[:slinky_require]}
138
+ while s.size > 0
139
+ n = s.delete s.first
140
+ l << n
141
+ @files.each{|m|
142
+ e = graph.find{|e| e[0] == n && e[1] == m}
143
+ next unless e
144
+ graph.delete e
145
+ s << m unless graph.any?{|e| e[1] == m}
146
+ }
147
+ end
148
+ if graph != []
149
+ problems = graph.collect{|e| e.collect{|x| x.source}.join(" -> ")}
150
+ $stderr.puts "Dependencies #{problems.join(", ")} could not be satisfied".foreground(:red)
151
+ raise DependencyError
152
+ end
153
+ l
154
+ end
155
+
156
+ def build
157
+ @manifest_dir.build
158
+ unless @devel
159
+ compress_scripts
160
+ compress_styles
161
+ end
162
+ end
163
+
164
+ private
165
+ def files_rec md
166
+ @files += md.files
167
+ md.children.each do |c|
168
+ files_rec c
169
+ end
170
+ end
171
+ end
172
+
173
+ class ManifestDir
174
+ attr_accessor :dir, :files, :children
175
+ def initialize dir, build_dir, manifest
176
+ @dir = dir
177
+ @files = []
178
+ @children = []
179
+ @build_dir = Pathname.new(build_dir)
180
+ @manifest = manifest
181
+
182
+ Dir.glob("#{dir}/*").each do |path|
183
+ # skip the build dir
184
+ next if Pathname.new(File.absolute_path(path)) == Pathname.new(build_dir)
185
+ if File.directory? path
186
+ build_dir = (@build_dir + File.basename(path)).cleanpath
187
+ @children << ManifestDir.new(path, build_dir, manifest)
188
+ else
189
+ @files << ManifestFile.new(path, @build_dir, manifest, self)
190
+ end
191
+ end
192
+ end
193
+
194
+ # Finds the file at the given path in the directory if one exists,
195
+ # otherwise nil.
196
+ #
197
+ # @param String path the path of the file relative to the directory
198
+ #
199
+ # @return ManifestFile the manifest file at that path if one exists
200
+ def find_by_path path
201
+ components = path.to_s.split(File::SEPARATOR).reject{|x| x == ""}
202
+ case components.size
203
+ when 0
204
+ self
205
+ when 1
206
+ @files.find{|f| f.matches? components[0]}
207
+ else
208
+ child = @children.find{|d|
209
+ Pathname.new(d.dir).basename.to_s == components[0]
210
+ }
211
+ child ? child.find_by_path(components[1..-1].join(File::SEPARATOR)) : nil
212
+ end
213
+ end
214
+
215
+ def build
216
+ if !@build_dir.exist?
217
+ @build_dir.mkdir
218
+ end
219
+ (@files + @children).each{|m|
220
+ m.build
221
+ }
222
+ end
223
+ end
224
+
225
+ class ManifestFile
226
+ attr_accessor :source, :build_path
227
+ attr_reader :last_built, :directives, :parent, :manifest
228
+
229
+ def initialize source, build_path, manifest, parent = nil, options = {:devel => false}
230
+ @parent = parent
231
+ @source = source
232
+ @last_built = Time.new(0)
233
+
234
+ @cfile = Compilers.cfile_for_file(@source)
235
+
236
+ @directives = find_directives
237
+ @build_path = build_path
238
+ @manifest = manifest
239
+ @devel = true if options[:devel]
240
+ end
241
+
242
+ # Predicate which determines whether the supplied name is the same
243
+ # as the file's name, taking into account compiled file
244
+ # extensions. For example, if mf refers to "/tmp/test/hello.sass",
245
+ # `mf.matches? "hello.sass"` and `mf.matches? "hello.css"` should
246
+ # both return true.
247
+ #
248
+ # @param [String] a filename
249
+ # @return [Bool] True if the filename matches, false otherwise
250
+ def matches? s
251
+ name = Pathname.new(@source).basename.to_s
252
+ name == s || output_path.basename.to_s == s
253
+ end
254
+
255
+ # Returns the path to which this file should be output. This is
256
+ # equal to the source path unless the file needs to be compiled,
257
+ # in which case the extension returned is the output extension
258
+ #
259
+ # @return Pathname the output path
260
+ def output_path
261
+ if @cfile
262
+ Pathname.new(@source).sub_ext ".#{@cfile.output_ext}"
263
+ else
264
+ Pathname.new(@source)
265
+ end
266
+ end
267
+
268
+ # Returns the output path relative to the manifest directory
269
+ def relative_output_path
270
+ output_path.relative_path_from Pathname.new(@manifest.dir)
271
+ end
272
+
273
+ # Looks through the file for directives
274
+ # @return {Symbol => [String]} the directives in the file
275
+ def find_directives
276
+ _, _, ext = @source.match(EXTENSION_REGEX).to_a
277
+ directives = {}
278
+ # check if this file might include directives
279
+ if @cfile || DIRECTIVE_FILES.include?(ext)
280
+ # make sure the file isn't too big to scan
281
+ stat = File::Stat.new(@source)
282
+ if stat.size < 1024*1024
283
+ File.open(@source) {|f|
284
+ matches = f.read.scan(BUILD_DIRECTIVES).to_a
285
+ matches.each{|slice|
286
+ key, value = slice.compact
287
+ directives[key.to_sym] ||= []
288
+ directives[key.to_sym] << value[1..-2] if value
289
+ }
290
+ } rescue nil
291
+ end
292
+ end
293
+
294
+ directives
295
+ end
296
+
297
+ # If there are any build directives for this file, the file is
298
+ # read and the directives are handled appropriately and a new file
299
+ # is written to a temp location.
300
+ #
301
+ # @return String the path of the de-directivefied file
302
+ def handle_directives path, to = nil
303
+ if @directives.size > 0
304
+ begin
305
+ out = File.read(path)
306
+ out.gsub!(REQUIRE_DIRECTIVE, "")
307
+ out.gsub!(SCRIPTS_DIRECTIVE, @manifest.scripts_string)
308
+ out.gsub!(STYLES_DIRECTIVE, @manifest.styles_string)
309
+ to = to || Tempfile.new("slinky").path
310
+ File.open(to, "w+"){|f|
311
+ f.write(out)
312
+ }
313
+ to
314
+ rescue
315
+ nil
316
+ end
317
+ else
318
+ path
319
+ end
320
+ end
321
+
322
+ # Takes a path and compiles the file if necessary.
323
+ # @return Pathname the path of the compiled file, or the original
324
+ # path if compiling is not necessary
325
+ def compile path, to = nil
326
+ if @cfile
327
+ cfile = @cfile.clone
328
+ cfile.source = path
329
+ cfile.print_name = @source
330
+ cfile.output_path = to if to
331
+ cfile.file do |cpath, _, _, _|
332
+ path = cpath
333
+ end
334
+ end
335
+ path ? Pathname.new(path) : nil
336
+ end
337
+
338
+ # Gets manifest file ready for serving or building by handling the
339
+ # directives and compiling the file if neccesary.
340
+ # @param String path to which the file should be compiled
341
+ #
342
+ # @return String the path of the processed file, ready for serving
343
+ def process to = nil
344
+ # mangle file appropriately
345
+ handle_directives (compile @source), to
346
+ end
347
+
348
+ # Path to which the file will be built
349
+ def build_to
350
+ Pathname.new(@build_path) + output_path.basename
351
+ end
352
+
353
+ # Builds the file by handling and compiling it and then copying it
354
+ # to the build path
355
+ def build
356
+ if !File.exists? @build_path
357
+ FileUtils.mkdir_p(@build_path)
358
+ end
359
+ to = build_to
360
+ path = process to
361
+
362
+ if !path
363
+ raise BuildFailedError
364
+ elsif path != to
365
+ FileUtils.cp(path.to_s, to.to_s)
366
+ @last_built = Time.now
367
+ end
368
+ to
369
+ end
370
+ end
371
+ end
data/lib/slinky/runner.rb CHANGED
@@ -1,12 +1,61 @@
1
1
  module Slinky
2
2
  class Runner
3
- class << self
4
- def run
5
- EM::run {
6
- EM::start_server "0.0.0.0", 5323, Slinky::Server
7
- puts "Started static file server on port 5323"
8
- }
3
+ COMMANDS = %w{start build}
4
+
5
+ def initialize argv
6
+ @argv = argv
7
+ @options = {
8
+ :build_dir => "build",
9
+ :port => 5323,
10
+ :src_dir => "."
11
+ }
12
+
13
+ parser.parse! @argv
14
+ @command = @argv.shift
15
+ @arguments = @argv
16
+ end
17
+
18
+ def version
19
+ root = File.expand_path(File.dirname(__FILE__))
20
+ File.open("#{root}/../../VERSION"){|f|
21
+ puts "slinky #{f.read.strip}"
22
+ }
23
+ exit
24
+ end
25
+
26
+ def parser
27
+ @parser ||= OptionParser.new do |opts|
28
+ opts.banner = "Usage: slinky [options] #{COMMANDS.join('|')}"
29
+ opts.on("-v", "--version", "Outputs current version number and exits"){ version }
30
+ opts.on("-o DIR", "--build-dir DIR", "Directory to which the site will be built.", "Use in conjunction with the 'build' command."){|dir| @options[:build_dir] = File.expand_path(dir)}
31
+ opts.on("-p PORT", "--port PORT", "Port to run on (default: #{@options[:port]})"){|p| @options[:port] = p.to_i}
32
+ opts.on("-s DIR", "--src-dir DIR", "Directory containing project source"){|p| @options[:src_dir] = p}
9
33
  end
10
- end
34
+ end
35
+
36
+ def run
37
+ case @command
38
+ when "start" then command_start
39
+ when "build" then command_build
40
+ when nil
41
+ abort "Must provide a command (one of #{COMMANDS.join(', ')})"
42
+ else
43
+ abort "Unknown command: #{@command}. Must be on of #{COMMANDS.join(', ')}."
44
+ end
45
+ end
46
+
47
+ def command_start
48
+ Signal.trap('INT') { puts "Slinky fading away ... "; exit(0); }
49
+
50
+ EM::run {
51
+ Slinky::Server.dir = @options[:src_dir]
52
+ EM::start_server "0.0.0.0", @options[:port], Slinky::Server
53
+ puts "Started static file server on port #{@options[:port]}"
54
+ }
55
+ end
56
+
57
+ def command_build
58
+ Builder.build(@options[:src_dir], @options[:build_dir])
59
+ end
11
60
  end
12
61
  end
data/lib/slinky/server.rb CHANGED
@@ -1,123 +1,72 @@
1
1
  module Slinky
2
- CONTENT_TYPES = {
3
- 'html' => 'text/html',
4
- 'js' => 'application/x-javascript',
5
- 'css' => 'text/css'
6
- }
7
-
8
- EXTENSION_REGEX = /(.+)\.(\w+)/
9
-
10
- class Server < EventMachine::Connection
2
+ module Server
11
3
  include EM::HttpServer
12
4
 
13
- @compilers = []
14
- @compilers_by_ext = {}
15
-
16
- @files = {}
17
-
18
- class << self
19
- def register_compiler klass, options
20
- options[:klass] = klass
21
- @compilers << options
22
- options[:outputs].each do |output|
23
- @compilers_by_ext[output] ||= []
24
- @compilers_by_ext[output] << options
25
- end
26
- end
5
+ # Sets the root directory from which files should be served
6
+ def self.dir= _dir; @dir = _dir; end
7
+ # Gets the root directory from which files should be served
8
+ def self.dir; @dir || "."; end
27
9
 
28
- def compilers_by_ext
29
- @compilers_by_ext
30
- end
10
+ # Splits a uri into its components, returning only the path sans
11
+ # initial forward slash.
12
+ def self.path_for_uri uri
13
+ _, _, _, _, _, path, _, _ = URI.split uri
14
+ path[1..-1] #get rid of the leading /
31
15
  end
32
16
 
33
- def files
34
- self.class.instance_variable_get(:@files)
35
- end
36
-
37
- def process_http_request
38
- @resp = EventMachine::DelegatedHttpResponse.new(self)
39
-
40
- _, _, _, _, _, path, _, query = URI.split @http_request_uri
41
- path = path[1..-1] #get rid of the leading /
42
- _, file, extension = path.match(EXTENSION_REGEX).to_a
43
-
44
- compilers = self.class.compilers_by_ext
45
-
46
- # Check if we've already seen this file. If so, we can skip a
47
- # bunch of processing.
48
- if files[path]
49
- serve_compiled_file files[path]
50
- return
51
- end
52
-
53
- # if there's a file extension and we have a compiler that
54
- # outputs that kind of file, look for an input with the same
55
- # name and an extension in our list
56
- if extension && extension != "" && compilers[extension]
57
- files_by_ext = {}
58
- # find possible source files
59
- Dir.glob("#{file}.*").each do |f|
60
- _, _, ext = f.match(EXTENSION_REGEX).to_a
61
- files_by_ext[ext] = f
62
- end
63
-
64
- cfile = nil
65
- # find a compiler that outputs the request kind of file and
66
- # which has an input file type that exists on the file system
67
- compilers[extension].each do |c|
68
- c[:inputs].each do |i|
69
- if files_by_ext[i]
70
- cfile = CompiledFile.new files_by_ext[i], c[:klass], extension
71
- files[path] = cfile
72
- break
73
- end
74
- end
75
- break if cfile
76
- end
77
-
78
- if cfile
79
- serve_compiled_file cfile
17
+ # Takes a manifest file and produces a response for it
18
+ def self.handle_file resp, mf
19
+ if mf
20
+ if path = mf.process
21
+ serve_file resp, path.to_s
80
22
  else
81
- serve_file path
23
+ resp.status = 500
24
+ resp.content = "Error compiling #{mf.source}"
82
25
  end
83
26
  else
84
- serve_file path
85
- end
86
- end
87
-
88
- def serve_compiled_file cfile
89
- cfile.file do |path, status, stdout, stderr|
90
- if path
91
- serve_file path
92
- else
93
- puts "Status: #{status.inspect}"
94
- @resp.status = 500
95
- @resp.content = "Error compiling #{cfile.source}:\n #{stdout}"
96
- @resp.send_response
97
- end
27
+ not_found resp
98
28
  end
29
+ resp
99
30
  end
100
31
 
101
- def serve_file path
102
- if File.exists?(path) && size = File.size?(path)
32
+ # Serves a file from the file system
33
+ def self.serve_file resp, path
34
+ if File.exists?(path) && !File.directory?(path)
35
+ size = File.size(path)
103
36
  _, _, extension = path.match(EXTENSION_REGEX).to_a
104
- @resp.content_type CONTENT_TYPES[extension]
37
+ resp.content_type MIME::Types.type_for(path).first
105
38
  # File reading code from rack/file.rb
106
39
  File.open path do |file|
107
- @resp.content = ""
40
+ resp.content = ""
108
41
  while size > 0
109
42
  part = file.read([8192, size].min)
110
43
  break unless part
111
44
  size -= part.length
112
- @resp.content << part
45
+ resp.content << part
113
46
  end
114
47
  end
115
- @resp.send_response
116
48
  else
117
- @resp.status = 404
118
- @resp.content = "File '#{path}' not found."
119
- @resp.send_response
49
+ not_found resp
50
+ end
51
+ end
52
+
53
+ # Returns the proper responce for files that do not exist
54
+ def self.not_found resp
55
+ resp.status = 404
56
+ resp.content = "File not found"
57
+ end
58
+
59
+ # Method called for every HTTP request made
60
+ def process_http_request
61
+ @manifest = Manifest.new(Server.dir)
62
+
63
+ path = Server.path_for_uri(@http_request_uri)
64
+ file = @manifest.find_by_path(path)
65
+ resp = EventMachine::DelegatedHttpResponse.new(self)
66
+ if file.is_a? ManifestDir
67
+ file = @manifest.find_by_path(path+"/index.html")
120
68
  end
69
+ Server.handle_file(resp, file).send_response
121
70
  end
122
71
  end
123
72
  end
data/lib/slinky.rb CHANGED
@@ -5,19 +5,25 @@ require 'evma_httpserver'
5
5
  require 'uri'
6
6
  require 'tempfile'
7
7
  require 'rainbow'
8
+ require 'optparse'
9
+ require 'mime/types'
10
+ require 'yui/compressor'
8
11
 
9
12
  require "#{ROOT}/slinky/em-popen3"
13
+ require "#{ROOT}/slinky/compilers"
14
+ require "#{ROOT}/slinky/manifest"
10
15
  require "#{ROOT}/slinky/compiled_file"
11
16
  require "#{ROOT}/slinky/server"
12
17
  require "#{ROOT}/slinky/runner"
18
+ require "#{ROOT}/slinky/builder"
13
19
 
14
20
  # load compilers
15
21
  Dir.glob("#{ROOT}/slinky/compilers/*.rb").each{|compiler|
16
- begin
17
- require compiler
18
- rescue
19
- puts "Failed to load #{compiler}: #{$!}"
20
- rescue LoadError
21
- puts "Failed to load #{compiler}: syntax error"
22
- end
22
+ begin
23
+ require compiler
24
+ rescue
25
+ puts "Failed to load #{compiler}: #{$!}"
26
+ rescue LoadError
27
+ puts "Failed to load #{compiler}: syntax error"
28
+ end
23
29
  }