lifer 0.7.0 → 0.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a2f61316d5e10ff4212470b5539741a4204f35976f97d193ef7aabfd944c62a5
4
- data.tar.gz: 03b9aafb098ab04f2aa1306fa34512d9627801d468910fb48cd5e10a736b7e47
3
+ metadata.gz: ea41a2cd1a058f5a103121b6b7641ce5857ec74300ecad4bb36ec33578170650
4
+ data.tar.gz: f752dc7498a0457945d895fc591eefc7a27a296e01a5b547264a13fd8113c3b5
5
5
  SHA512:
6
- metadata.gz: 5c1f46e3c16622144cc513b3496d4d0bb9f14231d1b92960b63249b3962753355ed92d5a27ed12ad3bd930e0e56ef871b4551bb4d888e11384191f5747580b19
7
- data.tar.gz: bcc5771d87e311dfe52d1c2bc79d9386c8f3677b7f3eafb66100815a9d76b2c8e2b5ce60855424d276c1cc8d8fb5e1c42f2298c5d662edf1ee85d03f8ec1a9c8
6
+ metadata.gz: 4665579521538df261c0839f6c1a7ee6b24362ca75c03cc6d8fbf42dfffa028ad0bc39c1fe01ef2407459cb1be6bdf9d08e60cd5abacd1e233b0819bbcab6186
7
+ data.tar.gz: e1bfbd9fa63bd2de10022f2b5810cfcf518acc5112f51bb843c19c936bc269436807a606c335ab90693bafac53569315b0bfcbe245b9c8570a56ff6ab60af6f0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  ## Next
2
2
 
3
+ ## v0.8.0
4
+
5
+ ### Tags
6
+
7
+ Entries now support tag frontmatter. This introduces a new way of making
8
+ associations between entries. Tags can encoded in entries as YAML arrays or as a
9
+ string (with comma and/or space delimiters):
10
+
11
+ ---
12
+ title: My Entry
13
+ tags: beautifulTag, lovelyTag
14
+ ---
15
+
16
+ Blah blah blah.
17
+
18
+ Then, in your ERB or Liquid templates you can get entries via tag:
19
+
20
+ <% tags.beautifulTag.entries.each do |entry| %>
21
+ <li><%= entry.title %></li>
22
+ <% end %>
23
+
24
+ ### Frontmatter support across all entry types
25
+
26
+ Before this release, frontmatter was only supported by Markdown files. This
27
+ started to be annoying, because of features I wanted like tags. I figured it
28
+ couldn't hurt to just check any entry file for frontmatter, so that's how it
29
+ works now.
30
+
3
31
  ## v0.7.0
4
32
 
5
33
  This release adds Atom feed support to the RSS builder. In your configuration
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- lifer (0.7.0)
4
+ lifer (0.8.0)
5
5
  i18n (< 2)
6
6
  kramdown (~> 2.4)
7
7
  liquid (~> 5.6, < 6)
data/lib/lifer/brain.rb CHANGED
@@ -65,7 +65,7 @@ class Lifer::Brain
65
65
  # @return [Array<Lifer::Collection>] All the collections for the current Lifer
66
66
  # project.
67
67
  def collections
68
- @collections ||= generate_collections + generate_selections
68
+ @collections ||= (generate_collections + generate_selections).to_a
69
69
  end
70
70
 
71
71
  # Returns the Lifer project's configuration object.
@@ -139,6 +139,19 @@ class Lifer::Brain
139
139
  config.setting *name, collection_name: collection&.name, strict: strict
140
140
  end
141
141
 
142
+ # Given the tag manifest, this returns an array of all tags for the current
143
+ # project. This method is preferrable for accessing and querying for tags.
144
+ #
145
+ # @return [Array<Lifer::Tag>]
146
+ def tags = tag_manifest.to_a
147
+
148
+ # The tag manifest tracks the unique tags added to the project as they're added.
149
+ # The writer method for this instance variable is used internally by Lifer when
150
+ # adding new tags.
151
+ #
152
+ # @return [Set<Lifer::Tag>]
153
+ def tag_manifest = (@tag_manifest ||= Set.new)
154
+
142
155
  private
143
156
 
144
157
  attr_reader :config_file_location
@@ -56,10 +56,16 @@ class Lifer::Builder::HTML
56
56
  end
57
57
 
58
58
  # @private
59
- # Each collection name is provided as a local variable. This allows you to
60
- # make ERB files that contain loops like:
59
+ # Each collection and tag name is provided as a local variable. This allows
60
+ # you to make ERB files that contain loops like:
61
61
  #
62
- # <% my_collection_name.entries.each do |entry| %>
62
+ # <% collections.my_collection_name.entries.each do |entry| %>
63
+ # <%= entry.title %>
64
+ # <% end %>
65
+ #
66
+ # or:
67
+ #
68
+ # <% tags.my_tag_name.entries.each do |entry| %>
63
69
  # <%= entry.title %>
64
70
  # <% end %>
65
71
  #
@@ -75,18 +81,31 @@ class Lifer::Builder::HTML
75
81
  end
