aerial 0.0.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +1 -0
- data/MIT-LICENSE +22 -0
- data/README.md +74 -0
- data/Rakefile +96 -0
- data/VERSION +1 -0
- data/config/config.yml +19 -0
- data/config/deploy.rb +50 -0
- data/lib/aerial.rb +100 -0
- data/lib/aerial/article.rb +241 -0
- data/lib/aerial/base.rb +172 -0
- data/lib/aerial/comment.rb +160 -0
- data/lib/aerial/config.rb +41 -0
- data/lib/aerial/content.rb +74 -0
- data/lib/aerial/vendor/akismetor.rb +52 -0
- data/lib/aerial/vendor/cache.rb +139 -0
- data/lib/features/article.feature +10 -0
- data/lib/features/home.feature +16 -0
- data/lib/features/step_definitions/article_steps.rb +4 -0
- data/lib/features/step_definitions/home_steps.rb +8 -0
- data/lib/features/support/env.rb +38 -0
- data/lib/features/support/pages/article.rb +9 -0
- data/lib/features/support/pages/homepage.rb +9 -0
- data/lib/spec/aerial_spec.rb +203 -0
- data/lib/spec/article_spec.rb +338 -0
- data/lib/spec/base_spec.rb +65 -0
- data/lib/spec/comment_spec.rb +216 -0
- data/lib/spec/config_spec.rb +25 -0
- data/lib/spec/fixtures/articles/sample-article/sample-article.article +6 -0
- data/lib/spec/fixtures/articles/test-article-one/test-article.article +7 -0
- data/lib/spec/fixtures/articles/test-article-three/test-article.article +7 -0
- data/lib/spec/fixtures/articles/test-article-two/comment-missing-fields.comment +8 -0
- data/lib/spec/fixtures/articles/test-article-two/test-article.article +7 -0
- data/lib/spec/fixtures/articles/test-article-two/test-comment.comment +10 -0
- data/lib/spec/fixtures/config.yml +35 -0
- data/lib/spec/fixtures/public/javascripts/application.js +109 -0
- data/lib/spec/fixtures/public/javascripts/jquery-1.3.1.min.js +19 -0
- data/lib/spec/fixtures/public/javascripts/jquery.template.js +255 -0
- data/lib/spec/fixtures/views/article.haml +19 -0
- data/lib/spec/fixtures/views/articles.haml +2 -0
- data/lib/spec/fixtures/views/comment.haml +8 -0
- data/lib/spec/fixtures/views/home.haml +2 -0
- data/lib/spec/fixtures/views/layout.haml +22 -0
- data/lib/spec/fixtures/views/post.haml +27 -0
- data/lib/spec/fixtures/views/rss.haml +15 -0
- data/lib/spec/fixtures/views/sidebar.haml +21 -0
- data/lib/spec/fixtures/views/style.sass +163 -0
- data/lib/spec/spec_helper.rb +117 -0
- metadata +101 -0
data/lib/aerial/base.rb
ADDED
@@ -0,0 +1,172 @@
|
|
1
|
+
CONFIG = YAML.load_file( File.join(AERIAL_ROOT, 'config', 'config.yml') ) unless defined?(CONFIG)
|
2
|
+
|
3
|
+
require 'aerial/content'
|
4
|
+
require 'aerial/article'
|
5
|
+
require 'aerial/comment'
|
6
|
+
require 'aerial/vendor/cache'
|
7
|
+
require 'aerial/vendor/akismetor'
|
8
|
+
require 'aerial/config'
|
9
|
+
|
10
|
+
module Aerial
|
11
|
+
|
12
|
+
VERSION = '0.1.0'
|
13
|
+
|
14
|
+
class << self
|
15
|
+
attr_accessor :debug, :logger, :repo, :config
|
16
|
+
|
17
|
+
def log(str)
|
18
|
+
logger.debug { str } if debug
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
# Make sure git is added to the env path
|
23
|
+
ENV['PATH'] = "#{ENV['PATH']}:/usr/local/bin"
|
24
|
+
@logger ||= ::Logger.new(STDOUT)
|
25
|
+
@config ||= Aerial::Config.new(CONFIG)
|
26
|
+
@repo ||= Grit::Repo.new(File.join(AERIAL_ROOT, '.'))
|
27
|
+
@debug ||= false
|
28
|
+
|
29
|
+
module Helper
|
30
|
+
|
31
|
+
# Defines the url for our application
|
32
|
+
def hostname
|
33
|
+
if request.env['HTTP_X_FORWARDED_SERVER'] =~ /[a-z]*/
|
34
|
+
request.env['HTTP_X_FORWARDED_SERVER']
|
35
|
+
else
|
36
|
+
request.env['HTTP_HOST']
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
# Returns the
|
41
|
+
def path
|
42
|
+
base = "#{request.env['REQUEST_URI']}".scan(/\w+/).first
|
43
|
+
return base.blank? ? "index" : base
|
44
|
+
end
|
45
|
+
|
46
|
+
# Creates a complete link including the hostname
|
47
|
+
def full_hostname(link = "")
|
48
|
+
"http://#{hostname}#{link}"
|
49
|
+
end
|
50
|
+
|
51
|
+
# Display the page titles in proper format
|
52
|
+
def page_title
|
53
|
+
title = @page_title ? "| #{@page_title}" : ""
|
54
|
+
return "#{Aerial.config.title} #{title}"
|
55
|
+
end
|
56
|
+
|
57
|
+
# Format just the DATE in a nice easy to read format
|
58
|
+
def humanized_date(date)
|
59
|
+
if date && date.respond_to?(:strftime)
|
60
|
+
date.strftime('%A %B, %d %Y').strip
|
61
|
+
else
|
62
|
+
'Never'
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# Format just the DATE in a short way
|
67
|
+
def short_date(date)
|
68
|
+
if date && date.respond_to?(:strftime)
|
69
|
+
date.strftime('%b %d').strip
|
70
|
+
else
|
71
|
+
'Never'
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
# Format for the rss 2.0 feed
|
76
|
+
def rss_date(date)
|
77
|
+
date.strftime("%a, %d %b %Y %H:%M:%S %Z") #Tue, 03 Jun 2003 09:39:21 GMT
|
78
|
+
end
|
79
|
+
|
80
|
+
# Truncate a string
|
81
|
+
def blurb(text, options ={})
|
82
|
+
options.merge!(:length => 160, :omission => "...")
|
83
|
+
if text
|
84
|
+
l = options[:length] - options[:omission].length
|
85
|
+
chars = text
|
86
|
+
(chars.length > options[:length] ? chars[0...l] + options[:omission] : text).to_s
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
# Handy method to render partials including collections
|
91
|
+
def partial(template, options = {})
|
92
|
+
options.merge!(:layout => false)
|
93
|
+
return if options.has_key?(:collection) && options[:collection].nil?
|
94
|
+
|
95
|
+
if collection = options.delete(:collection) then
|
96
|
+
collection.inject([]) do |buffer, member|
|
97
|
+
buffer << haml(template, options.merge(:layout => false,
|
98
|
+
:locals => {template.to_sym => member}))
|
99
|
+
end.join("\n")
|
100
|
+
else
|
101
|
+
haml(template, options)
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
# Author link
|
106
|
+
def link_to_author(comment)
|
107
|
+
unless comment.homepage.blank?
|
108
|
+
return "<a href='#{comment.homepage}' rel='external'>#{comment.author}</a>"
|
109
|
+
end
|
110
|
+
comment.author
|
111
|
+
end
|
112
|
+
|
113
|
+
# Create a list of hyperlinks with a set of tags
|
114
|
+
def link_to_tags(tags)
|
115
|
+
return unless tags
|
116
|
+
links = []
|
117
|
+
tags.each do |tag|
|
118
|
+
links << "<a href='/tags/#{tag}' rel='#{tag}'>#{tag}</a>"
|
119
|
+
end
|
120
|
+
links.join(", ")
|
121
|
+
end
|
122
|
+
|
123
|
+
end
|
124
|
+
|
125
|
+
# Provides a few methods for interacting with that Aerial repository
|
126
|
+
class Git
|
127
|
+
|
128
|
+
# Commit the new file and push it to the remote repository
|
129
|
+
def self.commit_and_push(path, message)
|
130
|
+
self.commit(path, message)
|
131
|
+
self.push
|
132
|
+
end
|
133
|
+
|
134
|
+
# Added the file in the path and commit the changs to the repo
|
135
|
+
# +path+ to the new file to commit
|
136
|
+
# +message+ description of the commit
|
137
|
+
def self.commit(path, message)
|
138
|
+
Dir.chdir(File.expand_path(Aerial.repo.working_dir)) do
|
139
|
+
Aerial.repo.add(path)
|
140
|
+
end
|
141
|
+
Aerial.repo.commit_index(message)
|
142
|
+
end
|
143
|
+
|
144
|
+
# Adds all untracked files and commits them to the repo
|
145
|
+
def self.commit_all(path = ".", message = "Commited all changes at: #{DateTime.now}")
|
146
|
+
unless Aerial.repo.status.untracked.empty?
|
147
|
+
self.commit(path, message)
|
148
|
+
end
|
149
|
+
true
|
150
|
+
end
|
151
|
+
|
152
|
+
# Upload all new commits to the remote repo (if exists)
|
153
|
+
def self.push
|
154
|
+
return unless Aerial.config.git.name && Aerial.config.git.branch
|
155
|
+
|
156
|
+
begin
|
157
|
+
cmd = "push #{Aerial.config.git.name} #{Aerial.config.git.branch} "
|
158
|
+
Aerial.repo.git.run('', cmd, '', {}, "")
|
159
|
+
rescue Exception => e
|
160
|
+
Aerial.log(e.message)
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
end
|
165
|
+
|
166
|
+
end
|
167
|
+
|
168
|
+
class Object
|
169
|
+
def blank?
|
170
|
+
respond_to?(:empty?) ? empty? : !self
|
171
|
+
end
|
172
|
+
end
|
@@ -0,0 +1,160 @@
|
|
1
|
+
module Aerial
|
2
|
+
|
3
|
+
# Anonymous feedback
|
4
|
+
class Comment < Content
|
5
|
+
|
6
|
+
attr_reader :id, :permalink, :article, :spam, :file_path
|
7
|
+
attr_accessor :archive_name, :spam, :published_at, :name, :referrer,
|
8
|
+
:email, :homepage, :user_ip, :user_agent, :file_name
|
9
|
+
|
10
|
+
def initialize(atts = {})
|
11
|
+
super
|
12
|
+
sanitize_url
|
13
|
+
end
|
14
|
+
|
15
|
+
# =============================================================================================
|
16
|
+
# PUBLIC CLASS METHODS
|
17
|
+
# =============================================================================================
|
18
|
+
|
19
|
+
# Create a new instance and write comment to disk
|
20
|
+
# +path+ the file location of the comment
|
21
|
+
def self.create(archive_name, attributes ={})
|
22
|
+
comment = Comment.new(attributes.merge(:archive_name => archive_name))
|
23
|
+
if comment.valid?
|
24
|
+
return self.save_new(comment)
|
25
|
+
end
|
26
|
+
false
|
27
|
+
end
|
28
|
+
|
29
|
+
# Open and existing comment
|
30
|
+
# +data+ contains info about the comment
|
31
|
+
def self.open(data, options={})
|
32
|
+
self.new( self.extract_comment_from(data, options) )
|
33
|
+
end
|
34
|
+
|
35
|
+
# =============================================================================================
|
36
|
+
# PUBLIC INSTANCE METHODS
|
37
|
+
# =============================================================================================
|
38
|
+
|
39
|
+
# Save the instance to disk
|
40
|
+
# +archive_name+ the parent directory of the article, we're forcing the parameter to ensure
|
41
|
+
# the archive_name is established before attemping to write it to disk
|
42
|
+
def save(archive_name)
|
43
|
+
self.archive_name = archive_name
|
44
|
+
if File.directory? self.archive_path
|
45
|
+
Comment.save_new(self)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
# Absolute path of the comment file
|
50
|
+
def expand_file
|
51
|
+
return unless self.archive_name
|
52
|
+
File.join(self.archive_path, self.name)
|
53
|
+
end
|
54
|
+
|
55
|
+
# The absolute file path of the archive
|
56
|
+
def archive_path
|
57
|
+
File.join(Aerial.repo.working_dir, Aerial.config.articles.dir, self.archive_name)
|
58
|
+
end
|
59
|
+
|
60
|
+
# Make sure comment has the required data
|
61
|
+
def valid?
|
62
|
+
return false if self.email.blank? || self.author.blank? || self.body.blank?
|
63
|
+
true
|
64
|
+
end
|
65
|
+
|
66
|
+
# Ask Akismetor if comment is spam
|
67
|
+
def suspicious?
|
68
|
+
return self.spam if self.spam
|
69
|
+
self.spam = Akismetor.spam?(akismet_attributes)
|
70
|
+
end
|
71
|
+
|
72
|
+
# Create a unique file name for this comment
|
73
|
+
def generate_name!
|
74
|
+
return self.name unless self.name.nil?
|
75
|
+
|
76
|
+
extenstion = self.suspicious? ? "spam" : "comment"
|
77
|
+
self.name = "#{DateTime.now.strftime("%Y%m%d%H%d%S")}_#{self.email}.#{extenstion}"
|
78
|
+
end
|
79
|
+
|
80
|
+
# String representation
|
81
|
+
def to_s
|
82
|
+
me = ""
|
83
|
+
me << "Author: #{self.author} \n" if self.author
|
84
|
+
me << "Published: #{self.published_at} \n" if self.published_at.to_s
|
85
|
+
me << "Email: #{self.email} \n" if self.email
|
86
|
+
me << "Homepage: #{self.homepage} \n" if self.homepage
|
87
|
+
me << "User IP: #{self.user_ip} \n" if self.user_ip
|
88
|
+
me << "User Agent: #{self.user_agent} \n" if self.user_agent
|
89
|
+
me << "Spam?: #{self.spam} \n" if self.user_agent
|
90
|
+
me << "\n#{self.body}" if self.body
|
91
|
+
return me
|
92
|
+
end
|
93
|
+
|
94
|
+
private
|
95
|
+
|
96
|
+
# =============================================================================================
|
97
|
+
# PRIVATE CLASS METHODS
|
98
|
+
# =============================================================================================
|
99
|
+
|
100
|
+
# Create a new Comment instance with data from the given file
|
101
|
+
def self.extract_comment_from(data, options = {})
|
102
|
+
comment_file = data.to_s
|
103
|
+
comment = options
|
104
|
+
comment[:id] = self.extract_header("id", comment_file)
|
105
|
+
comment[:user_ip] = self.extract_header("ip", comment_file)
|
106
|
+
comment[:user_agent] = self.extract_header("user-agent", comment_file)
|
107
|
+
comment[:referrer] = self.extract_header("referrer", comment_file)
|
108
|
+
comment[:permalink] = self.extract_header("permalink", comment_file)
|
109
|
+
comment[:author] = self.extract_header("author", comment_file)
|
110
|
+
comment[:email] = self.extract_header("email", comment_file)
|
111
|
+
comment[:homepage] = self.extract_header("homepage", comment_file)
|
112
|
+
comment[:published_at]= DateTime.parse(self.extract_header("published", comment_file))
|
113
|
+
comment[:body] = self.scan_for_field(comment_file, self.body_field)
|
114
|
+
return comment
|
115
|
+
end
|
116
|
+
|
117
|
+
# Write the contents of the comment to the same directory as the article
|
118
|
+
def self.save_new(comment)
|
119
|
+
return false unless comment && comment.archive_name
|
120
|
+
comment.generate_name!
|
121
|
+
comment.published_at = DateTime.now
|
122
|
+
path = File.join(Aerial.config.articles.dir, comment.archive_name, comment.name)
|
123
|
+
Dir.chdir(Aerial.repo.working_dir) do
|
124
|
+
File.open(path, 'w') do |file|
|
125
|
+
file << comment.to_s
|
126
|
+
end
|
127
|
+
end
|
128
|
+
Aerial::Git.commit_and_push(path, "New comment: #{comment.name}")
|
129
|
+
return comment
|
130
|
+
end
|
131
|
+
|
132
|
+
# =============================================================================================
|
133
|
+
# PRIVATE INSTANCE METHODS
|
134
|
+
# =============================================================================================
|
135
|
+
|
136
|
+
# Make sure the url is cleaned
|
137
|
+
def sanitize_url
|
138
|
+
return unless self.homepage
|
139
|
+
homepage.gsub!(/^(.*)/, 'http://\1') unless homepage =~ %r{^http://} or homepage.empty?
|
140
|
+
end
|
141
|
+
|
142
|
+
# Try to prevent spam with akismet
|
143
|
+
def akismet_attributes
|
144
|
+
{
|
145
|
+
:key => Aerial.config.akismet.key,
|
146
|
+
:blog => Aerial.config.akismet.url,
|
147
|
+
:user_ip => self.user_ip,
|
148
|
+
:user_agent => self.user_agent,
|
149
|
+
:referrer => self.referrer,
|
150
|
+
:permalink => self.permalink,
|
151
|
+
:comment_type => 'comment',
|
152
|
+
:comment_author => self.author,
|
153
|
+
:comment_author_email => self.email,
|
154
|
+
:comment_author_url => self.homepage,
|
155
|
+
:comment_content => self.body
|
156
|
+
}
|
157
|
+
end
|
158
|
+
|
159
|
+
end
|
160
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
module Aerial
|
2
|
+
|
3
|
+
class Config
|
4
|
+
|
5
|
+
class << self
|
6
|
+
attr_accessor :config
|
7
|
+
end
|
8
|
+
|
9
|
+
def initialize(yaml)
|
10
|
+
@config = nested_hash_to_openstruct(yaml)
|
11
|
+
end
|
12
|
+
|
13
|
+
# Complete path to the directory theme
|
14
|
+
def theme_directory
|
15
|
+
directory = File.join(AERIAL_ROOT, self.views.dir)
|
16
|
+
File.join(directory)
|
17
|
+
end
|
18
|
+
|
19
|
+
def method_missing(method_name, *attributes)
|
20
|
+
if @config.respond_to?(method_name.to_sym)
|
21
|
+
return @config.send(method_name.to_sym)
|
22
|
+
else
|
23
|
+
false
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
# Recursively convert nested Hashes into Openstructs
|
30
|
+
def nested_hash_to_openstruct(obj)
|
31
|
+
if obj.is_a? Hash
|
32
|
+
obj.each { |key, value| obj[key] = nested_hash_to_openstruct(value) }
|
33
|
+
OpenStruct.new(obj)
|
34
|
+
else
|
35
|
+
return obj
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
end
|
40
|
+
|
41
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
require 'coderay'
|
2
|
+
require 'date'
|
3
|
+
|
4
|
+
module Aerial
|
5
|
+
|
6
|
+
# Base class for all the site's content
|
7
|
+
class Content
|
8
|
+
|
9
|
+
attr_reader :id, :author, :title, :body, :published_at, :archive_name, :file_name
|
10
|
+
|
11
|
+
def initialize(atts = {})
|
12
|
+
atts.each_pair { |key, value| instance_variable_set("@#{key}", value) if self.respond_to? key}
|
13
|
+
end
|
14
|
+
|
15
|
+
protected
|
16
|
+
|
17
|
+
# =============================================================================================
|
18
|
+
# PROTECTED CLASS METHODS
|
19
|
+
# =============================================================================================
|
20
|
+
|
21
|
+
# With the comment string, attempt to find the given field
|
22
|
+
# +field+ the label before the ":"
|
23
|
+
# +comment+ is the contents of the comment
|
24
|
+
def self.extract_header(field, content)
|
25
|
+
return self.scan_for_field(content, self.header_field_for(field))
|
26
|
+
end
|
27
|
+
|
28
|
+
# Returns the string that matches the given pattern
|
29
|
+
def self.scan_for_field(contents, pattern)
|
30
|
+
content = contents.scan(pattern).first.to_s.strip
|
31
|
+
end
|
32
|
+
|
33
|
+
# Returns the regular expression pattern for the header fields
|
34
|
+
def self.header_field_for(header)
|
35
|
+
exp = Regexp.new('^'+header+'\s*:(.*)$', Regexp::IGNORECASE)
|
36
|
+
end
|
37
|
+
|
38
|
+
# Returns the regular expression pattern for the body field
|
39
|
+
def self.body_field
|
40
|
+
exp = Regexp.new('^\n(.*)$', Regexp::MULTILINE)
|
41
|
+
end
|
42
|
+
|
43
|
+
# Look for <code> blocks and convert it for syntax highlighting
|
44
|
+
def self.parse_coderay(text)
|
45
|
+
text.scan(/(\<code>(.+?)\<\/code>)/m).each do |match|
|
46
|
+
match[1] = match[1].gsub("<br>", "\n").
|
47
|
+
gsub("&nbsp;", " ").
|
48
|
+
gsub("&lt;", "<").
|
49
|
+
gsub("&gt;", ">").
|
50
|
+
gsub("&quot;", '"')
|
51
|
+
text.gsub!(match[0], CodeRay.scan(match[1].strip, :ruby).div(:line_numbers => :table, :css => :class))
|
52
|
+
end
|
53
|
+
return text
|
54
|
+
end
|
55
|
+
|
56
|
+
# =============================================================================================
|
57
|
+
# PROTECTED INSTANCE METHODS
|
58
|
+
# =============================================================================================
|
59
|
+
|
60
|
+
# Ensure string contains valid ASCII characters
|
61
|
+
def escape(string)
|
62
|
+
return unless string
|
63
|
+
result = String.new(string)
|
64
|
+
result.gsub!(/[^\x00-\x7F]+/, '') # Remove anything non-ASCII entirely (e.g. diacritics).
|
65
|
+
result.gsub!(/[^\w_ \-]+/i, '') # Remove unwanted chars.
|
66
|
+
result.gsub!(/[ \-]+/i, '-') # No more than one of the separator in a row.
|
67
|
+
result.gsub!(/^\-|\-$/i, '') # Remove leading/trailing separator.
|
68
|
+
result.downcase!
|
69
|
+
return result
|
70
|
+
end
|
71
|
+
|
72
|
+
end
|
73
|
+
|
74
|
+
end
|