serif 0.4 → 0.5

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 (50) hide show
  1. data/Gemfile.lock +29 -14
  2. data/README.md +28 -14
  3. data/bin/serif +2 -69
  4. data/lib/serif.rb +1 -0
  5. data/lib/serif/admin_server.rb +55 -26
  6. data/lib/serif/commands.rb +150 -0
  7. data/lib/serif/content_file.rb +4 -0
  8. data/lib/serif/draft.rb +24 -2
  9. data/lib/serif/errors.rb +10 -0
  10. data/lib/serif/post.rb +4 -3
  11. data/lib/serif/site.rb +73 -4
  12. data/rakefile +10 -0
  13. data/serif.gemspec +5 -3
  14. data/statics/skeleton/_layouts/default.html +1 -1
  15. data/statics/templates/admin/bookmarks.liquid +17 -13
  16. data/statics/templates/admin/edit_draft.liquid +1 -1
  17. data/statics/templates/admin/edit_post.liquid +1 -1
  18. data/statics/templates/admin/index.liquid +2 -2
  19. data/statics/templates/admin/layout.liquid +16 -0
  20. data/test/commands_spec.rb +77 -0
  21. data/test/content_file_spec.rb +32 -1
  22. data/test/draft_spec.rb +50 -3
  23. data/test/post_spec.rb +31 -2
  24. data/test/site_dir/_layouts/default.html +2 -0
  25. data/test/site_dir/_site/archive.html +2 -0
  26. data/test/site_dir/_site/drafts/another-sample-draft/{481da12b79709bfa0547fa9b5754c9506fbed29afd0334e07a8c95e76850.html → add25848a94509103cb492c47e3a04b7b2a56299de207155fbffec42dc4b.html} +5 -2
  27. data/test/site_dir/_site/drafts/sample-draft/{a986a62ad5f6edd1fcac3d08f5b461b92bcb667a2af69505230c291d405c.html → 0b6fc164b8534d5d5a9fcfc5c709265d33f1577cd0fe2f4e23042e92f0c1.html} +5 -2
  28. data/test/site_dir/_site/index.html +2 -0
  29. data/test/site_dir/_site/page-header-but-no-layout.html +2 -0
  30. data/test/site_dir/_site/test-archive/2012/11.html +2 -0
  31. data/test/site_dir/_site/test-archive/2012/12.html +2 -0
  32. data/test/site_dir/_site/test-archive/2013/01.html +2 -0
  33. data/test/site_dir/_site/test-archive/2013/03.html +2 -0
  34. data/test/site_dir/_site/test-archive/2399/01.html +2 -0
  35. data/test/site_dir/_site/test-archive/2400/01.html +2 -0
  36. data/test/site_dir/_site/test-blog/final-post.html +4 -1
  37. data/test/site_dir/_site/test-blog/penultimate-post.html +4 -1
  38. data/test/site_dir/_site/test-blog/post-to-be-published-on-generate.html +4 -1
  39. data/test/site_dir/_site/test-blog/post-with-custom-layout.html +1 -0
  40. data/test/site_dir/_site/test-blog/sample-post.html +4 -1
  41. data/test/site_dir/_site/test-blog/second-post.html +4 -1
  42. data/test/site_dir/_site/test-smarty-filter.html +2 -0
  43. data/test/site_dir/_templates/post.html +1 -0
  44. data/test/site_dir/_trash/1364747613-autopublish-draft +5 -0
  45. data/test/site_dir/_trash/{1363633154-test-draft → 1364747613-test-draft} +1 -1
  46. data/test/site_generation_spec.rb +40 -9
  47. data/test/site_spec.rb +63 -0
  48. data/test/test_helper.rb +9 -0
  49. metadata +46 -10
  50. data/test/site_dir/_trash/1363633154-autopublish-draft +0 -5
@@ -23,6 +23,10 @@ class ContentFile
23
23
  end
24
24
  end
25
25
 
26
+ def basename
27
+ File.basename(@path)
28
+ end
29
+
26
30
  def slug=(str)
