contraption 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +17 -0
  3. data/.travis.yml +5 -0
  4. data/Gemfile +4 -0
  5. data/LICENSE.txt +22 -0
  6. data/README.md +33 -0
  7. data/Rakefile +12 -0
  8. data/bin/contraption +7 -0
  9. data/contraption.gemspec +29 -0
  10. data/features/author_finalizes_draft.feature +21 -0
  11. data/features/author_generates_site.feature +35 -0
  12. data/features/step_definitions/author_steps.rb +52 -0
  13. data/features/support/env.rb +0 -0
  14. data/features/support/example_inputs.rb +188 -0
  15. data/features/support/hooks.rb +3 -0
  16. data/lib/contraption/catalog.rb +65 -0
  17. data/lib/contraption/formatter.rb +30 -0
  18. data/lib/contraption/header.rb +83 -0
  19. data/lib/contraption/http_handler.rb +11 -0
  20. data/lib/contraption/location.rb +81 -0
  21. data/lib/contraption/options.rb +56 -0
  22. data/lib/contraption/post.rb +69 -0
  23. data/lib/contraption/repository.rb +58 -0
  24. data/lib/contraption/rss_builder.rb +30 -0
  25. data/lib/contraption/runner.rb +60 -0
  26. data/lib/contraption/s3_uploader.rb +30 -0
  27. data/lib/contraption/site.rb +85 -0
  28. data/lib/contraption/tag.rb +20 -0
  29. data/lib/contraption/tag_cloud.rb +35 -0
  30. data/lib/contraption/version.rb +3 -0
  31. data/lib/contraption.rb +14 -0
  32. data/spec/contraption/lib/catalog_spec.rb +105 -0
  33. data/spec/contraption/lib/formatter_spec.rb +30 -0
  34. data/spec/contraption/lib/header_spec.rb +198 -0
  35. data/spec/contraption/lib/location_spec.rb +148 -0
  36. data/spec/contraption/lib/options_spec.rb +25 -0
  37. data/spec/contraption/lib/post_spec.rb +50 -0
  38. data/spec/contraption/lib/repository_spec.rb +38 -0
  39. data/spec/contraption/lib/tag_cloud_spec.rb +39 -0
  40. data/spec/contraption/lib/tag_spec.rb +38 -0
  41. data/spec/contraption/lib/version_spec.rb +9 -0
  42. data/spec/spec_helper.rb +6 -0
  43. metadata +201 -0
