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,1274 @@
1
+ require 'date'
2
+ require 'time'
3
+ require 'json'
4
+ require 'yaml'
5
+ require 'tilt'
6
+ require 'nokogiri'
7
+ require 'sassc'
8
+ require 'tilt/kramdown'
9
+ require 'kramdown-parser-gfm'
10
+ require 'rouge'
11
+
12
+ module Parrot
13
+ module Commands
14
+ # Build command builds the HTML static app
15
+ # @usage parrot build
16
+ # The build files will be kept in the build directory
17
+ class BuildCommand
18
+ # Wraps rouge's class-tagged <span> output in <pre><code>, same markup
19
+ # kramdown's default (deprecated) HTMLLegacy formatter produced.
20
+ class CodeFormatter < Rouge::Formatters::HTML
21
+ def initialize(opts = {})
22
+ super()
23
+ @wrap = opts.fetch(:wrap, true)
24
+ @css_class = opts.fetch(:css_class, 'highlight')
25
+ end
26
+
27
+ def stream(tokens, &)
28
+ yield %(<div class="highlight"><pre class="#{@css_class}"><code>) if @wrap
29
+ super
30
+ yield '</code></pre></div>' if @wrap
31
+ end
32
+ end
33
+
34
+ attr_accessor :app_root, :config, :build_path
35
+
36
+ def initialize(args = [], config)
37
+ @config = config
38
+ @args = args
39
+ @app_root = @config.root_dir
40
+ @build_path = File.join(app_root, 'public')
41
+ set_build_mode!
42
+ end
43
+
44
+ def unset_build_mode!
45
+ @config[:build_mode] = false
46
+ end
47
+
48
+ def set_build_mode!
49
+ @config[:build_mode] = true
50
+ end
51
+
52
+ # Builds index.html and, once there are more posts than post_listing's
53
+ # per_page, index2.html, index3.html, etc. — newest posts first, oldest
54
+ # posts on the highest-numbered page. Any previously-built index page
55
+ # beyond the current page count is removed, so a shrinking post count
56
+ # doesn't leave a stale trailing page behind.
57
+ def build_index_page
58
+ pages = paginated_posts(sorted_posts_metadata)
59
+
60
+ pages.each_with_index do |posts_meta, index|
61
+ build_index_file(index + 1, posts_meta, pages.length)
62
+ end
63
+
64
+ cleanup_stale_index_pages(pages.length)
65
+ end
66
+
67
+ def build_index_file(page_number, posts_meta, total_pages)
68
+ layout = Tilt.new("#{app_root}/views/layout.html.erb")
69
+
70
+ text = layout.render { markdown_string(index_markdown(page_number, posts_meta, total_pages)).render }
71
+
72
+ html = update_internal_links(text)
73
+ copy_image_assets(html)
74
+ apply_page_meta(html, index_filename(page_number))
75
+
76
+ output = File.join(build_path, index_filename(page_number))
77
+ File.write(output, html)
78
+ config.logger.info "Built #{output}"
79
+ end
80
+
81
+ # Builds category-<slug>.html for every category in use, plus
82
+ # category-<slug>_2.html, _3, … once a category has more posts than
83
+ # post_listing's per_page. Each lists just that category's posts, newest
84
+ # first, with the same list_format, group_by and pager as the index.
85
+ # Any category-*.html this build didn't write (a category no post uses
86
+ # any more, or a trailing page it no longer needs) is removed, unless
87
+ # it's a post's own page (category-theory.md when no such category
88
+ # exists).
89
+ def build_category_pages
90
+ keep = sorted_posts_metadata.map { |meta| meta['__filename'].sub('.md', '.html') }
91
+ keep += posts_by_category.flat_map do |slug, category|
92
+ pages = paginated_posts(category[:posts])
93
+ pages.each_with_index.map do |posts_meta, index|
94
+ build_category_file(slug, category[:name], index + 1, posts_meta, pages.length)
95
+ end
96
+ end
97
+
98
+ Dir[File.join(build_path, 'category-*.html')].each do |path|
99
+ File.delete(path) unless keep.include?(File.basename(path))
100
+ end
101
+ end
102
+
103
+ # Writes one category listing page and returns its filename. Every page
104
+ # repeats the category's name as its heading, so a visitor several
105
+ # pages in still knows which category they're browsing.
106
+ def build_category_file(slug, name, page_number, posts_meta, total_pages)
107
+ layout = Tilt.new("#{app_root}/views/layout.html.erb")
108
+ filename = category_filename(slug, page_number)
109
+
110
+ markdown = "### #{escape_markdown(name)}\n\n#{render_post_listing(posts_meta)}\n"
111
+ pager = pager_markdown(page_number, total_pages, ->(number) { category_filename(slug, number) })
112
+ markdown << "\n#{pager}\n" unless pager.empty?
113
+
114
+ html = update_internal_links(layout.render { markdown_string(markdown).render })
115
+ copy_image_assets(html)
116
+
117
+ title = page_number == 1 ? "#{name} posts" : "#{name} posts (page #{page_number})"
118
+ meta = { 'title' => title, 'description' => "Posts filed under #{name}." }
119
+ apply_page_meta(html, filename, meta, kind: :category)
120
+
121
+ output = File.join(build_path, filename)
122
+ File.write(output, html)
123
+ config.logger.info "Built #{output}"
124
+ filename
125
+ end
126
+
127
+ # Builds public/categories.html, the page the layout's nav links to:
128
+ # every category in use, by name, linking its listing, with its post
129
+ # count. Written even when no post has a category yet, so that nav link
130
+ # never 404s.
131
+ def build_categories_page
132
+ entries = posts_by_category.map do |slug, category|
133
+ "- [#{escape_markdown(category[:name])}](#{category_filename(slug, 1)}) (#{category[:posts].length})"
134
+ end
135
+ listing = entries.empty? ? 'No categories yet.' : "#{entries.join("\n")}\n{: .category-list}"
136
+
137
+ layout = Tilt.new("#{app_root}/views/layout.html.erb")
138
+ html = update_internal_links(layout.render { markdown_string("### Categories\n\n#{listing}\n").render })
139
+ copy_image_assets(html)
140
+ meta = { 'title' => 'Categories', 'description' => 'Every category on this blog.' }
141
+ apply_page_meta(html, 'categories.html', meta, kind: :category)
142
+
143
+ output = File.join(build_path, 'categories.html')
144
+ File.write(output, html)
145
+ config.logger.info "Built #{output}"
146
+ end
147
+
148
+ # Rebuilds every post, each linking its back-link at whichever index
149
+ # page it currently falls on — which can shift for posts far from the
150
+ # top whenever pagination is active and a post is added, removed, or
151
+ # re-dated, so every post is rebuilt together rather than in isolation.
152
+ def build_posts
153
+ posts_meta = sorted_posts_metadata
154
+ page_lookup = page_number_lookup(posts_meta)
155
+
156
+ published_post_paths.each do |post_path|
157
+ build_post(post_path, page_lookup.fetch(File.basename(post_path), 1))
158
+ end
159
+ end
160
+
161
+ # Builds public/404.html from views/404.md for hosts that serve it on a
162
+ # missing path. Marked noindex; not listed in the sitemap or feed.
163
+ def build_404_page
164
+ source = File.join(app_root, 'views', '404.md')
165
+ return unless File.exist?(source)
166
+
167
+ layout = Tilt.new("#{app_root}/views/layout.html.erb")
168
+ html = update_internal_links(layout.render { markdown(source).render })
169
+ copy_image_assets(html)
170
+
171
+ title = html.at('head title')
172
+ title.content = 'Page not found' if title
173
+
174
+ robots = Nokogiri::XML::Node.new('meta', html)
175
+ robots['name'] = 'robots'
176
+ robots['content'] = 'noindex'
177
+ (html.at('head') || html).add_child(robots)
178
+
179
+ output = File.join(build_path, '404.html')
180
+ File.write(output, html)
181
+ config.logger.info "Built #{output}"
182
+ end
183
+
184
+ # Builds public/about.html from views/about.md, the page the layout's nav
185
+ # links to. It takes the same optional `<!-- key: value -->` header as a
186
+ # post (title, description, lang) but isn't one: it stays out of the
187
+ # index listing and the feed, and is listed in the sitemap. A previously
188
+ # built page is removed once views/about.md is gone.
189
+ def build_about_page
190
+ source = File.join(app_root, 'views', 'about.md')
191
+ output = File.join(build_path, 'about.html')
192
+
193
+ unless File.exist?(source)
194
+ FileUtils.rm_f(output)
195
+ return
196
+ end
197
+
198
+ meta = post_metadata(source)
199
+ layout = Tilt.new("#{app_root}/views/layout.html.erb")
200
+ body = markdown(source).render
201
+
202
+ html = update_internal_links(layout.render { body })
203
+ copy_image_assets(html)
204
+
205
+ meta['title'] ||= html.at('main h1')&.text
206
+ meta['description'] ||= summarize(body)
207
+ apply_page_meta(html, 'about.html', meta, kind: :about)
208
+
209
+ File.write(output, html)
210
+ config.logger.info "Built #{output}"
211
+ end
212
+
213
+ def build_post(post_path, page_number = 1)
214
+ meta = post_metadata(post_path)
215
+ return if build_mode? && draft_post?(meta)
216
+
217
+ layout = Tilt.new("#{app_root}/views/layout.html.erb")
218
+
219
+ meta['__date'] = parse_post_date(meta['date'])
220
+
221
+ source = substitute_post_date(File.read(post_path), meta)
222
+ source = substitute_post_title(source, meta)
223
+ body = markdown_string(source).render
224
+ text = layout.render { body }
225
+
226
+ html = update_internal_links(text)
227
+ copy_image_assets(html)
228
+ html = inject_scripts(html)
229
+ html = inject_post_meta(html, meta)
230
+ html = inject_back_link(html, page_number)
231
+ html = inject_tags(html, post_tags(meta))
232
+
233
+ output_name = File.basename(post_path).sub('.md', '.html')
234
+ meta['description'] ||= summarize(body)
235
+ apply_page_meta(html, output_name, meta)
236
+
237
+ output = File.join(build_path, output_name)
238
+ File.write(output, html)
239
+ config.logger.info "Built #{output}"
240
+ end
241
+
242
+ def update_internal_links(text)
243
+ html = Nokogiri::HTML(text)
244
+
245
+ html.css('a').each do |link|
246
+ link['href'] = link['href'][1..].sub('.md', '.html') if link['href'].start_with?('#') && link['href'].end_with?('.md')
247
+ end
248
+
249
+ html
250
+ end
251
+
252
+ def inject_scripts(html)
253
+ script_tag = Nokogiri::XML::Node.new('script', html)
254
+ script_tag['src'] = MATHJAX_URL
255
+ script_tag['async'] = 'true' # optional attribute
256
+ script_tag.content = '' # Needed to close the tag properly
257
+ # Append the <script> tag to the <body>
258
+ html.at('body') << script_tag
259
+ html
260
+ end
261
+
262
+ # Adds a link back to the index above a post's own content, so a
263
+ # visitor who lands directly on a post can get back to the listing —
264
+ # specifically to whichever index page currently lists this post,
265
+ # since pagination can put it anywhere. Its text comes from
266
+ # config.yaml's post_listing.back_link_text, omitted entirely when
267
+ # that's explicitly set to an empty string.
268
+ def inject_back_link(html, page_number = 1)
269
+ settings = post_listing_settings
270
+ return html if settings.key?('back_link_text') && settings['back_link_text'].to_s.strip.empty?
271
+
272
+ main = html.at('main')
273
+ return html unless main
274
+
275
+ link = Nokogiri::XML::Node.new('a', html)
276
+ link['href'] = index_filename(page_number)
277
+ link.content = settings['back_link_text'] || DEFAULT_BACK_LINK_TEXT
278
+
279
+ paragraph = Nokogiri::XML::Node.new('p', html)
280
+ paragraph['class'] = 'back-link'
281
+ paragraph.add_child(link)
282
+
283
+ main.prepend_child(paragraph)
284
+ html
285
+ end
286
+
287
+ # Puts a `<p class="post-meta">` line right after the post's <h1> (or at
288
+ # the top of its <main> when it has none): its header `date`, formatted
289
+ # per post_date_format.on_post, and its `category` as a
290
+ # `<a class="category-tag">` link to that category's listing. Either is
291
+ # left out when the header doesn't have it, and the line when neither is
292
+ # there. A paragraph right after the <h1> holding nothing but that same
293
+ # date — the `_{post_date}_` line `parrot post` used to write — is
294
+ # replaced, so the date isn't shown twice.
295
+ def inject_post_meta(html, meta)
296
+ date = meta['__date']&.strftime(post_date_format('on_post'))
297
+ href = category_href(meta)
298
+ return html if date.nil? && href.nil?
299
+
300
+ main = html.at('main')
301
+ return html unless main
302
+
303
+ paragraph = Nokogiri::XML::Node.new('p', html)
304
+ paragraph['class'] = 'post-meta'
305
+
306
+ if date
307
+ time = Nokogiri::XML::Node.new('time', html)
308
+ time['datetime'] = meta['__date'].iso8601
309
+ time.content = date
310
+ paragraph.add_child(time)
311
+ end
312
+
313
+ if href
314
+ paragraph.add_child(Nokogiri::XML::Text.new(' · ', html)) if date
315
+ link = Nokogiri::XML::Node.new('a', html)
316
+ link['class'] = 'category-tag'
317
+ link['href'] = href
318
+ link.content = post_category(meta)
319
+ paragraph.add_child(link)
320
+ end
321
+
322
+ heading = main.at('h1')
323
+ following = heading&.next_element
324
+ if heading.nil?
325
+ main.prepend_child(paragraph)
326
+ elsif date && following&.name == 'p' && following.text.strip == date
327
+ following.replace(paragraph)
328
+ else
329
+ heading.add_next_sibling(paragraph)
330
+ end
331
+
332
+ html
333
+ end
334
+
335
+ # Lists a post's header `tags` at the bottom of its <main>, each in its
336
+ # own <span class="tag">. Nothing is added for a post without tags.
337
+ def inject_tags(html, tags)
338
+ return html if tags.empty?
339
+
340
+ main = html.at('main')
341
+ return html unless main
342
+
343
+ paragraph = Nokogiri::XML::Node.new('p', html)
344
+ paragraph['class'] = 'post-tags'
345
+ paragraph.add_child(Nokogiri::XML::Text.new('Tags: ', html))
346
+
347
+ tags.each_with_index do |tag, index|
348
+ paragraph.add_child(Nokogiri::XML::Text.new(' ', html)) if index.positive?
349
+ span = Nokogiri::XML::Node.new('span', html)
350
+ span['class'] = 'tag'
351
+ span.content = tag
352
+ paragraph.add_child(span)
353
+ end
354
+
355
+ main.add_child(paragraph)
356
+ html
357
+ end
358
+
359
+ def copy_image_assets(html)
360
+ html.css('img, link').each do |node|
361
+ # <img> carries the path in src, <link> (icons, favicons) in href.
362
+ src = node['src'] || node['href']
363
+ next if src.nil?
364
+
365
+ source_path = File.join(app_root, src)
366
+
367
+ copy_image(source_path) if src.start_with?('images/') && File.exist?(source_path)
368
+ end
369
+ end
370
+
371
+ def copy_image(source_path)
372
+ target_dir = File.join(build_path, 'images')
373
+ FileUtils.mkdir_p(target_dir)
374
+ FileUtils.cp(source_path, target_dir)
375
+ config.logger.info "Copied #{File.basename(source_path)} to #{target_dir}"
376
+ end
377
+
378
+ def compile_css
379
+ css_files = Dir[File.join(app_root, 'css', '**', '*.{scss,css}')]
380
+ combined_scss = css_files.map { |file| File.read(file) }.join("\n")
381
+
382
+ user_css =
383
+ begin
384
+ SassC::Engine.new(combined_scss, style: :compressed, syntax: :scss).render
385
+ rescue SassC::SyntaxError => e
386
+ puts "SassC Compilation Error: #{e.message}"
387
+ ''
388
+ end
389
+
390
+ # The syntax-highlight theme must always ship, even when the user's
391
+ # own stylesheet is empty or fails to compile.
392
+ compiled_css = "#{user_css}\n#{syntax_highlight_css}"
393
+
394
+ target_path = File.join(build_path, 'app.css')
395
+ File.write(target_path, compiled_css)
396
+ config.logger.info "Compiled and minified CSS written to #{target_path}"
397
+ end
398
+
399
+ def compile_js
400
+ FileUtils.cp(File.join(app_root, 'javascripts', 'app.js'), File.join(build_path))
401
+ config.logger.info "Copied app.js to #{build_path}"
402
+ end
403
+
404
+ def run
405
+ config.logger.info "Building application at #{app_root}"
406
+ reset_posts_metadata
407
+ check_reserved_post_names!
408
+ FileUtils.rm_rf('public')
409
+ FileUtils.mkdir('public')
410
+ build_index_page
411
+ build_posts
412
+ build_category_pages
413
+ build_categories_page
414
+ build_404_page
415
+ build_about_page
416
+ build_sitemap
417
+ build_robots
418
+ build_feed
419
+ compile_css
420
+ compile_js
421
+ ensure
422
+ reset_posts_metadata
423
+ end
424
+
425
+ # Writes public/sitemap.xml listing the index, every post and category
426
+ # page, and the about page, each URL
427
+ # built from the layout's base URL. Posts carry a <lastmod> from their
428
+ # header `date`; the index carries the newest post's date. Skipped when
429
+ # the layout declares no base URL.
430
+ def build_sitemap
431
+ base = site_base_url
432
+ unless base
433
+ config.logger.warn 'No og:url/canonical in the layout, skipping sitemap.xml'
434
+ return
435
+ end
436
+
437
+ posts = published_post_paths
438
+ post_dates = posts.map { |post_path| iso_date(post_metadata(post_path)['date']) }.compact
439
+ newest = post_dates.max
440
+
441
+ total_pages = paginated_posts(sorted_posts_metadata).length
442
+ entries = (1..total_pages).map do |page_number|
443
+ loc = page_number == 1 ? "#{base}/" : "#{base}/#{index_filename(page_number)}"
444
+ { loc: loc, lastmod: newest }
445
+ end
446
+
447
+ posts.each do |post_path|
448
+ name = File.basename(post_path).sub('.md', '.html')
449
+ entries << { loc: "#{base}/#{name}", lastmod: iso_date(post_metadata(post_path)['date']) }
450
+ end
451
+
452
+ entries << { loc: "#{base}/about.html" } if File.exist?(File.join(app_root, 'views', 'about.md'))
453
+
454
+ entries << { loc: "#{base}/categories.html", lastmod: newest }
455
+ posts_by_category.each do |slug, category|
456
+ lastmod = category[:posts].filter_map { |meta| iso_date(meta['date']) }.max
457
+ paginated_posts(category[:posts]).length.times do |index|
458
+ entries << { loc: "#{base}/#{category_filename(slug, index + 1)}", lastmod: lastmod }
459
+ end
460
+ end
461
+
462
+ xml = +%(<?xml version="1.0" encoding="UTF-8"?>\n)
463
+ xml << %(<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n)
464
+ entries.each do |entry|
465
+ xml << " <url>\n <loc>#{xml_escape(entry[:loc])}</loc>\n"
466
+ xml << " <lastmod>#{entry[:lastmod]}</lastmod>\n" if entry[:lastmod]
467
+ xml << " </url>\n"
468
+ end
469
+ xml << "</urlset>\n"
470
+
471
+ output = File.join(build_path, 'sitemap.xml')
472
+ File.write(output, xml)
473
+ config.logger.info "Built #{output}"
474
+ end
475
+
476
+ # Writes public/robots.txt allowing everything and pointing crawlers at
477
+ # the sitemap (when the layout gives us a base URL to build its address).
478
+ def build_robots
479
+ base = site_base_url
480
+ lines = ['User-agent: *', 'Allow: /']
481
+ lines << "Sitemap: #{base}/sitemap.xml" if base
482
+
483
+ output = File.join(build_path, 'robots.txt')
484
+ File.write(output, "#{lines.join("\n")}\n")
485
+ config.logger.info "Built #{output}"
486
+ end
487
+
488
+ # Writes public/feed.xml (RSS 2.0), newest post first. Channel details come
489
+ # from the layout; each item's description is its header `description` or
490
+ # first paragraph. Skipped when the layout declares no base URL.
491
+ def build_feed
492
+ base = site_base_url
493
+ unless base
494
+ config.logger.info 'No og:url/canonical in the layout, skipping feed.xml'
495
+ return
496
+ end
497
+
498
+ layout_html = Nokogiri::HTML(Tilt.new("#{app_root}/views/layout.html.erb").render { '' })
499
+ channel_title = meta_content(layout_html, 'meta[property="og:site_name"]') || 'Parrot'
500
+ channel_desc = meta_content(layout_html, 'meta[name="description"]') || ''
501
+
502
+ items = published_post_paths.map do |post_path|
503
+ meta = post_metadata(post_path)
504
+ url = "#{base}/#{File.basename(post_path).sub('.md', '.html')}"
505
+ {
506
+ title: meta['title'] || File.basename(post_path, '.md'),
507
+ url: url,
508
+ description: meta['description'] || summarize(markdown(post_path).render) || '',
509
+ iso: iso_date(meta['date']),
510
+ pub_date: rfc822_date(meta['date'])
511
+ }
512
+ end
513
+ items.sort_by! { |item| item[:iso] || '' }
514
+ items.reverse!
515
+
516
+ xml = +%(<?xml version="1.0" encoding="UTF-8"?>\n)
517
+ xml << %(<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">\n)
518
+ xml << " <channel>\n"
519
+ xml << " <title>#{xml_escape(channel_title)}</title>\n"
520
+ xml << " <link>#{base}/</link>\n"
521
+ xml << " <description>#{xml_escape(channel_desc)}</description>\n"
522
+ xml << %( <atom:link href="#{base}/feed.xml" rel="self" type="application/rss+xml"/>\n)
523
+ items.each do |item|
524
+ xml << " <item>\n"
525
+ xml << " <title>#{xml_escape(item[:title])}</title>\n"
526
+ xml << " <link>#{item[:url]}</link>\n"
527
+ xml << " <guid isPermaLink=\"true\">#{item[:url]}</guid>\n"
528
+ xml << " <pubDate>#{item[:pub_date]}</pubDate>\n" if item[:pub_date]
529
+ xml << " <description>#{xml_escape(item[:description])}</description>\n"
530
+ xml << " </item>\n"
531
+ end
532
+ xml << " </channel>\n</rss>\n"
533
+
534
+ output = File.join(build_path, 'feed.xml')
535
+ File.write(output, xml)
536
+ config.logger.info "Built #{output}"
537
+ end
538
+
539
+ # Rebuild a single file. Used by the file watcher, so `file` may be an
540
+ # existing watched file or one that was just added; it may be given as an
541
+ # absolute path or relative to the app root, as a String or as the
542
+ # MatchData watchr hands its callbacks. The build strategy is picked from
543
+ # where the file lives.
544
+ def build(file)
545
+ config.logger.info "Building changed file at #{file}"
546
+ path = File.expand_path(file.to_s, app_root)
547
+ relative = path.sub(%r{\A#{Regexp.escape(app_root)}/?}, '')
548
+ reset_posts_metadata
549
+
550
+ FileUtils.mkdir_p(build_path)
551
+
552
+ case relative
553
+ when 'views/layout.html.erb'
554
+ # The layout wraps every page, so everything is rebuilt. Its base URL
555
+ # feeds the sitemap, robots.txt and feed too.
556
+ build_index_page
557
+ build_posts
558
+ build_category_pages
559
+ build_categories_page
560
+ build_404_page
561
+ build_about_page
562
+ build_sitemap
563
+ build_robots
564
+ build_feed
565
+ when 'views/404.md'
566
+ build_404_page
567
+ when 'views/about.md'
568
+ build_about_page
569
+ build_sitemap
570
+ when 'config.yaml'
571
+ # post_listing settings change the index; post_date_format also
572
+ # affects the {post_date} placeholder inside every post's own body.
573
+ # per_page changes how many index pages there are, so the sitemap
574
+ # listing them is rewritten too. Category pages share all of that.
575
+ build_index_page
576
+ build_posts
577
+ build_category_pages
578
+ build_categories_page
579
+ build_sitemap
580
+ when %r{\Aviews/posts/[^/]+\.md\z}
581
+ check_reserved_post_names!
582
+ remove_built_post(path) unless File.exist?(path)
583
+ # A post was added, removed or had its date/title/summary changed,
584
+ # any of which can change the generated index listing and, when
585
+ # pagination is active, shift other posts onto a different index
586
+ # page — so every post is rebuilt to keep back-links correct. Its
587
+ # category may have changed too, so category pages are rebuilt.
588
+ build_posts
589
+ build_index_page
590
+ build_category_pages
591
+ build_categories_page
592
+ build_sitemap
593
+ build_feed
594
+ when %r{\Acss/.+\.(scss|css)\z}
595
+ # css is concatenated before compiling, so a single change recompiles all.
596
+ compile_css
597
+ when 'javascripts/app.js'
598
+ compile_js
599
+ when %r{\Aimages/[^/]+\z}
600
+ copy_image(path) if File.exist?(path)
601
+ else
602
+ config.logger.info "No build strategy for #{relative}, skipping"
603
+ end
604
+ ensure
605
+ reset_posts_metadata
606
+ end
607
+
608
+ private
609
+
610
+ # Fails the build, before anything is written, when a post's filename
611
+ # is one of RESERVED_POST_NAMES, or the name of a category page this
612
+ # build will write (category-ruby.html), and so would overwrite (or be
613
+ # overwritten by) a page Parrot generates itself.
614
+ def check_reserved_post_names!
615
+ category_pages = posts_by_category.flat_map do |slug, category|
616
+ (1..paginated_posts(category[:posts]).length).map { |number| category_filename(slug, number).delete_suffix('.html') }
617
+ end
618
+
619
+ clashes = Dir["#{app_root}/views/posts/*.md"].select do |post_path|
620
+ name = File.basename(post_path, '.md')
621
+ name.match?(RESERVED_POST_NAMES) || category_pages.include?(name.downcase)
622
+ end
623
+ return if clashes.empty?
624
+
625
+ names = clashes.map { |post_path| "views/posts/#{File.basename(post_path)}" }.join(', ')
626
+ raise "Reserved post filename: #{names}. index*, 404, about, categories, category, now, post(s), " \
627
+ "note(s) and category page names (category-<name>) can't be used as post names; " \
628
+ 'rename the post to build.'
629
+ end
630
+
631
+ # The posts this build writes out: every views/posts/*.md, minus drafts
632
+ # under `parrot build` (`serve` builds drafts so they can be previewed).
633
+ # The index listing, sitemap and feed read from this too, so none of
634
+ # them links a page that wasn't built.
635
+ def published_post_paths
636
+ Dir["#{app_root}/views/posts/*.md"].reject do |post_path|
637
+ build_mode? && draft_post?(post_metadata(post_path))
638
+ end
639
+ end
640
+
641
+ def draft_post?(meta)
642
+ meta['draft'] == 'true'
643
+ end
644
+
645
+ def build_mode?
646
+ @config[:build_mode]
647
+ end
648
+
649
+ # Reads the `<!-- key: value -->` comment header at the top of a post's
650
+ # Markdown file into a Hash. Returns {} when the file has no such header.
651
+ def post_metadata(post_path)
652
+ header = File.read(post_path)[/\A\s*<!--(.+?)-->/m, 1]
653
+ return {} unless header
654
+
655
+ meta = header.each_line.with_object({}) do |line, meta|
656
+ key, sep, value = line.partition(':')
657
+ next if sep.empty?
658
+
659
+ key = key.strip
660
+ value = value.strip
661
+ meta[key] = value unless key.empty? || value.empty?
662
+ end
663
+
664
+ meta['title']&.concat(' [Draft]') if draft_post?(meta)
665
+ meta
666
+ end
667
+
668
+ # "algorithms, coding" -> ["algorithms", "coding"]: the header's
669
+ # comma-separated `tags`, trimmed, without blanks or duplicates.
670
+ def post_tags(meta)
671
+ meta['tags'].to_s.split(',').map(&:strip).reject(&:empty?).uniq
672
+ end
673
+
674
+ # A post's header `category`, trimmed, or nil when it has none.
675
+ def post_category(meta)
676
+ category = meta['category'].to_s.strip
677
+ category.empty? ? nil : category
678
+ end
679
+
680
+ # The category listing a post's `category` links to ("category-ruby.html"),
681
+ # or nil when it has none, or one with no letters or digits to name a
682
+ # page after.
683
+ def category_href(meta)
684
+ category = post_category(meta)
685
+ slug = category && Helpers.slugify(category)
686
+ slug.nil? || slug.empty? ? nil : category_filename(slug, 1)
687
+ end
688
+
689
+ # "category-ruby.html" for page 1, "category-ruby_2.html", … after that.
690
+ # Slugs never contain "_", so a page number can't be mistaken for part of
691
+ # another category's name ("web3" vs page 3 of "web").
692
+ def category_filename(slug, page_number)
693
+ page_number == 1 ? "category-#{slug}.html" : "category-#{slug}_#{page_number}.html"
694
+ end
695
+
696
+ # Every category in use as { slug => { name:, posts: } }, sorted by name,
697
+ # each category's posts newest first. Names that slugify the same ("C++"
698
+ # and "C") share one listing under the first name seen, with a warning;
699
+ # a name with no letters or digits gets no listing at all.
700
+ def posts_by_category
701
+ @posts_by_category ||= begin
702
+ categories = {}
703
+
704
+ sorted_posts_metadata.each do |meta|
705
+ name = post_category(meta)
706
+ next unless name
707
+
708
+ slug = Helpers.slugify(name)
709
+ if slug.empty?
710
+ config.logger.warn "Category #{name.inspect} in #{meta['__filename']} has no letters or digits, skipping it"
711
+ next
712
+ end
713
+
714
+ category = categories[slug] ||= { name: name, posts: [] }
715
+ if category[:name] != name
716
+ config.logger.warn "Category #{name.inspect} in #{meta['__filename']} is listed under #{category[:name].inspect}"
717
+ end
718
+ category[:posts] << meta
719
+ end
720
+
721
+ categories.sort_by { |_slug, category| category[:name].downcase }.to_h
722
+ end
723
+ end
724
+
725
+ # Sets the per-page <html lang>, <title>, <meta property="og:*"> and
726
+ # <link rel="canonical"> on the built HTML, and turns a relative og:image
727
+ # path into an absolute URL (copying the file into the build). The site's
728
+ # base URL comes from the layout (its canonical/og:url tag); the generated
729
+ # file's path is appended so each page points at itself. `meta` is the
730
+ # post's header Hash (empty for the index). `kind` is :index, :post,
731
+ # :about or :category; only a post gets article metadata.
732
+ def apply_page_meta(html, output_name, meta = {}, kind: index_page?(output_name) ? :index : :post)
733
+ title = meta['title']
734
+
735
+ if title && !title.empty?
736
+ title_tag = html.at('head title')
737
+ title_tag.content = title if title_tag
738
+ end
739
+
740
+ lang = meta['lang']
741
+ if lang && !lang.empty?
742
+ root = html.at('html')
743
+ root['lang'] = lang if root
744
+ end
745
+
746
+ base = canonical_base(html)
747
+ return unless base
748
+
749
+ page_url = output_name == 'index.html' ? "#{base}/" : "#{base}/#{output_name}"
750
+
751
+ og = html.at('head meta[property="og:url"]')
752
+ og['content'] = page_url if og
753
+
754
+ canonical = html.at('head link[rel="canonical"]')
755
+ canonical['href'] = page_url if canonical
756
+
757
+ page_title = html.at('head title')&.text
758
+ og_title = html.at('head meta[property="og:title"]')
759
+ og_title['content'] = page_title if og_title && page_title && !page_title.empty?
760
+
761
+ apply_description(html, meta['description'])
762
+ apply_locale(html, meta['lang'])
763
+ apply_article_meta(html, meta) if kind == :post
764
+
765
+ resolve_og_image(html, base)
766
+
767
+ feed = html.at('head link[rel="alternate"][type="application/rss+xml"]')
768
+ feed['href'] = "#{base}/feed.xml" if feed && !feed['href'].to_s.start_with?('http')
769
+
770
+ inject_json_ld(html, kind, meta, page_url)
771
+ end
772
+
773
+ # Fills <meta name="description">, og:description and twitter:description
774
+ # from one string (the post header's `description` or its first paragraph).
775
+ def apply_description(html, description)
776
+ return if description.nil? || description.empty?
777
+
778
+ ['meta[name="description"]',
779
+ 'meta[property="og:description"]',
780
+ 'meta[name="twitter:description"]'].each do |selector|
781
+ node = html.at("head #{selector}")
782
+ node['content'] = description if node
783
+ end
784
+ end
785
+
786
+ # Maps the page's `lang` onto og:locale (en -> en_US, ml -> ml_IN, …).
787
+ def apply_locale(html, lang)
788
+ node = html.at('head meta[property="og:locale"]')
789
+ return unless node && lang && !lang.empty?
790
+
791
+ locales = { 'en' => 'en_US', 'ml' => 'ml_IN', 'hi' => 'hi_IN', 'ta' => 'ta_IN' }
792
+ node['content'] = locales.fetch(lang, lang)
793
+ end
794
+
795
+ # Adds a schema.org JSON-LD block: BlogPosting for a post, AboutPage for
796
+ # the about page, WebSite for the index. Values are read back from the
797
+ # <head> this method has just filled.
798
+ def inject_json_ld(html, kind, meta, page_url)
799
+ site_name = meta_content(html, 'meta[property="og:site_name"]')
800
+ description = meta_content(html, 'meta[name="description"]')
801
+
802
+ data = {
803
+ '@context' => 'https://schema.org',
804
+ '@type' => { post: 'BlogPosting', about: 'AboutPage', category: 'CollectionPage', index: 'WebSite' }.fetch(kind),
805
+ 'url' => page_url
806
+ }
807
+
808
+ case kind
809
+ when :post
810
+ data['headline'] = html.at('head title')&.text || meta['title']
811
+ data['mainEntityOfPage'] = page_url
812
+ data['inLanguage'] = meta['lang'] || 'en'
813
+ if (published = iso_date(meta['date']))
814
+ data['datePublished'] = published
815
+ data['dateModified'] = published
816
+ end
817
+ data['description'] = description if description
818
+ tags = post_tags(meta)
819
+ data['keywords'] = tags.join(', ') unless tags.empty?
820
+ image = meta_content(html, 'meta[property="og:image"]')
821
+ data['image'] = image if image&.start_with?('http')
822
+ author = meta_content(html, 'meta[name="author"]')
823
+ data['author'] = { '@type' => 'Person', 'name' => author } if author
824
+ data['publisher'] = { '@type' => 'Organization', 'name' => site_name } if site_name
825
+ when :about, :category
826
+ data['name'] = html.at('head title')&.text
827
+ data['description'] = description if description
828
+ else
829
+ data['name'] = site_name || html.at('head title')&.text
830
+ data['description'] = description if description
831
+ end
832
+
833
+ json = JSON.pretty_generate(data).gsub('</', '<\\/')
834
+ (html.at('head') || html).add_child(%(<script type="application/ld+json">#{json}</script>))
835
+ end
836
+
837
+ # A description drawn from the post body: the first paragraph with at least
838
+ # `minimum` characters (so a date byline or a "write your post here" stub
839
+ # is skipped), whitespace-collapsed and trimmed to ~155 characters on a
840
+ # word boundary. nil when nothing qualifies.
841
+ def summarize(fragment, limit = 155, minimum = 40)
842
+ para = Nokogiri::HTML(fragment.to_s)
843
+ .css('p')
844
+ .map { |node| node.text.gsub(/\s+/, ' ').strip }
845
+ .find { |text| text.length >= minimum }
846
+ return unless para
847
+ return para if para.length <= limit
848
+
849
+ "#{para[0, limit].sub(/\s+\S*\z/, '').rstrip}…"
850
+ end
851
+
852
+ def meta_content(html, selector)
853
+ value = html.at("head #{selector}")&.[]('content')
854
+ value unless value.nil? || value.empty?
855
+ end
856
+
857
+ # A post is an OG "article", not a "website"; add its publish date (from
858
+ # the header's dd/mm/yyyy `date`) as article:published_time in ISO form.
859
+ def apply_article_meta(html, meta)
860
+ og_type = html.at('head meta[property="og:type"]')
861
+ og_type['content'] = 'article' if og_type
862
+
863
+ post_tags(meta).each do |tag|
864
+ node = Nokogiri::XML::Node.new('meta', html)
865
+ node['property'] = 'article:tag'
866
+ node['content'] = tag
867
+ (html.at('head') || html).add_child(node)
868
+ end
869
+
870
+ published = iso_date(meta['date'])
871
+ return unless published
872
+
873
+ node = Nokogiri::XML::Node.new('meta', html)
874
+ node['property'] = 'article:published_time'
875
+ node['content'] = published
876
+ (html.at('head') || html).add_child(node)
877
+ end
878
+
879
+ # "31/12/2026" -> Date.new(2026, 12, 31); nil for a blank or invalid value.
880
+ def parse_post_date(value)
881
+ day, month, year = value.to_s.strip.split('/')
882
+ return unless day && month && year
883
+
884
+ Date.new(year.to_i, month.to_i, day.to_i)
885
+ rescue ArgumentError
886
+ nil
887
+ end
888
+
889
+ # "31/12/2026" -> "2026-12-31"; nil for a blank or invalid value.
890
+ def iso_date(value)
891
+ parse_post_date(value)&.iso8601
892
+ end
893
+
894
+ # "31/12/2026" -> "Thu, 31 Dec 2026 00:00:00 -0000" for RSS <pubDate>.
895
+ def rfc822_date(value)
896
+ date = parse_post_date(value)
897
+ return unless date
898
+
899
+ Time.utc(date.year, date.month, date.day).rfc2822
900
+ end
901
+
902
+ # Open Graph and Twitter require an absolute og:image URL. Rewrite a
903
+ # relative images/… path against the site's base URL and copy the file
904
+ # into the build; leave an already-absolute URL untouched. When the source
905
+ # is local, also emit og:image:width/height so scrapers can lay the card
906
+ # out without fetching the file first.
907
+ def resolve_og_image(html, base)
908
+ og_image = html.at('head meta[property="og:image"]')
909
+ src = og_image && og_image['content']
910
+ return if src.nil? || src.empty? || src.start_with?('http://', 'https://', '//')
911
+
912
+ source_path = File.join(app_root, src)
913
+ if src.start_with?('images/') && File.exist?(source_path)
914
+ copy_image(source_path)
915
+
916
+ if (dimensions = image_dimensions(source_path))
917
+ set_head_meta(html, 'og:image:width', dimensions[0].to_s)
918
+ set_head_meta(html, 'og:image:height', dimensions[1].to_s)
919
+ end
920
+ end
921
+
922
+ og_image['content'] = "#{base}/#{src}"
923
+ end
924
+
925
+ # Sets <meta property="…">, adding the tag to <head> if it isn't there.
926
+ def set_head_meta(html, property, content)
927
+ node = html.at(%(head meta[property="#{property}"]))
928
+ unless node
929
+ node = Nokogiri::XML::Node.new('meta', html)
930
+ node['property'] = property
931
+ (html.at('head') || html).add_child(node)
932
+ end
933
+ node['content'] = content # rubocop:disable Lint/UselessSetterCall -- node lives in the document
934
+ end
935
+
936
+ # [width, height] of a PNG, JPEG or GIF, read from the file header only.
937
+ # nil for anything else or an unreadable file.
938
+ def image_dimensions(path)
939
+ File.open(path, 'rb') do |io|
940
+ head = io.read(24) or return nil
941
+
942
+ if head.byteslice(0, 8) == "\x89PNG\r\n\x1a\n".b
943
+ head.byteslice(16, 8).unpack('N2')
944
+ elsif head.byteslice(0, 3) == 'GIF'.b
945
+ head.byteslice(6, 4).unpack('v2')
946
+ elsif head.byteslice(0, 2) == "\xFF\xD8".b
947
+ jpeg_dimensions(io)
948
+ end
949
+ end
950
+ rescue SystemCallError
951
+ nil
952
+ end
953
+
954
+ # Walks a JPEG's markers to the start-of-frame, which carries the size.
955
+ def jpeg_dimensions(io)
956
+ io.seek(2)
957
+ loop do
958
+ byte = io.getbyte
959
+ return nil if byte.nil?
960
+ next unless byte == 0xFF
961
+
962
+ marker = io.getbyte
963
+ marker = io.getbyte while marker == 0xFF
964
+ return nil if marker.nil?
965
+
966
+ # Standalone markers (RSTn, SOI, EOI, TEM) carry no length.
967
+ next if marker == 0x01 || marker.between?(0xD0, 0xD9)
968
+
969
+ length = io.read(2)&.unpack1('n')
970
+ return nil if length.nil?
971
+
972
+ if marker.between?(0xC0, 0xCF) && ![0xC4, 0xC8, 0xCC].include?(marker)
973
+ frame = io.read(5) or return nil
974
+ height, width = frame.byteslice(1, 4).unpack('n2')
975
+ return [width, height]
976
+ end
977
+
978
+ io.seek(length - 2, IO::SEEK_CUR)
979
+ end
980
+ end
981
+
982
+ # The site's base URL as declared in the layout, without a trailing slash.
983
+ def canonical_base(html)
984
+ node = html.at('head link[rel="canonical"]') || html.at('head meta[property="og:url"]')
985
+ value = node && (node['href'] || node['content'])
986
+ value&.strip&.chomp('/')
987
+ end
988
+
989
+ # Same base URL, read straight from the rendered layout — for build steps
990
+ # (sitemap, robots) that aren't tied to one page.
991
+ def site_base_url
992
+ layout = Tilt.new("#{app_root}/views/layout.html.erb")
993
+ canonical_base(Nokogiri::HTML(layout.render { '' }))
994
+ end
995
+
996
+ def xml_escape(text)
997
+ text.gsub('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;')
998
+ end
999
+
1000
+ def remove_built_post(post_path)
1001
+ output = File.join(build_path, File.basename(post_path).sub('.md', '.html'))
1002
+ return unless File.exist?(output)
1003
+
1004
+ File.delete(output)
1005
+ config.logger.info "Removed #{output}"
1006
+ end
1007
+
1008
+ # Colour rules for the fenced code blocks kramdown/rouge produced. Scoped
1009
+ # to .highlighter-rouge (rouge's wrapper) so the theme's background and
1010
+ # token colours never leak onto inline `code` spans.
1011
+ def syntax_highlight_css
1012
+ theme = Rouge::Theme.find(HIGHLIGHT_THEME) || Rouge::Themes::Monokai
1013
+ [
1014
+ theme.render(scope: '.highlighter-rouge'),
1015
+ '.highlighter-rouge{margin:1rem 0;padding:1rem;overflow-x:auto;border-radius:6px}',
1016
+ '.highlighter-rouge pre,.highlighter-rouge code{margin:0;padding:0;background:none;border:0}'
1017
+ ].join("\n")
1018
+ end
1019
+
1020
+ def markdown(file)
1021
+ markdown_string(File.read(file))
1022
+ end
1023
+
1024
+ # Same rendering as #markdown, for content that isn't backed by a file
1025
+ # (the generated post listing on the index page).
1026
+ def markdown_string(content)
1027
+ # GFM so ```lang fences work; rouge tags every token in a fenced code
1028
+ # block with a class, which #syntax_highlight_css then colours.
1029
+ Tilt::KramdownTemplate.new(
1030
+ input: 'GFM',
1031
+ hard_wrap: false,
1032
+ syntax_highlighter: 'rouge',
1033
+ syntax_highlighter_opts: { formatter: CodeFormatter },
1034
+ math_engine: 'mathjax',
1035
+ math_engine_opts: { format: [:html] },
1036
+ auto_ids: false
1037
+ ) { content }
1038
+ end
1039
+
1040
+ # One index page's Markdown body: a heading (config.yaml's
1041
+ # post_listing.list_title, page 1 only, omitted entirely when
1042
+ # explicitly set to an empty string) followed by that page's slice of
1043
+ # the post listing and, when there's more than one page, a pager —
1044
+ # generated from views/posts/*.md, there is no views/index.md.
1045
+ def index_markdown(page_number, posts_meta, total_pages)
1046
+ heading = page_number == 1 ? list_title_heading : ''
1047
+ markdown = "#{heading}#{render_post_listing(posts_meta)}\n"
1048
+
1049
+ pager = pager_markdown(page_number, total_pages)
1050
+ markdown << "\n#{pager}\n" unless pager.empty?
1051
+
1052
+ markdown
1053
+ end
1054
+
1055
+ # "index.html" for page 1, "index2.html", "index3.html", … after that.
1056
+ def index_filename(page_number)
1057
+ page_number == 1 ? 'index.html' : "index#{page_number}.html"
1058
+ end
1059
+
1060
+ def index_page?(output_name)
1061
+ output_name.match?(/\Aindex\d*\.html\z/)
1062
+ end
1063
+
1064
+ # config.yaml's post_listing.per_page as an Integer, or nil when unset
1065
+ # (or not a positive number) — meaning "don't paginate".
1066
+ def per_page_setting
1067
+ value = post_listing_settings['per_page'].to_i
1068
+ value.positive? ? value : nil
1069
+ end
1070
+
1071
+ # Splits already-sorted (newest-first) post metadata into per_page-sized
1072
+ # pages. A single page (even an empty one) when per_page isn't set or
1073
+ # there aren't enough posts to need a second page — same as Parrot's
1074
+ # single-index-page behaviour before pagination existed.
1075
+ def paginated_posts(posts_meta)
1076
+ size = per_page_setting
1077
+ return [posts_meta] if size.nil? || posts_meta.length <= size
1078
+
1079
+ posts_meta.each_slice(size).to_a
1080
+ end
1081
+
1082
+ # Maps each post's filename to the index page number it appears on, so
1083
+ # every post's back-link can point at the right page.
1084
+ def page_number_lookup(posts_meta)
1085
+ lookup = {}
1086
+ paginated_posts(posts_meta).each_with_index do |chunk, index|
1087
+ chunk.each { |meta| lookup[meta['__filename']] = index + 1 }
1088
+ end
1089
+ lookup
1090
+ end
1091
+
1092
+ # A "← Newer posts" / "Older posts →" markdown line for one index page,
1093
+ # linking to the adjacent page(s); "" when there's nothing to link to
1094
+ # (a single-page site, or the newer/older side is explicitly disabled
1095
+ # via an empty post_listing.newer_link_text/older_link_text).
1096
+ # `filename` maps a page number to its file: index pages by default,
1097
+ # category pages pass their own.
1098
+ def pager_markdown(page_number, total_pages, filename = method(:index_filename))
1099
+ settings = post_listing_settings
1100
+ newer_text = settings.fetch('newer_link_text', DEFAULT_NEWER_LINK_TEXT)
1101
+ older_text = settings.fetch('older_link_text', DEFAULT_OLDER_LINK_TEXT)
1102
+
1103
+ links = []
1104
+ links << "[#{newer_text}](#{filename.call(page_number - 1)})" if page_number > 1 && !newer_text.to_s.empty?
1105
+ links << "[#{older_text}](#{filename.call(page_number + 1)})" if page_number < total_pages && !older_text.to_s.empty?
1106
+ return '' if links.empty?
1107
+
1108
+ "#{links.join(' ~ ')}\n{: .pagination}"
1109
+ end
1110
+
1111
+ # Deletes any previously-built index page beyond the current page
1112
+ # count, so a shrinking post count doesn't leave a stale index3.html
1113
+ # behind after a rebuild drops it to 2 pages.
1114
+ def cleanup_stale_index_pages(total_pages)
1115
+ Dir[File.join(build_path, 'index*.html')].each do |path|
1116
+ match = File.basename(path).match(/\Aindex(\d*)\.html\z/)
1117
+ next unless match
1118
+
1119
+ page_number = match[1].empty? ? 1 : match[1].to_i
1120
+ File.delete(path) if page_number > total_pages
1121
+ end
1122
+ end
1123
+
1124
+ def list_title_heading
1125
+ settings = post_listing_settings
1126
+ return '' if settings.key?('list_title') && settings['list_title'].to_s.strip.empty?
1127
+
1128
+ "### #{settings['list_title'] || DEFAULT_LIST_TITLE}\n\n"
1129
+ end
1130
+
1131
+ # Every post's header metadata plus its parsed date and source filename,
1132
+ # newest first. Undated posts (or posts with an unparsable date) sort last.
1133
+ # Only headers are read, never post bodies. The index, category pages
1134
+ # and sitemap all list from this, so it's read once per #run or #build
1135
+ # and reused (see #reset_posts_metadata).
1136
+ def sorted_posts_metadata
1137
+ @sorted_posts_metadata ||= begin
1138
+ posts_meta = published_post_paths.map do |post_path|
1139
+ meta = post_metadata(post_path)
1140
+ meta.merge(
1141
+ '__filename' => File.basename(post_path),
1142
+ '__date' => parse_post_date(meta['date'])
1143
+ )
1144
+ end
1145
+ posts_meta.sort_by { |meta| meta['__date'] || Date.new(0) }.reverse
1146
+ end
1147
+ end
1148
+
1149
+ # Forgets the posts read by #sorted_posts_metadata, at the start and end
1150
+ # of every #run and #build, so the watcher always sees the latest edits.
1151
+ def reset_posts_metadata
1152
+ @sorted_posts_metadata = nil
1153
+ @posts_by_category = nil
1154
+ end
1155
+
1156
+ # Renders the Markdown post listing per config.yaml's
1157
+ # post_listing settings (list_format, and group_by: year/month/none).
1158
+ def render_post_listing(posts_meta)
1159
+ settings = post_listing_settings
1160
+ format = settings['list_format'] || DEFAULT_LIST_FORMAT
1161
+ group_by = settings['group_by'] || DEFAULT_GROUP_BY
1162
+
1163
+ case group_by
1164
+ when 'year'
1165
+ grouped_listing(posts_meta, format) { |date| date.strftime('%Y') }
1166
+ when 'month'
1167
+ grouped_listing(posts_meta, format) { |date| date.strftime('%B %Y') }
1168
+ else
1169
+ flat_listing(posts_meta, format)
1170
+ end
1171
+ end
1172
+
1173
+ def flat_listing(posts_meta, format)
1174
+ posts_meta.map { |meta| "- #{format_list_entry(format, meta)}" }.join("\n")
1175
+ end
1176
+
1177
+ # Splits the (already newest-first) posts into labelled sections, in the
1178
+ # order their label was first seen, so sections stay newest-first too.
1179
+ def grouped_listing(posts_meta, format)
1180
+ posts_meta
1181
+ .group_by { |meta| meta['__date'] ? yield(meta['__date']) : 'Undated' }
1182
+ .map { |label, entries| "## #{label}\n\n#{flat_listing(entries, format)}" }
1183
+ .join("\n\n")
1184
+ end
1185
+
1186
+ # Expands a list_format string like
1187
+ # "{post_date} ~ [{post_title}]({post_link})" against one post's
1188
+ # metadata. `{post_<key>}` is that key read straight from the post's
1189
+ # `<!-- key: value -->` header, as plain text (so `{post_title}`,
1190
+ # `{post_lang}`, or any custom header field); `{post_date}` is the same
1191
+ # header field but formatted per post_date_format.on_list, and
1192
+ # `{post_link}` is the one field Parrot computes itself — the post's
1193
+ # href, rewritten to the built page by #update_internal_links. Wrap
1194
+ # whichever one should be clickable in Markdown link syntax yourself.
1195
+ # Any other `{...}` is a strftime format string (see Date#strftime)
1196
+ # applied to the post's header `date`.
1197
+ def format_list_entry(format, meta)
1198
+ format.gsub(/\{([^}]*)\}/) do
1199
+ token = ::Regexp.last_match(1)
1200
+ if token == 'post_link'
1201
+ "##{meta['__filename']}"
1202
+ elsif token == 'post_category_tag'
1203
+ category_tag_markdown(meta)
1204
+ elsif token == 'post_date'
1205
+ meta['__date']&.strftime(post_date_format('on_list')) || ''
1206
+ elsif token.start_with?('post_')
1207
+ meta[token.sub(/\Apost_/, '')].to_s
1208
+ else
1209
+ meta['__date']&.strftime(token) || ''
1210
+ end
1211
+ end
1212
+ end
1213
+
1214
+ # `{post_category_tag}` in a list_format: the post's category as a link to
1215
+ # its listing, given the same `category-tag` class as the one under a
1216
+ # post's title; "" for a post without a category.
1217
+ def category_tag_markdown(meta)
1218
+ href = category_href(meta)
1219
+ return '' unless href
1220
+
1221
+ "[#{escape_markdown(post_category(meta))}](#{href}){: .category-tag}"
1222
+ end
1223
+
1224
+ # Backslash-escapes the characters kramdown would otherwise read as
1225
+ # markup, for header text (a category name) dropped into Markdown.
1226
+ def escape_markdown(text)
1227
+ text.gsub(/([\\`*_{}\[\]()#+\-.!|<>])/) { "\\#{::Regexp.last_match(1)}" }
1228
+ end
1229
+
1230
+ # Expands a literal "{post_title}" placeholder inside a post's own
1231
+ def substitute_post_title(content, meta)
1232
+ return content if meta['title'].nil?
1233
+
1234
+ content.gsub('{post_title}', meta['title'])
1235
+ end
1236
+
1237
+ # Expands a literal "{post_date}" placeholder inside a post's own
1238
+ # Markdown body (as opposed to a post_listing list_format), formatted
1239
+ # per post_date_format.on_post.
1240
+ def substitute_post_date(content, meta)
1241
+ return content unless meta['__date']
1242
+
1243
+ content.gsub('{post_date}') { meta['__date'].strftime(post_date_format('on_post')) }
1244
+ end
1245
+
1246
+ # The `on_list` or `on_post` pattern from config.yaml's
1247
+ # `post_date_format` section, falling back to DEFAULT_POST_DATE_FORMAT.
1248
+ def post_date_format(context)
1249
+ post_date_format_settings[context] || DEFAULT_POST_DATE_FORMAT.fetch(context)
1250
+ end
1251
+
1252
+ # The `post_listing` section of config.yaml, or {} when the
1253
+ # file is missing or invalid.
1254
+ def post_listing_settings
1255
+ posts_config['post_listing'] || {}
1256
+ end
1257
+
1258
+ # The `post_date_format` section of config.yaml, or {}.
1259
+ def post_date_format_settings
1260
+ posts_config['post_date_format'] || {}
1261
+ end
1262
+
1263
+ def posts_config
1264
+ path = File.join(app_root, 'config.yaml')
1265
+ return {} unless File.exist?(path)
1266
+
1267
+ YAML.safe_load_file(path) || {}
1268
+ rescue Psych::SyntaxError => e
1269
+ config.logger.info "Invalid config.yaml, using defaults: #{e.message}"
1270
+ {}
1271
+ end
1272
+ end
1273
+ end
1274
+ end