27
31
  @slug = str
28
32
 
@@ -11,6 +11,27 @@ class Draft < ContentFile
11
11
  File.rename("#{site.directory}/#{dirname}/#{original_slug}", "#{site.directory}/#{dirname}/#{new_slug}")
12
12
  end
13
13
 
14
+ # Returns the URL that would be used for this post if it were
15
+ # to be published now.
16
+ def url
17
+ permalink_style = headers[:permalink] || site.config.permalink
18
+
19
+ parts = {
20
+ "title" => slug.to_s,
21
+ "year" => Time.now.year.to_s,
22
+ "month" => Time.now.month.to_s.rjust(2, "0"),
23
+ "day" => Time.now.day.to_s.rjust(2, "0")
24
+ }
25
+
26
+ output = permalink_style
27
+
28
+ parts.each do |placeholder, value|
29
+ output = output.gsub(Regexp.quote(":" + placeholder), value)
30
+ end
31
+
32
+ output
33
+ end
34
+
14
35
  def delete!
15
36
  FileUtils.mkdir_p("#{site.directory}/_trash")
16
37
  File.rename(@path, File.expand_path("#{site.directory}/_trash/#{Time.now.to_i}-#{slug}"))
@@ -33,7 +54,7 @@ class Draft < ContentFile
33
54
  File.rename(path, full_published_path)
34
55
 
35
56
  # update the path since the file has now changed
36
- @path = Post.from_slug(site, slug).path
57
+ @path = Post.new(site, full_published_path).path
37
58
  end
38
59
 
39
60
  # if the assigned value is truthy, the "publish" header
@@ -63,7 +84,8 @@ class Draft < ContentFile
63
84
  "slug" => slug,
64
85
  "type" => "draft",
65
86
  "draft" => draft?,
66
- "published" => published?
87
+ "published" => published?,
88
+ "url" => url
67
89
  }
68
90
 
69
91
  headers.each do |key, value|
@@ -0,0 +1,10 @@
1
+ module Serif
2
+ # General error class. Allows capturing general errors
3
+ # applicable only to Serif.
4
+ class Error < RuntimeError; end
5
+
6
+ # Represents a conflict between published posts and drafts.
7
+ # This should be used whenever two posts would occupy the same
8
+ # URL / file path.
9
+ class PostConflictError < Error; end
10
+ end
@@ -62,8 +62,8 @@ class Post < ContentFile
62
62
  files.map { |f| new(site, f) }
63
63
  end
64
64
 
65
- def self.from_slug(site, slug)
66
- all(site).find { |p| p.slug == slug }
65
+ def self.from_basename(site, filename)
66
+ all(site).find { |p| p.basename == filename }
67
67
  end
68
68
 
69
69
  def to_liquid
@@ -76,7 +76,8 @@ class Post < ContentFile
76
76
  "url" => url,
77
77
  "type" => "post",
78
78
  "draft" => draft?,
79
- "published" => published?
79
+ "published" => published?,
80
+ "basename" => basename
80
81
  }
81
82
 
82
83
  headers.each do |key, value|
@@ -1,11 +1,13 @@
1
1
 
2
- module Liquid::StandardFilters
2
+ module Liquid #:nodoc:#
3
+ module StandardFilters #:nodoc:#
3
4
  alias_method :date_orig, :date
4
5
 
5
6
  def date(input, format)
6
7
  input == "now" ? date_orig(Time.now, format) : date_orig(input, format)
7
8
  end
8
9
  end
10
+ end
9
11
 
10
12
  module Serif
11
13
  module Filters
@@ -225,6 +227,67 @@ class Site
225
227
  h
226
228
  end
227
229
 
