prt 0.3.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 (53) hide show
  1. checksums.yaml +7 -0
  2. data/.githooks/pre-commit +17 -0
  3. data/.github/workflows/ci.yml +29 -0
  4. data/.gitignore +24 -0
  5. data/.rspec +2 -0
  6. data/.rubocop.yml +28 -0
  7. data/.ruby-gemset +1 -0
  8. data/.ruby-version +1 -0
  9. data/CLAUDE.md +8 -0
  10. data/Gemfile +13 -0
  11. data/LICENSE.txt +22 -0
  12. data/README.md +340 -0
  13. data/Rakefile +26 -0
  14. data/bin/setup +11 -0
  15. data/exe/parrot +12 -0
  16. data/lib/helpers.rb +19 -0
  17. data/lib/parrot/commands/build.rb +1274 -0
  18. data/lib/parrot/commands/new.rb +25 -0
  19. data/lib/parrot/commands/post.rb +83 -0
  20. data/lib/parrot/commands/serve.rb +129 -0
  21. data/lib/parrot/constants.rb +45 -0
  22. data/lib/parrot/file_cache.rb +45 -0
  23. data/lib/parrot/logger.rb +23 -0
  24. data/lib/parrot/metadata.rb +4 -0
  25. data/lib/parrot/runner.rb +29 -0
  26. data/lib/parrot/template_handler.rb +19 -0
  27. data/lib/parrot.rb +120 -0
  28. data/parrot.gemspec +36 -0
  29. data/skel/.gitignore +3 -0
  30. data/skel/config.yaml +68 -0
  31. data/skel/css/app.scss +189 -0
  32. data/skel/images/.keep +0 -0
  33. data/skel/images/apple-touch-icon.png +0 -0
  34. data/skel/images/favicon.ico +0 -0
  35. data/skel/images/favicon.svg +40 -0
  36. data/skel/images/parrot.jpeg +0 -0
  37. data/skel/javascripts/app.js +37 -0
  38. data/skel/public/.keep +0 -0
  39. data/skel/views/404.md +7 -0
  40. data/skel/views/about.md +17 -0
  41. data/skel/views/layout.html.erb +72 -0
  42. data/skel/views/posts/about_parrot.md +63 -0
  43. data/skel/views/posts/sample.md +37 -0
  44. data/spec/parrot/commands/build_spec.rb +747 -0
  45. data/spec/parrot/commands/new_spec.rb +43 -0
  46. data/spec/parrot/commands/post_spec.rb +80 -0
  47. data/spec/parrot/commands/serve_spec.rb +66 -0
  48. data/spec/parrot/logger_spec.rb +39 -0
  49. data/spec/parrot/metadata_spec.rb +11 -0
  50. data/spec/parrot/runner_spec.rb +28 -0
  51. data/spec/parrot_spec.rb +37 -0
  52. data/spec/spec_helper.rb +7 -0
  53. metadata +237 -0