76
82
  end
77
83
 
78
- collections = collection_context_class.new Lifer.collections.to_a
84
+ Lifer.tags.each do |tag|
85
+ binding.local_variable_set tag.name, tag
86
+
87
+ tag_context_class.define_method(tag.name) do
88
+ tag
89
+ end
90
+ end
91
+
92
+ collections = collection_context_class.new Lifer.collections
93
+ tags = tag_context_class.new Lifer.tags
79
94
 
80
95
  binding.local_variable_set :collections, collections
81
96
  binding.local_variable_set :settings, Lifer.settings
97
+ binding.local_variable_set :tags, tags
82
98
  binding.local_variable_set :content,
83
99
  ERB.new(entry.to_html).result(binding)
84
100
  }
85
101
  end
86
102
 
87
- # @private
88
103
  def collection_context_class
89
104
  @collection_context_class ||= Class.new(Array) do end
90
105
  end
106
+
107
+ def tag_context_class
108
+ @tag_context_class ||= Class.new(Array) do end
109
+ end
91
110
  end
92
111
  end
@@ -28,7 +28,7 @@ module Lifer::Builder::HTML::FromLiquid::Drops
28
28
  # @return [Array<EntryDrop>]
29
29
  def entries
30
30
  @entries ||= lifer_collection.entries.map {
31
- EntryDrop.new _1, collection: self
31
+ EntryDrop.new _1, collection: self, tags: _1.tags
32
32
  }
33
33
  end
34
34
 
@@ -18,7 +18,6 @@ module Lifer::Builder::HTML::FromLiquid::Drops
18
18
  #
19
19
  # @yield [CollectionDrop] All available collection drops.
20
20
  def each(&block)
21
-
22
21
  collections.each(&block)
23
22
  end
24
23
 
@@ -9,9 +9,10 @@ module Lifer::Builder::HTML::FromLiquid::Drops
9
9
  class EntryDrop < Liquid::Drop
10
10
  attr_accessor :lifer_entry, :collection
11
11
 
12
- def initialize(lifer_entry, collection:)
12
+ def initialize(lifer_entry, collection:, tags:)
13
13
  @lifer_entry = lifer_entry
14
14
  @collection = collection
15
+ @tags = tags
15
16
  end
16
17
 
17
18
  # The entry author (or authors).
@@ -0,0 +1,42 @@
1
+ module Lifer::Builder::HTML::FromLiquid::Drops
2
+ # This drop allows users to access Lifer tag information from within
3
+ # Liquid templates.
4
+ #
5
+ # @example Usage
6
+ # {{ tag.name }}
7
+ # {% for entries in tag.entries %}
8
+ # {{ entry.title }}
9
+ # {% endfor %}
10
+ #
11
+ class TagDrop < Liquid::Drop
12
+ attr_accessor :lifer_tag
13
+
14
+ def initialize(lifer_tag) = (@lifer_tag = lifer_tag)
15
+
16
+ # The tag name.
17
+ #
18
+ # @return [Symbol]
19
+ def name = (@name ||= lifer_tag.name)
20
+
21
+ # Gets all entries in a tag and converts them to entry drops that can
22
+ # be accessed in Liquid templates. Example:
23
+ #
24
+ # {% for entry in tags..entries %}
25
+ # {{ entry.title }}
26
+ # {% endfor %}
27
+ #
28
+ # @return [Array<EntryDrop>]
29
+ def entries
30
+ @entries ||= lifer_tag.entries.map { |lifer_entry|
31
+ EntryDrop.new lifer_entry,
32
+ collection: CollectionDrop.new(lifer_entry.collection),
33
+ tags: lifer_entry.tags.map { TagDrop.new _1 }
34
+ }
35
+ end
36
+
37
+ # The tag's layout file path.
38
+ #
39
+ # @return [String] The path to the layout file.
40
+ def layout_file = (@lifer_tag.layout_file)
41
+ end
42
+ end
@@ -0,0 +1,43 @@
1
+ module Lifer::Builder::HTML::FromLiquid::Drops
2
+ # This drop allows users to iterate over their Lifer tags in Liquid
3
+ # templates.
4
+ #
5
+ # @example Usage
6
+ # {% for tag in tags %}
7
+ # {{ tag.name }}
8
+ # {% endfor %}
9
+ #
10
+ # {% for entry in tags.name-of-tag.entries %}
11
+ # {{ entry.title }}
12
+ # {% endfor %}
13
+ #
14
+ class TagsDrop < Liquid::Drop
15
+ attr_accessor :tags
16
+
17
+ def initialize
18
+ @tags = Lifer.tags.map { TagDrop.new _1 }
19
+ end
20
+
21
+ # Allow tags to be iterable in Liquid templates.
22
+ #
23
+ # @yield [CollectionDrop] All available collection drops.
24
+ def each(&block) = tags.each(&block)
25
+
26
+ # Allow tags to be rendered as an array in Liquid templates.
27
+ #
28
+ # @return [Array]
29
+ def to_a = @tags
30
+
31
+ # Dynamically define Liquid accessors based on the Lifer project's
32
+ # collection names.
33
+ #
34
+ # @example Get the "tagName" tag's entries.
35
+ # {{ tags.tagName.entries }}
36
+ #
37
+ # @param arg [String] The name of a collection.
38
+ # @return [CollectionDrop, NilClass]
39
+ def liquid_method_missing(arg)
40
+ tags.detect { arg.to_s == _1.name.to_s }
41
+ end
42
+ end
43
+ end
@@ -13,3 +13,5 @@ require_relative "drops/collections_drop"
13
13
  require_relative "drops/entry_drop"