230
+ # Returns a hash representing any conflicting URLs,
231
+ # in the form
232
+ #
233
+ # { "/url_1" => [e_1, e_2, ..., e_n], ... }
234
+ #
235
+ # The elements e_i are the conflicting Post and
236
+ # Draft instances that share the URL "/url_1".
237
+ #
238
+ # Note that if n = 1 (that is, the array value is
239
+ # [e_1], containing a single element), then it is
240
+ # not included in the Hash, since it does not
241
+ # contribute to a conflict.
242
+ #
243
+ # If there are no conflicts found, returns nil.
244
+ #
245
+ # If an argument is specified, its #url value is
246
+ # compared against all post and draft URLs, and
247
+ # the value returned is either:
248
+ #
249
+ # 1. an array of post/draft instances that
250
+ # conflict, _including_ the argument given; or,
251
+ # 2. nil if there is no conflict.
252
+ def conflicts(content_to_check = nil)
253
+ if content_to_check
254
+ content = drafts + posts + [content_to_check]
255
+
256
+ # In the event that the given argument is actually one of the
257
+ # drafts + posts, we need to de-duplicate, otherwise our return
258
+ # value will contain two of the same Draft/Post, which isn't
259
+ # actually a conflict.
260
+ #
261
+ # So to do that, we can use the path on the filesystem. However,
262
+ # we can't just rely on calling #path, because if content_to_check
263
+ # doesn't have a #path value, it'll be nil and it's possible that
264
+ # we might expand checking to multiple files/Drafts/Posts.
265
+ #
266
+ # Thus, if #path is nil, simply rely on object_id.
267
+ #
268
+ # FIXME: Replace this with a proper implementation of
269
+ # ContentFile equality/hashing.
270
+ content.uniq! { |e| e.path ? e.path : e.object_id }
271
+
272
+ conflicts = content.select { |e| e.url == content_to_check.url }
273
+
274
+ if conflicts.length <= 1
275
+ return nil
276
+ else
277
+ return conflicts
278
+ end
279
+ end
280
+
281
+ conflicts = (drafts + posts).group_by { |e| e.url }
282
+ conflicts.reject! { |k, v| v.length == 1 }
283
+
284
+ if conflicts.empty?
285
+ nil
286
+ else
287
+ conflicts
288
+ end
289
+ end
290
+
228
291
  def to_liquid
229
292
  @liquid_cache_store ||= TimeoutCache.new(1)
230
293
 
@@ -249,6 +312,10 @@ class Site
249
312
 
250
313
  default_layout = Liquid::Template.parse(File.read("_layouts/default.html"))
251
314
 
315
+ if conflicts
316
+ raise PostConflictError, "Generating would cause a conflict."
317
+ end
318
+
252
319
  # preprocess any drafts marked for autopublish, before grabbing the posts
253
320
  # to operate on.
254
321
  preprocess_autopublish_drafts
@@ -292,7 +359,7 @@ class Site
292
359
  layout_file = File.join(self.directory, "_layouts", "#{layout_option}.html")
293
360
  layout = Liquid::Template.parse(File.read(layout_file))
294
361
  end
295
- f.puts layout.render!("site" => self, "page" => { "title" => [title].compact }, "content" => Liquid::Template.parse(file.to_s).render!("site" => self))
362
+ f.puts layout.render!("site" => self, "page" => { "title" => title }, "content" => Liquid::Template.parse(file.to_s).render!("site" => self))
296
363
  end
297
364
  end
298
365
  end
@@ -323,13 +390,15 @@ class Site
323
390
  # variables available in the post template
324
391
  post_template_variables = {
325
392
  "post" => post,
393
+ "post_page" => true,
326
394
  "prev_post" => prev_post,
327
395
  "next_post" => next_post
328
396
  }
329
397
 
330
398
  f.puts post_layout.render!(
331
399
  "site" => self,
332
- "page" => { "title" => ["Posts", "#{post.title}"] },
400
+ "page" => { "title" => post.title },
401
+ "post_page" => true,
333
402
  "content" => Liquid::Template.parse(File.read("_templates/post.html")).render!(post_template_variables)
334
403
  )
335
404
  end
@@ -393,7 +462,7 @@ class Site
393
462
  f.puts layout.render!(
394
463
  "site" => self,
395
464
  "draft_preview" => true,
396
- "page" => { "title" => [ "Draft Preview", draft.title ] },
465
+ "page" => { "title" => draft.title },
397
466
  "content" => template.render!("site" => self, "post" => draft, "draft_preview" => true)
398
467
  )
