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