@@ -0,0 +1,81 @@
1
+ require_relative '../contraption'
2
+ require 'pathname'
3
+
4
+ module Contraption
5
+ class Location
6
+ def initialize path="."
7
+ @pn = Pathname.new path
8
+ end
9
+
10
+ def list ext=/.*/
11
+ ext = Array(ext).join('|')
12
+ ext = Regexp.new ext
13
+ pn.find
14
+ .reject { |f| f == pn }
15
+ .select { |f| ext.match f.extname }
16
+ .map{ |f| f.basename.to_s }
17
+ end
18
+
19
+ def path
20
+ pn.to_s
21
+ end
22
+
23
+ def read file_name
24
+ complete_name = pn+file_name
25
+ return :file_does_not_exist unless complete_name.exist?
26
+ return :file_not_readable unless complete_name.readable?
27
+
28
+ return complete_name.read unless block_given?
29
+ complete_name.open("r") { |f| yield f }
30
+ end
31
+
32
+ def cd directory
33
+ l = Location.new(pn+directory)
34
+ l.create! unless l.pn.directory?
35
+ l
36
+ end
37
+
38
+ def create!
39
+ pn.mkpath unless exist?
40
+ end
41
+
42
+ def remove file_name
43
+ complete_name = pn+file_name
44
+ return :file_does_not_exist unless complete_name.exist?
45
+
46
+ complete_name.delete
47
+ end
48
+
49
+ def write file_name, content
50
+ complete_name = pn+file_name
51
+ return :file_already_exists if complete_name.exist?
52
+
53
+ complete_name.dirname.mkpath unless complete_name.dirname.exist?
54
+
55
+ complete_name.open("w:UTF-8") {|f| f.print content}
56
+ end
57
+
58
+ def == other_location
59
+ path == other_location.path
60
+ end
61
+
62
+ def exist?
63
+ pn.exist?
64
+ end
65
+
66
+ def readable?
67
+ pn.readable?
68
+ end
69
+
70
+ def writable?
71
+ pn.writable?
72
+ end
73
+
74
+ def executable?
75
+ pn.executable?
76
+ end
77
+
78
+ protected
79
+ attr_reader :pn
80
+ end
81
+ end
@@ -0,0 +1,56 @@
1
+ require 'optparse'
2
+ require 'yaml'
3
+
4
+ require_relative 'location'
5
+
6
+ module Contraption
7
+
8
+ DEFAULT_SOURCE = "content"
9
+ DEFAULT_DESTINATION = "public_html"
10
+ DEFAULT_CONFIG_FILE = Pathname.new("~/.contraption.yaml").expand_path
11
+
12
+ class Options
13
+ attr_reader :values
14
+
15
+ def initialize args
16
+ @values = {
17
+ source: DEFAULT_SOURCE,
18
+ destination: DEFAULT_DESTINATION,
19
+ s3_access_key_id: nil,
20
+ s3_secret_access_key: nil,
21
+ s3_target_bucket: nil
22
+ }
23
+ @values.merge!(YAML.load_file(DEFAULT_CONFIG_FILE)) if DEFAULT_CONFIG_FILE.file?
24
+ @values.each_pair {|k, v| @values[k] = Location.new(v) if %i[source destination].include? k}
25
+ parse(args)
26
+ end
27
+
28
+ private
29
+ def method_missing m, *a, &b
30
+ values.fetch(m) { super }
31
+ end
32
+
33
+ def parse args
34
+ OptionParser.new do |opts|
35
+ opts.banner = "Usage: contraption [ options ]"
36
+ opts.on("-d", "--destination path", "Path to destination") do |path|
37
+ @values[:destination] = Location.new path
38
+ end
39
+ opts.on("-s", "--source path", "Path to source") do |path|
40
+ @values[:source] = Location.new path
41
+ end
42
+ opts.on("--generate-config-file", "Create YAML file containing configuration options") do
43
+ DEFAULT_CONFIG_FILE.open('w+') {|f| f.write(@values.to_yaml) }
44
+ exit
45
+ end
46
+ begin
47
+ opts.parse!(args)
48
+ rescue OptionParser::ParseError => e
49
+ STDERR.puts e.message, "\n", opts
50
+ exit(1)
51
+ end
52
+ end
53
+ end
54
+
55
+ end
56
+ end
@@ -0,0 +1,69 @@
1
+ require_relative 'header'
2
+
3
+ require 'redcarpet'
4
+
5
+ module Contraption
6
+ class Post
7
+ class << self
8
+ def build data=""
9
+ data = data.to_s
10
+
11
+ splitted = data.split(/\n\n/, 2)
12
+ Post.new Header.from(splitted.first), translate_markdown(splitted.last)
13
+ rescue ArgumentError => e
14
+ Post.new Header.from(""), ""
15
+ end
16
+
17
+ def translate_markdown content
18
+ Redcarpet::Markdown.new(Redcarpet::Render::HTML).render(content)
19
+ end
20
+ end
21
+
22
+ def publish link_handlers=[]
23
+ links = list_links
24
+
25
+ links.map! do |link|
26
+ h = link_handlers.select{|l| l.protocol == link.first.to_sym}
27
+ if h.length > 1
28
+ raise "Multiple link handlers for #{link.first} protocol"
29
+ elsif h.length == 0
30
+ nil
31
+ else
32
+ h = h.first
33
+ [link, h.handle(link)]
34
+ end
35
+ end.compact!
36
+
37
+ update_links links
38
+
39
+ Post.new( metadata.update({publication_date: Time.now}), body)
40
+ end
41
+
42
+ attr_reader :body, :metadata
43
+ private
44
+ def initialize metadata="", body=""
45
+ @body = body
46
+ @metadata = metadata
47
+ end
48
+
49
+ def method_missing m, *a, &b
50
+ metadata.public_send(m, *a, &b)
51
+ end
52
+
53
+ def list_links
54
+ links = []
55
+ body.scan(/="([^"]*)"/) do |link|
56
+ links << link.first.split('://', 2)
57
+ end
58
+ links
59
+ end
60
+
61
+ def update_links new_links
62
+ new_links.each do |link|
63
+ orig = link.first.join('://')
64
+ new = link.last.join('://')
65
+ @body.gsub!("=\"#{orig}\"", "=\"#{new}\"")
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,58 @@
1
+ require_relative "post"
2
+ require_relative "formatter"
3
+ require_relative "catalog"
4
+
5
+ module Contraption
6
+ class Repository
7
+ attr_reader :formatter
8
+
9
+ def initialize path
10
+ @path = path
11
+ validate
12
+ construct_locations
13
+ @formatter = Formatter.new @formats_source
14
+ end
15
+
16
+ def validate
17
+ raise "Invalid source directory structure in #{@path}" unless valid_layout?
18
+ end
19
+
20
+ def valid_layout?
21
+ @path.exist? and @path.list.include? "drafts" and @path.list.include? "posts"
22
+ end
23
+
24
+ def drafts
25
+ @drafts_source.list
26
+ end
27
+
28
+ def posts
29
+ Catalog.new( @posts_source.list.map{|p| Post.build(@posts_source.read p)} )
30
+ end
31
+
32
+ def static_files
33
+ @static_source.path
34
+ end
35
+
36
+ def completed_drafts
37
+ drafts.map{|draft| [draft, @drafts_source.read(draft)]}
38
+ .each_with_object([]) do |draft, result|
39
+ result << draft.first if Post.build(draft.last).new?
40
+ end
41
+ end
42
+
43
+ def finalize_completed_drafts link_handlers=[]
44
+ completed_drafts.map{|draft| [draft, Post.build(@drafts_source.read(draft)).publish(link_handlers)]}
45
+ .each do |filename, post|
46
+ @posts_source.write post.publication_date.strftime("%Y%m%d-")+post.filename, @formatter.format(post, :raw)
47
+ @drafts_source.remove filename
48
+ end
49
+ end
50
+
51
+ def construct_locations
52
+ @drafts_source = @path.cd("drafts")
53
+ @posts_source = @path.cd("posts")
54
+ @formats_source = @path.cd("formats")
55
+ @static_source = @path.cd("static")
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,30 @@
1
+ require 'rss/maker'
2
+
3
+ module Contraption
4
+ class RSSBuilder
5
+ def initialize posts
6
+ @posts = posts
7
+ end
8
+
9
+ def to_rss
10
+ RSS::Maker.make('2.0') do |maker|
11
+ maker.channel.author = 'Casey Robinson'
12
+ maker.channel.updated = Time.now.to_s
13
+ maker.channel.link = 'http://rampantmonkey.com/rss'
14
+ maker.channel.title = 'Rampant Monkey - Everything'
15
+ maker.channel.description = 'Everything published on rampantmonkey.com'
16
+
17
+ @posts.most_recent(-1).each do |post|
18
+ maker.items.new_item do |item|
19
+ item.link = "http://rampantmonkey.com/#{post.publication_date.strftime('%Y/%m')}/#{post.filename('.html')}"
20
+ item.title = post.title
21
+ item.pubDate = post.publication_date.to_s
22
+ item.updated = post.publication_date.to_s
23
+ item.description = post.summary
24
+ item.content_encoded = post.body
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,60 @@
1
+ require_relative 'repository'
2
+ require_relative 'site'
3
+ require_relative 's3_uploader'
4
+ require_relative 'http_handler'
5
+
6
+ module Contraption
7
+ class Runner
8
+ def initialize options
9
+ @options = options
10
+ end
11
+
12
+ def run!
13
+ source.finalize_completed_drafts handlers
14
+ site.build_all_individuals
15
+ site.build_month_pages
16
+ site.build_year_pages
17
+ site.build_tag_pages
18
+ site.build_tag_cloud
19
+ site.build_recent
20
+ site.build_landing
21
+ site.build_rss
22
+ copy_static_files
23
+ end
24
+
25
+ private
26
+ def formatter
27
+ @formatter ||= source.formatter
28
+ end
29
+
30
+ def posts
31
+ @posts ||= source.posts
32
+ end
33
+
34
+ def site
35
+ @site ||= Site.new @options.destination, posts, formatter
36
+ end
37
+
38
+ def source
39
+ @source ||= Repository.new @options.source
40
+ end
41
+
42
+ def copy_static_files
43
+ `cp -r #{source.static_files}/* #{site.root}` unless (Dir[source.static_files.to_s + "/*"]).length == 0
44
+ end
45
+
46
+ def handlers
47
+ if @options.s3_access_key_id
48
+ [
49
+ HttpHandler.new,
50
+ S3Uploader.new({access_key_id: @options.s3_access_key_id,
51
+ secret_access_key: @options.s3_secret_access_key},
52
+ @options.s3_target_bucket,
53
+ @options.source)
54
+ ]
55
+ else
56
+ [ HttpHandler.new]
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,30 @@
1
+ require 'aws/s3'
2
+ require 'pathname'
3
+
4
+ module Contraption
5
+ class S3Uploader
6
+ def initialize connection_args={}, target_bucket, path
7
+ AWS::S3::Base.establish_connection! connection_args
8
+ @target_bucket = target_bucket
9
+ @path = path.cd "drafts"
10
+ end
11
+
12
+ def protocol
13
+ :s3
14
+ end
15
+
16
+ def handle request
17
+ local_path = Pathname.new(@path.path) + request.last
18
+ if local_path.exist?
19
+ upload local_path, request.last
20
+ ["http:", "#{@target_bucket}/#{request.last}"]
21
+ else
22
+ raise "#{request.last} does not exist"
23
+ end
24
+ end
25
+
26
+ def upload local_path, remote_path
27
+ AWS::S3::S3Object.store(remote_path, open(local_path), @target_bucket, :access => :public_read, 'Cache-Control' => 'max-age=3136000')
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,85 @@
1
+ require_relative 'tag_cloud'
2
+ require_relative 'rss_builder'
3
+
4
+ module Contraption
5
+ class Site
6
+ def initialize location, posts, formats
7
+ @location = location
8
+ @formats = formats
9
+ @posts = posts
10
+ end
11
+
12
+ def build_all_individuals
13
+ @posts.each.to_a.each { |i| build_individual i }
14
+ end
15
+
16
+ def build_individual i
17
+ post_location = @location.cd(post_path i)
18
+ context = build i
19
+ page = @formats.format(context, :page)
20
+ post_location.write(i.filename('.html'), page)
21
+ end
22
+
23
+ def build_month_pages
24
+ build_pages_by(:month) {|m| "#{m.year}/#{"%02d" % m.month}"}
25
+ end
26
+
27
+ def build_year_pages
28
+ build_pages_by(:year) {|y| y.year.to_s}
29
+ end
30
+
31
+ def build_tag_pages
32
+ build_pages_by(:tag) {|t| "tags/#{Tag.new(t).to_url}"}
33
+ end
34
+
35
+ def build_recent
36
+ recent_posts = @posts.most_recent 8
37
+ combined_posts = build recent_posts
38
+ write_as_page combined_posts, @location, 'recent.html'
39
+ end
40
+
41
+ def build_landing
42
+ content = @formats.format({most_recent: @posts.most_recent.first}, :landing_page)
43
+ @location.write('index.html', content)
44
+ end
45
+
46
+ def build_tag_cloud
47
+ tag_cloud = TagCloud.new @posts
48
+ content = @formats.format({content: tag_cloud.to_s}, :tag_cloud)
49
+ path = @location.cd 'tags'
50
+ write_as_page content, path
51
+ end
52
+
53
+ def build_rss
54
+ feed = RSSBuilder.new(@posts).to_rss
55
+ @location.write 'rss', feed
56
+ end
57
+
58
+ def root
59
+ @location.path
60
+ end
61
+
62
+ private
63
+ def build collection
64
+ Array(collection).map {|p| @formats.format p}.join
65
+ end
66
+
67
+ def build_pages_by type, &determine_path
68
+ @posts.public_send("by_#{type}".to_sym).each_pair do |key, posts|
69
+ path = @location.cd determine_path.call key
70
+ combined_posts = build posts
71
+ intermediate_page = @formats.format({content: combined_posts, key: key}, type.to_sym)
72
+ write_as_page intermediate_page, path
73
+ end
74
+ end
75
+
76
+ def post_path post
77
+ post.publication_date.strftime('%Y') + '/' + post.publication_date.strftime('%m')
78
+ end
79
+
80
+ def write_as_page content, path, name='index.html'
81
+ page = @formats.format(content, :page)
82
+ path.write(name, page)
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,20 @@
1
+ module Contraption
2
+ class Tag
3
+ def initialize text
4
+ @display_text = text.to_s
5
+ end
6
+
7
+ def to_s
8
+ @display_text
9
+ end
10
+
11
+ def to_url
12
+ @display_text.downcase
13
+ .gsub ' ', '-'
14
+ end
15
+
16
+ def to_sym
17
+ @display_text.to_sym
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,35 @@
1
+ module Contraption
2
+ class TagCloud
3
+ def initialize posts
4
+ @posts = posts
5
+ end
6
+
7
+ def to_s
8
+ @posts.by_tag
9
+ .each_pair
10
+ .map {|k, v| [tag_link(k), tag_size(v.length)]}
11
+ .map {|el| scale el}
12
+ .join "\n"
13
+ end
14
+
15
+ private
16
+ def tag_link tag
17
+ tag = Tag.new tag
18
+ %Q{<a href="tags/#{tag.to_url}/index.html">#{tag.to_s}</a>}
19
+ end
20
+
21
+ def scale element
22
+ link = element.first
23
+ size = element.last
24
+ %Q{<span style="font-size: #{size}em;">#{link}</span>}
25
+ end
26
+
27
+ def max
28
+ @posts.by_tag.each_pair.map {|k, v| v.length}.max
29
+ end
30
+
31
+ def tag_size length
32
+ (1 + length.to_f/max) * 0.8
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,3 @@
1
+ module Contraption
2
+ VERSION = "0.2.0"
3
+ end
@@ -0,0 +1,14 @@
1
+ require "contraption/version"
2
+ require "contraption/post"
3
+ require "contraption/header"
4
+ require "contraption/catalog"
5
+ require "contraption/location"
6
+ require "contraption/runner"
7
+ require "contraption/tag"
8
+ require "contraption/tag_cloud"
9
+
10
+ module Contraption
11
+ Year = Struct.new :year
12
+ Month = Struct.new :year, :month
13
+ Day = Struct.new :year, :month, :day
14
+ end
@@ -0,0 +1,105 @@
1
+ require_relative '../../../lib/contraption/catalog'
2
+ require 'date'
3
+
4
+ module Contraption
5
+ describe Catalog do
6
+ context "item storage" do
7
+ before (:each) do
8
+ @items = %w[a b c d e f g]
9
+ @c = Catalog.new @items
10
+ end
11
+
12
+ it "stores items" do
13
+ @c.items.should eq @items
14
+ end
15
+
16
+ it "stores new items" do
17
+ @c << "P"
18
+ @c.items.should eq(@items << "P")
19
+ end
20
+
21
+ it "delegates #each to @items" do
22
+ @c.each.to_a.should eq @items
23
+ end
24
+ end
25
+
26
+ context "date grouping" do
27
+ let(:item_1) { stub(publication_date: Date.new(2012, 2, 1)) }
28
+ let(:item_2) { stub(publication_date: Date.new(2012, 1, 1)) }
29
+ let(:item_3) { stub(publication_date: Date.new(2013, 1, 7)) }
30
+ let(:item_4) { stub(publication_date: Date.new(2013, 1, 8)) }
31
+ let(:catalog) { Catalog.new [item_1, item_2, item_3, item_4] }
32
+
33
+ it "groups items by year" do
34
+ catalog.by_year.should eq ({
35
+ Year.new(2012) => [item_1, item_2],
36
+ Year.new(2013) => [item_3, item_4]
37
+ })
38
+ end
39
+
40
+ it "groups items by month" do
41
+ catalog.by_month.should eq ({
42
+ Month.new(2012, 2) => [item_1],
43
+ Month.new(2012, 1) => [item_2],
44
+ Month.new(2013, 1) => [item_3, item_4]
45
+ })
46
+ end
47
+
48
+ it "groups items by passed block" do
49
+ catalog.by_day.should eq ({
50
+ Day.new(2012, 2, 1) => [item_1],
51
+ Day.new(2012, 1, 1) => [item_2],
52
+ Day.new(2013, 1, 7) => [item_3],
53
+ Day.new(2013, 1, 8) => [item_4]
54
+ })
55
+ end
56
+
57
+ context "#most_recent" do
58
+ it "returns the most recently published item" do
59
+ catalog.most_recent.should eq [item_4]
60
+ end
61
+
62
+ it "returns the 3 most recently published items" do
63
+ catalog.most_recent(3).should eq [item_4, item_3, item_1]
64
+ end
65
+
66
+ it "returns all posts given a negative parameter" do
67
+ catalog.most_recent(-1).should eq [item_4, item_3, item_1, item_2]
68
+ end
69
+ end
70
+ end
71
+
72
+ context "tag grouping" do
73
+ let(:item_1) { stub(tags: %w[a b c]) }
74
+ let(:item_2) { stub(tags: [])}
75
+ let(:item_3) { stub(tags: %w[a d]) }
76
+ let(:item_4) { stub(tags: %w[b c d]) }
77
+ let(:item_5) { stub(tags: %w[c e]) }
78
+ let(:catalog) { Catalog.new [item_1, item_2, item_3, item_4, item_5] }
79
+
80
+ it "groups all items tagged with 'a'" do
81
+ catalog.by_tag[:a].should eq [item_1, item_3]
82
+ end
83
+
84
+ it "groups all items tagged with 'b'" do
85
+ catalog.by_tag[:b].should eq [item_1, item_4]
86
+ end
87
+
88
+ it "groups all items tagged with 'c'" do
89
+ catalog.by_tag[:c].should eq [item_1, item_4, item_5]
90
+ end
91
+
92
+ it "groups all items tagged with 'd'" do
93
+ catalog.by_tag[:d].should eq [item_3, item_4]
94
+ end
95
+
96
+ it "groups all items tagged with 'e'" do
97
+ catalog.by_tag[:e].should eq [item_5]
98
+ end
99
+ end
100
+
101
+ it "includes enumerable" do
102
+ Catalog.included_modules.should include Enumerable
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,30 @@
1
+ require_relative '../../../lib/contraption/formatter'
2
+
3
+ module Contraption
4
+ describe Formatter do
5
+ it "accepts a source location" do
6
+ source = "/some/path/"
7
+ source.stub(:list).and_return([])
8
+ Formatter.new source
9
+ end
10
+
11
+ context "#formats" do
12
+ it "contains a list of available formats" do
13
+ source = double()
14
+ source.stub(:list).with('.erb').and_return(%w[article.erb link.erb])
15
+ f = Formatter.new source
16
+ f.formats.should eq %i[article link]
17
+ end
18
+ end
19
+
20
+ context "#format" do
21
+ it "raises an error if format does not exist" do
22
+ source = double()
23
+ source.stub(:list).and_return([])
24
+ f = Formatter.new source
25
+ context = Struct.new(:type).new(:article)
26
+ expect {f.format(context)}.to raise_error(ArgumentError)
27
+ end
28
+ end
29
+ end
30
+ end