14
14
  require_relative "drops/frontmatter_drop"
15
15
  require_relative "drops/settings_drop"
16
+ require_relative "drops/tag_drop"
17
+ require_relative "drops/tags_drop"
@@ -5,7 +5,6 @@ require_relative "from_liquid/filters"
5
5
  require_relative "from_liquid/layout_tag"
6
6
  require_relative "from_liquid/liquid_env"
7
7
 
8
-
9
8
  class Lifer::Builder::HTML
10
9
  # If the HTML builder is given a Liquid template, it uses this class to parse
11
10
  # the Liquid into HTML. Lifer project metadata is provided as context. For
@@ -64,13 +63,16 @@ class Lifer::Builder::HTML
64
63
 
65
64
  def context
66
65
  collections = Drops::CollectionsDrop.new
66
+ tags = Drops::TagsDrop.new
67
67
  collection = collections
68
68
  .to_a
69
69
  .detect { _1.name.to_sym == entry.collection.name }
70
+ entry_tags = tags.to_a.select { entry.tags.include? _1 }
70
71
 
71
72
  {
72
73
  "collections" => collections,
73
- "entry" => Drops::EntryDrop.new(entry, collection:),
74
+ "tags" => tags,
75
+ "entry" => Drops::EntryDrop.new(entry, collection:, tags: entry_tags),
74
76
  "parse_options" => parse_options,
75
77
  "render_options" => render_options,
76
78
  "settings" => Drops::SettingsDrop.new
@@ -7,21 +7,18 @@ class Lifer::Entry::HTML < Lifer::Entry
7
7
  self.input_extensions = ["html", "html.erb", "html.liquid"]
8
8
  self.output_extension = :html
9
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.
10
+ # If there is no available metadata in the HTML file, we can extract a
11
+ # makeshift title from the permalink.
13
12
  #
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.
13
+ # Depending on the filename and URI strategy being used for the collection,
14
+ # it's possible that the extracted title would be "index", which is not very
15
+ # descriptive. If that's the case, we attempt to go up a directory to find a
16
+ # non-"index" title.
22
17
  #
23
- # @return [String] The extracted title of the entry.
18
+ # @return [String] The given or extracted title of the entry.
24
19
  def title
20
+ return frontmatter[:title] if frontmatter[:title]
21
+
25
22
  candidate = File.basename(permalink, ".html")
26
23
 
27
24
  if candidate.include?("index") && !file.to_s.include?("index")
@@ -35,5 +32,5 @@ class Lifer::Entry::HTML < Lifer::Entry
35
32
  # doesn't do much here.
36
33
  #
37
34
  # @return [String]
38
- def to_html = full_text
35
+ def to_html = body
39
36
  end
@@ -14,78 +14,10 @@ require_relative "../utilities"
14
14
  # it may make sense to pull some of these methods into a separate module.
15
15
  #
16
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
17
  self.include_in_feeds = true
32
18
  self.input_extensions = ["md"]
33
19
  self.output_extension = :html
34
20
 
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
21
  # If given a summary in the frontmatter of the entry, we can use this to
90
22
  # provide a summary. Otherwise, we can truncate the first paragraph and use
91
23
  # that as a summary, although that is a bit annoying. This is useful for
@@ -96,7 +28,7 @@ class Lifer::Entry::Markdown < Lifer::Entry
96
28
  #
97
29
  # @return [String] A summary of the entry.
98
30
  def summary
99
- return frontmatter[:summary] if frontmatter[:summary]
31
+ return super if super
100
32
 
101
33
  return if first_paragraph.nil?
102
34
  return first_paragraph if first_paragraph.length <= TRUNCATION_THRESHOLD
@@ -130,11 +62,17 @@ class Lifer::Entry::Markdown < Lifer::Entry
130
62
 
131
63
  private
132
64
 
133
- # @private
134
- def filename_date
135
- return unless file && File.basename(file).match?(FILENAME_DATE_FORMAT)
136
-
137
- File.basename(file).match(FILENAME_DATE_FORMAT)[1]
65
+ # It is conventional for users to use spaces or commas to delimit tags in
66
+ # other systems, so let's support that. But let's also support YAML-style
67
+ # arrays.
68
+ #
69
+ # @return [Array<String>] An array of candidate tag names.
70
+ def candidate_tag_names
71
+ case frontmatter[:tags]
72
+ when Array then frontmatter[:tags].map(&:to_s)
73
+ when String then frontmatter[:tags].split(TAG_DELIMITER_REGEX)
74
+ else []
75
+ end.uniq
138
76
  end
139
77
 
140
78
  # Using Kramdown we can detect the first paragraph of the entry.
@@ -149,11 +87,6 @@ class Lifer::Entry::Markdown < Lifer::Entry
149
87
  )
