lifer 0.2.0 → 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 +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
|