399
468
  end
data/rakefile CHANGED
@@ -2,6 +2,7 @@ require "rake"
2
2
  require "time"
3
3
  require "rspec/core/rake_task"
4
4
  require "benchmark"
5
+ require "rdoc/task"
5
6
 
6
7
  RSpec::Core::RakeTask.new(:test) do |t|
7
8
  t.rspec_opts = "-I test --color --format nested"
@@ -12,6 +13,15 @@ end
12
13
 
13
14
  task :default => :test
14
15
 
16
+ Rake::RDocTask.new(:docs) do |rd|
17
+ rd.main = "README.md"
18
+ rd.rdoc_dir = "docs"
19
+ rd.rdoc_files.include("README.md", "lib/**/*.rb")
20
+ rd.options << "--markup=markdown"
21
+ end
22
+
23
+ task :rdoc => :docs
24
+
15
25
  task :stress, [:n] do |t, args|
16
26
  iterations = args[:n] || 250
17
27
  iterations = iterations.to_i
@@ -1,11 +1,11 @@
1
1
  Gem::Specification.new do |s|
2
2
  s.name = "serif"
3
- s.version = "0.4"
3
+ s.version = "0.5"
4
4
  s.authors = ["Adam Prescott"]
5
5
  s.email = ["adam@aprescott.com"]
6
6
  s.homepage = "https://github.com/aprescott/serif"
7
- s.summary = "Markdown-powered blogging with an optional admin interface with drag-and-drop image uploading."
8
- s.description = "Serif is a blogging system powered by markdown files and an optional admin interface complete with drag-and-drop image uploading."
7
+ s.summary = "Static site generator and markdown-based blogging with an optional admin interface complete with drag-and-drop image uploading."
8
+ s.description = "Serif is a static site generator and blogging system powered by markdown files and an optional admin interface complete with drag-and-drop image uploading."
9
9
  s.files = Dir["{lib/**/*,statics/**/*,bin/*,test/**/*}"] + %w[serif.gemspec rakefile LICENSE Gemfile Gemfile.lock README.md]
10
10
  s.require_path = "lib"
11
11
  s.bindir = "bin"
@@ -30,4 +30,6 @@ Gem::Specification.new do |s|
30
30
  s.add_development_dependency("rspec", "~> 2.5")
31
31
  s.add_development_dependency("simplecov", "~> 0.7")
32
32
  s.add_development_dependency("timecop", "~> 0.6.1")
33
+ s.add_development_dependency("rdoc", "~> 4.0.0")
34
+ s.add_development_dependency("coveralls")
33
35
  end
@@ -1,6 +1,6 @@
1
1
  <!doctype html>
2
2
  <meta charset="UTF-8">
3
- <title>My site: {% if page.title and page.title != empty %}{{ page.title | join:" - " }}{% endif %}</title>
3
+ <title>My site: {{ page.title }}</title>
4
4
  <h1>mysite.com</h1>
5
5
 
6
6
  {{ content }}
@@ -25,17 +25,17 @@ compress me!
25
25
  -->
26
26
  {% endcomment %}
27
27
 