150
88
  end
151
89
 
152
- # @private
153
- def frontmatter?
154
- full_text && full_text.match?(FRONTMATTER_REGEX)
155
- end
156
-
157
90
  # @private
158
91
  def kramdown_paragraph_text(kramdown_element)
159
92
  return if kramdown_element.nil?
@@ -7,21 +7,18 @@ class Lifer::Entry::TXT < Lifer::Entry
7
7
  self.input_extensions = ["txt"]
8
8
  self.output_extension = :txt
9
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.
10
+ # If there is no available metadata in the text file, we can extract a
11
+ # makeshift title from the permalink.
13
12
  #
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.
13
+ # Depending on the filename and URI strategy being used for the collection,
14
+ # it's possible that the extracted title would be "index", which is not very
15
+ # descriptive. If that's the case, we attempt to go up a directory to find a
16
+ # non-"index" title.
22
17
  #
23
- # @return [String] The extracted title of the entry.
18
+ # @return [String] The given or extracted title of the entry.
24
19
  def title
20
+ return frontmatter[:title] if frontmatter[:title]
21
+
25
22
  candidate = File.basename(permalink, ".txt")
26
23
 
27
24
  if candidate.include?("index") && !file.to_s.include?("index")
@@ -37,5 +34,5 @@ class Lifer::Entry::TXT < Lifer::Entry
37
34
  # @fixme Maybe the `#to_html` methods should be renamed, then?
38
35
  #
39
36
  # @return [String] The output HTML (not actually HTML).
40
- def to_html = full_text
37
+ def to_html = body
41
38
  end
data/lib/lifer/entry.rb CHANGED
@@ -1,166 +1,289 @@
1
1
  require "digest/sha1"
2
2
 
3
- # An entry is a Lifer file that will be built into the output directory.
4
- # There are more than one subclass of entry: Markdown entries are the most
5
- # traditional, but HTML and text files are also very valid entries.
6
- #
7
- # This class provides a baseline of the functionality that all entry subclasses
8
- # should implement. It also provides the entry generator for *all* entry
9
- # subclasses.
10
- #
11
- # @fixme Markdown entries are able to provide metadata via frontmatter, but
12
- # other entry types do not currently support frontmatter. Should they? Or is
13
- # there some nicer way to provide entry metadata for non-Markdown files in
14
- # 2024?
15
- #
16
- class Lifer::Entry
17
- class << self
18
- attr_accessor :include_in_feeds
19
- attr_accessor :input_extensions
20
- attr_accessor :output_extension
21
- end
3
+ module Lifer
4
+ # An entry is a Lifer file that will be built into the output directory.
5
+ # There are more than one subclass of entry: Markdown entries are the most
6
+ # traditional, but HTML and text files are also very valid entries.
7
+ #
8
+ # This class provides a baseline of the functionality that all entry
9
+ # subclasses should implement. It also provides the entry generator for
10
+ # *all* entry subclasses.
11
+ #
12
+ # @fixme Markdown entries are able to provide metadata via frontmatter, but
13
+ # other entry types do not currently support frontmatter. Should they? Or is
14
+ # there some nicer way to provide entry metadata for non-Markdown files in
15
+ # 2024?
16
+ #
17
+ class Entry
18
+ class << self
19
+ attr_accessor :include_in_feeds
20
+ attr_accessor :input_extensions
21
+ attr_accessor :output_extension
22
+ end
22
23
 
23
- self.include_in_feeds = false
24
- self.input_extensions = []
25
- self.output_extension = nil
24
+ self.include_in_feeds = false
25
+ self.input_extensions = []
26
+ self.output_extension = nil
26
27
 
27
- require_relative "entry/html"
28
- require_relative "entry/markdown"
29
- require_relative "entry/txt"
28
+ require_relative "entry/html"
29
+ require_relative "entry/markdown"
30
+ require_relative "entry/txt"
30
31
 
31
- # We provide a default date for entries that have no date and entry types that
32
- # otherwise could not have a date due to no real way of getting that metadata.
33
- #
34
- DEFAULT_DATE = Time.new(1900, 01, 01, 0, 0, 0, "+00:00")
32
+ # We provide a default date for entries that have no date and entry types that
33
+ # otherwise could not have a date due to no real way of getting that metadata.
34
+ #
35
+ DEFAULT_DATE = Time.new(1900, 01, 01, 0, 0, 0, "+00:00")
36
+
37
+ # If a filename contains a date, we should expect it to be in the following
38
+ # format.
39
+ #
40
+ FILENAME_DATE_FORMAT = /^(\d{4}-\d{1,2}-\d{1,2})-/
41
+
42
+ # We expect frontmatter to be provided in the following format.
43
+ #
44
+ FRONTMATTER_REGEX = /^---\n(.*)---\n/m
35
45
 
