contraption 0.2.0

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