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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/main.yml +1 -1
  3. data/.gitignore +1 -0
  4. data/CHANGELOG.md +26 -0
  5. data/Gemfile +9 -0
  6. data/Gemfile.lock +110 -25
  7. data/LICENSE +18 -0
  8. data/README.md +79 -14
  9. data/Rakefile +2 -4
  10. data/bin/lifer +4 -2
  11. data/lib/lifer/brain.rb +171 -21
  12. data/lib/lifer/builder/html/from_erb.rb +92 -0
  13. data/lib/lifer/builder/html/from_liquid/drops/collection_drop.rb +40 -0
  14. data/lib/lifer/builder/html/from_liquid/drops/collections_drop.rb +40 -0
  15. data/lib/lifer/builder/html/from_liquid/drops/entry_drop.rb +63 -0
  16. data/lib/lifer/builder/html/from_liquid/drops/frontmatter_drop.rb +45 -0
  17. data/lib/lifer/builder/html/from_liquid/drops/settings_drop.rb +42 -0
  18. data/lib/lifer/builder/html/from_liquid/drops.rb +15 -0
  19. data/lib/lifer/builder/html/from_liquid/filters.rb +27 -0
  20. data/lib/lifer/builder/html/from_liquid/layout_tag.rb +67 -0
  21. data/lib/lifer/builder/html/from_liquid.rb +116 -0
  22. data/lib/lifer/builder/html.rb +107 -51
  23. data/lib/lifer/builder/rss.rb +113 -0
  24. data/lib/lifer/builder/txt.rb +60 -0
  25. data/lib/lifer/builder.rb +100 -1
  26. data/lib/lifer/cli.rb +105 -0
  27. data/lib/lifer/collection.rb +87 -8
  28. data/lib/lifer/config.rb +159 -31
  29. data/lib/lifer/dev/response.rb +61 -0
  30. data/lib/lifer/dev/router.rb +44 -0
  31. data/lib/lifer/dev/server.rb +97 -0
  32. data/lib/lifer/entry/html.rb +39 -0
  33. data/lib/lifer/entry/markdown.rb +162 -0
  34. data/lib/lifer/entry/txt.rb +41 -0
  35. data/lib/lifer/entry.rb +142 -41
  36. data/lib/lifer/message.rb +58 -0
  37. data/lib/lifer/selection/all_markdown.rb +16 -0
  38. data/lib/lifer/selection/included_in_feeds.rb +15 -0
  39. data/lib/lifer/selection.rb +79 -0
  40. data/lib/lifer/shared/finder_methods.rb +35 -0
  41. data/lib/lifer/shared.rb +6 -0
  42. data/lib/lifer/templates/cli.txt.erb +10 -0
  43. data/lib/lifer/templates/config.yaml +77 -0
  44. data/lib/lifer/templates/its-a-living.png +0 -0
  45. data/lib/lifer/templates/layout.html.erb +1 -1
  46. data/lib/lifer/uri_strategy/pretty.rb +14 -6
  47. data/lib/lifer/uri_strategy/pretty_root.rb +24 -0
  48. data/lib/lifer/uri_strategy/pretty_yyyy_mm_dd.rb +32 -0
  49. data/lib/lifer/uri_strategy/root.rb +17 -0
  50. data/lib/lifer/uri_strategy/simple.rb +10 -6
  51. data/lib/lifer/uri_strategy.rb +46 -6
  52. data/lib/lifer/utilities.rb +117 -0
  53. data/lib/lifer/version.rb +3 -0
  54. data/lib/lifer.rb +130 -23
  55. data/lifer.gemspec +12 -6
  56. data/locales/en.yml +54 -0
  57. metadata +142 -9
  58. data/lib/lifer/layout.rb +0 -25
  59. data/lib/lifer/templates/config +0 -4
  60. 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
- DEFAULT = "%s/templates/config" % File.expand_path(File.dirname(__FILE__))
5
-
6
- REGISTERED_SETTINGS = [
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
- def build(file:)
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
- puts "No configuration file at #{file}. Using default configuration."
77
+ Lifer::Message.log("config.no_config_file_at", file:)
17
78
 
18
- new file: DEFAULT
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
- def collections
26
- raw.keys.select { |setting| has_settings? setting }
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
- def settings
30
- raw.select { |setting, _|
31
- if REGISTERED_SETTINGS.include? setting
32
- true
33
- elsif has_settings? setting
34
- true
35
- end
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
- def initialize(file:)
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 has_settings?(subdirectory)
46
- subdirectories_with_settings =
47
- subdirectories & unregistered_settings.keys
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
- subdirectories_with_settings.include? subdirectory
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 root_directory
59
- @root_directory ||= ("%s/.." % File.expand_path(File.dirname(@file)))
60
- end
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
- def subdirectories
63
- subs = Dir.glob("#{root_directory}/**/*")
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
- subs.select { |dir| !Lifer.ignoreable? dir }.map(&:to_sym).sort.reverse
195
+ has_collection_settings? setting
68
196
  end
69
197
 
70
198
  def unregistered_settings
71
- raw.reject { |setting, _| REGISTERED_SETTINGS.include? 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