36
- attr_reader :file, :collection
46
+ # If tags are represented in YAML frontmatter as a string, they're split on
47
+ # commas and/or spaces.
48
+ #
49
+ TAG_DELIMITER_REGEX = /[,\s]+/
37
50
 
38
- class << self
39
- # The entrypoint for generating entry objects. We should never end up with
40
- # `Lifer::Entry` records: only subclasses.
51
+ # We truncate anything that needs to be truncated (summaries, meta
52
+ # descriptions) at the following character count.
41
53
  #
42
- # @param file [String] The absolute filename of an entry file.
43
- # @param collection [Lifer::Collection] The collection for the entry.
44
- # @return [Lifer::Entry::HTML, Lifer::Entry::Markdown]
45
- def generate(file:, collection:)
46
- error!(file) unless File.exist?(file)
54
+ TRUNCATION_THRESHOLD = 120
55
+
56
+ attr_reader :file, :collection
57
+
58
+ class << self
59
+ # The entrypoint for generating entry objects. We should never end up with
60
+ # `Lifer::Entry` records: only subclasses.
61
+ #
62
+ # @param file [String] The absolute filename of an entry file.
63
+ # @param collection [Lifer::Collection] The collection for the entry.
64
+ # @return [Lifer::Entry] An entry.
65
+ def generate(file:, collection:)
66
+ error!(file) unless File.exist?(file)
67
+
68
+ if (new_entry = subclass_for(file)&.new(file:, collection:))
69
+ Lifer.entry_manifest << new_entry
70
+ new_entry.tags
71
+ end
72
+
73
+ new_entry
74
+ end
75
+
76
+ # Whenever an entry is generated it should be added to the entry manifest.
77
+ # This lets us get a list of all generated entries.
78
+ #
79
+ # @return [Array<Lifer::Entry>] A list of all entries that currently exist.
80
+ def manifest
81
+ return Lifer.entry_manifest if self == Lifer::Entry
82
+
83
+ Lifer.entry_manifest.select { |entry| entry.class == self }
84
+ end
47
85
 
48
- if (new_entry = subclass_for(file)&.new(file:, collection:))
49
- Lifer.entry_manifest << new_entry
86
+ # Checks whether the given filename is supported entry type (using only its
87
+ # file extension).
88
+ #
89
+ # @param filename [String] The absolute filename to an entry.
90
+ # @param file_extensions [Array<String>] An array of file extensions to
91
+ # check against.
92
+ # @return [Boolean]
93
+ def supported?(filename, file_extensions= supported_file_extensions)
94
+ file_extensions.any? { |ext| filename.end_with? ext }
95
+ end
96
+
97
+ private
98
+
99
+ def supported_file_extensions
100
+ @supported_file_extensions ||= subclasses.flat_map(&:input_extensions)
101
+ end
102
+
103
+ # @private
104
+ # Retrieve the entry subclass based on the current filename.
105
+ #
106
+ # @param filename [String] The current entry's filename.
107
+ # @return [Class] The entry subclass for the current entry.
108
+ def subclass_for(filename)
109
+ Lifer::Entry.subclasses.detect { |klass|
110
+ klass.input_extensions.any? { |ext| filename.end_with? ext }
111
+ }
112
+ end
113
+
114
+ # @private
115
+ def error!(file)
116
+ raise StandardError, I18n.t("entry.not_found", file:)
50
117
  end
51
- new_entry
52
118
  end
53
119
 
54
- # Whenever an entry is generated it should be added to the entry manifest.
55
- # This lets us get a list of all generated entries.
120
+ # When a new entry is initialized we expect the file to already exist, and
121
+ # we expect to know which `Lifer::Collection` it belongs to.
56
122
  #
57
- # @return [Array<Lifer::Entry>] A list of all entries that currently exist.
58
- def manifest
59
- return Lifer.entry_manifest if self == Lifer::Entry
60
-
61
- Lifer.entry_manifest.select { |entry| entry.class == self }
123
+ # @param file [String] An absolute path to a file.
124
+ # @param collection [Lifer::Collection] A collection.
125
+ # @return [Lifer::Entry]
126
+ def initialize(file:, collection:)
127
+ @file = Pathname file
128
+ @collection = collection
62
129
  end
63
130
 
64
- # Checks whether the given filename is supported entry type (using only its
65
- # file extension).
131
+ # Given the entry's frontmatter, we should be able to get a list of authors.
132
+ # We always prefer authors (as opposed to a singular author) because it makes
133
+ # handling both cases easier in the long run.
66
134
  #
67
- # @param filename [String] The absolute filename to an entry.
68
- # @param file_extensions [Array<String>] An array of file extensions to
69
- # check against.
70
- # @return [Boolean]
71
- def supported?(filename, file_extensions= supported_file_extensions)
72
- file_extensions.any? { |ext| filename.end_with? ext }
135
+ # The return value here is likely an author's name. Whether that's a full
136
+ # name, a first name, or a handle is up to the end user.
137
+ #
138
+ # @return [Array<String>] An array of authors's names.
139
+ def authors
140
+ Array(frontmatter[:author] || frontmatter[:authors]).compact
73
141
  end
