slinky 0.2.1 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
  }