extruder 0.0.1
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/.gitignore +4 -0
- data/Gemfile +2 -0
- data/README.md +71 -0
- data/extruder.gemspec +16 -0
- data/lib/extruder.rb +8 -0
- data/lib/extruder/dsl.rb +155 -0
- data/lib/extruder/errors.rb +13 -0
- data/lib/extruder/file_set.rb +141 -0
- data/lib/extruder/file_wrapper.rb +191 -0
- data/lib/extruder/filter.rb +172 -0
- data/lib/extruder/filters/concat_filter.rb +139 -0
- data/lib/extruder/filters/ordering_concat_filter.rb +50 -0
- data/lib/extruder/main.rb +166 -0
- data/lib/extruder/version.rb +3 -0
- metadata +62 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
data/README.md
ADDED
@@ -0,0 +1,71 @@
|
|
1
|
+
# A simplified rewrite of Yehuda Katz & Tom Dale's awesome rake-pipeline gem.
|
2
|
+
|
3
|
+
...documentation / server / executable / assetfile support / tests etc coming.
|
4
|
+
|
5
|
+
## A trip through the extruder looks like this:
|
6
|
+
|
7
|
+
1. Define input directories to be processed.
|
8
|
+
2. Define output directory for processed files.
|
9
|
+
3. Define matches with file globs (ie: *.js) to determine which files from the input directories will be processed.
|
10
|
+
4. Define filters within each match block that will process files.
|
11
|
+
5. When processing an Extruder instance, the first step is to create a FileSet of eligible files from the input directories.
|
12
|
+
6. As each match block is opened, the files that matched the glob are removed from the FileSet and passed in.
|
13
|
+
7. As each filter is run, the files created by the previous filter are passed on to the next.
|
14
|
+
4. As each match block is closed, the filter-created/modified files are added to the Extruder's FileSet.
|
15
|
+
5. After the final match block has run, FileSet.create is called, writing the filtered files to disk.
|
16
|
+
|
17
|
+
Extruder layout:
|
18
|
+
|
19
|
+
```ruby
|
20
|
+
inputs => {'app/js'=>'*/**','app/css'=>'*/**'},
|
21
|
+
output_root => 'build',
|
22
|
+
matches => [
|
23
|
+
{ glob: "*.js", filters: [<ConcatFilter>] },
|
24
|
+
{ glob: "*.txt", filters: [<ClosureFilter>,<CopyFilter>,<CopyFilter>] }
|
25
|
+
]
|
26
|
+
```
|
27
|
+
|
28
|
+
## New concepts
|
29
|
+
FileSets contain an array of FileWrappers. A FileSet instance has a glob value (ie: *.js) which is transformed into a regular expression that can be used to determine which files match. An array of matching FileWrappers can be retrieved any time by calling the instance function 'eligible_files'
|
30
|
+
|
31
|
+
FileWrappers write to the instance variable @contents until the instance function create is called, at which point @contents is dumped to the output_root+path.
|
32
|
+
|
33
|
+
All filtering occurs in memory.
|
34
|
+
|
35
|
+
## Junky proof of concept trial run:
|
36
|
+
|
37
|
+
1. Build and install gem
|
38
|
+
2. Grab the emberjs/todos repo
|
39
|
+
3. Load irb in its root
|
40
|
+
4. Run this
|
41
|
+
|
42
|
+
```ruby
|
43
|
+
require 'extruder'
|
44
|
+
require 'json'
|
45
|
+
class ClosureFilter < Extruder::Filter
|
46
|
+
def generate_output(inputs, output)
|
47
|
+
inputs.each do |input|
|
48
|
+
output.write "(function() { #{input.read.to_json} })()"
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
extruder = Extruder.build do
|
54
|
+
input "app"
|
55
|
+
output "output"
|
56
|
+
match "*.js" do
|
57
|
+
concat "test.txt"
|
58
|
+
end
|
59
|
+
match "*.txt" do
|
60
|
+
filter ClosureFilter
|
61
|
+
copy "1.txt"
|
62
|
+
copy "2.txt"
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# show resulting fileset
|
67
|
+
extruder.result
|
68
|
+
|
69
|
+
# save resulting fileset
|
70
|
+
extruder.result.create
|
71
|
+
```
|
data/extruder.gemspec
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
require File.expand_path('../lib/extruder/version', __FILE__)
|
3
|
+
|
4
|
+
Gem::Specification.new do |gem|
|
5
|
+
gem.authors = ["Yehuda Katz", "Tom Dale", "Tyler Kellen"]
|
6
|
+
gem.email = ["tyler@sleekcode.net"]
|
7
|
+
gem.description = "A simple and powerful file pipeline."
|
8
|
+
gem.summary = "A simple and powerful file pipeline."
|
9
|
+
gem.homepage = ""
|
10
|
+
|
11
|
+
gem.files = `git ls-files`.split("\n")
|
12
|
+
gem.name = "extruder"
|
13
|
+
gem.require_paths = ["lib"]
|
14
|
+
gem.version = Extruder::VERSION
|
15
|
+
|
16
|
+
end
|
data/lib/extruder.rb
ADDED
data/lib/extruder/dsl.rb
ADDED
@@ -0,0 +1,155 @@
|
|
1
|
+
module Extruder
|
2
|
+
|
3
|
+
##
|
4
|
+
#
|
5
|
+
# This class exists purely to provide a convenient DSL for
|
6
|
+
# configuring an extruder.
|
7
|
+
#
|
8
|
+
# All instance methods of {DSL} are available in the context
|
9
|
+
# the block passed to {Extruder.build}
|
10
|
+
#
|
11
|
+
class DSL
|
12
|
+
|
13
|
+
# @return [Extruder::Main] the extruder the DSL should configure
|
14
|
+
attr_reader :extruder
|
15
|
+
|
16
|
+
##
|
17
|
+
#
|
18
|
+
# Configure an extruder with a passed block.
|
19
|
+
#
|
20
|
+
# @param [extruder] extruder the extruder that the DSL
|
21
|
+
# should configure.
|
22
|
+
# @param [Proc] block the block describing the
|
23
|
+
# configuration. This block will be evaluated in
|
24
|
+
# the context of a new instance of {DSL}
|
25
|
+
# @return [void]
|
26
|
+
#
|
27
|
+
def self.evaluate(extruder, &block)
|
28
|
+
# create instance of self with the pipline passed
|
29
|
+
# and evalute the block within it
|
30
|
+
new(extruder).instance_eval(&block)
|
31
|
+
end
|
32
|
+
|
33
|
+
##
|
34
|
+
#
|
35
|
+
# Create a new {DSL} to configure an extruder.
|
36
|
+
#
|
37
|
+
# @param [Extruder::Main] extruder the extruder that the DSL
|
38
|
+
# should configure.
|
39
|
+
# @return [void]
|
40
|
+
#
|
41
|
+
def initialize(extruder)
|
42
|
+
@extruder = extruder
|
43
|
+
end
|
44
|
+
|
45
|
+
##
|
46
|
+
#
|
47
|
+
# Define the input location and files for the extruder.
|
48
|
+
#
|
49
|
+
# @example
|
50
|
+
# !!!ruby
|
51
|
+
# Extruder.build do
|
52
|
+
# input "app/assets", "**/*.js"
|
53
|
+
# # ...
|
54
|
+
# end
|
55
|
+
#
|
56
|
+
# @param [String] root the root path where the extruder
|
57
|
+
# should find its input files.
|
58
|
+
# @param [String] glob a file pattern that represents
|
59
|
+
# the list of all files that the extruder should
|
60
|
+
# process within +root+. The default is +"**/*"+.
|
61
|
+
# @return [void]
|
62
|
+
#
|
63
|
+
def input(root, pattern='**/*')
|
64
|
+
extruder.add_input root, pattern
|
65
|
+
end
|
66
|
+
|
67
|
+
##
|
68
|
+
#
|
69
|
+
# Add a match to the pipeline with a passed block
|
70
|
+
# containing filters that belong to it.
|
71
|
+
#
|
72
|
+
# @example
|
73
|
+
# !!!ruby
|
74
|
+
# Extruder.build do
|
75
|
+
# input "app/assets"
|
76
|
+
#
|
77
|
+
# match "*.js" do
|
78
|
+
# concat "app.js"
|
79
|
+
# end
|
80
|
+
#
|
81
|
+
# end
|
82
|
+
#
|
83
|
+
# @param [String] root the output directory.
|
84
|
+
# @return [void]
|
85
|
+
#
|
86
|
+
def match(pattern, &block)
|
87
|
+
extruder.add_match pattern
|
88
|
+
block.call
|
89
|
+
end
|
90
|
+
|
91
|
+
##
|
92
|
+
#
|
93
|
+
# Add a filter to the pipeline, normally called from
|
94
|
+
# within a match block. If a filter is added without
|
95
|
+
# a match block, the Extruder will automatically create
|
96
|
+
# one that matches all input files.
|
97
|
+
#
|
98
|
+
# In addition to a filter class, {#filter} takes a
|
99
|
+
# block that describes how the filter should map
|
100
|
+
# input files to output files.
|
101
|
+
#
|
102
|
+
# By default, the block maps an input file into
|
103
|
+
# an output file with the same name.
|
104
|
+
#
|
105
|
+
# Any additional arguments passed to {#filter} will
|
106
|
+
# be passed on to the filter class's constructor.
|
107
|
+
#
|
108
|
+
# @see Filter#outputs Filter#output (for an example
|
109
|
+
# of how a list of input files gets mapped to
|
110
|
+
# its outputs)
|
111
|
+
#
|
112
|
+
# @param [Class] filter_class the class of the filter.
|
113
|
+
# @param [Array] ctor_args a list of arguments to pass
|
114
|
+
# to the filter's constructor.
|
115
|
+
# @param [Proc] block an output file name generator.
|
116
|
+
# @return [void]
|
117
|
+
#
|
118
|
+
def filter(filter_class, *ctor_args, &block)
|
119
|
+
filter = filter_class.new(*ctor_args, &block)
|
120
|
+
extruder.add_filter(filter)
|
121
|
+
end
|
122
|
+
|
123
|
+
##
|
124
|
+
#
|
125
|
+
# Specify the output directory for the pipeline.
|
126
|
+
#
|
127
|
+
# @param [String] root the output directory.
|
128
|
+
# @return [void]
|
129
|
+
#
|
130
|
+
def output(root)
|
131
|
+
extruder.output_root = root
|
132
|
+
end
|
133
|
+
|
134
|
+
##
|
135
|
+
#
|
136
|
+
# A helper method for adding a concat filter to
|
137
|
+
# the Extruder.
|
138
|
+
# If the first argument is an Array, it adds a new
|
139
|
+
# {OrderingConcatFilter}, otherwise it adds a new
|
140
|
+
# {ConcatFilter}.
|
141
|
+
#
|
142
|
+
# @see OrderingConcatFilter#initialize
|
143
|
+
# @see ConcatFilter#initialize
|
144
|
+
#
|
145
|
+
def concat(*args, &block)
|
146
|
+
if args.first.kind_of?(Array)
|
147
|
+
filter(OrderingConcatFilter, *args, &block)
|
148
|
+
else
|
149
|
+
filter(ConcatFilter, *args, &block)
|
150
|
+
end
|
151
|
+
end
|
152
|
+
alias_method :copy, :concat
|
153
|
+
|
154
|
+
end
|
155
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
module Extruder
|
2
|
+
# The general Extruder error class
|
3
|
+
class Error < ::StandardError
|
4
|
+
end
|
5
|
+
# The error that Extruder uses when it detects
|
6
|
+
# that a file uses an improper encoding.
|
7
|
+
class EncodingError < Error
|
8
|
+
end
|
9
|
+
# The error that Extruder uses if you try to
|
10
|
+
# write to a FileWrapper before creating it.
|
11
|
+
class UnopenedFile < Error
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,141 @@
|
|
1
|
+
require "strscan"
|
2
|
+
|
3
|
+
module Extruder
|
4
|
+
|
5
|
+
##
|
6
|
+
#
|
7
|
+
# This class contains an array of {FileWrapper}s which can be
|
8
|
+
# searched with a glob.
|
9
|
+
#
|
10
|
+
class FileSet
|
11
|
+
|
12
|
+
# @return [Array[FileWrapper]] an array of FileWrapped files
|
13
|
+
attr_accessor :files
|
14
|
+
|
15
|
+
# @return [String] a glob that a set of input files are matched against
|
16
|
+
attr_accessor :glob
|
17
|
+
|
18
|
+
# @return [String] regular expression pattern to match against input files
|
19
|
+
attr_reader :pattern
|
20
|
+
|
21
|
+
def initialize(files, glob='**/*')
|
22
|
+
@files = files
|
23
|
+
self.glob = glob
|
24
|
+
end
|
25
|
+
|
26
|
+
def glob=(glob)
|
27
|
+
@glob = glob
|
28
|
+
@pattern = build_pattern(glob)
|
29
|
+
end
|
30
|
+
|
31
|
+
# Build a regular expression pattern from a file glob that
|
32
|
+
# can be used to narrow a selection of files from a Extruder.
|
33
|
+
def build_pattern(glob)
|
34
|
+
|
35
|
+
scanner = StringScanner.new(glob)
|
36
|
+
|
37
|
+
output, pos = "", 0
|
38
|
+
|
39
|
+
# keep scanning until end of String
|
40
|
+
until scanner.eos?
|
41
|
+
|
42
|
+
# look for **/, *, {...}, or the end of the string
|
43
|
+
new_chars = scanner.scan_until %r{
|
44
|
+
\*\*/
|
45
|
+
| /\*\*/
|
46
|
+
| \*
|
47
|
+
| \{[^\}]*\}
|
48
|
+
| $
|
49
|
+
}x
|
50
|
+
|
51
|
+
# get the new part of the string up to the match
|
52
|
+
before = new_chars[0, new_chars.size - scanner.matched_size]
|
53
|
+
|
54
|
+
# get the match and new position
|
55
|
+
match = scanner.matched
|
56
|
+
pos = scanner.pos
|
57
|
+
|
58
|
+
# add any literal characters to the output
|
59
|
+
output << Regexp.escape(before) if before
|
60
|
+
|
61
|
+
output << case match
|
62
|
+
when "/**/"
|
63
|
+
# /**/ matches either a "/" followed by any number
|
64
|
+
# of characters or a single "/"
|
65
|
+
"(/.*|/)"
|
66
|
+
when "**/"
|
67
|
+
# **/ matches the beginning of the path or
|
68
|
+
# any number of characters followed by a "/"
|
69
|
+
"(^|.*/)"
|
70
|
+
when "*"
|
71
|
+
# * matches any number of non-"/" characters
|
72
|
+
"[^/]*"
|
73
|
+
when /\{.*\}/
|
74
|
+
# {...} is split over "," and glued back together
|
75
|
+
# as an or condition
|
76
|
+
"(" + match[1...-1].gsub(",", "|") + ")"
|
77
|
+
else String
|
78
|
+
# otherwise, we've grabbed until the end
|
79
|
+
match
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
if glob.include?("/")
|
84
|
+
# if the pattern includes a /, it must match the
|
85
|
+
# entire input, not just the end.
|
86
|
+
@pattern = Regexp.new("^#{output}$", "i")
|
87
|
+
else
|
88
|
+
# anchor the pattern either at the beginning of the
|
89
|
+
# path or at any "/" character
|
90
|
+
@pattern = Regexp.new("(^|/)#{output}$", "i")
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
##
|
95
|
+
#
|
96
|
+
# A list of eligible files based on the current glob
|
97
|
+
#
|
98
|
+
# @return [Array<FileWrapper>]
|
99
|
+
#
|
100
|
+
def eligible_files
|
101
|
+
files.select do |file|
|
102
|
+
file.path =~ @pattern
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
##
|
107
|
+
#
|
108
|
+
# Remove eligible files from this {FileSet} and return them
|
109
|
+
#
|
110
|
+
# @return [Array<FileWrapper>]
|
111
|
+
#
|
112
|
+
def extract_eligible
|
113
|
+
keep = eligible_files
|
114
|
+
@files -= keep
|
115
|
+
keep
|
116
|
+
end
|
117
|
+
|
118
|
+
##
|
119
|
+
#
|
120
|
+
# Select files in this {FileSet} which will be saved to disk
|
121
|
+
# if {#create} is run. The save flag is set true for any file
|
122
|
+
# that passed through a filter.
|
123
|
+
#
|
124
|
+
# @return [Array<FileWrapper>]
|
125
|
+
#
|
126
|
+
def will_save
|
127
|
+
files.select { |file| file.save }
|
128
|
+
end
|
129
|
+
|
130
|
+
##
|
131
|
+
#
|
132
|
+
# Write all files in this {FileSet} to their output_root.
|
133
|
+
#
|
134
|
+
def create
|
135
|
+
will_save.each do |file|
|
136
|
+
file.create
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
end
|
141
|
+
end
|
@@ -0,0 +1,191 @@
|
|
1
|
+
require 'fileutils'
|
2
|
+
|
3
|
+
module Extruder
|
4
|
+
|
5
|
+
##
|
6
|
+
#
|
7
|
+
# This class wraps a file for consumption inside of filters. It is
|
8
|
+
# initialized with a root and path, and filters usually use the
|
9
|
+
# {#read} and {#write} methods to work with these files.
|
10
|
+
#
|
11
|
+
# The {#root} and +path+ parameters are provided by the {Extruder}
|
12
|
+
# class' internal implementation. Individual filters do not need
|
13
|
+
# to worry about them.
|
14
|
+
#
|
15
|
+
# The root of a {FileWrapper} is always an absolute path.
|
16
|
+
#
|
17
|
+
class FileWrapper
|
18
|
+
# @return [String] an absolute path representing this {FileWrapper}'s
|
19
|
+
# root directory.
|
20
|
+
attr_accessor :root
|
21
|
+
|
22
|
+
# @return [String] the path to the file represented by the {FileWrapper}
|
23
|
+
# relative to its {#root}.
|
24
|
+
attr_accessor :path
|
25
|
+
|
26
|
+
# @return [String] contents of the file represented by the {FileWrapper}
|
27
|
+
attr_accessor :contents
|
28
|
+
|
29
|
+
# @return [String] the encoding that the file represented by this
|
30
|
+
# {FileWrapper} is encoded in. Filters set the {#encoding} to
|
31
|
+
# +BINARY+ if they are declared as processing binary data.
|
32
|
+
attr_accessor :encoding
|
33
|
+
|
34
|
+
# @return [Boolean] true if created by a filter, otherwise false
|
35
|
+
# this flag determines if a file will be written when its container
|
36
|
+
# {FileSet} is saved.
|
37
|
+
attr_accessor :save
|
38
|
+
|
39
|
+
# @return [String] A pretty representation of the {FileWrapper}.
|
40
|
+
def inspect
|
41
|
+
"#<FileWrapper root=#{root.inspect} path=#{path.inspect} encoding=#{encoding.inspect}>"
|
42
|
+
end
|
43
|
+
alias to_s inspect
|
44
|
+
|
45
|
+
##
|
46
|
+
#
|
47
|
+
# Create a new {FileWrapper}, passing in optional root, path, and
|
48
|
+
# encoding. Any of the parameters can be ommitted and supplied later.
|
49
|
+
#
|
50
|
+
# @return [void]
|
51
|
+
#
|
52
|
+
def initialize(root=nil, path=nil, encoding="UTF-8")
|
53
|
+
@root, @path, @encoding = root, path, encoding
|
54
|
+
@created_file = nil
|
55
|
+
@save = false
|
56
|
+
end
|
57
|
+
|
58
|
+
##
|
59
|
+
#
|
60
|
+
# Create a new {FileWrapper} with the same root and path as
|
61
|
+
# this {FileWrapper}, but with a specified encoding.
|
62
|
+
#
|
63
|
+
# @param [String] encoding the encoding for the new object
|
64
|
+
# @return [FileWrapper]
|
65
|
+
#
|
66
|
+
def with_encoding(encoding)
|
67
|
+
self.class.new(@root, @path, encoding)
|
68
|
+
end
|
69
|
+
|
70
|
+
##
|
71
|
+
#
|
72
|
+
# A {FileWrapper} is equal to another {FileWrapper} for hashing purposes
|
73
|
+
# if they have the same {#root} and {#path}
|
74
|
+
#
|
75
|
+
# @param [FileWrapper] other another {FileWrapper} to compare.
|
76
|
+
# @return [true,false]
|
77
|
+
#
|
78
|
+
def eql?(other)
|
79
|
+
return false unless other.is_a?(self.class)
|
80
|
+
root == other.root && path == other.path
|
81
|
+
end
|
82
|
+
alias == eql?
|
83
|
+
|
84
|
+
##
|
85
|
+
#
|
86
|
+
# Similar to {#eql?}, generate a {FileWrapper}'s {#hash} from its {#root}
|
87
|
+
# and {#path}
|
88
|
+
#
|
89
|
+
# @see #eql?
|
90
|
+
# @return [Fixnum] a hash code
|
91
|
+
#
|
92
|
+
def hash
|
93
|
+
[root, path].hash
|
94
|
+
end
|
95
|
+
|
96
|
+
##
|
97
|
+
#
|
98
|
+
# The full path of a FileWrapper is its root joined with its path
|
99
|
+
#
|
100
|
+
# @return [String] the {FileWrapper}'s full path
|
101
|
+
#
|
102
|
+
def fullpath
|
103
|
+
raise "#{root}, #{path}" unless root =~ /^\//
|
104
|
+
File.join(root, path)
|
105
|
+
end
|
106
|
+
|
107
|
+
##
|
108
|
+
#
|
109
|
+
# Make FileWrappers sortable
|
110
|
+
#
|
111
|
+
# @param [FileWrapper] other {FileWrapper FileWrapper}
|
112
|
+
# @return [Fixnum] -1, 0, or 1
|
113
|
+
#
|
114
|
+
def <=>(other)
|
115
|
+
[root, path, encoding] <=> [other.root, other.path, other.encoding]
|
116
|
+
end
|
117
|
+
|
118
|
+
##
|
119
|
+
#
|
120
|
+
# Does the file represented by the {FileWrapper} exist in the file system?
|
121
|
+
#
|
122
|
+
# @return [true,false]
|
123
|
+
#
|
124
|
+
def exists?
|
125
|
+
File.exists?(fullpath)
|
126
|
+
end
|
127
|
+
|
128
|
+
##
|
129
|
+
#
|
130
|
+
# Read the contents of the file represented by the {FileWrapper}.
|
131
|
+
#
|
132
|
+
# Read the file using the {FileWrapper}'s encoding, which will result in
|
133
|
+
# this method returning a +String+ tagged with the {FileWrapper}'s encoding.
|
134
|
+
#
|
135
|
+
# @return [String] the contents of the file
|
136
|
+
# @raise [EncodingError] when the contents of the file are not valid in the
|
137
|
+
# expected encoding specified in {#encoding}.
|
138
|
+
#
|
139
|
+
def read
|
140
|
+
if @contents.nil?
|
141
|
+
|
142
|
+
@contents = if "".respond_to?(:encode)
|
143
|
+
File.read(fullpath, :encoding => encoding)
|
144
|
+
else
|
145
|
+
File.read(fullpath)
|
146
|
+
end
|
147
|
+
|
148
|
+
if "".respond_to?(:encode) && !@contents.valid_encoding?
|
149
|
+
raise EncodingError, "The file at the path #{fullpath} is not valid UTF-8. Please save it again as UTF-8."
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
@contents
|
154
|
+
end
|
155
|
+
|
156
|
+
##
|
157
|
+
#
|
158
|
+
# Append a string to the cached contents of the file this wrapper
|
159
|
+
# represents. This method is called repeatedly by a {Filter}'s
|
160
|
+
# +#generate_output+ method.
|
161
|
+
#
|
162
|
+
def write(string)
|
163
|
+
@contents = "" if contents.nil?
|
164
|
+
@contents << string
|
165
|
+
end
|
166
|
+
|
167
|
+
##
|
168
|
+
#
|
169
|
+
# Create a new file at the {FileWrapper}'s {#fullpath} and fill it
|
170
|
+
# with the value of @contents. If the file already exists, it will
|
171
|
+
# be overwritten.
|
172
|
+
#
|
173
|
+
# @return [File]
|
174
|
+
#
|
175
|
+
def create
|
176
|
+
FileUtils.mkdir_p(File.dirname(fullpath))
|
177
|
+
|
178
|
+
@created_file = if "".respond_to?(:encode)
|
179
|
+
File.open(fullpath, "w:#{encoding}")
|
180
|
+
else
|
181
|
+
File.open(fullpath, "w")
|
182
|
+
end
|
183
|
+
|
184
|
+
@created_file.write(@contents)
|
185
|
+
@created_file.close
|
186
|
+
@created_file
|
187
|
+
end
|
188
|
+
|
189
|
+
end
|
190
|
+
|
191
|
+
end
|
@@ -0,0 +1,172 @@
|
|
1
|
+
module Extruder
|
2
|
+
|
3
|
+
##
|
4
|
+
#
|
5
|
+
# This abstract class is used for building Filters that will be
|
6
|
+
# assigned to an Extruder. Their function is to transform one
|
7
|
+
# array of FileWrappers to another.
|
8
|
+
#
|
9
|
+
# @abstract
|
10
|
+
#
|
11
|
+
class Filter
|
12
|
+
|
13
|
+
# @return [Array[FileWrapper]] the files that this filter
|
14
|
+
# will operate on.
|
15
|
+
attr_accessor :files
|
16
|
+
|
17
|
+
# @return [String] the expanded root path that this
|
18
|
+
# filter's created files will eventually be written to.
|
19
|
+
attr_accessor :output_root
|
20
|
+
|
21
|
+
# @return [Proc] a block that returns the relative output
|
22
|
+
# filename for a particular input file. If the block accepts
|
23
|
+
# just one argument, it will be passed the input's path. If
|
24
|
+
# it accepts two, it will also be passed the file contents.
|
25
|
+
attr_accessor :output_name_generator
|
26
|
+
|
27
|
+
##
|
28
|
+
#
|
29
|
+
# Invoke this method in a subclass of Filter to declare that
|
30
|
+
# it expects to work with BINARY data, and that data that is
|
31
|
+
# not valid UTF-8 should be allowed.
|
32
|
+
#
|
33
|
+
# @return [void]
|
34
|
+
#
|
35
|
+
def self.processes_binary_files
|
36
|
+
define_method(:encoding) { "BINARY" }
|
37
|
+
end
|
38
|
+
|
39
|
+
##
|
40
|
+
#
|
41
|
+
# ...more soon
|
42
|
+
#
|
43
|
+
# @param [Proc] block a block to use as the Filter's
|
44
|
+
# {#output_name_generator}.
|
45
|
+
#
|
46
|
+
def initialize(&block)
|
47
|
+
if self.respond_to?(:external_dependencies)
|
48
|
+
require_dependencies!
|
49
|
+
end
|
50
|
+
block ||= proc { |input| input }
|
51
|
+
@output_name_generator = block
|
52
|
+
@files = []
|
53
|
+
end
|
54
|
+
|
55
|
+
##
|
56
|
+
#
|
57
|
+
# A hash of output files pointing at their associated input
|
58
|
+
# files. The output names are created by applying the
|
59
|
+
# {#output_name_generator} to each input file.
|
60
|
+
#
|
61
|
+
# For exmaple, if you had the following input files:
|
62
|
+
#
|
63
|
+
# javascripts/jquery.js
|
64
|
+
# javascripts/sproutcore.js
|
65
|
+
# stylesheets/sproutcore.css
|
66
|
+
#
|
67
|
+
# And you had the following {#output_name_generator}:
|
68
|
+
#
|
69
|
+
# !!!ruby
|
70
|
+
# filter.output_name_generator = proc do |filename|
|
71
|
+
# # javascripts/jquery.js becomes:
|
72
|
+
# # ["javascripts", "jquery", "js"]
|
73
|
+
# directory, file, ext = file.split(/[\.\/]/)
|
74
|
+
#
|
75
|
+
# "#{directory}.#{ext}"
|
76
|
+
# end
|
77
|
+
#
|
78
|
+
# You would end up with the following hash:
|
79
|
+
#
|
80
|
+
# !!!ruby
|
81
|
+
# {
|
82
|
+
# #<FileWrapper path="javascripts.js"> => [
|
83
|
+
# #<FileWrapper path="javascripts/jquery.js">,
|
84
|
+
# #<FileWrapper path="javascripts/sproutcore.js">
|
85
|
+
# ],
|
86
|
+
# #<FileWrapper path="stylesheets.css"> => [
|
87
|
+
# #<FileWrapper path="stylesheets/sproutcore.css">
|
88
|
+
# ]
|
89
|
+
# }
|
90
|
+
#
|
91
|
+
# @return [Hash{FileWrapper => Array<FileWrapper>}]
|
92
|
+
#
|
93
|
+
def outputs
|
94
|
+
hash = {}
|
95
|
+
|
96
|
+
@files.each do |file|
|
97
|
+
outputs = output_paths(file)
|
98
|
+
|
99
|
+
output_wrappers(file).each do |output|
|
100
|
+
hash[output] ||= []
|
101
|
+
hash[output] << file
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
hash
|
106
|
+
end
|
107
|
+
|
108
|
+
##
|
109
|
+
#
|
110
|
+
# For each {FileWrapper} file this filter will create, call
|
111
|
+
# generate_output (defined in subclasses of this) to fill the
|
112
|
+
# @contents.
|
113
|
+
#
|
114
|
+
# These files should be flagged as saveable so the {FileSet} that
|
115
|
+
# they wind up in will know they should ultimately be written to
|
116
|
+
# the output directory.
|
117
|
+
#
|
118
|
+
# @return [Array{FileWrapper}] An array of FileWrapped ouputs for
|
119
|
+
# this filter.
|
120
|
+
#
|
121
|
+
def process
|
122
|
+
outputs.map do |output, inputs|
|
123
|
+
generate_output(inputs, output)
|
124
|
+
output.save = true
|
125
|
+
output
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
private
|
130
|
+
def encoding
|
131
|
+
"UTF-8"
|
132
|
+
end
|
133
|
+
|
134
|
+
##
|
135
|
+
#
|
136
|
+
# ...more soon
|
137
|
+
#
|
138
|
+
# @return [FileWrapper]
|
139
|
+
#
|
140
|
+
def output_wrappers(input)
|
141
|
+
output_paths(input).map do |path|
|
142
|
+
FileWrapper.new(output_root, path, encoding)
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
##
|
147
|
+
#
|
148
|
+
# ...more soon
|
149
|
+
#
|
150
|
+
# @return [Array]
|
151
|
+
#
|
152
|
+
def output_paths(input)
|
153
|
+
args = [ input.path ]
|
154
|
+
args << input if output_name_generator.arity == 2
|
155
|
+
Array(output_name_generator.call(*args))
|
156
|
+
end
|
157
|
+
|
158
|
+
##
|
159
|
+
#
|
160
|
+
# ... more soon
|
161
|
+
#
|
162
|
+
def require_dependencies!
|
163
|
+
external_dependencies.each do |d|
|
164
|
+
begin
|
165
|
+
require d
|
166
|
+
rescue LoadError => error
|
167
|
+
raise error, "#{self.class} requires #{d}, but it is not available."
|
168
|
+
end
|
169
|
+
end
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
@@ -0,0 +1,139 @@
|
|
1
|
+
module Extruder
|
2
|
+
|
3
|
+
##
|
4
|
+
#
|
5
|
+
# A built-in filter that simply accepts a series
|
6
|
+
# of inputs and concatenates them into output files
|
7
|
+
# with the output file generator.
|
8
|
+
#
|
9
|
+
# @example
|
10
|
+
# !!!ruby
|
11
|
+
# Extruder.build do
|
12
|
+
# input "app/assets", "**/*.js"
|
13
|
+
# output "public"
|
14
|
+
#
|
15
|
+
# # create a concatenated output file for each
|
16
|
+
# # directory of inputs.
|
17
|
+
# filter(Extruder::ConcatFilter) do |input|
|
18
|
+
# # input files will look something like:
|
19
|
+
# # javascripts/admin/main.js
|
20
|
+
# # javascripts/admin/app.js
|
21
|
+
# # javascripts/users/main.js
|
22
|
+
# #
|
23
|
+
# # and the outputs will look like:
|
24
|
+
# # javascripts/admin.js
|
25
|
+
# # javascripts/users.js
|
26
|
+
# directory = File.dirname(input)
|
27
|
+
# ext = File.extname(input)
|
28
|
+
#
|
29
|
+
# "#{directory}#{ext}"
|
30
|
+
# end
|
31
|
+
# end
|
32
|
+
#
|
33
|
+
class ConcatFilter < Extruder::Filter
|
34
|
+
##
|
35
|
+
#
|
36
|
+
# ... more soon
|
37
|
+
#
|
38
|
+
# @param [String] string the name of the output file to
|
39
|
+
# concatenate inputs to.
|
40
|
+
# @param [Proc] block a block to use as the Filter's
|
41
|
+
# {#output_name_generator}.
|
42
|
+
def initialize(string=nil, &block)
|
43
|
+
block = proc { string } if string
|
44
|
+
super(&block)
|
45
|
+
end
|
46
|
+
|
47
|
+
# @method encoding
|
48
|
+
# @return [String] the String +"BINARY"+
|
49
|
+
processes_binary_files
|
50
|
+
|
51
|
+
###
|
52
|
+
#
|
53
|
+
# implement the {#generate_output} method required by
|
54
|
+
# the {Filter} API. In this case, simply loop through
|
55
|
+
# the inputs and write their contents to the output.
|
56
|
+
#
|
57
|
+
# Recall that this method will be called once for each
|
58
|
+
# unique output file.
|
59
|
+
#
|
60
|
+
# @param [Array<FileWrapper>] inputs an Array of
|
61
|
+
# {FileWrapper} objects representing the inputs to
|
62
|
+
# this filter.
|
63
|
+
# @param [FileWrapper] a single {FileWrapper} object
|
64
|
+
# representing the output.
|
65
|
+
#
|
66
|
+
def generate_output(inputs, output)
|
67
|
+
inputs.each do |input|
|
68
|
+
output.write input.read
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
|
75
|
+
module Extruder
|
76
|
+
# A filter that compiles input files written in SCSS
|
77
|
+
# to CSS using the Sass compiler and the Compass CSS
|
78
|
+
# framework.
|
79
|
+
#
|
80
|
+
# Requires {http://rubygems.org/gems/sass sass} and
|
81
|
+
# {http://rubygems.org/gems/compass compass}
|
82
|
+
#
|
83
|
+
# @example
|
84
|
+
# !!!ruby
|
85
|
+
# Rake::Pipeline.build do
|
86
|
+
# input "app/assets", "**/*.scss"
|
87
|
+
# output "public"
|
88
|
+
#
|
89
|
+
# # Compile each SCSS file under the app/assets
|
90
|
+
# # directory.
|
91
|
+
# filter Rake::Pipeline::Web::Filters::SassFilter
|
92
|
+
# end
|
93
|
+
class SassFilter < Extruder::Filter
|
94
|
+
|
95
|
+
# @return [Hash] a hash of options to pass to Sass
|
96
|
+
# when compiling.
|
97
|
+
attr_reader :options
|
98
|
+
|
99
|
+
# @param [Hash] options options to pass to the Sass
|
100
|
+
# compiler
|
101
|
+
# @option options [Array] :additional_load_paths a
|
102
|
+
# list of paths to append to Sass's :load_path.
|
103
|
+
# @param [Proc] block a block to use as the Filter's
|
104
|
+
# {#output_name_generator}.
|
105
|
+
def initialize(options={}, &block)
|
106
|
+
block ||= proc { |input| input.sub(/\.(scss|sass)$/, '.css') }
|
107
|
+
super(&block)
|
108
|
+
Compass.add_project_configuration
|
109
|
+
@options = Compass.configuration.to_sass_engine_options
|
110
|
+
@options[:load_paths].concat(Array(options.delete(:additional_load_paths)))
|
111
|
+
@options.merge!(options)
|
112
|
+
end
|
113
|
+
|
114
|
+
# Implement the {#generate_output} method required by
|
115
|
+
# the {Filter} API. Compiles each input file with Sass.
|
116
|
+
#
|
117
|
+
# @param [Array<FileWrapper>] inputs an Array of
|
118
|
+
# {FileWrapper} objects representing the inputs to
|
119
|
+
# this filter.
|
120
|
+
# @param [FileWrapper] output a single {FileWrapper}
|
121
|
+
# object representing the output.
|
122
|
+
def generate_output(inputs, output)
|
123
|
+
inputs.each do |input|
|
124
|
+
sass_opts = if input.path.match(/\.sass$/)
|
125
|
+
options.merge(:syntax => :sass)
|
126
|
+
else
|
127
|
+
options
|
128
|
+
end
|
129
|
+
output.write Sass.compile(input.read, sass_opts)
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
private
|
134
|
+
|
135
|
+
def external_dependencies
|
136
|
+
[ 'sass', 'compass' ]
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
module Extruder
|
2
|
+
|
3
|
+
##
|
4
|
+
#
|
5
|
+
# A filter that concats files in a specified order.
|
6
|
+
#
|
7
|
+
# @example
|
8
|
+
# !!!ruby
|
9
|
+
# Extruder.build do
|
10
|
+
# input "app/assets"
|
11
|
+
# output "public"
|
12
|
+
#
|
13
|
+
# # Concat each file into libs.js but make sure
|
14
|
+
# # that jQuery and Ember come first.
|
15
|
+
# match "**/*.js"
|
16
|
+
# filter Rake::Extruder::OrderingConcatFilter, ["jquery.js", "ember.js"], "libs.js"
|
17
|
+
# end
|
18
|
+
# end
|
19
|
+
#
|
20
|
+
class OrderingConcatFilter < ConcatFilter
|
21
|
+
|
22
|
+
##
|
23
|
+
#
|
24
|
+
# @param [Array<String>] ordering an Array of Strings
|
25
|
+
# of file names that should come in the specified order
|
26
|
+
# @param [String] string the name of the output file to
|
27
|
+
# concatenate inputs to.
|
28
|
+
# @param [Proc] block a block to use as the Filter's
|
29
|
+
# {#output_name_generator}.
|
30
|
+
#
|
31
|
+
def initialize(ordering, string=nil, &block)
|
32
|
+
@ordering = ordering
|
33
|
+
super(string, &block)
|
34
|
+
end
|
35
|
+
|
36
|
+
##
|
37
|
+
#
|
38
|
+
# Extend the {#generate_output} method supplied by {ConcatFilter}.
|
39
|
+
# Re-orders the inputs such that the specified files come first.
|
40
|
+
# If a file is not in the list it will come after the specified files.
|
41
|
+
#
|
42
|
+
def generate_output(inputs, output)
|
43
|
+
@ordering.reverse.each do |name|
|
44
|
+
file = inputs.find{|i| i.path == name }
|
45
|
+
inputs.unshift(inputs.delete(file)) if file
|
46
|
+
end
|
47
|
+
super
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,166 @@
|
|
1
|
+
module Extruder
|
2
|
+
|
3
|
+
##
|
4
|
+
#
|
5
|
+
# Build a new Extruder taking a block. The block will be evaluated
|
6
|
+
# by Extruder::DSL.
|
7
|
+
#
|
8
|
+
# @example
|
9
|
+
# Extruder.build do
|
10
|
+
# input "app"
|
11
|
+
# output "public"
|
12
|
+
#
|
13
|
+
# match "*.js"
|
14
|
+
# concat "app.js"
|
15
|
+
# end
|
16
|
+
# end
|
17
|
+
#
|
18
|
+
# All instance methods of Extruder::DSL are available inside
|
19
|
+
# the build block.
|
20
|
+
#
|
21
|
+
# @return [Extruder] the newly configured Extruder
|
22
|
+
#
|
23
|
+
def self.build(&block)
|
24
|
+
extruder = Main.new
|
25
|
+
DSL.evaluate(extruder, &block) if block
|
26
|
+
extruder
|
27
|
+
end
|
28
|
+
|
29
|
+
##
|
30
|
+
#
|
31
|
+
# The main Extruder class, this is where everything gets tied together.
|
32
|
+
#
|
33
|
+
class Main
|
34
|
+
|
35
|
+
# @return [Hash[String, String]] the directory paths for the input files
|
36
|
+
# and their matching globs.
|
37
|
+
attr_accessor :inputs
|
38
|
+
|
39
|
+
# @return [[{:glob=>"",:filters=>[Filter,Filter]}]] this Extruder's matches
|
40
|
+
# and the filters they contain.
|
41
|
+
attr_accessor :matches
|
42
|
+
|
43
|
+
# @return [String] this Extruder's output directory
|
44
|
+
attr_reader :output_root
|
45
|
+
|
46
|
+
##
|
47
|
+
#
|
48
|
+
# Initalize matches and inputs on instantiation.
|
49
|
+
#
|
50
|
+
def initialize
|
51
|
+
@matches = []
|
52
|
+
@inputs = {}
|
53
|
+
end
|
54
|
+
|
55
|
+
##
|
56
|
+
#
|
57
|
+
# Set the output root of this Extruder and expand its path.
|
58
|
+
#
|
59
|
+
# @param [String] root this Extruder's output root
|
60
|
+
#
|
61
|
+
def output_root=(root)
|
62
|
+
@output_root = File.expand_path(root)
|
63
|
+
end
|
64
|
+
|
65
|
+
##
|
66
|
+
#
|
67
|
+
# Add an input directory, optionally filtering which files within
|
68
|
+
# the input directory are included.
|
69
|
+
#
|
70
|
+
# @param [String] root the input root directory; required
|
71
|
+
# @param [String] pattern a pattern to match within +root+;
|
72
|
+
# optional; defaults to "**/*"
|
73
|
+
#
|
74
|
+
def add_input(root, pattern='**/*')
|
75
|
+
@inputs[root] = pattern
|
76
|
+
end
|
77
|
+
|
78
|
+
##
|
79
|
+
#
|
80
|
+
# Add a match and initialize it's filters to an array.
|
81
|
+
#
|
82
|
+
# @param [String] glob the file glob to match with
|
83
|
+
#
|
84
|
+
def add_match(glob)
|
85
|
+
@matches.push({:glob=>glob,:filters=>[]})
|
86
|
+
end
|
87
|
+
|
88
|
+
##
|
89
|
+
#
|
90
|
+
# Add a filter to the current match.
|
91
|
+
#
|
92
|
+
# @param [Extruder::Filter] filter a filter to run
|
93
|
+
#
|
94
|
+
def add_filter(filter)
|
95
|
+
# add a match that accepts everything if none are specified
|
96
|
+
add_match('**/*') if @matches.empty?
|
97
|
+
@matches.last[:filters].push(filter)
|
98
|
+
end
|
99
|
+
|
100
|
+
##
|
101
|
+
#
|
102
|
+
# Return a sorted array of FileWrappers representing all files
|
103
|
+
# in the input paths.
|
104
|
+
#
|
105
|
+
# @return [Array[FileWrapper]]
|
106
|
+
#
|
107
|
+
def input_files
|
108
|
+
|
109
|
+
if inputs.empty?
|
110
|
+
raise Extruder::Error, "You cannot get input files without " \
|
111
|
+
"first providing an input root"
|
112
|
+
end
|
113
|
+
|
114
|
+
result = []
|
115
|
+
|
116
|
+
@inputs.each do |root, glob|
|
117
|
+
expanded_root = File.expand_path(root)
|
118
|
+
files = Dir[File.join(expanded_root, glob)].select { |f| File.file?(f) }
|
119
|
+
|
120
|
+
files.each do |file|
|
121
|
+
relative_path = file.sub(%r{^#{Regexp.escape(expanded_root)}/}, '')
|
122
|
+
result << FileWrapper.new(expanded_root, relative_path)
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
result.sort
|
127
|
+
end
|
128
|
+
|
129
|
+
##
|
130
|
+
#
|
131
|
+
# Create a {FileSet} from {#input_files}, iterate through matches,
|
132
|
+
# running the filters on the files they contain.
|
133
|
+
#
|
134
|
+
# @return [FileSet] a {FileSet} containing the filtered files.
|
135
|
+
#
|
136
|
+
def result
|
137
|
+
# create a fileset to process filters with
|
138
|
+
fileset = FileSet.new(input_files)
|
139
|
+
|
140
|
+
matches.each do |match|
|
141
|
+
|
142
|
+
# set match on fileset for filter
|
143
|
+
fileset.glob = match[:glob]
|
144
|
+
|
145
|
+
# get files for filter (and remove them from fileset)
|
146
|
+
files = fileset.extract_eligible
|
147
|
+
|
148
|
+
# add the results of this match to the fileset
|
149
|
+
fileset.files += match[:filters].map do |filter|
|
150
|
+
|
151
|
+
# hand filter its files
|
152
|
+
filter.files = files
|
153
|
+
|
154
|
+
# tell filter where to save them
|
155
|
+
filter.output_root = output_root
|
156
|
+
|
157
|
+
# process files and return them for another loop
|
158
|
+
files = filter.process
|
159
|
+
end.flatten
|
160
|
+
end
|
161
|
+
|
162
|
+
fileset
|
163
|
+
end
|
164
|
+
|
165
|
+
end
|
166
|
+
end
|
metadata
ADDED
@@ -0,0 +1,62 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: extruder
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Yehuda Katz
|
9
|
+
- Tom Dale
|
10
|
+
- Tyler Kellen
|
11
|
+
autorequire:
|
12
|
+
bindir: bin
|
13
|
+
cert_chain: []
|
14
|
+
date: 2012-02-07 00:00:00.000000000 Z
|
15
|
+
dependencies: []
|
16
|
+
description: A simple and powerful file pipeline.
|
17
|
+
email:
|
18
|
+
- tyler@sleekcode.net
|
19
|
+
executables: []
|
20
|
+
extensions: []
|
21
|
+
extra_rdoc_files: []
|
22
|
+
files:
|
23
|
+
- .gitignore
|
24
|
+
- Gemfile
|
25
|
+
- README.md
|
26
|
+
- extruder.gemspec
|
27
|
+
- lib/extruder.rb
|
28
|
+
- lib/extruder/dsl.rb
|
29
|
+
- lib/extruder/errors.rb
|
30
|
+
- lib/extruder/file_set.rb
|
31
|
+
- lib/extruder/file_wrapper.rb
|
32
|
+
- lib/extruder/filter.rb
|
33
|
+
- lib/extruder/filters/concat_filter.rb
|
34
|
+
- lib/extruder/filters/ordering_concat_filter.rb
|
35
|
+
- lib/extruder/main.rb
|
36
|
+
- lib/extruder/version.rb
|
37
|
+
homepage: ''
|
38
|
+
licenses: []
|
39
|
+
post_install_message:
|
40
|
+
rdoc_options: []
|
41
|
+
require_paths:
|
42
|
+
- lib
|
43
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
44
|
+
none: false
|
45
|
+
requirements:
|
46
|
+
- - ! '>='
|
47
|
+
- !ruby/object:Gem::Version
|
48
|
+
version: '0'
|
49
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
50
|
+
none: false
|
51
|
+
requirements:
|
52
|
+
- - ! '>='
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
requirements: []
|
56
|
+
rubyforge_project:
|
57
|
+
rubygems_version: 1.8.10
|
58
|
+
signing_key:
|
59
|
+
specification_version: 3
|
60
|
+
summary: A simple and powerful file pipeline.
|
61
|
+
test_files: []
|
62
|
+
has_rdoc:
|