74
142
 
75
- private
143
+ # This method returns the full text of the entry, only removing the
144
+ # frontmatter. It should not parse anything other than frontmatter.
145
+ #
146
+ # @return [String] The body of the entry.
147
+ def body
148
+ return full_text.strip unless frontmatter?
76
149
 
77
- def supported_file_extensions
78
- @supported_file_extensions ||= subclasses.flat_map(&:input_extensions)
150
+ full_text.gsub(FRONTMATTER_REGEX, "").strip
79
151
  end
80
152
 
81
- # @private
82
- # Retrieve the entry subclass based on the current filename.
153
+ # Since text files would only store dates as simple strings, it's nice to
154
+ # attempt to convert those into Ruby date or datetime objects.
83
155
  #
84
- # @param filename [String] The current entry's filename.
85
- # @return [Class] The entry subclass for the current entry.
86
- def subclass_for(filename)
87
- Lifer::Entry.subclasses.detect { |klass|
88
- klass.input_extensions.any? { |ext| filename.end_with? ext }
89
- }
156
+ # @return [Time] A Ruby representation of the date and time provided by the
157
+ # entry frontmatter or filename.
158
+ def date
159
+ date_data = frontmatter[:date] || filename_date
160
+
161
+ case date_data
162
+ when Time then date_data
163
+ when String then DateTime.parse(date_data).to_time
164
+ else
165
+ Lifer::Message.log("entry.no_date_metadata", filename: file)
166
+ Lifer::Entry::DEFAULT_DATE
167
+ end
168
+ rescue ArgumentError => error
169
+ Lifer::Message.error("entry.date_error", filename: file, error:)
170
+ Lifer::Entry::DEFAULT_DATE
90
171
  end
91
172
 
92
- # @private
93
- def error!(file)
94
- raise StandardError, I18n.t("entry.not_found", file:)
173
+ def feedable?
174
+ if (setting = self.class.include_in_feeds).nil?
175
+ raise NotImplementedError,
176
+ I18n.t("entry.feedable_error", entry_class: self.class)
177
+ end
178
+
179
+ setting
95
180
  end
96
- end
97
181
 
98
- # When a new Markdown entry is initialized we expect the file to already
99
- # exist, and we expect to know which `Lifer::Collection` it belongs to.
100
- #
101
- # @param file [String] An absolute path to a file.
102
- # @param collection [Lifer::Collection] A collection.
103
- # @return [Lifer::Entry]
104
- def initialize(file:, collection:)
105
- @file = Pathname file
106
- @collection = collection
107
- end
182
+ # Frontmatter is a widely supported YAML metadata block found at the top of
183
+ # text--often Markdown--files. We attempt to parse all entries for
184
+ # frontmatter.
185
+ #
186
+ # @return [Hash] A hash representation of the entry frontmatter.
187
+ def frontmatter
188
+ return {} unless frontmatter?
108
189
 
109
- def feedable?
110
- if (setting = self.class.include_in_feeds).nil?
111
- raise NotImplementedError,
112
- I18n.t("entry.feedable_error", entry_class: self.class)
190
+ Lifer::Utilities.symbolize_keys(
191
+ YAML.load(full_text[FRONTMATTER_REGEX, 1], permitted_classes: [Time])
192
+ )
113
193
  end
114
194
 
115
- setting
116
- end
195
+ # The full text of the entry.
196
+ #
197
+ # @return [String]
198
+ def full_text
199
+ @full_text ||= File.readlines(file).join if file
200
+ end
117
201
 
118
- # The full text of the entry.
119
- #
120
- # @return [String]
121
- def full_text
122
- @full_text ||= File.readlines(file).join if file
123
- end
202
+ # Using the current Lifer configuration, we can calculate the expected
203
+ # permalink for the entry. For example:
204
+ #
205
+ # https://example.com/index.html
206
+ # https://example.com/blog/my-trip-to-toronto.html
207
+ #
208
+ # This would be useful for indexes and feeds and so on.
209
+ #
210
+ # @return [String] A permalink to the current entry.
211
+ def permalink(host: Lifer.setting(:global, :host))
212
+ cached_permalink_variable =
213
+ "@entry_permalink_" + Digest::SHA1.hexdigest(host)
124
214
 