28
- {% capture compressed_js %}
29
-
30
- (function(){var e,n,t,o;n=location.href,document.getSelection?(t=document.getSelection(),""==""+t?t="":(t=t.getRangeAt(0).cloneContents(),o=document.createElement("div"),o.appendChild(t),t=o.innerHTML)):t="",e=document.title,window.location.href="[BASE_URL]url="+encodeURIComponent(n)+"&content="+encodeURIComponent(t)+"&title="+encodeURIComponent(e)})();
31
-
32
- {% endcapture %}
33
-
34
- {% assign compressed_js = compressed_js | strip | replace:'"',"'" | prepend: "javascript:" %}
35
- {% assign non_edit_url = base_url | append:"/admin/quick-draft?" %}
36
- {% assign edit_now_url = non_edit_url | append:"edit=1&" %}
37
- {% assign js_later = compressed_js | replace:"[BASE_URL]",non_edit_url %}
38
- {% assign js_now = compressed_js | replace:"[BASE_URL]",edit_now_url %}
28
+ <script>
29
+ var compressed_js = 'javascript:(function(){var e,n,t,o;n=location.href,document.getSelection?(t=document.getSelection(),""==""+t?t="":(t=t.getRangeAt(0).cloneContents(),o=document.createElement("div"),o.appendChild(t),t=o.innerHTML)):t="",e=document.title,window.location.href="[BASE_URL]url="+encodeURIComponent(n)+"&content="+encodeURIComponent(t)+"&title="+encodeURIComponent(e)})();';
30
+ var base_url = window.location.protocol + "//" + window.location.host + "/admin/quick-draft?";
31
+ var js_now = compressed_js.replace('[BASE_URL]', base_url + "edit=1&");
32
+ var js_later = compressed_js.replace('[BASE_URL]', base_url);
33
+
34
+ $(function() {
35
+ $("[data-bookmark-later]").attr("href", js_later);
36
+ $("[data-bookmark-now]").attr("href", js_now);
37
+ });
38
+ </script>
39
39
 
40
40
  <section id="bookmarks">
41
41
  <h2>Bookmarks</h2>
@@ -44,13 +44,17 @@ compress me!
44
44
 
45
45
  <p>Any selected text will go in as Markdown, ready for editing!</p>
46
46
 
47
+ <noscript>
48
+ <p><strong>You need to enable JavaScript!</strong></p>
49
+ </noscript>
50
+
47
51
  <hr>
48
52
 
49
- <p class="bookmark"><a href="{{ js_later }}">Save draft for later</a></p>
53
+ <p class="bookmark"><a href="#" data-bookmark-later>Save draft for later</a></p>
50
54
 
51
55
  <p>Saves the current page as a draft. Takes you straight back to where you were so you can keep reading.</p>
52
56
 
53
- <p class="bookmark"><a href="{{ js_now }}">Save draft and edit</a></p>
57
+ <p class="bookmark"><a href="#" data-bookmark-now>Save draft and edit</a></p>
54
58
 
55
59
  <p>Saves the current page as a draft and lets you start editing immediately.</p>
56
60
  </section>
@@ -15,7 +15,7 @@
15
15
  </h2>
16
16
 
17
17
  <h2 id="private-url">
18
- <code><span>preview:</span> <a href="{{ private_url }}">{% assign parts = private_url | split:"/" %}/{{ parts[1] }}/{{ parts[2] }}/{{ parts[3] | truncate: 10, '' }}&hellip;</a></code>
18
+ <code><span>preview:</span> {% if private_url %}<a href="{{ private_url }}">{% assign parts = private_url | split:"/" %}/{{ parts[1] }}/{{ parts[2] }}/{{ parts[3] | truncate: 10, '' }}&hellip;</a>{% else %}(none){% endif %}</code>
19
19
  </h2>
20
20
 
21
21
  <div class="post{% if post.draft %} draft{% endif %}">
@@ -6,7 +6,7 @@
6
6
 
7
7
  <div id="edit">
8
8
  <form action="/admin/edit/posts" method="post">
9
- <input type="hidden" name="original_slug" value="{{ post.slug | escape }}">
9
+ <input type="hidden" name="original_basename" value="{{ post.basename | escape }}">
10
10
 
11
11
  <h2 id="slug">
12
12
  <code>
@@ -4,7 +4,7 @@
4
4
 
5
5
  <ul>
6
6
  {% for draft in drafts %}
7
- <li><a href="/admin/edit/drafts/{{ draft.slug }}">{% if draft.slug != empty %}{{ draft.slug }}{% else %}<em>(untitled)</em>{% endif %}</a></li>
7
+ <li><a href="/admin/edit/drafts/{{ draft.slug }}">{{ draft.slug }}</a></li>
8
8
  {% endfor %}
9
9
  </ul>
10
10
 
