lifer 0.2.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
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