125
- # Using the current Lifer configuration, we can calculate the expected
126
- # permalink for the entry. For example:
127
- #
128
- # https://example.com/index.html
129
- # https://example.com/blog/my-trip-to-toronto.html
130
- #
131
- # This would be useful for indexes and feeds and so on.
132
- #
133
- # @return [String] A permalink to the current entry.
134
- def permalink(host: Lifer.setting(:global, :host))
135
- cached_permalink_variable =
136
- "@entry_permalink_" + Digest::SHA1.hexdigest(host)
137
-
138
- instance_variable_get(cached_permalink_variable) ||
139
- instance_variable_set(
140
- cached_permalink_variable,
141
- File.join(
142
- host,
143
- Lifer::URIStrategy.find(collection.setting :uri_strategy)
144
- .new(root: Lifer.root)
145
- .output_file(self)
215
+ instance_variable_get(cached_permalink_variable) ||
216
+ instance_variable_set(
217
+ cached_permalink_variable,
218
+ File.join(
219
+ host,
220
+ Lifer::URIStrategy.find(collection.setting :uri_strategy)
221
+ .new(root: Lifer.root)
222
+ .output_file(self)
223
+ )
146
224
  )
147
- )
148
- end
225
+ end
149
226
 
150
- # The expected, absolute URI path to the entry. For example:
151
- #
152
- # /index.html
153
- # /blog/my-trip-to-toronto.html
154
- #
155
- # @return [String] The absolute URI path to the entry.
156
- def path = permalink(host: "/")
227
+ # The expected, absolute URI path to the entry. For example:
228
+ #
229
+ # /index.html
230
+ # /blog/my-trip-to-toronto.html
231
+ #
232
+ # @return [String] The absolute URI path to the entry.
233
+ def path = permalink(host: "/")
157
234
 
158
- def title
159
- raise NotImplementedError, I18n.t("shared.not_implemented_method")
160
- end
235
+ # If given a summary in the frontmatter of the entry, we can use this to
236
+ # provide a summary.
237
+ #
238
+ # Since subclasses may have more sophisticated access to the document, they
239
+ # may override this method with their own distinct implementations.
240
+ ##
241
+ # @return [String] A summary of the entry.
242
+ def summary
243
+ return frontmatter[:summary] if frontmatter[:summary]
244
+ end
245
+
246
+ # Locates and returns all tags defined in the entry.
247
+ #
248
+ # @return [Array<Lifer::Tag>] The entry's tags.
249
+ def tags
250
+ @tags ||= candidate_tag_names
251
+ .map { Lifer::Tag.build_or_update(name: _1, entries: [self]) }
252
+ end
253
+
254
+ # Returns the title of the entry. Every entry subclass must implement this
255
+ # method so that builders have access to *some* kind of title for each entry.
256
+ #
257
+ # @return [String]
258
+ def title
259
+ raise NotImplementedError, I18n.t("shared.not_implemented_method")
260
+ end
261
+
262
+ def to_html
263
+ raise NotImplementedError, I18n.t("shared.not_implemented_method")
264
+ end
161
265
 
162
- def to_html
163
- raise NotImplementedError, I18n.t("shared.not_implemented_method")
266
+ private
267
+
268
+ # It is conventional for users to use spaces or commas to delimit tags in
269
+ # other systems, so let's support that. But let's also support YAML-style
270
+ # arrays.
271
+ #
272
+ # @return [Array<String>] An array of candidate tag names.
273
+ def candidate_tag_names
274
+ case frontmatter[:tags]
275
+ when Array then frontmatter[:tags].map(&:to_s)
276
+ when String then frontmatter[:tags].split(TAG_DELIMITER_REGEX)
277
+ else []
278
+ end.uniq
279
+ end
280
+
281
+ def filename_date
282
+ return unless file && File.basename(file).match?(FILENAME_DATE_FORMAT)
283
+
284
+ File.basename(file).match(FILENAME_DATE_FORMAT)[1]
285
+ end
286
+
287
+ def frontmatter? = (full_text && full_text.match?(FRONTMATTER_REGEX))
164
288
  end
165
289
  end
166
-
data/lib/lifer/tag.rb ADDED
@@ -0,0 +1,53 @@
1
+ module Lifer
2
+ # A tag is a way to categorize entries. You've likely encountered tags in
3
+ # other software before. In Lifer, tags are sort of the inverse of
4
+ # collections. It's a nice way to associate entries across many collections.
5
+ #
6
+ # Because tags are used to link entries, we definitely do not want duplicate
7
+ # tags. So the only way to build or retrieve tags is via the
8
+ # `.build_or_update` class method, which helps us responsibly manage the
9
+ # global tag manifest.
10
+ #
11
+ class Tag
12
+ class << self
13
+ # Builds or updates a Lifer tag. On update, its list of entries gets
14
+ # freshened.
15
+ #
16
+ # @param name [String] The name of the tag.
17
+ # @param entries [Array<Lifer::Entry>] A list of entries that should be
18
+ # associated with the tag. This parameter is not a true writer, in that
19
+ # if the tag already exists, old entry associations won't be removed--
20
+ # only appended to.
21
+ # @return [Lifer:Tag] The new or updated tag.
22
+ def build_or_update(name:, entries: [])
23
+ update(name:, entries:) || build(name:, entries:)
24
+ end
25
+
26
+ private
27
+
28
+ def build(name:, entries:)
29
+ if (new_tag = new(name:, entries:))
30
+ Lifer.tag_manifest << new_tag
31
+ end
32
+ new_tag || false
33
+ end
34
+
35
+ def update(name:, entries:)
36
+ if (tag = Lifer.tags.detect { _1.name == name })
37
+ tag.instance_variable_set :@entries,
38
+ (tag.instance_variable_get(:@entries) | entries)
39
+ end
40
+ tag || false
41
+ end
42
+ end
43
+
44
+ attr_accessor :name
45
+
46
+ attr_reader :entries
47
+
48
+ def initialize(name:, entries:)
49
+ @name = name
50
+ @entries = entries
51
+ end
52
+ end
53
+ end
data/lib/lifer/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Lifer
2
- VERSION = "0.7.0"
2
+ VERSION = "0.8.0"
3
3
  end
data/lib/lifer.rb CHANGED
@@ -141,6 +141,17 @@ module Lifer
141
141
  #
142
142
  # @return [Hash] The `Lifer::Config#settings`.
143
143
  def settings = brain.config.settings
144
+
145
+ # All of the tags represented in Lifer entries for the current project.
146
+ #
147
+ # @return [Array<Lifer::Tag>] The complete list of tags.
148
+ def tags = brain.tags
149
+
150
+ # A set of all tags added to the project. Prefer using the `#tags` method
151
+ # for tag queries.
152
+ #
153
+ # @return [Set<Lifer::Tag>] The complete set of tags.
154
+ def tag_manifest = brain.tag_manifest
144
155
  end
145
156
  end
146
157
 
@@ -158,4 +169,5 @@ require_relative "lifer/builder"
158
169
  require_relative "lifer/collection"
159
170
  require_relative "lifer/entry"
160
171
  require_relative "lifer/message"
172
+ require_relative "lifer/tag"
161
173
  require_relative "lifer/uri_strategy"
data/lifer.gemspec CHANGED
@@ -17,9 +17,9 @@ Gem::Specification.new do |spec|
17
17
  spec.metadata["allowed_push_host"] = "https://rubygems.org"
18
18
 
19
19
  spec.metadata["homepage_uri"] =
20
- "%s/blob/%s/README.md" % [spec.homepage, Lifer::VERSION]
20
+ "%s/blob/%s/README.md" % [spec.homepage, "v#{Lifer::VERSION}"]
21
21
  spec.metadata["source_code_uri"] =
22
- "%s/tree/%s" % [spec.homepage, Lifer::VERSION]
22
+ "%s/tree/%s" % [spec.homepage, "v#{Lifer::VERSION}"]
23
23
  spec.metadata["changelog_uri"] = "%s/blob/main/CHANGELOG.md" % spec.homepage
24
24
 
25
25
  # Specify which files should be added to the gem when it is released. The
data/locales/en.yml CHANGED
@@ -30,11 +30,10 @@ en:
30
30
  content_type_not_implemented: no content type defined for files like %{path} yet
31
31
  four_oh_four: 404 Not Found
32
32
  entry:
33
+ date_error: "[%{filename}]: %{error}"
33
34
  feedable_error: >
34
35
  please set `%{entry_class}.include_in_feeds` to true or false
35
- markdown:
36
- date_error: "[%{filename}]: %{error}"
37
- no_date_metadata: "[%{filename}]: no date metadata"
36
+ no_date_metadata: "[%{filename}]: no date metadata"
38
37
  not_found: >
39
38
  file "%{file}" does not exist
40
39
  config:
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lifer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - benjamin wil
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-03-03 00:00:00.000000000 Z
11
+ date: 2025-03-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: i18n
@@ -167,6 +167,8 @@ files:
167
167
  - lib/lifer/builder/html/from_liquid/drops/entry_drop.rb
168
168
  - lib/lifer/builder/html/from_liquid/drops/frontmatter_drop.rb
169
169
  - lib/lifer/builder/html/from_liquid/drops/settings_drop.rb
170
+ - lib/lifer/builder/html/from_liquid/drops/tag_drop.rb
171
+ - lib/lifer/builder/html/from_liquid/drops/tags_drop.rb
170
172
  - lib/lifer/builder/html/from_liquid/filters.rb
171
173
  - lib/lifer/builder/html/from_liquid/layout_tag.rb
172
174
  - lib/lifer/builder/html/from_liquid/liquid_env.rb
@@ -188,6 +190,7 @@ files:
188
190
  - lib/lifer/selection/included_in_feeds.rb
189
191
  - lib/lifer/shared.rb
190
192
  - lib/lifer/shared/finder_methods.rb
193
+ - lib/lifer/tag.rb
191
194
  - lib/lifer/templates/cli.txt.erb
192
195
  - lib/lifer/templates/config.yaml
193
196
  - lib/lifer/templates/its-a-living.png
@@ -207,8 +210,8 @@ licenses:
207
210
  - MIT
208
211
  metadata:
209
212
  allowed_push_host: https://rubygems.org
210
- homepage_uri: https://github.com/benjaminwil/lifer/blob/0.7.0/README.md
211
- source_code_uri: https://github.com/benjaminwil/lifer/tree/0.7.0
213
+ homepage_uri: https://github.com/benjaminwil/lifer/blob/v0.8.0/README.md
214
+ source_code_uri: https://github.com/benjaminwil/lifer/tree/v0.8.0
212
215
  changelog_uri: https://github.com/benjaminwil/lifer/blob/main/CHANGELOG.md
213
216
  post_install_message:
214
217
  rdoc_options: []