lifer 0.2.0 → 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.github/workflows/main.yml +1 -1
- data/.gitignore +1 -0
- data/CHANGELOG.md +26 -0
- data/Gemfile +9 -0
- data/Gemfile.lock +110 -25
- data/LICENSE +18 -0
- data/README.md +79 -14
- data/Rakefile +2 -4
- data/bin/lifer +4 -2
- data/lib/lifer/brain.rb +171 -21
- data/lib/lifer/builder/html/from_erb.rb +92 -0
- data/lib/lifer/builder/html/from_liquid/drops/collection_drop.rb +40 -0
- data/lib/lifer/builder/html/from_liquid/drops/collections_drop.rb +40 -0
- data/lib/lifer/builder/html/from_liquid/drops/entry_drop.rb +63 -0
- data/lib/lifer/builder/html/from_liquid/drops/frontmatter_drop.rb +45 -0
- data/lib/lifer/builder/html/from_liquid/drops/settings_drop.rb +42 -0
- data/lib/lifer/builder/html/from_liquid/drops.rb +15 -0
- data/lib/lifer/builder/html/from_liquid/filters.rb +27 -0
- data/lib/lifer/builder/html/from_liquid/layout_tag.rb +67 -0
- data/lib/lifer/builder/html/from_liquid.rb +116 -0
- data/lib/lifer/builder/html.rb +107 -51
- data/lib/lifer/builder/rss.rb +113 -0
- data/lib/lifer/builder/txt.rb +60 -0
- data/lib/lifer/builder.rb +100 -1
- data/lib/lifer/cli.rb +105 -0
- data/lib/lifer/collection.rb +87 -8
- data/lib/lifer/config.rb +159 -31
- data/lib/lifer/dev/response.rb +61 -0
- data/lib/lifer/dev/router.rb +44 -0
- data/lib/lifer/dev/server.rb +97 -0
- data/lib/lifer/entry/html.rb +39 -0
- data/lib/lifer/entry/markdown.rb +162 -0
- data/lib/lifer/entry/txt.rb +41 -0
- data/lib/lifer/entry.rb +142 -41
- data/lib/lifer/message.rb +58 -0
- data/lib/lifer/selection/all_markdown.rb +16 -0
- data/lib/lifer/selection/included_in_feeds.rb +15 -0
- data/lib/lifer/selection.rb +79 -0
- data/lib/lifer/shared/finder_methods.rb +35 -0
- data/lib/lifer/shared.rb +6 -0
- data/lib/lifer/templates/cli.txt.erb +10 -0
- data/lib/lifer/templates/config.yaml +77 -0
- data/lib/lifer/templates/its-a-living.png +0 -0
- data/lib/lifer/templates/layout.html.erb +1 -1
- data/lib/lifer/uri_strategy/pretty.rb +14 -6
- data/lib/lifer/uri_strategy/pretty_root.rb +24 -0
- data/lib/lifer/uri_strategy/pretty_yyyy_mm_dd.rb +32 -0
- data/lib/lifer/uri_strategy/root.rb +17 -0
- data/lib/lifer/uri_strategy/simple.rb +10 -6
- data/lib/lifer/uri_strategy.rb +46 -6
- data/lib/lifer/utilities.rb +117 -0
- data/lib/lifer/version.rb +3 -0
- data/lib/lifer.rb +130 -23
- data/lifer.gemspec +12 -6
- data/locales/en.yml +54 -0
- metadata +142 -9
- data/lib/lifer/layout.rb +0 -25
- data/lib/lifer/templates/config +0 -4
- data/lib/lifer/uri_strategy/base.rb +0 -15
data/lib/lifer/config.rb
CHANGED
@@ -1,52 +1,182 @@
|
|
1
1
|
require_relative "utilities"
|
2
2
|
|
3
|
+
# This class is responsible for reading the Lifer configuration YAML file. This
|
4
|
+
# file should provided by the user, but the Lifer Ruby gem does provide a default
|
5
|
+
# file, as well.
|
6
|
+
#
|
3
7
|
class Lifer::Config
|
4
|
-
|
5
|
-
|
6
|
-
|
8
|
+
# Some settings may take variants based on the current Lifer environment. The
|
9
|
+
# environments with variant configurations include "build" (for static builds,
|
10
|
+
# or: production mode) and "serve" (for development mode).
|
11
|
+
#
|
12
|
+
CONFIG_ENVIRONMENTS = [:build, :serve]
|
13
|
+
|
14
|
+
# The "global" section of the config file is the one explicitly special part
|
15
|
+
# of the config. It's used to provide information Lifer needs to keep track of
|
16
|
+
# across the entire pre-build and build process.
|
17
|
+
#
|
18
|
+
GLOBAL_SETTINGS = [
|
19
|
+
{build: CONFIG_ENVIRONMENTS},
|
20
|
+
:host,
|
7
21
|
:output_directory,
|
22
|
+
{prebuild: CONFIG_ENVIRONMENTS}
|
23
|
+
]
|
24
|
+
|
25
|
+
# The Lifer Ruby gem provides a default configuration file as a template.
|
26
|
+
#
|
27
|
+
DEFAULT_CONFIG_FILE = "%s/lib/lifer/templates/config.yaml" % Lifer.gem_root
|
28
|
+
|
29
|
+
# The Lifer Ruby gem provides a default layout file (ERB) as a template.
|
30
|
+
#
|
31
|
+
DEFAULT_LAYOUT_FILE = "%s/lib/lifer/templates/layout.html.erb" % Lifer.gem_root
|
32
|
+
|
33
|
+
# Provides "implicit settings" that may not be set anywhere but really do
|
34
|
+
# require a value.
|
35
|
+
#
|
36
|
+
# FIXME: I don't think this really belongs here. But in some cases we need
|
37
|
+
# to provide the implicit setting key and a default value when calling the
|
38
|
+
# `#setting` method. It would be nicer if the HTML builder handled this,
|
39
|
+
# somehow.
|
40
|
+
#
|
41
|
+
DEFAULT_IMPLICIT_SETTINGS = {
|
42
|
+
layout_file: DEFAULT_LAYOUT_FILE
|
43
|
+
}
|
44
|
+
|
45
|
+
# A setting must be registered before Lifer will read it and do anything with
|
46
|
+
# it. The following settings are registered by default.
|
47
|
+
#
|
48
|
+
# (Note that when users add their own custom Ruby classes with custom
|
49
|
+
# settings, they must register those settings dynamically. Search this source
|
50
|
+
# code for `Lifer.register_settings` to see examples of settings being
|
51
|
+
# registered.)
|
52
|
+
#
|
53
|
+
DEFAULT_REGISTERED_SETTINGS = [
|
54
|
+
:author,
|
55
|
+
:description,
|
56
|
+
{entries: [:default_title]},
|
57
|
+
{feed: [:builder, :uri]},
|
58
|
+
{global: GLOBAL_SETTINGS},
|
59
|
+
:language,
|
60
|
+
:layout_file,
|
61
|
+
:selections,
|
62
|
+
:title,
|
8
63
|
:uri_strategy
|
9
64
|
]
|
10
65
|
|
11
66
|
class << self
|
12
|
-
|
67
|
+
# A configuration file must be present in order to bootstrap Lifer. If a
|
68
|
+
# configuration file cannot be found at the given path, then the default
|
69
|
+
# configuration file is used.
|
70
|
+
#
|
71
|
+
# @param file [String] The path to the user-provided configuration file.
|
72
|
+
# @return [void]
|
73
|
+
def build(file:, root: Lifer.root)
|
13
74
|
if File.file? file
|
14
|
-
new file: file
|
75
|
+
new file: file, root: root
|
15
76
|
else
|
16
|
-
|
77
|
+
Lifer::Message.log("config.no_config_file_at", file:)
|
17
78
|
|
18
|
-
new file:
|
79
|
+
new file: DEFAULT_CONFIG_FILE, root: root
|
19
80
|
end
|
20
81
|
end
|
21
82
|
end
|
22
83
|
|
84
|
+
attr_accessor :registered_settings
|
23
85
|
attr_reader :file
|
24
86
|
|
25
|
-
|
26
|
-
|
87
|
+
# Provides Lifer with a list of collections as interpreted by reading the
|
88
|
+
# configuration YAML file. Collectionables are used to generate collections.
|
89
|
+
#
|
90
|
+
# @return [Array<Symbol>] A list of non-root collection names.
|
91
|
+
def collectionables
|
92
|
+
@collectionables ||=
|
93
|
+
raw.keys.select { |setting| has_collection_settings? setting }
|
27
94
|
end
|
28
95
|
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
96
|
+
# This method allows user scripts and extensions to register arbitrary
|
97
|
+
# settings in their configuration YAML files.
|
98
|
+
#
|
99
|
+
# @param settings [*Symbol, *Hash] A list of symbols and/or hashs to be added
|
100
|
+
# to Lifer's registered settings.
|
101
|
+
# @return [void]
|
102
|
+
def register_settings(*settings)
|
103
|
+
settings.each do |setting|
|
104
|
+
registered_settings << setting
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
# Returns the best in-scope setting value, where the best is the current
|
109
|
+
# collection's setting, then the root collection's setting, and then Lifer's
|
110
|
+
# default setting. If none these are available the method will return `nil`.
|
111
|
+
#
|
112
|
+
# @param name [Symbol] The configuration setting.
|
113
|
+
# @param collection_name [Symbol] A collection name.
|
114
|
+
# @param strict [boolean] Strictly return the collection setting without
|
115
|
+
# falling back to higher-level settings.
|
116
|
+
# @return [String, NilClass] The value of the best in-scope setting.
|
117
|
+
def setting(*name, collection_name: nil, strict: false)
|
118
|
+
name_in_collection = name.dup.unshift(collection_name) if collection_name
|
119
|
+
|
120
|
+
return if strict && collection_name.nil?
|
121
|
+
return settings.dig(*name_in_collection) if (strict && collection_name)
|
122
|
+
|
123
|
+
candidates = [
|
124
|
+
settings.dig(*name),
|
125
|
+
default_settings.dig(*name),
|
126
|
+
DEFAULT_IMPLICIT_SETTINGS.dig(*name)
|
127
|
+
]
|
128
|
+
candidates.unshift settings.dig(*name_in_collection) if name_in_collection
|
129
|
+
|
130
|
+
candidates.detect &:itself
|
131
|
+
end
|
132
|
+
|
133
|
+
# Provide a nice, readable, registered settings hash. If given a subset of
|
134
|
+
# settings (like a collection's settings), it will also provide a hash of
|
135
|
+
# registered settings within scope.
|
136
|
+
#
|
137
|
+
# @param settings_hash [Hash] A hash of settings.
|
138
|
+
# @return [Hash] A compact hash of registered settings.
|
139
|
+
def settings(settings_hash = raw)
|
140
|
+
settings_hash.select { |setting, value|
|
141
|
+
value = settings(value) if value.is_a?(Hash)
|
142
|
+
|
143
|
+
next unless registered_setting?(setting)
|
144
|
+
|
145
|
+
[setting, value]
|
146
|
+
}.compact.to_h
|
37
147
|
end
|
38
148
|
|
39
149
|
private
|
40
150
|
|
41
|
-
|
151
|
+
attr_reader :root
|
152
|
+
|
153
|
+
def initialize(file:, root:)
|
42
154
|
@file = Pathname(file)
|
155
|
+
@root = Pathname(root)
|
156
|
+
|
157
|
+
@registered_settings = DEFAULT_REGISTERED_SETTINGS.to_set
|
43
158
|
end
|
44
159
|
|
45
|
-
def
|
46
|
-
|
47
|
-
|
160
|
+
def collection_candidates
|
161
|
+
@collection_candidates ||=
|
162
|
+
Dir.glob("#{root}/**/*")
|
163
|
+
.select { |entry| File.directory? entry }
|
164
|
+
.map { |entry| entry.gsub("#{root}/", "") }
|
165
|
+
.select { |dir| !Lifer.ignoreable? dir }
|
166
|
+
.map(&:to_sym)
|
167
|
+
.sort
|
168
|
+
.reverse
|
169
|
+
end
|
48
170
|
|
49
|
-
|
171
|
+
def default_settings
|
172
|
+
@default_settings ||=
|
173
|
+
Lifer::Utilities.symbolize_keys(YAML.load_file DEFAULT_CONFIG_FILE).to_h
|
174
|
+
end
|
175
|
+
|
176
|
+
def has_collection_settings?(settings_key)
|
177
|
+
confirmed_collections = collection_candidates & unregistered_settings.keys
|
178
|
+
|
179
|
+
confirmed_collections.include? settings_key
|
50
180
|
end
|
51
181
|
|
52
182
|
def raw
|
@@ -55,19 +185,17 @@ class Lifer::Config
|
|
55
185
|
)
|
56
186
|
end
|
57
187
|
|
58
|
-
def
|
59
|
-
|
60
|
-
|
188
|
+
def registered_setting?(setting)
|
189
|
+
simple_settings = registered_settings.select { _1.is_a? Symbol }
|
190
|
+
return true if simple_settings.include?(setting)
|
61
191
|
|
62
|
-
|
63
|
-
|
64
|
-
.select { |entry| File.directory? entry }
|
65
|
-
.map { |entry| entry.gsub("#{root_directory}/", "") }
|
192
|
+
hash_settings = registered_settings.select { _1.is_a? Hash }
|
193
|
+
return true if hash_settings.flat_map(&:keys).include?(setting)
|
66
194
|
|
67
|
-
|
195
|
+
has_collection_settings? setting
|
68
196
|
end
|
69
197
|
|
70
198
|
def unregistered_settings
|
71
|
-
raw.reject { |setting, _|
|
199
|
+
raw.reject { |setting, _| registered_settings.include? setting }
|
72
200
|
end
|
73
201
|
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
module Lifer::Dev
|
2
|
+
# This class is responsible for building Rack-compatible responses for the
|
3
|
+
# `Lifer::Dev::Server`. This code would never be run in a production
|
4
|
+
# environment, where the Lifer builders are concerned.
|
5
|
+
#
|
6
|
+
class Response
|
7
|
+
attr_accessor :path
|
8
|
+
|
9
|
+
# Builds a single, Rack-compatible response object.
|
10
|
+
#
|
11
|
+
# @param path [String] A path URI.
|
12
|
+
# @return [void]
|
13
|
+
def initialize(path)
|
14
|
+
@path = path
|
15
|
+
end
|
16
|
+
|
17
|
+
# The Rack-compatible response. That's an array with three items:
|
18
|
+
#
|
19
|
+
# 1. The HTTP status.
|
20
|
+
# 2. The HTTP headers.
|
21
|
+
# 3. The HTTP body response.
|
22
|
+
#
|
23
|
+
# @return [Array] A Rack-compatible response.
|
24
|
+
def build
|
25
|
+
[status, {"Content-Type": content_type}, contents]
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def contents
|
31
|
+
return [I18n.t("dev.router.four_oh_four")] unless File.exist?(path)
|
32
|
+
|
33
|
+
[File.read(path)]
|
34
|
+
end
|
35
|
+
|
36
|
+
# FIXME:
|
37
|
+
# It would be very nice to not manually manage this list of content types.
|
38
|
+
# Is there a nice, dependency-free way to do this?
|
39
|
+
#
|
40
|
+
def content_type
|
41
|
+
case File.extname(path)
|
42
|
+
when ".css" then "text/css"
|
43
|
+
when ".html" then "text/html"
|
44
|
+
when ".ico" then "image/ico"
|
45
|
+
when ".js" then "text/javascript"
|
46
|
+
when ".map" then "application/json"
|
47
|
+
when ".txt" then "text/plain"
|
48
|
+
when ".woff" then "application/font-woff2"
|
49
|
+
when ".woff2" then "application/font-woff2"
|
50
|
+
when ".xml" then "application/xml"
|
51
|
+
else
|
52
|
+
raise NotImplementedError,
|
53
|
+
I18n.t("dev.router.content_type_not_implemented", path:)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def status
|
58
|
+
File.exist?(path) ? 200 : 404
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
require_relative "response"
|
2
|
+
|
3
|
+
module Lifer::Dev
|
4
|
+
# The dev router is responsible for routing requests from the
|
5
|
+
# `Lifer::Dev:Server`. Note that in production, the dev server would never be
|
6
|
+
# used. So the dev router does not need to be very sophisticated.
|
7
|
+
#
|
8
|
+
class Router
|
9
|
+
attr_accessor :build_directory
|
10
|
+
|
11
|
+
# Builds an instance of the router. In development mode, we'd expect only
|
12
|
+
# one router to be initialized, but in test mode, there'd be a new one for
|
13
|
+
# each test.
|
14
|
+
#
|
15
|
+
# @param build_directory [String] The path to the Lifer output directory
|
16
|
+
# (i.e. `/path/to/_build`).
|
17
|
+
# @return [void]
|
18
|
+
def initialize(build_directory:)
|
19
|
+
@build_directory = build_directory
|
20
|
+
end
|
21
|
+
|
22
|
+
# Give a Rack request env object, return a Rack-compatible response.
|
23
|
+
#
|
24
|
+
# @param request_env [Hash] A Rack request env object.
|
25
|
+
# @return [Lifer::Dev::Response]
|
26
|
+
def response_for(request_env)
|
27
|
+
local_path = local_path_to request_env["PATH_INFO"]
|
28
|
+
|
29
|
+
Lifer::Dev::Response.new(local_path).build
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def local_path_to(requested_path)
|
35
|
+
if requested_path.end_with?("/")
|
36
|
+
requested_path = requested_path + "index.html"
|
37
|
+
elsif Lifer::Utilities.file_extension(requested_path) == ""
|
38
|
+
requested_path = requested_path + "/index.html"
|
39
|
+
end
|
40
|
+
|
41
|
+
"%s%s" % [build_directory, requested_path]
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
require "listen"
|
2
|
+
require "puma"
|
3
|
+
require "puma/configuration"
|
4
|
+
require "rack"
|
5
|
+
|
6
|
+
require_relative "router"
|
7
|
+
|
8
|
+
# This module namespace contains development mode resources for Lifer. This
|
9
|
+
# functionality is subject to immense change and generally is not safe to use in
|
10
|
+
# production environment.
|
11
|
+
#
|
12
|
+
module Lifer::Dev
|
13
|
+
# This server is used in development and test modes to preview and serve a
|
14
|
+
# Lifer project. It's for convenience and is not super sophisticated. The
|
15
|
+
# server wraps a Puma process with some reasonable, default settings. It also
|
16
|
+
# listens for file changes and rebuilds the project on the next request made to
|
17
|
+
# the web server.
|
18
|
+
#
|
19
|
+
class Server
|
20
|
+
# The default port to run the Puma server on.
|
21
|
+
#
|
22
|
+
DEFAULT_PORT = 9292
|
23
|
+
|
24
|
+
class << self
|
25
|
+
# Start a Puma server to preview your Lifer project locally.
|
26
|
+
#
|
27
|
+
# @param port [Integer] The port to start the Puma server with.
|
28
|
+
# @return [void] The foregrounded Puma process.
|
29
|
+
def start!(port:)
|
30
|
+
puma_configuration = Puma::Configuration.new do |config|
|
31
|
+
config.app rack_app
|
32
|
+
config.bind "tcp://127.0.0.1:#{port || DEFAULT_PORT}"
|
33
|
+
config.environment "development"
|
34
|
+
config.log_requests true
|
35
|
+
end
|
36
|
+
|
37
|
+
Lifer.build!(environment: :serve)
|
38
|
+
|
39
|
+
listener.start
|
40
|
+
|
41
|
+
Puma::Launcher.new(puma_configuration).run
|
42
|
+
end
|
43
|
+
|
44
|
+
# A proc that follows the [Rack server specification][1]. Because we don't
|
45
|
+
# want to commit a rackup configuration file at this time, any "middleware"
|
46
|
+
# we want to insert should be a part of this method.
|
47
|
+
#
|
48
|
+
# [1]: https://github.com/rack/rack/blob/main/SPEC.rdoc
|
49
|
+
#
|
50
|
+
# @return [Array] A Rack server-compatible array.
|
51
|
+
def rack_app
|
52
|
+
-> (env) {
|
53
|
+
reload!
|
54
|
+
router.response_for(env)
|
55
|
+
}
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
# @private
|
61
|
+
# We notify the dev server whether there are changes within the Lifer root
|
62
|
+
# using a Listener callback method.
|
63
|
+
#
|
64
|
+
def listener
|
65
|
+
@listener ||=
|
66
|
+
Listen.to(Lifer.root) do |modified, added, removed|
|
67
|
+
@changes = true
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
# @private
|
72
|
+
# On reload, we rebuild the Lifer project.
|
73
|
+
#
|
74
|
+
# FIXME:
|
75
|
+
# Partial rebuilds would be a nice enhancement for performance reasons.
|
76
|
+
#
|
77
|
+
def reload!
|
78
|
+
if @changes
|
79
|
+
Lifer.build!
|
80
|
+
|
81
|
+
@changes = false
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
# @private
|
86
|
+
# @return [Lifer::Dev::Router] Our dev server router.
|
87
|
+
#
|
88
|
+
def router
|
89
|
+
return @router if @router && !test_mode?
|
90
|
+
|
91
|
+
@router = Lifer::Dev::Router.new build_directory: Lifer.output_directory
|
92
|
+
end
|
93
|
+
|
94
|
+
def test_mode? = ENV["LIFER_ENV"] == "test"
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# If the entry input is mainly HTML, then this subclass should track it and
|
2
|
+
# define its functionality. That means HTML files, and any file that compiles
|
3
|
+
# into an HTML file.
|
4
|
+
#
|
5
|
+
class Lifer::Entry::HTML < Lifer::Entry
|
6
|
+
self.include_in_feeds = false
|
7
|
+
self.input_extensions = ["html", "html.erb", "html.liquid"]
|
8
|
+
self.output_extension = :html
|
9
|
+
|
10
|
+
# FIXME: This could probably get more sophisticated, but at the moment HTML
|
11
|
+
# entries don't have any way to provide metadata about themselves. So let's
|
12
|
+
# just give them a default date to start.
|
13
|
+
#
|
14
|
+
# @return [Time] The publication date of the HTML entry.
|
15
|
+
def date = Lifer::Entry::DEFAULT_DATE
|
16
|
+
|
17
|
+
# Since HTML entries cannot provide metadata about themselves, we must extract
|
18
|
+
# a title from the permalink. Depending on the filename and URI strategy being
|
19
|
+
# used for the collection, it's possible that the extracted title would be
|
20
|
+
# "index", which is not very descriptive. If that's the case, we attempt to go
|
21
|
+
# up a directory to find a "non-index" title.
|
22
|
+
#
|
23
|
+
# @return [String] The extracted title of the entry.
|
24
|
+
def title
|
25
|
+
candidate = File.basename(permalink, ".html")
|
26
|
+
|
27
|
+
if candidate.include?("index") && !file.to_s.include?("index")
|
28
|
+
File.basename(permalink.sub(/\/#{candidate}\.html$/, ""))
|
29
|
+
else
|
30
|
+
candidate
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
# As an entry subclass, this method must be implemented, even though it
|
35
|
+
# doesn't do much here.
|
36
|
+
#
|
37
|
+
# @return [String]
|
38
|
+
def to_html = full_text
|
39
|
+
end
|
@@ -0,0 +1,162 @@
|
|
1
|
+
require "date"
|
2
|
+
require "kramdown"
|
3
|
+
require "time"
|
4
|
+
|
5
|
+
require_relative "../utilities"
|
6
|
+
|
7
|
+
# We should initialize each Markdown file in a Lifer project as a
|
8
|
+
# `Lifer::Entry::Markdown` object. This class contains convenience methods for
|
9
|
+
# parsing a Markdown file with frontmatter as a weblog post or article. Of course,
|
10
|
+
# all frontmatter key-values will be available for users to render as they will in
|
11
|
+
# their template files.
|
12
|
+
#
|
13
|
+
# FIXME: As we add other types of entries, especially ones that use frontmatter,
|
14
|
+
# it may make sense to pull some of these methods into a separate module.
|
15
|
+
#
|
16
|
+
class Lifer::Entry::Markdown < Lifer::Entry
|
17
|
+
# If a filename contains a date, we should expect it to be in the following
|
18
|
+
# format.
|
19
|
+
#
|
20
|
+
FILENAME_DATE_FORMAT = /^(\d{4}-\d{1,2}-\d{1,2})-/
|
21
|
+
|
22
|
+
# We expect frontmatter to be provided in the following format.
|
23
|
+
#
|
24
|
+
FRONTMATTER_REGEX = /^---\n(.*)---\n/m
|
25
|
+
|
26
|
+
# We truncate anything that needs to be truncated (summaries, meta
|
27
|
+
# descriptions) at the following character count.
|
28
|
+
#
|
29
|
+
TRUNCATION_THRESHOLD = 120
|
30
|
+
|
31
|
+
self.include_in_feeds = true
|
32
|
+
self.input_extensions = ["md"]
|
33
|
+
self.output_extension = :html
|
34
|
+
|
35
|
+
# Given the entry's frontmatter, we should be able to get a list of authors.
|
36
|
+
# We always prefer authors (as opposed to a singular author) because it makes
|
37
|
+
# handling both cases easier in the long run.
|
38
|
+
#
|
39
|
+
# The return value here is likely an author's name. Whether that's a full
|
40
|
+
# name, a first name, or a handle is up to the end user.
|
41
|
+
#
|
42
|
+
# @return [Array<String>] An array of authors's names.
|
43
|
+
def authors
|
44
|
+
Array(frontmatter[:author] || frontmatter[:authors]).compact
|
45
|
+
end
|
46
|
+
|
47
|
+
# This method returns the full text of the entry, only removing the
|
48
|
+
# frontmatter. It should not parse anything other than frontmatter.
|
49
|
+
#
|
50
|
+
# @return [String] The body of the entry.
|
51
|
+
def body
|
52
|
+
return full_text.strip unless frontmatter?
|
53
|
+
|
54
|
+
full_text.gsub(FRONTMATTER_REGEX, "").strip
|
55
|
+
end
|
56
|
+
|
57
|
+
# Since Markdown files would only store dates as simple strings, it's nice to
|
58
|
+
# attempt to convert those into Ruby date or datetime objects.
|
59
|
+
#
|
60
|
+
# @return [Time] A Ruby representation of the date and time provided by the
|
61
|
+
# entry frontmatter or filename.
|
62
|
+
def date
|
63
|
+
date_data = frontmatter[:date] || filename_date
|
64
|
+
|
65
|
+
case date_data
|
66
|
+
when Time then date_data
|
67
|
+
when String then DateTime.parse(date_data).to_time
|
68
|
+
else
|
69
|
+
Lifer::Message.log("entry.markdown.no_date_metadata", filename: file)
|
70
|
+
Lifer::Entry::DEFAULT_DATE
|
71
|
+
end
|
72
|
+
rescue ArgumentError => error
|
73
|
+
Lifer::Message.error("entry.markdown.date_error", filename: file, error:)
|
74
|
+
Lifer::Entry::DEFAULT_DATE
|
75
|
+
end
|
76
|
+
|
77
|
+
# Frontmatter is a widely supported YAML metadata block found at the top of
|
78
|
+
# Markdown files. We should attempt to parse Markdown entries for it.
|
79
|
+
#
|
80
|
+
# @return [Hash] A hash representation of the entry frontmatter.
|
81
|
+
def frontmatter
|
82
|
+
return {} unless frontmatter?
|
83
|
+
|
84
|
+
Lifer::Utilities.symbolize_keys(
|
85
|
+
YAML.load(full_text[FRONTMATTER_REGEX, 1], permitted_classes: [Time])
|
86
|
+
)
|
87
|
+
end
|
88
|
+
|
89
|
+
# FIXME:
|
90
|
+
# This would be easier to test and more appropriate as a module method
|
91
|
+
# takes text and options as arguments.
|
92
|
+
#
|
93
|
+
# If given a summary in the frontmatter of the entry, we can use this to
|
94
|
+
# provide a summary. Otherwise, we can truncate the first paragraph and use
|
95
|
+
# that as a summary, although that is a bit annoying. This is useful for
|
96
|
+
# indexes and feeds and so on.
|
97
|
+
#
|
98
|
+
# @return [String] A summary of the entry.
|
99
|
+
def summary
|
100
|
+
return frontmatter[:summary] if frontmatter[:summary]
|
101
|
+
|
102
|
+
return if first_paragraph.nil?
|
103
|
+
return first_paragraph if first_paragraph.length <= TRUNCATION_THRESHOLD
|
104
|
+
|
105
|
+
truncated_paragraph = first_paragraph[0..TRUNCATION_THRESHOLD]
|
106
|
+
if (index_of_final_fullstop = truncated_paragraph.rindex ". ")
|
107
|
+
truncated_paragraph[0..index_of_final_fullstop]
|
108
|
+
else
|
109
|
+
"%s..." % truncated_paragraph
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
# The title or a default title.
|
114
|
+
#
|
115
|
+
# @return [String] The title of the entry.
|
116
|
+
def title
|
117
|
+
frontmatter[:title] || Lifer.setting(:entries, :default_title)
|
118
|
+
end
|
119
|
+
|
120
|
+
# The HTML representation of the Markdown entry as parsed by Kramdown.
|
121
|
+
#
|
122
|
+
# @return [String] The HTML for the body of the entry.
|
123
|
+
def to_html
|
124
|
+
Kramdown::Document.new(body).to_html
|
125
|
+
end
|
126
|
+
|
127
|
+
private
|
128
|
+
|
129
|
+
# @private
|
130
|
+
def filename_date
|
131
|
+
return unless file && File.basename(file).match?(FILENAME_DATE_FORMAT)
|
132
|
+
|
133
|
+
File.basename(file).match(FILENAME_DATE_FORMAT)[1]
|
134
|
+
end
|
135
|
+
|
136
|
+
# Using Kramdown we can detect the first paragraph of the entry.
|
137
|
+
#
|
138
|
+
# @private
|
139
|
+
def first_paragraph
|
140
|
+
@first_paragraph ||=
|
141
|
+
kramdown_paragraph_text(
|
142
|
+
Kramdown::Document.new(body).root
|
143
|
+
.children
|
144
|
+
.detect { |child| child.type == :p }
|
145
|
+
)
|
146
|
+
end
|
147
|
+
|
148
|
+
# @private
|
149
|
+
def frontmatter?
|
150
|
+
full_text && full_text.match?(FRONTMATTER_REGEX)
|
151
|
+
end
|
152
|
+
|
153
|
+
# @private
|
154
|
+
def kramdown_paragraph_text(kramdown_element)
|
155
|
+
return if kramdown_element.nil?
|
156
|
+
|
157
|
+
kramdown_element.children
|
158
|
+
.flat_map { |child| child.value || kramdown_paragraph_text(child) }
|
159
|
+
.join
|
160
|
+
.gsub(/\n/, " ")
|
161
|
+
end
|
162
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# One may want to provide browser-readable text files without any layout or
|
2
|
+
# metadata information. In those cases, here's a good entry subclass. Just make
|
3
|
+
# sure the entry ends in `.txt`.
|
4
|
+
#
|
5
|
+
class Lifer::Entry::TXT < Lifer::Entry
|
6
|
+
self.include_in_feeds = false
|
7
|
+
self.input_extensions = ["txt"]
|
8
|
+
self.output_extension = :txt
|
9
|
+
|
10
|
+
# FIXME: This could probably get more sophisticated, but at the moment HTML
|
11
|
+
# entries don't have any way to provide metadata about themselves. So let's
|
12
|
+
# just give them a default date to start.
|
13
|
+
#
|
14
|
+
# @return [Time] The publication date of the HTML entry.
|
15
|
+
def date = Lifer::Entry::DEFAULT_DATE
|
16
|
+
|
17
|
+
# Since text entries cannot provide metadata about themselves, we must extract
|
18
|
+
# a title from the permalink. Depending on the filename and URI strategy being
|
19
|
+
# used for the collection, it's possible that the extracted title would be
|
20
|
+
# "index", which is not very descriptive. If that's the case, we attempt to go
|
21
|
+
# up a directory to find a "non-index" title.
|
22
|
+
#
|
23
|
+
# @return [String] The extracted title of the entry.
|
24
|
+
def title
|
25
|
+
candidate = File.basename(permalink, ".txt")
|
26
|
+
|
27
|
+
if candidate.include?("index") && !file.to_s.include?("index")
|
28
|
+
File.basename(permalink.sub(/\/#{candidate}\.html$/, ""))
|
29
|
+
else
|
30
|
+
candidate
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
# While we don't actually output text to HTML, we need to implement this
|
35
|
+
# method so that the RSS feed builder can add text files as feed entries.
|
36
|
+
#
|
37
|
+
# FIXME: Maybe the `#to_html` methods should be renamed, then?
|
38
|
+
#
|
39
|
+
# @return [String] The output HTML (not actually HTML).
|
40
|
+
def to_html = full_text
|
41
|
+
end
|