@@ -0,0 +1,747 @@
1
+ require 'spec_helper'
2
+
3
+ describe Parrot::Commands do
4
+ let(:config) { Parrot::Config.new(Dir.pwd, TestLogger) }
5
+
6
+ before do
7
+ # create a new application
8
+ Parrot::Commands::NewCommand.new(%w[blog], config).run
9
+ end
10
+
11
+ after do
12
+ # cleanup
13
+ FileUtils.rm_rf('blog')
14
+ end
15
+
16
+ context 'BuildCommand' do
17
+ it 'has a run method' do
18
+ expect(Parrot::Commands::BuildCommand.new([], config)).to respond_to(:run)
19
+ end
20
+
21
+ context 'per-page head metadata' do
22
+ let(:build_config) { Parrot::Config.new(File.join(Dir.pwd, 'blog'), TestLogger) }
23
+
24
+ before { Parrot::Commands::BuildCommand.new([], build_config).run }
25
+
26
+ it 'sets the <title> from the post header' do
27
+ expect(File.read('blog/public/about_parrot.html')).to include('<title>About Parrot</title>')
28
+ end
29
+
30
+ it 'keeps the layout title on the index page' do
31
+ expect(File.read('blog/public/index.html')).to include('<title>Parrot</title>')
32
+ end
33
+
34
+ it 'points og:url and canonical at each generated page' do
35
+ post = File.read('blog/public/sample.html')
36
+ expect(post).to include('<meta property="og:url" content="https://example.com/sample.html">')
37
+ expect(post).to include('<link rel="canonical" href="https://example.com/sample.html">')
38
+ end
39
+
40
+ it 'points the index og:url and canonical at the site root' do
41
+ index = File.read('blog/public/index.html')
42
+ expect(index).to include('<meta property="og:url" content="https://example.com/">')
43
+ expect(index).to include('<link rel="canonical" href="https://example.com/">')
44
+ end
45
+
46
+ it 'sets og:title per page from the same source as <title>' do
47
+ expect(File.read('blog/public/about_parrot.html')).to include('<meta property="og:title" content="About Parrot">')
48
+ expect(File.read('blog/public/index.html')).to include('<meta property="og:title" content="Parrot">')
49
+ end
50
+
51
+ it 'rewrites a relative og:image to an absolute URL and ships the file' do
52
+ expect(File.read('blog/public/about_parrot.html'))
53
+ .to include('<meta property="og:image" content="https://example.com/images/parrot.jpeg">')
54
+ expect(File.exist?('blog/public/images/parrot.jpeg')).to be true
55
+ end
56
+
57
+ it 'keeps the Twitter Card tag from the layout' do
58
+ expect(File.read('blog/public/about_parrot.html')).to include('<meta name="twitter:card" content="summary_large_image">')
59
+ end
60
+
61
+ it 'marks posts as og:type article with an ISO article:published_time' do
62
+ post = File.read('blog/public/about_parrot.html')
63
+ expect(post).to include('<meta property="og:type" content="article">')
64
+ expect(post).to include('<meta property="article:published_time" content="2026-09-08">')
65
+ end
66
+
67
+ it 'keeps the index as og:type website with no article date' do
68
+ index = File.read('blog/public/index.html')
69
+ expect(index).to include('<meta property="og:type" content="website">')
70
+ expect(index).not_to include('article:published_time')
71
+ end
72
+
73
+ it 'writes a sitemap.xml covering the index and every post' do
74
+ sitemap = File.read('blog/public/sitemap.xml')
75
+ expect(sitemap).to start_with('<?xml version="1.0" encoding="UTF-8"?>')
76
+ expect(sitemap).to include('<loc>https://example.com/</loc>')
77
+ expect(sitemap).to include('<loc>https://example.com/about_parrot.html</loc>')
78
+ expect(sitemap).to include('<loc>https://example.com/sample.html</loc>')
79
+ expect(sitemap).to include('<lastmod>2026-09-08</lastmod>')
80
+ end
81
+
82
+ it 'writes a robots.txt pointing at the sitemap' do
83
+ robots = File.read('blog/public/robots.txt')
84
+ expect(robots).to include('User-agent: *')
85
+ expect(robots).to include('Sitemap: https://example.com/sitemap.xml')
86
+ end
87
+
88
+ it 'sets <html lang> from the post header' do
89
+ expect(File.read('blog/public/about_parrot.html')).to include('<html lang="en">')
90
+ end
91
+
92
+ it 'loads app.js with defer and warms up the CDNs' do
93
+ head = File.read('blog/public/about_parrot.html')
94
+ expect(head).to include('<script src="app.js" defer>')
95
+ expect(head).to include('<link rel="preconnect" href="https://cdn.simplecss.org"')
96
+ expect(head).to include('<link rel="preconnect" href="https://cdn.jsdelivr.net"')
97
+ end
98
+
99
+ it 'ships every icon referenced from the layout' do
100
+ expect(File.exist?('blog/public/images/favicon.ico')).to be true
101
+ expect(File.exist?('blog/public/images/favicon.svg')).to be true
102
+ expect(File.exist?('blog/public/images/apple-touch-icon.png')).to be true
103
+ end
104
+
105
+ it 'fills description, og:description and twitter:description from the first paragraph' do
106
+ head = File.read('blog/public/about_parrot.html')
107
+ summary = 'Parrot turns a folder of Markdown into a static blog.'
108
+ expect(head).to include(%(<meta name="description" content="#{summary}))
109
+ expect(head).to include(%(<meta property="og:description" content="#{summary}))
110
+ expect(head).to include(%(<meta name="twitter:description" content="#{summary}))
111
+ end
112
+
113
+ it 'maps lang onto og:locale' do
114
+ expect(File.read('blog/public/about_parrot.html')).to include('<meta property="og:locale" content="en_US">')
115
+ end
116
+
117
+ it 'renders exactly one <h1> per page' do
118
+ %w[about_parrot sample].each do |name|
119
+ expect(File.read("blog/public/#{name}.html").scan('<h1').size).to eq(1)
120
+ end
121
+ end
122
+
123
+ it 'links back to the index from the top of each post, but not from the index itself' do
124
+ post = File.read('blog/public/about_parrot.html')
125
+ expect(post).to match(%r{<main>\s*<p class="back-link"><a href="index\.html">.*?</a></p>})
126
+
127
+ expect(File.read('blog/public/index.html')).not_to include('back-link')
128
+ end
129
+
130
+ it 'embeds BlogPosting JSON-LD on posts' do
131
+ data = JSON.parse(File.read('blog/public/about_parrot.html')[%r{<script type="application/ld\+json">(.+?)</script>}m, 1])
132
+ expect(data['@type']).to eq('BlogPosting')
133
+ expect(data['headline']).to eq('About Parrot')
134
+ expect(data['datePublished']).to eq('2026-09-08')
135
+ expect(data['author']['name']).to eq('Your name')
136
+ end
137
+
138
+ it 'embeds WebSite JSON-LD on the index' do
139
+ data = JSON.parse(File.read('blog/public/index.html')[%r{<script type="application/ld\+json">(.+?)</script>}m, 1])
140
+ expect(data['@type']).to eq('WebSite')
141
+ expect(data['url']).to eq('https://example.com/')
142
+ end
143
+
144
+ it 'writes an RSS feed and links it for autodiscovery' do
145
+ feed = File.read('blog/public/feed.xml')
146
+ expect(feed).to include('<rss version="2.0"')
147
+ expect(feed).to include('<link>https://example.com/about_parrot.html</link>')
148
+ expect(feed).to include('<pubDate>Tue, 08 Sep 2026 00:00:00 -0000</pubDate>')
149
+ expect(File.read('blog/public/about_parrot.html'))
150
+ .to include('<link rel="alternate" type="application/rss+xml" title="Parrot" href="https://example.com/feed.xml">')
151
+ end
152
+
153
+ it 'emits og:image:width/height read from the image file' do
154
+ head = File.read('blog/public/about_parrot.html')
155
+ expect(head).to include('<meta property="og:image:width" content="1024">')
156
+ expect(head).to include('<meta property="og:image:height" content="1024">')
157
+ end
158
+
159
+ it 'gives the sitemap index entry the newest post date as <lastmod>' do
160
+ sitemap = File.read('blog/public/sitemap.xml')
161
+ expect(sitemap).to match(%r{<loc>https://example\.com/</loc>\s*<lastmod>2026-09-08</lastmod>})
162
+ end
163
+
164
+ it 'builds a noindex 404 page with a parrot image, outside the sitemap and feed' do
165
+ page = File.read('blog/public/404.html')
166
+ expect(page).to include('<title>Page not found</title>')
167
+ expect(page).to include('<meta name="robots" content="noindex">')
168
+ expect(page).to include('images/parrot.jpeg')
169
+ expect(page.scan('<h1').size).to eq(1)
170
+ expect(File.exist?('blog/public/images/parrot.jpeg')).to be true
171
+
172
+ expect(File.read('blog/public/sitemap.xml')).not_to include('404.html')
173
+ expect(File.read('blog/public/feed.xml')).not_to include('404.html')
174
+ end
175
+ end
176
+
177
+ context 'per-post language' do
178
+ let(:build_config) { Parrot::Config.new(File.join(Dir.pwd, 'blog'), Logger.new(File::NULL)) }
179
+
180
+ it 'uses the post header lang for that page only' do
181
+ File.write('blog/views/posts/story.md', "<!--\ntitle: കഥ\nlang: ml\n-->\n\n# കഥ\n")
182
+ Parrot::Commands::BuildCommand.new([], build_config).run
183
+
184
+ expect(File.read('blog/public/story.html')).to include('<html lang="ml">')
185
+ expect(File.read('blog/public/about_parrot.html')).to include('<html lang="en">')
186
+ expect(File.read('blog/public/index.html')).to include('<html lang="en">')
187
+ end
188
+ end
189
+
190
+ context 'draft posts' do
191
+ let(:build_config) { Parrot::Config.new(File.join(Dir.pwd, 'blog'), Logger.new(File::NULL)) }
192
+
193
+ it 'builds draft posts while editing posts' do
194
+ File.write('blog/views/posts/story.md', "<!--\ntitle: Story\ndraft: true\n-->\n\n# story\n")
195
+ builder = Parrot::Commands::BuildCommand.new([], build_config)
196
+ builder.unset_build_mode!
197
+ builder.run
198
+
199
+ expect(File.read('blog/public/story.html')).to include('<html lang="en">')
200
+ expect(File.read('blog/public/about_parrot.html')).to include('<html lang="en">')
201
+ expect(File.read('blog/public/index.html')).to include('<html lang="en">')
202
+ end
203
+
204
+ it 'skip draft posts while building posts' do
205
+ File.write('blog/views/posts/story.md', "<!--\ntitle: Story\ndraft: true\n-->\n\n# story\n")
206
+ Parrot::Commands::BuildCommand.new([], build_config).run
207
+
208
+ expect(File.exist?('blog/public/story.html')).to be false
209
+ expect(File.read('blog/public/about_parrot.html')).to include('<html lang="en">')
210
+ expect(File.read('blog/public/index.html')).to include('<html lang="en">')
211
+ end
212
+
213
+ it 'leaves skipped drafts out of the index listing, sitemap and feed' do
214
+ File.write('blog/views/posts/story.md', "<!--\ntitle: Story\ndate: 01/10/2026\ndraft: true\n-->\n\n# story\n")
215
+ Parrot::Commands::BuildCommand.new([], build_config).run
216
+
217
+ %w[index.html sitemap.xml feed.xml].each do |name|
218
+ expect(File.read("blog/public/#{name}")).not_to include('story.html')
219
+ end
220
+ end
221
+
222
+ it 'lists drafts in the index listing, sitemap and feed while editing, since they are built' do
223
+ File.write('blog/views/posts/story.md', "<!--\ntitle: Story\ndate: 01/10/2026\ndraft: true\n-->\n\n# story\n")
224
+ builder = Parrot::Commands::BuildCommand.new([], build_config)
225
+ builder.unset_build_mode!
226
+ builder.run
227
+
228
+ %w[index.html sitemap.xml feed.xml].each do |name|
229
+ expect(File.read("blog/public/#{name}")).to include('story.html')
230
+ end
231
+ end
232
+ end
233
+
234
+ context 'post tags' do
235
+ let(:build_config) { Parrot::Config.new(File.join(Dir.pwd, 'blog'), Logger.new(File::NULL)) }
236
+
237
+ def build_story(header)
238
+ File.write('blog/views/posts/story.md', "<!--\ntitle: Story\ndate: 01/01/2012\n#{header}-->\n\n# story\n\nSome text.\n")
239
+ Parrot::Commands::BuildCommand.new([], build_config).run
240
+ Nokogiri::HTML(File.read('blog/public/story.html'))
241
+ end
242
+
243
+ it 'lists the header tags at the bottom of the post' do
244
+ html = build_story("tags: algorithms, coding\n")
245
+
246
+ last = html.at('main').element_children.last
247
+ expect(last.name).to eq('p')
248
+ expect(last['class']).to eq('post-tags')
249
+ expect(last.css('span.tag').map(&:text)).to eq(%w[algorithms coding])
250
+ end
251
+
252
+ it 'trims tags and drops blanks and duplicates' do
253
+ html = build_story("tags: a, , b ,a\n")
254
+
255
+ expect(html.css('.post-tags span.tag').map(&:text)).to eq(%w[a b])
256
+ end
257
+
258
+ it 'adds nothing to posts without tags or to the index' do
259
+ html = build_story('')
260
+
261
+ expect(html.at('.post-tags')).to be_nil
262
+ expect(File.read('blog/public/index.html')).not_to include('post-tags')
263
+ end
264
+
265
+ it 'emits article:tag metas and JSON-LD keywords' do
266
+ html = build_story("tags: algorithms, coding\n")
267
+
268
+ expect(html.css('head meta[property="article:tag"]').map { |m| m['content'] }).to eq(%w[algorithms coding])
269
+ json = JSON.parse(html.at('script[type="application/ld+json"]').text)
270
+ expect(json['keywords']).to eq('algorithms, coding')
271
+ end
272
+ end
273
+
274
+ context 'generated post index listing' do
275
+ let(:build_config) { Parrot::Config.new(File.join(Dir.pwd, 'blog'), Logger.new(File::NULL)) }
276
+
277
+ it 'lists posts newest first using the default format, with no views/index.md' do
278
+ expect(File.exist?('blog/views/index.md')).to be false
279
+
280
+ File.write('blog/views/posts/oldest.md', "<!--\ntitle: Oldest\ndate: 01/01/2020\n-->\n\n# Oldest\n")
281
+ Parrot::Commands::BuildCommand.new([], build_config).run
282
+
283
+ index = File.read('blog/public/index.html')
284
+ about_pos = index.index('about_parrot.html')
285
+ sample_pos = index.index('sample.html')
286
+ oldest_pos = index.index('oldest.html')
287
+
288
+ # the default list_format's {post_date} goes through
289
+ # post_date_format.on_list ("%m/%Y" by default), not the raw header date
290
+ expect(index).to include('09/2026')
291
+ expect(index).to include('01/2020')
292
+ expect([about_pos, sample_pos]).to all(be < oldest_pos)
293
+ end
294
+
295
+ it 'titles the index page from config.yaml post_listing.list_title' do
296
+ Parrot::Commands::BuildCommand.new([], build_config).run
297
+ expect(File.read('blog/public/index.html')).to include('<h3>Post listing</h3>')
298
+
299
+ File.write('blog/config.yaml', "post_listing:\n list_title: \"Latest writing\"\n")
300
+ Parrot::Commands::BuildCommand.new([], build_config).run
301
+
302
+ expect(File.read('blog/public/index.html')).to include('<h3>Latest writing</h3>')
303
+ end
304
+
305
+ it 'omits the index heading entirely when list_title is empty' do
306
+ File.write('blog/config.yaml', "post_listing:\n list_title: \"\"\n")
307
+ Parrot::Commands::BuildCommand.new([], build_config).run
308
+
309
+ index = File.read('blog/public/index.html')
310
+ expect(index).not_to include('<h1')
311
+ end
312
+
313
+ it 'uses config.yaml post_listing.back_link_text for the post back link' do
314
+ File.write('blog/config.yaml', "post_listing:\n back_link_text: \"Home\"\n")
315
+ Parrot::Commands::BuildCommand.new([], build_config).run
316
+
317
+ expect(File.read('blog/public/about_parrot.html')).to include('<p class="back-link"><a href="index.html">Home</a></p>')
318
+ end
319
+
320
+ it 'omits the post back link entirely when back_link_text is empty' do
321
+ File.write('blog/config.yaml', "post_listing:\n back_link_text: \"\"\n")
322
+ Parrot::Commands::BuildCommand.new([], build_config).run
323
+
324
+ expect(File.read('blog/public/about_parrot.html')).not_to include('back-link')
325
+ end
326
+
327
+ it 'honours a custom list_format from config.yaml' do
328
+ File.write('blog/config.yaml', "post_listing:\n list_format: \"{post_title} -- {%A, %B %d %Y}\"\n")
329
+ Parrot::Commands::BuildCommand.new([], build_config).run
330
+
331
+ index = File.read('blog/public/index.html')
332
+ expect(index).to include('Tuesday, September 08 2026')
333
+ end
334
+
335
+ it 'links only the title when {post_link} wraps just {post_title}' do
336
+ File.write('blog/config.yaml', "post_listing:\n list_format: \"{%d/%m} ~ [{post_title}]({post_link})\"\n")
337
+ Parrot::Commands::BuildCommand.new([], build_config).run
338
+
339
+ index = File.read('blog/public/index.html')
340
+ expect(index).to include('08/09 ~ <a href="about_parrot.html">About Parrot</a>')
341
+ end
342
+
343
+ it 'links the whole line when {post_link} wraps the entire format' do
344
+ File.write('blog/config.yaml', "post_listing:\n list_format: \"[{%d/%m} ~ {post_title}]({post_link})\"\n")
345
+ Parrot::Commands::BuildCommand.new([], build_config).run
346
+
347
+ index = File.read('blog/public/index.html')
348
+ expect(index).to include('<a href="about_parrot.html">08/09 ~ About Parrot</a>')
349
+ end
350
+
351
+ it 'exposes any custom header field as {post_<key>}' do
352
+ File.write('blog/config.yaml', "post_listing:\n list_format: \"{post_title} [{post_category}]\"\n")
353
+ File.write('blog/views/posts/story.md', "<!--\ntitle: A story\ncategory: Fiction\n-->\n\n# A story\n")
354
+ Parrot::Commands::BuildCommand.new([], build_config).run
355
+
356
+ index = File.read('blog/public/index.html')
357
+ expect(index).to include('A story [Fiction]')
358
+ end
359
+
360
+ it 'formats {post_date} in list_format per post_date_format.on_list' do
361
+ File.write('blog/config.yaml',
362
+ "post_listing:\n list_format: \"{post_date} ~ {post_title}\"\npost_date_format:\n on_list: \"%m/%Y\"\n")
363
+ Parrot::Commands::BuildCommand.new([], build_config).run
364
+
365
+ index = File.read('blog/public/index.html')
366
+ expect(index).to include('09/2026 ~ About Parrot')
367
+ end
368
+
369
+ it 'formats a literal {post_date} inside a post body per post_date_format.on_post' do
370
+ File.write('blog/config.yaml', "post_date_format:\n on_post: \"%A, %B %d %Y\"\n")
371
+ File.write('blog/views/posts/story.md', "<!--\ntitle: A story\ndate: 08/09/2026\n-->\n\n# A story\n\n_{post_date}_\n")
372
+ Parrot::Commands::BuildCommand.new([], build_config).run
373
+
374
+ expect(File.read('blog/public/story.html')).to include('Tuesday, September 08 2026')
375
+ end
376
+
377
+ it 'groups posts by year under a heading when group_by is year' do
378
+ File.write('blog/config.yaml', "post_listing:\n group_by: year\n")
379
+ File.write('blog/views/posts/old.md', "<!--\ntitle: Old\ndate: 01/01/2020\n-->\n\n# Old\n")
380
+ Parrot::Commands::BuildCommand.new([], build_config).run
381
+
382
+ index = File.read('blog/public/index.html')
383
+ expect(index).to include('<h2>2026</h2>')
384
+ expect(index).to include('<h2>2020</h2>')
385
+ expect(index.index('2026</h2>')).to be < index.index('2020</h2>')
386
+ end
387
+ end
388
+
389
+ context 'post index pagination' do
390
+ let(:build_config) { Parrot::Config.new(File.join(Dir.pwd, 'blog'), Logger.new(File::NULL)) }
391
+
392
+ it 'keeps every post on a single index.html when per_page is unset' do
393
+ Parrot::Commands::BuildCommand.new([], build_config).run
394
+ expect(File.exist?('blog/public/index2.html')).to be false
395
+ end
396
+
397
+ it 'splits the listing across index.html, index2.html, etc. once per_page is exceeded' do
398
+ File.write('blog/config.yaml', "post_listing:\n per_page: 1\n")
399
+ File.write('blog/views/posts/oldest.md', "<!--\ntitle: Oldest\ndate: 01/01/2020\n-->\n\n# Oldest\n")
400
+ Parrot::Commands::BuildCommand.new([], build_config).run
401
+
402
+ expect(File.exist?('blog/public/index.html')).to be true
403
+ expect(File.exist?('blog/public/index2.html')).to be true
404
+ expect(File.exist?('blog/public/index3.html')).to be true
405
+ expect(File.exist?('blog/public/index4.html')).to be false
406
+ expect(File.read('blog/public/index3.html')).to include('oldest.html')
407
+ end
408
+
409
+ it 'links older/newer pages with the default pager text' do
410
+ File.write('blog/config.yaml', "post_listing:\n per_page: 1\n")
411
+ Parrot::Commands::BuildCommand.new([], build_config).run
412
+
413
+ page1 = File.read('blog/public/index.html')
414
+ expect(page1).to include('<a href="index2.html">Older posts →</a>')
415
+ expect(page1).not_to include('Newer posts')
416
+
417
+ page2 = File.read('blog/public/index2.html')
418
+ expect(page2).to include('<a href="index.html">← Newer posts</a>')
419
+ expect(page2).not_to include('Older posts')
420
+ end
421
+
422
+ it 'honours custom newer_link_text/older_link_text from config.yaml' do
423
+ File.write('blog/config.yaml',
424
+ "post_listing:\n per_page: 1\n newer_link_text: \"Prev\"\n older_link_text: \"Next\"\n")
425
+ Parrot::Commands::BuildCommand.new([], build_config).run
426
+
427
+ expect(File.read('blog/public/index.html')).to include('>Next<')
428
+ expect(File.read('blog/public/index2.html')).to include('>Prev<')
429
+ end
430
+
431
+ it 'omits the pager entirely when the only applicable link text is set to empty' do
432
+ File.write('blog/config.yaml', "post_listing:\n per_page: 1\n older_link_text: \"\"\n")
433
+ Parrot::Commands::BuildCommand.new([], build_config).run
434
+
435
+ expect(File.read('blog/public/index.html')).not_to include('pagination')
436
+ end
437
+
438
+ it 'omits the list_title heading on pages after the first' do
439
+ File.write('blog/config.yaml', "post_listing:\n per_page: 1\n")
440
+ Parrot::Commands::BuildCommand.new([], build_config).run
441
+
442
+ expect(File.read('blog/public/index.html')).to include('<h3')
443
+ expect(File.read('blog/public/index2.html')).not_to include('<h3')
444
+ end
445
+
446
+ it "points a pushed-down post's back-link at the index page it actually appears on" do
447
+ File.write('blog/config.yaml', "post_listing:\n per_page: 1\n")
448
+ Parrot::Commands::BuildCommand.new([], build_config).run
449
+
450
+ main_html = File.read('blog/public/index2.html')[%r{<main>.*?</main>}m]
451
+ pushed_post = main_html[/href="([^"]+\.html)"/, 1]
452
+ expect(File.read("blog/public/#{pushed_post}"))
453
+ .to include(%(<a href="index2.html">← Back to all posts</a>))
454
+ end
455
+
456
+ it 'removes a stale trailing index page once the post count drops back below per_page' do
457
+ File.write('blog/config.yaml', "post_listing:\n per_page: 1\n")
458
+ Parrot::Commands::BuildCommand.new([], build_config).run
459
+ expect(File.exist?('blog/public/index2.html')).to be true
460
+
461
+ File.write('blog/config.yaml', "post_listing:\n per_page: 10\n")
462
+ Parrot::Commands::BuildCommand.new([], build_config).run
463
+ expect(File.exist?('blog/public/index2.html')).to be false
464
+ end
465
+
466
+ it 'lists every index page in the sitemap' do
467
+ File.write('blog/config.yaml', "post_listing:\n per_page: 1\n")
468
+ Parrot::Commands::BuildCommand.new([], build_config).run
469
+
470
+ sitemap = File.read('blog/public/sitemap.xml')
471
+ expect(sitemap).to include('<loc>https://example.com/</loc>')
472
+ expect(sitemap).to include('<loc>https://example.com/index2.html</loc>')
473
+ end
474
+
475
+ it 'rewrites the sitemap when the watcher rebuilds after per_page changes in config.yaml' do
476
+ command = Parrot::Commands::BuildCommand.new([], build_config)
477
+ command.run
478
+ expect(File.read('blog/public/sitemap.xml')).not_to include('index2.html')
479
+
480
+ File.write('blog/config.yaml', "post_listing:\n per_page: 1\n")
481
+ command.build('config.yaml')
482
+
483
+ expect(File.read('blog/public/sitemap.xml')).to include('<loc>https://example.com/index2.html</loc>')
484
+ end
485
+
486
+ it "updates a pushed post's back-link when the watcher rebuilds after a new post shifts pagination" do
487
+ File.write('blog/config.yaml', "post_listing:\n per_page: 1\n")
488
+ command = Parrot::Commands::BuildCommand.new([], build_config)
489
+ command.run
490
+
491
+ new_post = 'blog/views/posts/newest.md'
492
+ File.write(new_post, "<!--\ntitle: Newest\ndate: 01/01/2027\n-->\n\n# Newest\n")
493
+ command.build(new_post)
494
+
495
+ main_html = File.read('blog/public/index2.html')[%r{<main>.*?</main>}m]
496
+ pushed_post = main_html[/href="([^"]+\.html)"/, 1]
497
+ expect(File.read("blog/public/#{pushed_post}")).to include('<a href="index2.html">')
498
+ end
499
+ end
500
+
501
+ context 'about page' do
502
+ let(:build_config) { Parrot::Config.new(File.join(Dir.pwd, 'blog'), Logger.new(File::NULL)) }
503
+ let(:command) { Parrot::Commands::BuildCommand.new([], build_config) }
504
+
505
+ before { command.run }
506
+
507
+ it 'builds about.html with its own title, description and social links' do
508
+ page = File.read('blog/public/about.html')
509
+ expect(page).to include('<title>About</title>')
510
+ expect(page).to include('<meta name="description" content="Who writes this blog, and where else to find them.">')
511
+ expect(page).to include('<meta property="og:url" content="https://example.com/about.html">')
512
+ expect(page).to include('href="https://github.com/your-username"')
513
+ expect(page.scan('<h1').size).to eq(1)
514
+ end
515
+
516
+ it 'is an AboutPage, not an article' do
517
+ page = File.read('blog/public/about.html')
518
+ expect(page).to include('<meta property="og:type" content="website">')
519
+ expect(page).not_to include('article:published_time')
520
+ expect(page).to include('"@type": "AboutPage"')
521
+ end
522
+
523
+ it 'is linked from the nav on every page' do
524
+ %w[index.html sample.html about.html 404.html].each do |name|
525
+ nav = File.read("blog/public/#{name}")[%r{<nav class="site-nav">.*?</nav>}m]
526
+ expect(nav).to include('<a href="/about.html">About</a>')
527
+ end
528
+ end
529
+
530
+ it 'is in the sitemap but not the index listing or the feed' do
531
+ expect(File.read('blog/public/sitemap.xml')).to include('<loc>https://example.com/about.html</loc>')
532
+ expect(File.read('blog/public/index.html')[%r{<main>.*?</main>}m]).not_to include('about.html')
533
+ expect(File.read('blog/public/feed.xml')).not_to include('about.html')
534
+ end
535
+
536
+ it 'is rebuilt by the watcher when views/about.md changes' do
537
+ File.write('blog/views/about.md', "# About me\n\nSomething new about the author of this blog.\n")
538
+ command.build('views/about.md')
539
+
540
+ page = File.read('blog/public/about.html')
541
+ expect(page).to include('<title>About me</title>')
542
+ expect(page).to include('Something new about the author of this blog.')
543
+ end
544
+
545
+ it 'is removed, along with its sitemap entry, when views/about.md is deleted' do
546
+ File.delete('blog/views/about.md')
547
+ command.build('views/about.md')
548
+
549
+ expect(File.exist?('blog/public/about.html')).to be false
550
+ expect(File.read('blog/public/sitemap.xml')).not_to include('about.html')
551
+ end
552
+ end
553
+
554
+ context 'post categories' do
555
+ let(:build_config) { Parrot::Config.new(File.join(Dir.pwd, 'blog'), Logger.new(File::NULL)) }
556
+ let(:command) { Parrot::Commands::BuildCommand.new([], build_config) }
557
+
558
+ def write_post(name, title, date, category = nil)
559
+ header = "title: #{title}\ndate: #{date}\n"
560
+ header << "category: #{category}\n" if category
561
+ File.write("blog/views/posts/#{name}.md", "<!--\n#{header}-->\n\n# #{title}\n\nBody text.\n")
562
+ end
563
+
564
+ def main_of(file)
565
+ File.read("blog/public/#{file}")[%r{<main>.*?</main>}m]
566
+ end
567
+
568
+ before do
569
+ write_post('ruby-old', 'Old Ruby', '01/01/2024', 'Ruby')
570
+ write_post('ruby-new', 'New Ruby', '01/01/2025', 'Ruby')
571
+ write_post('web', 'Web things', '01/06/2025', 'Web Dev')
572
+ end
573
+
574
+ it 'lists only that category\'s posts, newest first, under its name' do
575
+ command.run
576
+ main = main_of('category-ruby.html')
577
+
578
+ expect(main).to include('<h3>Ruby</h3>')
579
+ expect(main).not_to include('web.html')
580
+ expect(main).not_to include('sample.html')
581
+ expect(main.index('ruby-new.html')).to be < main.index('ruby-old.html')
582
+ expect(File.read('blog/public/category-ruby.html')).to include('<title>Ruby posts</title>')
583
+ end
584
+
585
+ it 'names pages after the slugified category' do
586
+ command.run
587
+ expect(File.exist?('blog/public/category-web-dev.html')).to be true
588
+ end
589
+
590
+ it 'paginates a category past per_page, with a pager between its pages' do
591
+ File.write('blog/config.yaml', "post_listing:\n per_page: 1\n")
592
+ command.run
593
+
594
+ page1 = main_of('category-ruby.html')
595
+ page2 = main_of('category-ruby_2.html')
596
+ expect(page1).to include('ruby-new.html')
597
+ expect(page1).to include('<a href="category-ruby_2.html">Older posts →</a>')
598
+ expect(page2).to include('ruby-old.html')
599
+ expect(page2).to include('<a href="category-ruby.html">← Newer posts</a>')
600
+ expect(page2).to include('<h3>Ruby</h3>')
601
+ expect(File.exist?('blog/public/category-ruby_3.html')).to be false
602
+ end
603
+
604
+ it 'lists every category with its post count on categories.html' do
605
+ command.run
606
+ main = main_of('categories.html')
607
+
608
+ expect(main).to include('<a href="category-guides.html">Guides</a> (2)')
609
+ expect(main).to include('<a href="category-ruby.html">Ruby</a> (2)')
610
+ expect(main).to include('<a href="category-web-dev.html">Web Dev</a> (1)')
611
+ end
612
+
613
+ it 'still writes categories.html when no post has a category' do
614
+ Dir['blog/views/posts/*.md'].each { |path| File.write(path, File.read(path).gsub(/^category:.*\n/, '')) }
615
+ command.run
616
+
617
+ expect(main_of('categories.html')).to include('No categories yet.')
618
+ expect(Dir['blog/public/category-*.html']).to be_empty
619
+ end
620
+
621
+ it 'puts the date and a category link right after the post title' do
622
+ command.run
623
+ expect(main_of('ruby-new.html')).to include(
624
+ '<h1>New Ruby</h1>' \
625
+ "\n" \
626
+ '<p class="post-meta"><time datetime="2025-01-01">01/01/2025</time> · ' \
627
+ '<a class="category-tag" href="category-ruby.html">Ruby</a></p>'
628
+ )
629
+ end
630
+
631
+ it 'shows just the date for a post without a category, and nothing without either' do
632
+ write_post('plain', 'Plain', '01/02/2025')
633
+ File.write('blog/views/posts/bare.md', "# Bare\n\nBody text.\n")
634
+ command.run
635
+
636
+ expect(main_of('plain.html')).to include('<p class="post-meta"><time datetime="2025-02-01">01/02/2025</time></p>')
637
+ expect(main_of('bare.html')).not_to include('post-meta')
638
+ end
639
+
640
+ it 'replaces an old _{post_date}_ line instead of showing the date twice' do
641
+ File.write('blog/views/posts/story.md',
642
+ "<!--\ntitle: A story\ndate: 08/09/2026\ncategory: Fiction\n-->\n\n# A story\n\n_{post_date}_\n\nBody.\n")
643
+ command.run
644
+
645
+ # the header comment is copied into <main> as-is, so leave it out
646
+ main = main_of('story.html').gsub(/<!--.*?-->/m, '')
647
+ expect(main.scan('08/09/2026').length).to eq(1)
648
+ expect(main).to include('class="category-tag" href="category-fiction.html"')
649
+ end
650
+
651
+ it 'shows a category tag in the index listing by default' do
652
+ command.run
653
+ expect(main_of('index.html')).to include('<a href="category-ruby.html" class="category-tag">Ruby</a>')
654
+ end
655
+
656
+ it 'leaves the tag out when list_format drops {post_category_tag}' do
657
+ File.write('blog/config.yaml', "post_listing:\n list_format: \"[{post_title}]({post_link})\"\n")
658
+ command.run
659
+ expect(main_of('index.html')).not_to include('category-tag')
660
+ expect(main_of('category-ruby.html')).not_to include('category-tag')
661
+ end
662
+
663
+ it 'lists categories.html and every category page in the sitemap' do
664
+ File.write('blog/config.yaml', "post_listing:\n per_page: 1\n")
665
+ command.run
666
+
667
+ sitemap = File.read('blog/public/sitemap.xml')
668
+ expect(sitemap).to include('<loc>https://example.com/categories.html</loc>')
669
+ expect(sitemap).to include("<loc>https://example.com/category-ruby.html</loc>\n <lastmod>2025-01-01</lastmod>")
670
+ expect(sitemap).to include('<loc>https://example.com/category-ruby_2.html</loc>')
671
+ end
672
+
673
+ it 'removes a category\'s pages once the watcher sees its last post move away' do
674
+ command.run
675
+ write_post('web', 'Web things', '01/06/2025', 'Ruby')
676
+ command.build('views/posts/web.md')
677
+
678
+ expect(File.exist?('blog/public/category-web-dev.html')).to be false
679
+ expect(main_of('category-ruby.html')).to include('web.html')
680
+ expect(main_of('categories.html')).not_to include('Web Dev')
681
+ end
682
+
683
+ it 'shares one page between names that slugify the same' do
684
+ write_post('cpp', 'C plus plus', '01/03/2025', 'Web-Dev')
685
+ command.run
686
+ main = main_of('category-web-dev.html')
687
+
688
+ expect(main).to include('web.html')
689
+ expect(main).to include('cpp.html')
690
+ end
691
+
692
+ it 'fails the build when a post is named like a category page' do
693
+ File.write('blog/views/posts/category-ruby.md', "# Clash\n")
694
+ expect { command.run }.to raise_error(%r{Reserved post filename: views/posts/category-ruby\.md})
695
+ end
696
+
697
+ it 'builds a post named category-<something> when no such category exists' do
698
+ File.write('blog/views/posts/category-theory.md', "# Category theory\n")
699
+ expect { command.run }.not_to raise_error
700
+ expect(File.exist?('blog/public/category-theory.html')).to be true
701
+ end
702
+ end
703
+
704
+ context 'reserved post filenames' do
705
+ let(:build_config) { Parrot::Config.new(File.join(Dir.pwd, 'blog'), Logger.new(File::NULL)) }
706
+ let(:command) { Parrot::Commands::BuildCommand.new([], build_config) }
707
+
708
+ %w[about.md 404.md index.md index3.md About.md now.md post.md posts.md note.md notes.md categories.md category.md].each do |name|
709
+ it "fails the build on views/posts/#{name} before writing anything" do
710
+ command.run
711
+ File.write("blog/views/posts/#{name}", "# Clash\n")
712
+
713
+ expect { command.run }.to raise_error(%r{Reserved post filename: views/posts/#{Regexp.escape(name)}})
714
+ expect(File.exist?('blog/public/index.html')).to be true
715
+ end
716
+ end
717
+
718
+ it 'fails the watcher rebuild when such a post appears' do
719
+ command.run
720
+ File.write('blog/views/posts/about.md', "# Clash\n")
721
+
722
+ expect { command.build('views/posts/about.md') }.to raise_error(/Reserved post filename/)
723
+ expect(File.read('blog/public/about.html')).to include('<title>About</title>')
724
+ end
725
+
726
+ it 'builds posts whose names only contain a reserved word' do
727
+ File.write('blog/views/posts/about-me.md', "# About me\n")
728
+ expect { command.run }.not_to raise_error
729
+ expect(File.exist?('blog/public/about-me.html')).to be true
730
+ end
731
+ end
732
+
733
+ context 'post header description' do
734
+ let(:build_config) { Parrot::Config.new(File.join(Dir.pwd, 'blog'), Logger.new(File::NULL)) }
735
+
736
+ it 'wins over the first-paragraph fallback' do
737
+ File.write('blog/views/posts/story.md',
738
+ "<!--\ntitle: A story\ndescription: Hand-written summary.\n-->\n\n# A story\n\nThe opening paragraph.\n")
739
+ Parrot::Commands::BuildCommand.new([], build_config).run
740
+
741
+ head = File.read('blog/public/story.html')
742
+ expect(head).to include('<meta name="description" content="Hand-written summary.">')
743
+ expect(head).to include('<meta property="og:description" content="Hand-written summary.">')
744
+ end
745
+ end
746
+ end
747
+ end