serif 0.0.0 → 0.1.2

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.
@@ -0,0 +1,154 @@
1
+ #
2
+ # ContentFile represents a file on the filesystem
3
+ # which contains the contents of a post, be it in
4
+ # draft form or otherwise.
5
+ #
6
+ # A ContentFile can determine its type based on
7
+ # the presence or absence of a "published"
8
+ # timestamp value.
9
+ #
10
+
11
+ module Serif
12
+ class ContentFile
13
+ attr_reader :path, :slug, :site
14
+
15
+ def initialize(site, path = nil)
16
+ @site = site
17
+ @path = path
18
+
19
+ if @path
20
+ # we have to parse out the source first so that we get necessary
21
+ # metadata like published vs. draft.
22
+ source = File.read(path).gsub(/\r?\n/, "\n")
23
+ source.force_encoding("UTF-8")
24
+ @source = Redhead::String[source]
25
+
26
+ dirname = File.basename(File.dirname(@path))
27
+ basename = File.basename(@path)
28
+ @slug = draft? ? basename : basename.split("-")[3..-1].join("-")
29
+ end
30
+ end
31
+
32
+ def slug=(str)
33
+ @slug = str
34
+
35
+ # if we're adding a slug and there's no path yet, then create the path.
36
+ # this will run for new drafts
37
+
38
+ if !@path
39
+ @path = File.expand_path("#{self.class.dirname}/#{@slug}")
40
+ end
41
+ end
42
+
43
+ def title
44
+ return nil if new?
45
+ headers[:title]
46
+ end
47
+
48
+ def title=(new_title)
49
+ if new?
50
+ @source = Redhead::String["title: #{new_title}\n\n"]
51
+ else
52
+ @source.headers[:title] = new_title
53
+ end
54
+ end
55
+
56
+ def modified
57
+ File.mtime(@path)
58
+ end
59
+
60
+ def draft?
61
+ !published?
62
+ end
63
+
64
+ def published?
65
+ headers.key?(:created)
66
+ end
67
+
68
+ def content(include_headers = false)
69
+ include_headers ? "#{raw_headers}\n\n#{@source.to_s}" : @source.to_s
70
+ end
71
+
72
+ def new?
73
+ !@source
74
+ end
75
+
76
+ def raw_headers
77
+ @source.headers.to_s
78
+ end
79
+
80
+ def created
81
+ return nil if new?
82
+ headers[:created].utc
83
+ end
84
+
85
+ def updated
86
+ return nil if new?
87
+ (headers[:updated] || created).utc
88
+ end
89
+
90
+ def headers
91
+ return {} unless @source
92
+
93
+ headers = @source.headers
94
+ converted_headers = {}
95
+
96
+ headers.each do |header|
97
+ key, value = header.key, header.value
98
+
99
+ if key == :created || key == :updated
100
+ value = Time.parse(value)
101
+ end
102
+
103
+ converted_headers[key] = value
104
+ end
105
+
106
+ converted_headers
107
+ end
108
+
109
+ def self.rename(original_slug, new_slug)
110
+ raise if File.exist?("#{dirname}/#{new_slug}")
111
+ File.rename("#{dirname}/#{original_slug}", "#{dirname}/#{new_slug}")
112
+ end
113
+
114
+ def save(markdown = nil)
115
+ markdown ||= content if !new?
116
+
117
+ save_path = path || "#{self.class.dirname}/#{@slug}"
118
+ File.open(save_path, "w") do |f|
119
+ f.puts %Q{#{raw_headers}
120
+
121
+ #{markdown}}.strip
122
+ end
123
+
124
+ true # always return true for now
125
+ end
126
+
127
+ def [](header)
128
+ h = headers[header]
129
+ if h
130
+ h
131
+ else
132
+ raise "no such header #{header}"
133
+ end
134
+ end
135
+
136
+ def inspect
137
+ %Q{<#{self.class} #{headers.inspect}>}
138
+ end
139
+
140
+ def self.all
141
+ Post.all + Draft.all
142
+ end
143
+
144
+ protected
145
+
146
+ def set_publish_time(time)
147
+ @source.headers[:created] = time.xmlschema
148
+ end
149
+
150
+ def set_updated_time(time)
151
+ @source.headers[:updated] = time.xmlschema
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,58 @@
1
+ module Serif
2
+ class Draft < ContentFile
3
+ def self.dirname
4
+ "_drafts"
5
+ end
6
+
7
+ def delete!
8
+ FileUtils.mkdir_p("_trash")
9
+ File.rename(@path, File.expand_path("_trash/#{Time.now.to_i}-#{slug}"))
10
+ end
11
+
12
+ def publish!
13
+ publish_time = Time.now
14
+ date = Time.now.strftime("%Y-%m-%d")
15
+ filename = "#{date}-#{slug}"
16
+ full_published_path = File.expand_path("#{Post.dirname}/#{filename}")
17
+
18
+ raise "conflict, post exists already" if File.exist?(full_published_path)
19
+
20
+ set_publish_time(publish_time)
21
+ save
22
+
23
+ File.rename(path, full_published_path)
24
+ end
25
+
26
+ def to_liquid
27
+ h = {
28
+ "title" => title,
29
+ "content" => content,
30
+ "slug" => slug,
31
+ "type" => "draft",
32
+ "draft" => draft?,
33
+ "published" => published?
34
+ }
35
+
36
+ headers.each do |key, value|
37
+ h[key] = value
38
+ end
39
+
40
+ h
41
+ end
42
+
43
+ def self.exist?(site, slug)
44
+ all(site).any? { |d| d.slug == slug }
45
+ end
46
+
47
+ def self.all(site)
48
+ files = Dir[File.join(site.directory, dirname, "*")].select { |f| File.file?(f) }.map { |f| File.expand_path(f) }
49
+ files.map { |f| new(site, f) }
50
+ end
51
+
52
+ def self.from_slug(site, slug)
53
+ path = File.expand_path(File.join(site.directory, dirname, slug))
54
+ p path
55
+ new(site, path)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,24 @@
1
+ module Serif
2
+ class MarkupRenderer < Redcarpet::Render::SmartyHTML
3
+ def block_code(code, language)
4
+ # bypass it all to avoid sticking higlihting markup on stuff with no language
5
+ return Redcarpet::Markdown.new(Redcarpet::Render::SmartyHTML, fenced_code_blocks: true).render(%Q{```
6
+ #{code}
7
+ ```}).strip unless language
8
+
9
+ out = Pygments.highlight(code, lexer: language)
10
+
11
+ # first, get rid of the div, since we want
12
+ # to stick the class onto the <pre>, to stay
13
+ # clean markup-wise.
14
+ out.sub!(/^<div[^>]*>/, "")
15
+ out.strip!
16
+ out.sub!(/<\/div>\z/, "")
17
+
18
+ out.sub!(/^<pre>/, "<pre#{" class=\"highlight\""}><code>")
19
+ out.sub!(/<\/pre>\z/, "</code></pre>\n")
20
+
21
+ out
22
+ end
23
+ end
24
+ end
data/lib/serif/post.rb ADDED
@@ -0,0 +1,65 @@
1
+ require "fileutils"
2
+
3
+ module Serif
4
+ class Post < ContentFile
5
+ def self.dirname
6
+ "_posts"
7
+ end
8
+
9
+ def url
10
+ permalink_style = headers[:permalink] || site.config.permalink
11
+
12
+ filename_parts = File.basename(path).split("-")
13
+
14
+ parts = {
15
+ "title" => slug,
16
+ "year" => filename_parts[0],
17
+ "month" => filename_parts[1],
18
+ "day" => filename_parts[2]
19
+ }
20
+
21
+ output = permalink_style
22
+
23
+ parts.each do |placeholder, value|
24
+ output = output.gsub(Regexp.quote(":" + placeholder), value)
25
+ end
26
+
27
+ output
28
+ end
29
+
30
+ def self.all(site)
31
+ files = Dir[File.join(site.directory, dirname, "*")].select { |f| File.file?(f) }.map { |f| File.expand_path(f) }
32
+ files.map { |f| new(site, f) }
33
+ end
34
+
35
+ def self.from_slug(site, slug)
36
+ all(site).find { |p| p.slug == slug }
37
+ end
38
+
39
+ def save(markdown)
40
+ # update the timestamp if the content has actually changed
41
+ set_updated_time(Time.now) unless markdown.strip == content.strip
42
+ super
43
+ end
44
+
45
+ def to_liquid
46
+ h = {
47
+ "title" => title,
48
+ "created" => created,
49
+ "updated" => updated,
50
+ "content" => content,
51
+ "slug" => slug,
52
+ "url" => url,
53
+ "type" => "post",
54
+ "draft" => draft?,
55
+ "published" => published?
56
+ }
57
+
58
+ headers.each do |key, value|
59
+ h[key.to_s] = value
60
+ end
61
+
62
+ h
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,32 @@
1
+ require "sinatra/base"
2
+ require "fileutils"
3
+ require "rack/rewrite"
4
+
5
+ module Serif
6
+ class DevelopmentServer
7
+ class DevApp < Sinatra::Base
8
+ set :public_folder, Dir.pwd
9
+
10
+ get "/" do
11
+ File.read(File.expand_path("_site/index.html"))
12
+ end
13
+
14
+ # it seems Rack::Rewrite doesn't like public_folder files, so here we are
15
+ get "*" do
16
+ File.read(Dir[File.expand_path("_site#{params[:splat].join("/")}.*")].first)
17
+ end
18
+ end
19
+
20
+ attr_reader :source_directory
21
+
22
+ def initialize(source_directory)
23
+ @source_directory = source_directory
24
+ end
25
+
26
+ def start
27
+ FileUtils.cd @source_directory
28
+ app = Sinatra.new(DevApp)
29
+ app.run!(:port => 8000)
30
+ end
31
+ end
32
+ end
data/lib/serif/site.rb ADDED
@@ -0,0 +1,148 @@
1
+ class StandardFilterCheck
2
+ include Liquid::StandardFilters
3
+
4
+ def date_supports_now?
5
+ date("now", "%Y") == Time.now.year
6
+ end
7
+ end
8
+
9
+ if StandardFilterCheck.new.date_supports_now?
10
+ puts "NOTICE! 'now' is supported by 'date' filter. Remove the patch"
11
+ sleep 5 # incur a penalty
12
+ else
13
+ module Liquid::StandardFilters
14
+ alias_method :date_orig, :date
15
+
16
+ def date(input, format)
17
+ input == "now" ? date_orig(Time.now, format) : date_orig(input, format)
18
+ end
19
+ end
20
+ end
21
+
22
+ module Filters
23
+ def strip(input)
24
+ input.strip
25
+ end
26
+
27
+ def encode_uri_component(string)
28
+ CGI.escape(string)
29
+ end
30
+
31
+ def markdown(body)
32
+ Redcarpet::Markdown.new(Serif::MarkupRenderer, fenced_code_blocks: true).render(body).strip
33
+ end
34
+
35
+ def xmlschema(input)
36
+ input.xmlschema
37
+ end
38
+ end
39
+
40
+ Liquid::Template.register_filter(Filters)
41
+
42
+ module Serif
43
+ class Site
44
+ def initialize(source_directory)
45
+ @source_directory = source_directory
46
+ end
47
+
48
+ def directory
49
+ @source_directory
50
+ end
51
+
52
+ def posts
53
+ Post.all(self)
54
+ end
55
+
56
+ def drafts
57
+ Draft.all(self)
58
+ end
59
+
60
+ def config
61
+ Serif::Config.new(File.join(@source_directory, "_config.yml"))
62
+ end
63
+
64
+ def site_path(path)
65
+ File.join("_site", path)
66
+ end
67
+
68
+ def tmp_path(path)
69
+ File.join("tmp", site_path(path))
70
+ end
71
+
72
+ def latest_update_time
73
+ most_recent = posts.max_by { |p| p.updated }
74
+ most_recent ? most_recent.updated : Time.now
75
+ end
76
+
77
+ def bypass?(filename)
78
+ !%w[.html .xml].include?(File.extname(filename))
79
+ end
80
+
81
+ # TODO: fix all these File.join calls
82
+ def generate
83
+ FileUtils.cd(@source_directory)
84
+
85
+ FileUtils.rm_rf("tmp/_site")
86
+ FileUtils.mkdir_p("tmp/_site")
87
+
88
+ files = Dir["**/*"].select { |f| f !~ /\A_/ && File.file?(f) }
89
+
90
+ layout = Liquid::Template.parse(File.read("_layouts/default.html"))
91
+ posts = self.posts.sort_by { |entry| entry.created }.reverse
92
+
93
+ files.each do |path|
94
+ puts "Processing file: #{path}"
95
+
96
+ dirname = File.dirname(path)
97
+ filename = File.basename(path)
98
+
99
+ FileUtils.mkdir_p(tmp_path(dirname))
100
+ if bypass?(filename)
101
+ FileUtils.cp(path, tmp_path(path))
102
+ else
103
+ File.open(tmp_path(path), "w") do |f|
104
+ file = File.read(path)
105
+ title = nil
106
+ layout_option = :default
107
+
108
+ begin
109
+ file_with_headers = Redhead::String[File.read(path)]
110
+ title = file_with_headers.headers[:title] && file_with_headers.headers[:title].value
111
+ layout_option = file_with_headers.headers[:layout] && file_with_headers.headers[:layout].value
112
+
113
+ # all good? use the headered string
114
+ file = file_with_headers
115
+ rescue => e
116
+ puts "Warning! Problem trying to get headers out of #{path}"
117
+ puts e
118
+ # stick with what we've got!
119
+ end
120
+
121
+ if layout_option == "none"
122
+ f.puts Liquid::Template.parse(file.to_s).render!("site" => { "posts" => posts, "latest_update_time" => latest_update_time })
123
+ else
124
+ f.puts layout.render!("page" => { "title" => [title].compact }, "content" => Liquid::Template.parse(file.to_s).render!("site" => { "posts" => posts, "latest_update_time" => latest_update_time }))
125
+ end
126
+ end
127
+ end
128
+ end
129
+
130
+ posts.each do |post|
131
+ puts "Processing post: #{post.path}"
132
+
133
+ FileUtils.mkdir_p(tmp_path(File.dirname(post.url)))
134
+
135
+ File.open(tmp_path(post.url + ".html"), "w") do |f|
136
+ f.puts layout.render!("page" => { "title" => ["Posts", "#{post.title}"] }, "content" => Liquid::Template.parse(File.read("_templates/post.html")).render!("post" => post))
137
+ end
138
+ end
139
+
140
+ if Dir.exist?("_site")
141
+ FileUtils.mv("_site", "/tmp/_site.#{Time.now.strftime("%Y-%m-%d-%H-%M-%S")}")
142
+ end
143
+
144
+ FileUtils.mv("tmp/_site", ".") && FileUtils.rm_rf("tmp/_site")
145
+ FileUtils.rmdir("tmp")
146
+ end
147
+ end
148
+ end
data/lib/serif.rb ADDED
@@ -0,0 +1,19 @@
1
+ require "time"
2
+
3
+ require "liquid"
4
+ require "redcarpet"
5
+ require "pygments.rb"
6
+ require "redhead"
7
+
8
+ require "cgi"
9
+ require "digest"
10
+
11
+ require "serif/content_file"
12
+ require "serif/post"
13
+ require "serif/draft"
14
+ require "serif/markup_renderer"
15
+ require "serif/site"
16
+ require "serif/config"
17
+
18
+ module Serif
19
+ end
data/serif.gemspec ADDED
@@ -0,0 +1,28 @@
1
+ Gem::Specification.new do |s|
2
+ s.name = "serif"
3
+ s.version = "0.1.2"
4
+ s.authors = ["Adam Prescott"]
5
+ s.email = ["adam@aprescott.com"]
6
+ s.homepage = "https://github.com/aprescott/serif"
7
+ s.summary = "Simple file-based blogging system."
8
+ s.description = "Serif is a simple file-based blogging system which generates static content and allows dynamic editing through an interface."
9
+ s.files = Dir["{lib/**/*,statics/**/*,bin/*}"] + %w[serif.gemspec LICENSE Gemfile Gemfile.lock README.md]
10
+ s.require_path = "lib"
11
+ s.bindir = "bin"
12
+ s.executables = "serif"
13
+ #s.test_files = Dir["test/*"]
14
+
15
+ [
16
+ "rake", "~> 0.9",
17
+ "rack", "~> 1.0",
18
+ "yui-compressor", ">= 0",
19
+ "redcarpet", "~> 2.2",
20
+ "pygments.rb", "~> 0.3",
21
+ "sinatra", "~> 1.3",
22
+ "redhead", "~> 0.0.6",
23
+ "liquid", "~> 2.4",
24
+ "slop", "~> 3.3"
25
+ ].each_slice(2) do |name, version|
26
+ s.add_runtime_dependency(name, version)
27
+ end
28
+ end
@@ -0,0 +1,175 @@
1
+ // Autosize 1.13 - jQuery plugin for textareas
2
+ // (c) 2012 Jack Moore - jacklmoore.com
3
+ // license: www.opensource.org/licenses/mit-license.php
4
+
5
+ (function ($) {
6
+ var
7
+ hidden = 'hidden',
8
+ borderBox = 'border-box',
9
+ lineHeight = 'lineHeight',
10
+ copy = '<textarea tabindex="-1" style="position:absolute; top:-9999px; left:-9999px; right:auto; bottom:auto; -moz-box-sizing:content-box; -webkit-box-sizing:content-box; box-sizing:content-box; word-wrap:break-word; height:0 !important; min-height:0 !important; overflow:hidden;"/>',
11
+ // line-height is omitted because IE7/IE8 doesn't return the correct value.
12
+ copyStyle = [
13
+ 'fontFamily',
14
+ 'fontSize',
15
+ 'fontWeight',
16
+ 'fontStyle',
17
+ 'letterSpacing',
18
+ 'textTransform',
19
+ 'wordSpacing',
20
+ 'textIndent'
21
+ ],
22
+ oninput = 'oninput',
23
+ onpropertychange = 'onpropertychange',
24
+ test = $(copy)[0];
25
+
26
+ // For testing support in old FireFox
27
+ test.setAttribute(oninput, "return");
28
+
29
+ if ($.isFunction(test[oninput]) || onpropertychange in test) {
30
+
31
+ // test that line-height can be accurately copied to avoid
32
+ // incorrect value reporting in old IE and old Opera
33
+ $(test).css(lineHeight, '99px');
34
+ if ($(test).css(lineHeight) === '99px') {
35
+ copyStyle.push(lineHeight);
36
+ }
37
+
38
+ $.fn.autosize = function (options) {
39
+ options = options || {};
40
+
41
+ return this.each(function () {
42
+ var
43
+ ta = this,
44
+ $ta = $(ta),
45
+ mirror,
46
+ minHeight = $ta.height(),
47
+ maxHeight = parseInt($ta.css('maxHeight'), 10),
48
+ active,
49
+ i = copyStyle.length,
50
+ resize,
51
+ boxOffset = 0,
52
+ value = ta.value,
53
+ callback = $.isFunction(options.callback);
54
+
55
+ if ($ta.css('box-sizing') === borderBox || $ta.css('-moz-box-sizing') === borderBox || $ta.css('-webkit-box-sizing') === borderBox){
56
+ boxOffset = $ta.outerHeight() - $ta.height();
57
+ }
58
+
59
+ if ($ta.data('mirror') || $ta.data('ismirror')) {
60
+ // if autosize has already been applied, exit.
61
+ // if autosize is being applied to a mirror element, exit.
62
+ return;
63
+ } else {
64
+ mirror = $(copy).data('ismirror', true).addClass(options.className || 'autosizejs')[0];
65
+
66
+ resize = $ta.css('resize') === 'none' ? 'none' : 'horizontal';
67
+
68
+ $ta.data('mirror', $(mirror)).css({
69
+ overflow: hidden,
70
+ overflowY: hidden,
71
+ wordWrap: 'break-word',
72
+ resize: resize
73
+ });
74
+ }
75
+
76
+ // Opera returns '-1px' when max-height is set to 'none'.
77
+ maxHeight = maxHeight && maxHeight > 0 ? maxHeight : 9e4;
78
+
79
+ // Using mainly bare JS in this function because it is going
80
+ // to fire very often while typing, and needs to very efficient.
81
+ function adjust() {
82
+ var height, overflow, original;
83
+
84
+ // the active flag keeps IE from tripping all over itself. Otherwise
85
+ // actions in the adjust function will cause IE to call adjust again.
86
+ if (!active) {
87
+ active = true;
88
+ mirror.value = ta.value;
89
+ mirror.style.overflowY = ta.style.overflowY;
90
+ original = parseInt(ta.style.height,10);
91
+
92
+ // Update the width in case the original textarea width has changed
93
+ mirror.style.width = $ta.css('width');
94
+
95
+ // Needed for IE to reliably return the correct scrollHeight
96
+ mirror.scrollTop = 0;
97
+
98
+ // Set a very high value for scrollTop to be sure the
99
+ // mirror is scrolled all the way to the bottom.
100
+ mirror.scrollTop = 9e4;
101
+
102
+ height = mirror.scrollTop;
103
+ overflow = hidden;
104
+ if (height > maxHeight) {
105
+ height = maxHeight;
106
+ overflow = 'scroll';
107
+ } else if (height < minHeight) {
108
+ height = minHeight;
109
+ }
110
+ height += boxOffset;
111
+ ta.style.overflowY = overflow;
112
+
113
+ if (original !== height) {
114
+ ta.style.height = height + 'px';
115
+ if (callback) {
116
+ options.callback.call(ta);
117
+ }
118
+ }
119
+
120
+ // This small timeout gives IE a chance to draw it's scrollbar
121
+ // before adjust can be run again (prevents an infinite loop).
122
+ setTimeout(function () {
123
+ active = false;
124
+ }, 1);
125
+ }
126
+ }
127
+
128
+ // mirror is a duplicate textarea located off-screen that
129
+ // is automatically updated to contain the same text as the
130
+ // original textarea. mirror always has a height of 0.
131
+ // This gives a cross-browser supported way getting the actual
132
+ // height of the text, through the scrollTop property.
133
+ while (i--) {
134
+ mirror.style[copyStyle[i]] = $ta.css(copyStyle[i]);
135
+ }
136
+
137
+ $('body').append(mirror);
138
+
139
+ if (onpropertychange in ta) {
140
+ if (oninput in ta) {
141
+ // Detects IE9. IE9 does not fire onpropertychange or oninput for deletions,
142
+ // so binding to onkeyup to catch most of those occassions. There is no way that I
143
+ // know of to detect something like 'cut' in IE9.
144
+ ta[oninput] = ta.onkeyup = adjust;
145
+ } else {
146
+ // IE7 / IE8
147
+ ta[onpropertychange] = adjust;
148
+ }
149
+ } else {
150
+ // Modern Browsers
151
+ ta[oninput] = adjust;
152
+
153
+ // The textarea overflow is now hidden. But Chrome doesn't reflow the text after the scrollbars are removed.
154
+ // This is a hack to get Chrome to reflow it's text.
155
+ ta.value = '';
156
+ ta.value = value;
157
+ }
158
+
159
+ $(window).resize(adjust);
160
+
161
+ // Allow for manual triggering if needed.
162
+ $ta.bind('autosize', adjust);
163
+
164
+ // Call adjust in case the textarea already contains text.
165
+ adjust();
166
+ });
167
+ };
168
+ } else {
169
+ // Makes no changes for older browsers (FireFox3- and Safari4-)
170
+ $.fn.autosize = function (callback) {
171
+ return this;
172
+ };
173
+ }
174
+
175
+ }(jQuery));