@@ -14,6 +14,6 @@
14
14
 
15
15
  <ul>
16
16
  {% for post in posts %}
17
- <li><a href="/admin/edit/posts/{{ post.slug }}">{% if post.title != empty %}{{ post.title }}{% else %}<em>(untitled)</em>{% endif %}</a></li>
17
+ <li><a href="/admin/edit/posts/{{ post.basename }}">{{ post.title | smarty }}</a></li>
18
18
  {% endfor %}
19
19
  </ul>
@@ -547,6 +547,22 @@ body > nav ul li:last-child {
547
547
  </ul>
548
548
  </nav>
549
549
 
550
+ {% if conflicts %}
551
+ <p>The following URLs have conflicts and must be resolved:</p>
552
+ <ul>
553
+ {% for conflict in conflicts %}
554
+ <li>
555
+ {{ conflict[0] | escape }}
556
+ <ul>
557
+ {% for conflicting_content in conflict[1] %}
558
+ <li>{% if conflicting_content.draft %}Draft{% else %}Post{% endif %}: {{ conflicting_content.title | escape }}</li>
559
+ {% endfor %}
560
+ </ul>
561
+ </li>
562
+ {% endfor %}
563
+ </ul>
564
+ {% endif %}
565
+
550
566
  {{ content }}
551
567
 
552
568
  <div id="shortcuts">
@@ -0,0 +1,77 @@
1
+ require "test_helper"
2
+
3
+ class Serif::Commands
4
+ def exit(code)
5
+ "Fake exit with code #{code}"
6
+ end
7
+ end
8
+
9
+ describe Serif::Commands do
10
+ def expect_method_call(arg, method)
11
+ c = Serif::Commands.new([arg])
12
+ c.should_receive(method)
13
+ capture_stdout { c.process }
14
+ end
15
+
16
+ describe "#process" do
17
+ it "takes -h and --help and calls print usage" do
18
+ %w[-h --help].each do |cmd|
19
+ expect_method_call(cmd, :print_help)
20
+ end
21
+ end
22
+
23
+ {
24
+ "admin" => :initialize_admin_server,
25
+ "dev" => :initialize_dev_server,
26
+ "new" => :produce_skeleton,
27
+ "generate" => :generate_site
28
+ }.each do |command, meth|
29
+ it "takes the command '#{command}' and runs #{meth}" do
30
+ expect_method_call(command, meth)
31
+ end
32
+ end
33
+
34
+ it "exits on help" do
35
+ expect_method_call("-h", :exit)
36
+ end
37
+ end
38
+
39
+ describe "#generate_site" do
40
+ it "calls Site#generate" do
41
+ Serif::Site.stub(:generation_called)
42
+ Serif::Site.any_instance.stub(:generate) { Serif::Site.generation_called }
43
+
44
+ # if this is called, it means any instance of Site had #generate called.
45
+ Serif::Site.should_receive(:generation_called)
46
+
47
+ Serif::Commands.new([]).generate_site("anything")
48
+ end
49
+
50
+ context "with a conflict" do
51
+ def conflicting_generate_command
52
+ a = b = double("")
53
+ a.stub(:url) { "/foo" }
54
+ b.stub(:url) { "/foo" }
55
+ a.stub(:path) { "/anything" }
56
+ b.stub(:path) { "/anything" }
57
+
58
+ # any non-nil value will do
59
+ Serif::Site.any_instance.stub(:conflicts) { { "/foo" => [a, b] } }
60
+
61
+ command = Serif::Commands.new([])
62
+ command.generate_site(testing_dir)
63
+ command.should_receive(:exit)
64
+ command
65
+ end
66
+
67
+ it "exits" do
68
+ capture_stdout { conflicting_generate_command.process }
69
+ end
70
+
71
+ it "prints the urls that conflict" do
72
+ output = capture_stdout { conflicting_generate_command.process }
73
+ output.should match(/Conflicts at:\n\n\/foo\n\t\/anything\n\t\/anything/)
74
+ end
75
+ end
76
+ end
77
+ end