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.
- checksums.yaml +7 -0
- data/.githooks/pre-commit +17 -0
- data/.github/workflows/ci.yml +29 -0
- data/.gitignore +24 -0
- data/.rspec +2 -0
- data/.rubocop.yml +28 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/CLAUDE.md +8 -0
- data/Gemfile +13 -0
- data/LICENSE.txt +22 -0
- data/README.md +340 -0
- data/Rakefile +26 -0
- data/bin/setup +11 -0
- data/exe/parrot +12 -0
- data/lib/helpers.rb +19 -0
- data/lib/parrot/commands/build.rb +1274 -0
- data/lib/parrot/commands/new.rb +25 -0
- data/lib/parrot/commands/post.rb +83 -0
- data/lib/parrot/commands/serve.rb +129 -0
- data/lib/parrot/constants.rb +45 -0
- data/lib/parrot/file_cache.rb +45 -0
- data/lib/parrot/logger.rb +23 -0
- data/lib/parrot/metadata.rb +4 -0
- data/lib/parrot/runner.rb +29 -0
- data/lib/parrot/template_handler.rb +19 -0
- data/lib/parrot.rb +120 -0
- data/parrot.gemspec +36 -0
- data/skel/.gitignore +3 -0
- data/skel/config.yaml +68 -0
- data/skel/css/app.scss +189 -0
- data/skel/images/.keep +0 -0
- data/skel/images/apple-touch-icon.png +0 -0
- data/skel/images/favicon.ico +0 -0
- data/skel/images/favicon.svg +40 -0
- data/skel/images/parrot.jpeg +0 -0
- data/skel/javascripts/app.js +37 -0
- data/skel/public/.keep +0 -0
- data/skel/views/404.md +7 -0
- data/skel/views/about.md +17 -0
- data/skel/views/layout.html.erb +72 -0
- data/skel/views/posts/about_parrot.md +63 -0
- data/skel/views/posts/sample.md +37 -0
- data/spec/parrot/commands/build_spec.rb +747 -0
- data/spec/parrot/commands/new_spec.rb +43 -0
- data/spec/parrot/commands/post_spec.rb +80 -0
- data/spec/parrot/commands/serve_spec.rb +66 -0
- data/spec/parrot/logger_spec.rb +39 -0
- data/spec/parrot/metadata_spec.rb +11 -0
- data/spec/parrot/runner_spec.rb +28 -0
- data/spec/parrot_spec.rb +37 -0
- data/spec/spec_helper.rb +7 -0
- 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('&', '&').gsub('<', '<').gsub('>', '>')
|
|
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
|