lifer 0.12.4 → 0.14.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: 56e9416088ac954ed5ede64c4f4675153d8d6fb0a0a1d6cc04b91d387538c388
4
- data.tar.gz: 638c9b87b742d4aa393bc312649f6d5801d855fd3c95dc16fb774c6d5a415fd2
3
+ metadata.gz: b4416a9edb890d8d98adc06d1baf7e58b695d85da827b48005d7285b6f5a1dbe
4
+ data.tar.gz: 0c45a40304601d473c946315ddea56860041339e029467f8bcecb603ca70a58a
5
5
  SHA512:
6
- metadata.gz: 2b65f34b010f2117b52598cb10586717c13bac9ecb5bae417968fcf8919ab0128bb266e8ff02de7ac31ff578e4525e067423c2ad78bbaaeb594a9a81cc14d23b
7
- data.tar.gz: df56506ab2cc5d0fae8a7714e59d0dc2a55b01ccbf77f07e787e8f0ee6d3995120b2b42c3ebd8f71f8ef69a720467cd396c7f0554374629d1f9eeb4a4f1049fc
6
+ metadata.gz: b8aa3c53f350191b59ff5105ac1130c62c4a2f690aca83794877b933e7d7bfa23e501d2c91bd90dfc4954a4db9edf57b5db8ea4c4bf75fe78aa3e3e3e7e20e0c
7
+ data.tar.gz: f9b729936ec8ef025d9bc6dc8b6deab6d4d63c1d281da50e99df5932cd1f245c581c911378a703f1ddb7e6a5167f21fa746a780ac9e9ad72f29721d93bf34215
data/CHANGELOG.md CHANGED
@@ -1,4 +1,33 @@
1
1
  ## Next
2
+ ## v0.14.0
3
+
4
+ This release includes some really nice improvements and bug fixes:
5
+
6
+ - When returning a list of entries belonging to a `Lifer::Collection` or a
7
+ `Lifer::Tag`, the entries are now returned in reverse chronological order
8
+ by default. (But you can pass the argument `order: :oldest` to get them
9
+ in reverse, if you want.)
10
+ - We fixed a bug where entries in `.html.erb` format that attempted to render
11
+ view partials would error out due to the `render` method not having been
12
+ defined with the context of the entry being built yet. This bug was a bit
13
+ unpleasant to reason about, but it resolves a major defect with ERB building.
14
+ - The included Lifer configuration file template now enables the
15
+ JSON Feed builder by default.
16
+ - Every `Lifer::Selection` name is now automatically registered as a setting so
17
+ that you can configure it like you configure any other collection of entries.
18
+ - Lastly, this release removes `Lifer.manifest`. (A collection of absolute
19
+ paths to every entry file ended up not being incredibly useful. We get
20
+ the same-ish functionality from `Lifer.entry_manifest`.)
21
+
22
+ ## v0.13.0
23
+
24
+ This release allows projects to build JSON Feed 1.1. Now, a project can
25
+ build both Atom (or RSS 2.0) and JSON Feed, which is nice. In order to
26
+ support JSON Feed, we modelled out `Lifer::Asset` and `Lifer::Author`. This
27
+ is because JSON Feed explicitly supports author objects (with name, URL,
28
+ and avatars URLs), and images and banner images per feed item.
29
+
30
+ This release also improves the documentation for the `Lifer::Builder::RSS`.
2
31
 
3
32
  ## v0.12.4
4
33
 
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- lifer (0.12.4)
4
+ lifer (0.14.0)
5
5
  base64
6
6
  i18n (< 2)
7
7
  kramdown (~> 2.4)
data/guix.scm ADDED
@@ -0,0 +1,19 @@
1
+ (use-modules (guix)
2
+ (guix build-system gnu)
3
+ ((guix licenses) #:prefix license:)
4
+ (gnu packages ruby)
5
+ (gnu packages serialization))
6
+
7
+ (package
8
+ (name "ruby-lifer-dev")
9
+ (version "0.12.4-git")
10
+ (source #f)
11
+ (build-system gnu-build-system)
12
+ (inputs
13
+ (append (list ruby libyaml)))
14
+ (synopsis "Another Ruby-based static website generator")
15
+ (description
16
+ "A ruby-based static website generator focused on extensibility and having
17
+ few dependencies.")
18
+ (home-page "https://github.com/benjaminwil/lifer")
19
+ (license license:expat))
@@ -0,0 +1,82 @@
1
+ class Lifer::Asset
2
+ # An asset is a file, often a multimedia file, that belongs to one or
3
+ # many entries. Right now, assets don't have much functionality of their
4
+ # own, but it's valuable to know what entries an asset has a relationship
5
+ # with. In the future, it may be valuable for us to build out functionality
6
+ # that allows users to have one or more asset hosts. This allows files that
7
+ # aren't checked into version control still be treated as entry dependencies.
8
+ #
9
+ class << self
10
+ # Builds or updates a Lifer asset. On update, this list of an asset's
11
+ # entries would get freshened.
12
+ #
13
+ # @param url [String] An absolute URL or path relative to the host root.
14
+ # @param entries [Array<Lifer::Entry>] An array of entries that the asset
15
+ # belongs to.
16
+ # @return [Lifer::Asset] The new or updated asset.
17
+ def build_or_update(url: nil, entries: [])
18
+ update(url:, entries:) || build(url:, entries:)
19
+ end
20
+
21
+ # The default host for all assets.
22
+ #
23
+ # @return [String] A URL to a host. (Default: the configured global host.)
24
+ def default_host = Lifer.setting(:global, :host)
25
+
26
+ private
27
+
28
+ def build(url:, entries:)
29
+ if (new_asset = new(url:, entries:))
30
+ Lifer.asset_manifest << new_asset
31
+ end
32
+ new_asset || false
33
+ end
34
+
35
+ def update(url:, entries:)
36
+ normalized_url = Lifer::Utilities.uri_from url,
37
+ host: default_host,
38
+ object_type: self
39
+
40
+ if (asset = Lifer.asset_manifest.detect { _1.url == normalized_url })
41
+ asset.instance_variable_set :@entries,
42
+ (asset.instance_variable_get(:@entries) | entries)
43
+ end
44
+ asset || false
45
+ end
46
+
47
+ end
48
+
49
+ attr_reader :url, :entries
50
+
51
+ def initialize(url:, entries:)
52
+ normalized_url = Lifer::Utilities.uri_from url,
53
+ host: self.class.default_host,
54
+ object_type: self
55
+
56
+ @url = normalized_url
57
+ @entries = entries
58
+ end
59
+
60
+ # Checks whether a given URL matches the current asset's URL.
61
+ #
62
+ # @param url [String] A URL.
63
+ # @param host [String] The host URL. (Default: The configured global host
64
+ # URL.)
65
+ # @return [boolean] Whether the given URL matches the object's URL.
66
+ def match?(url:, host: self.class.default_host)
67
+ @url == Lifer::Utilities.uri_from(url, host:, object_type: self.class)
68
+ end
69
+
70
+ # Gets the current URL. If given a host, the asset's true host will be
71
+ # replaced with the given host.
72
+ #
73
+ # @param host [String] A host URL. (Default: The configured global host URL.)
74
+ # @return [String] The URL to the current asset.
75
+ def url(host: self.class.default_host)
76
+ return @url if host == self.class.default_host
77
+
78
+ path = URI(@url).path
79
+
80
+ Lifer::Utilities.uri_from(path, host:, object_type: self.class)
81
+ end
82
+ end
@@ -0,0 +1,106 @@
1
+ module Lifer
2
+ # An author is a representation of a unique author of entries in the current
3
+ # project. This allows us to refer to an author's name in an entry's
4
+ # frontmatter, i.e.:
5
+ #
6
+ # ---
7
+ # title: My blog post
8
+ # author: Nat McCartney
9
+ # ---
10
+ #
11
+ # And let that reference load in a bunch of other metadata about the
12
+ # author. While it's possible to add all of this metadata at the entry level,
13
+ # the first entry loaded will be the source of truth for every reference
14
+ # back to the author. So it's preferrable to set the author metadata in your
15
+ # global configuration:
16
+ #
17
+ # # my-lifer.conf
18
+ # authors:
19
+ # - name: Nat McCartney
20
+ # url: https://example.com/nat
21
+ # avatar: https://example.com/nat.png
22
+ #
23
+ # Within a Lifer project, this allows you to do powerful things like get
24
+ # a list of entries by a unique author (or set of authors). It also allows
25
+ # you to provide author-specific URLs and avatar images in your website's
26
+ # JSON Feeds.
27
+ #
28
+ # The author's name is used as the primary identifier. We have tried to be
29
+ # smart about this so that, for example, "Nat McCartney", "nat mccartney",
30
+ # and "nat-mccartney" will all load up the same author object.
31
+ #
32
+ class Author
33
+ class << self
34
+ # Builds or updates a Lifer author. On update, the list of an author's
35
+ # entries would get freshened.
36
+ #
37
+ # @param name [String] The name of the author.
38
+ # @param url [String] A relative or absolute URL to learn more about
39
+ # the author at.
40
+ # @param avatar [String] A relative or absolute URL to an image that
41
+ # represents the author.
42
+ # @return [Lifer::Author] The new or updated author.
43
+ def build_or_update(name:, url: nil, avatar: nil, entries: [])
44
+ update(name:, url:, avatar:, entries:) ||
45
+ build(name:, url:, avatar:, entries:)
46
+ end
47
+
48
+ private
49
+
50
+ def build(name:, url:, avatar:, entries:)
51
+ if (new_author = new(name:, url:, avatar:, entries:))
52
+ Lifer.author_manifest << new_author
53
+ end
54
+ new_author || false
55
+ end
56
+
57
+ def update(name:, url:, avatar:, entries:)
58
+ author_id = Lifer::Utilities.handleize(name)
59
+
60
+ if (author = Lifer.authors.detect { _1.id == author_id })
61
+ author.instance_variable_set :@entries,
62
+ (author.instance_variable_get(:@entries) | entries)
63
+ end
64
+ author || false
65
+ end
66
+ end
67
+
68
+ attr_reader :name, :entries
69
+
70
+ def initialize(name:, url:, avatar:, entries:)
71
+ @name = name
72
+ @url = url
73
+ @avatar = avatar
74
+ @entries = entries
75
+ end
76
+
77
+ # An avatar image URL that represents the author. The URL can either be
78
+ # relative from the website's root or an absolute URL. If the relative or
79
+ # absolute URL is ambiguous, it is sanitized and this method returns nil.
80
+ #
81
+ # @param host [String] The host to prefix to relative URLs. By default,
82
+ # this is the Lifer project's global host.
83
+ # @return [String] The absolute URL to the avatar image.
84
+ def avatar(host: Lifer.setting(:global, :host))
85
+ Lifer::Utilities.uri_from(@avatar, host:, object_type: self.class)
86
+ end
87
+
88
+ # An identifier built from the author's name. This uses our generic
89
+ # handle-izer function. So a name like "Nat McCartney" becomes
90
+ # "nat-mccartney".
91
+ #
92
+ # @return [String] The identifier for the author.
93
+ def id = (@id ||= Lifer::Utilities.handleize(name))
94
+
95
+ # A URL that provides more info about the author. The URL can either be
96
+ # relative from the website's root or an absolute URL. If the relative or
97
+ # absolute URL is ambiguous, it is sanitized and this method returns nil.
98
+ #
99
+ # @param host [String] The host to prefix to relative URLs. By default,
100
+ # this is the Lifer project's global host.
101
+ # @return [String] The absolute version of the URL.
102
+ def url(host: Lifer.setting(:global, :host))
103
+ Lifer::Utilities.uri_from(@url, host:, object_type: self.class)
104
+ end
105
+ end
106
+ end
data/lib/lifer/brain.rb CHANGED
@@ -25,6 +25,27 @@ class Lifer::Brain
25
25
  def init(root: Dir.pwd, config_file: nil) = new(root:, config_file:)
26
26
  end
27
27
 
28
+ # The asset manifest tracks the unique assets added to the project as they
29
+ # are added. The writer methods for this instanc evariable is used internally
30
+ # by Lifer when adding new assets.
31
+ #
32
+ # @return [Set<Lifer::Asset>]
33
+ def asset_manifest = (@asset_manifest ||= Set.new)
34
+
35
+ # Given the author manifest, this returns an array of all authors for the
36
+ # current project. This method is preferrable for accessing and querying for
37
+ # authors.
38
+ #
39
+ # @return [Array<Lifer::Author>]
40
+ def authors = author_manifest.to_a
41
+
42
+ # The author manifest tracks the unique authors added to the project as
43
+ # they are added. The writer method for this instance variable is used
44
+ # internally by Lifer when adding new authors.
45
+ #
46
+ # @return [Set<Lifer::Author>]
47
+ def author_manifest = (@author_manifest ||= Set.new)
48
+
28
49
  # Destroy any existing build output and then build the Lifer project with all
29
50
  # configured `Lifer::Builder`s.
30
51
  #
@@ -73,17 +94,12 @@ class Lifer::Brain
73
94
  # @return [Lifer::Config] The Lifer configuration object.
74
95
  def config = (@config ||= Lifer::Config.build file: config_file_location)
75
96
 
76
- # Returns all entries that have been added to the manifest. If all is working
77
- # as intended, this should be every entry ever generated.
97
+ # Allows for getting the entry manifest or shovelling new entries to the
98
+ # entry manifest.
78
99
  #
79
100
  # @return [Set<Lifer::Entry>] All entries that currently exist.
80
101
  def entry_manifest = (@entry_manifest ||= Set.new)
81
102
 
82
- # A manifest of all Lifer project entries.
83
- #
84
- # @return [Set<Lifer::Entry>] A set of all entries.
85
- def manifest = (@manifest ||= Set.new)
86
-
87
103
  # Returns the build directory for the Lifer project's build output.
88
104
  #
89
105
  # @return [String] The Lifer build directory.
@@ -18,7 +18,7 @@ class Lifer::Builder
18
18
  # should be expected to.
19
19
  #
20
20
  # @raise [NotImplementedError]
21
- def render
21
+ def render(_path, _locals, _context)
22
22
  raise NotImplementedError,
23
23
  "subclasses must implement a custom `#render` method"
24
24
  end
@@ -11,7 +11,7 @@ class Lifer::Builder::HTML
11
11
  # </head>
12
12
  #
13
13
  # <body>
14
- # <%= partial "_layouts/header.html.erb" %>
14
+ # <%= render "_layouts/header.html.erb" %>
15
15
  #
16
16
  # <h1><%= my_collection.name %></h1>
17
17
  #
@@ -23,7 +23,7 @@ class Lifer::Builder::HTML
23
23
  # </section>
24
24
  # <% end %>
25
25
  #
26
- # <%= partial "_layouts/footer.html.erb" %>
26
+ # <%= render "_layouts/footer.html.erb" %>
27
27
  # </body>
28
28
  # </html>
29
29
  #
@@ -101,16 +101,20 @@ class Lifer::Builder::HTML
101
101
  collections = collection_context_class.new Lifer.collections
102
102
  tags = tag_context_class.new Lifer.tags
103
103
 
104
- binding.local_variable_set :collections, collections
105
- binding.local_variable_set :settings, Lifer.settings
106
- binding.local_variable_set :tags, tags
107
- binding.local_variable_set :content,
108
- ERB.new(entry.to_html).result(binding)
104
+ current_binding = binding
105
+ current_binding.local_variable_set :collections, collections
106
+ current_binding.local_variable_set :settings, Lifer.settings
107
+ current_binding.local_variable_set :tags, tags
109
108
 
110
109
  define_singleton_method :render,
111
110
  -> (relative_path_to_template, locals = {}) {
112
- partial_render_method relative_path_to_template, locals
111
+ partial_render_method relative_path_to_template,
112
+ locals,
113
+ current_binding
113
114
  }
115
+
116
+ current_binding.local_variable_set :content,
117
+ ERB.new(entry.to_html).result(binding)
114
118
  }
115
119
  end
116
120
 
@@ -130,21 +134,23 @@ class Lifer::Builder::HTML
130
134
  # available from entry and layout templates.
131
135
  #
132
136
  # @example Usage
133
- # <%= partial "_layouts/my_partial.html.erb", id: "123" %>
137
+ # <%= render "_layouts/my_partial.html.erb", id: "123" %>
134
138
  # @param relative_path_to_template [String] The path, from the Lifer root,
135
139
  # to the partial layout file.
136
140
  # @param locals [Hash] Additional data that should be passed along for
137
141
  # rendering the partial.
142
+ # @param source_binding [Binding] A binding object carrying context
143
+ # required to render the current partial layout.
138
144
  # @return [String] The rendered partial document.
139
- def partial_render_method(relative_path_to_template, locals)
145
+ def partial_render_method(relative_path_to_template, locals, source_binding)
140
146
  template_path = File.join(Lifer.root, relative_path_to_template)
141
147
 
142
148
  partial_binding = binding.tap { |binding|
143
- context.local_variables.each do |variable|
149
+ source_binding.local_variables.each do |variable|
144
150
  next if variable == :content
145
151
 
146
152
  binding.local_variable_set variable,
147
- context.local_variable_get(variable)
153
+ source_binding.local_variable_get(variable)
148
154
  end
149
155
 
150
156
  locals.each do |key, value|
@@ -0,0 +1,33 @@
1
+ module Lifer::Builder::HTML::FromLiquid::Drops
2
+ class AuthorDrop < Liquid::Drop
3
+ attr_accessor :lifer_author
4
+
5
+ def initialize(lifer_author) = (@lifer_author = lifer_author)
6
+
7
+ def avatar = (@avatar ||= lifer_author.avatar)
8
+
9
+ def name = (@name ||= lifer_author.name)
10
+
11
+ def url = (@url ||= lifer_author.url)
12
+
13
+ def entries
14
+ @entries ||= lifer_author.entries.map {
15
+ EntryDrop.new _1, collection: _1.collection, tags: _1.tags
16
+ }
17
+ end
18
+ end
19
+
20
+ class AuthorsDrop < Liquid::Drop
21
+ attr_accessor :authors
22
+
23
+ def initialize(lifer_authors)
24
+ @authors = lifer_authors.map { AuthorDrop.new _1 }
25
+ end
26
+
27
+ def each(&block)
28
+ authors.each(&block)
29
+ end
30
+
31
+ def to_a = @authors
32
+ end
33
+ end
@@ -25,7 +25,9 @@ module Lifer::Builder::HTML::FromLiquid::Drops
25
25
  # The entry authors (or author).
26
26
  #
27
27
  # @return [String]
28
- def authors = (@authors ||= lifer_entry.authors.join(", "))
28
+ def authors
29
+ @authors ||= AuthorsDrop.new(lifer_entry.authors)
30
+ end
29
31
 
30
32
  # The entry content.
31
33
  #
@@ -8,6 +8,7 @@ class Lifer::Builder::HTML::FromLiquid
8
8
  module Drops; end
9
9
  end
10
10
 
11
+ require_relative "drops/authors_drop"
11
12
  require_relative "drops/collection_drop"
12
13
  require_relative "drops/collections_drop"
13
14
  require_relative "drops/entry_drop"
@@ -116,7 +116,7 @@ class Lifer::Builder::HTML < Lifer::Builder
116
116
  # entry cannot be output to HTML. We should not care about this return
117
117
  # value.
118
118
  def generate_output_file_for(entry)
119
- return unless entry.class.output_extension == :html
119
+ return unless entry.output_extension == :html
120
120
 
121
121
  relative_path = output_file entry
122
122
  absolute_path = File.join(Lifer.output_directory, relative_path)
@@ -0,0 +1,214 @@
1
+ require "fileutils"
2
+
3
+ class Lifer::Builder
4
+ # This builds JSON feed compliant with the JSON Feed 1.1
5
+ # specification[1]. Note that we don't currently support *all* of the features
6
+ # of JSON Feed, but it shouldn't be too hard to add them.
7
+ #
8
+ # The JSON Feed builder can be configured in a number of ways:
9
+ #
10
+ # 1. Boolean
11
+ #
12
+ # Simply set `json_feed: true` or `json_feed: false` to enable or disable
13
+ # a feed for a collection. If `true`, a JSON feed will be build to
14
+ # `/name-of-collection.json` at the root of the Lifer output directory.
15
+ #
16
+ # 2. Simple
17
+ #
18
+ # Simply set `json_feed: name-of-output-file.json` to specify the name of
19
+ # the output JSON Feed file.
20
+ #
21
+ # 3. Fine-grained
22
+ #
23
+ # Provide an object under `json_feed:` for more fine-grained control over
24
+ # configuration. The following sub-settings are supported:
25
+ #
26
+ # - `authors:` A list of authors to fall back to if an entry does not
27
+ # have its own authors data. Each author can include the following fields:
28
+ # - `name:` The author's name.
29
+ # - `url:` A URL that represents the author.
30
+ # - `avatar:` The URL to an avatar that represents the author.
31
+ # - `content_format:` The format of the feed entries for the entire feed.
32
+ # Either `html` or `text`. (Default: `html`.)
33
+ # - `count:` - The limit of JSON Feed items that should be included in the
34
+ # output document. Leave unset or set to `0` to include all entries.
35
+ # - `expired:` Set the expired flag on the feed to broadcast whether
36
+ # the feed will continue to be updated.
37
+ # - `home_page_url:` A URL that represents the home page of the current
38
+ # feed.
39
+ # - `url:` The path to the filename of the output JSON Feed file.
40
+ #
41
+ # [1] https://www.jsonfeed.org/version/1.1/
42
+ #
43
+ class JSONFeed < Lifer::Builder
44
+ # As of this writing, we support the latest version of the JSON Feed
45
+ # specification.
46
+ #
47
+ JSON_FEED_VERSION = "1.1"
48
+
49
+ self.name = :json_feed
50
+ self.settings = [
51
+ json_feed: [
52
+ {authors: [:name, :url, :avatar]},
53
+ :content_format,
54
+ :count,
55
+ :expired,
56
+ :home_page_url,
57
+ :url
58
+ ]
59
+ ]
60
+
61
+ class << self
62
+ # Traverses and renders a JSON Feed for each JSON Feed-enabled, feedable
63
+ # collection in the configured output directory for the Lifer project.
64
+ #
65
+ # @param root [String] The Lifer root.
66
+ # @return [void]
67
+ def execute(root:)
68
+ Dir.chdir Lifer.output_directory do
69
+ new(root:).execute
70
+ end
71
+ end
72
+ end
73
+
74
+ # Traverses and renders a JSON Feed for JSON Feed-enabled feedable
75
+ # collections.
76
+ #
77
+ # @return [void]
78
+ def execute
79
+ collections_with_feeds.each do |collection|
80
+ next unless (filename = output_filename(collection))
81
+
82
+ FileUtils.mkdir_p File.dirname(filename)
83
+
84
+ File.open filename, "w" do |file|
85
+ file.puts(
86
+ json_feed_for(collection) do |current_feed|
87
+ max_index = max_feed_items(collection) - 1
88
+
89
+ collection.entries
90
+ .select { |entry| entry.feedable? }[0..max_index]
91
+ .each { |entry| json_feed_entry(current_feed, entry, collection) }
92
+ end
93
+ )
94
+ end
95
+ end
96
+ end
97
+
98
+ private
99
+
100
+ attr_reader :collections_with_feeds, :root
101
+
102
+ def initialize(root:)
103
+ @collections_with_feeds =
104
+ Lifer.collections.select { |collection| collection.setting :json_feed }
105
+ @root = root
106
+ end
107
+
108
+ # Builds an entry in the JSON Feed given the current JSON Feed being
109
+ # built, a Lifer entry, and a Lifer collection.
110
+ #
111
+ # @param feed_object [Hash] JSON Feed currently being built.
112
+ # @param lifer_entry [Lifer::Entry] The Lifer entry to build an entry for.
113
+ # @param lifer_collection [Lifer::Collection] The Lifer collection for
114
+ # the entry. This provides additional context that may be required
115
+ # for some parts of JSON Feed entry schema.
116
+ # @return [void] The entry is added to the JSON Feed as a side effect
117
+ # by the end of this procedure.
118
+ def json_feed_entry(feed_object, lifer_entry, lifer_collection)
119
+ feed_item_object = {
120
+ id: lifer_entry.permalink,
121
+ url: lifer_entry.permalink,
122
+ external_url: lifer_entry.frontmatter[:external_url],
123
+ title: lifer_entry.title,
124
+ summary: lifer_entry.summary,
125
+ image: lifer_entry.assets.detect { |asset|
126
+ asset.match?(
127
+ url: lifer_entry.frontmatter[:image] ||
128
+ lifer_entry.frontmatter[:images]&.first
129
+ )
130
+ }&.url,
131
+ banner_image: lifer_entry.assets.detect { |asset|
132
+ asset.match? url: lifer_entry.frontmatter[:banner_image]
133
+ }&.url,
134
+ date_published: lifer_entry.published_at,
135
+ date_modified: lifer_entry.updated_at(fallback: lifer_entry.published_at),
136
+ tags: lifer_entry.tags,
137
+ language: lifer_collection.setting(:language)
138
+ }
139
+
140
+ feed_content_format =
141
+ lifer_collection.setting(:json_feed, :content_format)&.to_sym || :html
142
+ case feed_content_format
143
+ when :html
144
+ feed_item_object[:content_html] = lifer_entry.to_html
145
+ when :text
146
+ # Currently, `Entry#to_html` is just the name of the method we use
147
+ # to get the entry content, regardless of whether the entry output is
148
+ # HTML or not.
149
+ feed_item_object[:content_text] = lifer_entry.to_html
150
+ end
151
+
152
+ if (authors = lifer_entry.authors).any?
153
+ feed_item_object[:authors] = authors.map { |author|
154
+ {
155
+ name: author.name,
156
+ avatar: author.avatar,
157
+ url: author.url
158
+ }.reject { |_key, value| value.nil? }
159
+ }
160
+ end
161
+
162
+ feed_object[:items] << feed_item_object
163
+ end
164
+
165
+ # Provides the entire feed object for serialization.
166
+ #
167
+ # Note that we don't currently support JSON Feed 1.1 *exhaustively*. For
168
+ # example, we don't currently support pagniation or hubs. Here's a list
169
+ # of root-level 1.1 fields we do not currently support:
170
+ #
171
+ # - authors
172
+ # - favicon
173
+ # - hubs
174
+ # - icon
175
+ # - next_url
176
+ # - user_comment
177
+ #
178
+ # @param collection [Lifer::Collection] The current Lifer collection for
179
+ # metadata context.
180
+ # @return [String] The JSON representation of the JSON Feed.
181
+ def json_feed_for(collection, &block)
182
+ feed_object = {
183
+ version: JSON_FEED_VERSION,
184
+ title: collection.setting(:title),
185
+ description: collection.setting(:description) || collection.setting(:site_title),
186
+ expired: collection.setting(:json_feed, :expired, strict: true),
187
+ feed_url: collection.setting(:json_feed, :url, strict: true),
188
+ home_page_url: collection.setting(:json_feed, :home_page_url, strict: true),
189
+ language: collection.setting(:language),
190
+ items: []
191
+ }
192
+ feed_object = feed_object.reject { |_key, value| value.nil? }
193
+
194
+ yield feed_object
195
+
196
+ feed_object.to_json
197
+ end
198
+
199
+ def max_feed_items(collection) = collection.setting(:json_feed, :count) || 0
200
+
201
+ def output_filename(collection)
202
+ strict = !collection.root?
203
+
204
+ case collection.setting(:json_feed, strict:)
205
+ when FalseClass, NilClass then nil
206
+ when TrueClass then File.join(Dir.pwd, "#{collection.name}.json")
207
+ when Hash
208
+ File.join Dir.pwd, collection.setting(:json_feed, :url, strict:)
209
+ when String
210
+ File.join Dir.pwd, collection.setting(:json_feed, strict:)
211
+ end
212
+ end
213
+ end
214
+ end
@@ -15,7 +15,8 @@ require "rss"
15
15
  # 1. Boolean
16
16
  #
17
17
  # Simply set `rss: true` or `rss: false` to enable or disable a feed for a
18
- # collection. If `true`, an RSS feed will be built to `name-of-collection.xml`
18
+ # collection. If `true`, an RSS feed will be built to
19
+ # `/name-of-collection.xml` at the root of the Lifer output directory.
19
20
  #
20
21
  # 2. Simple
21
22
  #
@@ -78,8 +79,8 @@ class Lifer::Builder::RSS < Lifer::Builder
78
79
  ]
79
80
 
80
81
  class << self
81
- # Traverses and renders an RSS feed for each feedable collection in the
82
- # configured output directory for the Lifer project.
82
+ # Traverses and renders an RSS feed for each RSS-enabled feedable
83
+ # collection in the configured output directory for the Lifer project.
83
84
  #
84
85
  # @param root [String] The Lifer root.
85
86
  # @return [void]
@@ -98,7 +99,7 @@ class Lifer::Builder::RSS < Lifer::Builder
98
99
  DEFAULT_MAKER_FORMAT_NAME
99
100
  end
100
101
 
101
- # Traverses and renders an RSS feed for feedable collection.
102
+ # Traverses and renders an RSS feed for RSS-enabled feedable collections.
102
103
  #
103
104
  # @return [void]
104
105
  def execute
@@ -56,7 +56,7 @@ class Lifer::Builder::TXT < Lifer::Builder
56
56
  end
57
57
 
58
58
  def generate_output_file_for(entry)
59
- return unless entry.class.output_extension == :txt
59
+ return unless entry.output_extension == :txt
60
60
 
61
61
  relative_path = output_file entry
62
62
  absolute_path = File.join(Lifer.output_directory, relative_path)
data/lib/lifer/builder.rb CHANGED
@@ -101,4 +101,5 @@ end
101
101
 
102
102
  require_relative "builder/rss"
103
103
  require_relative "builder/html"
104
+ require_relative "builder/json_feed"
104
105
  require_relative "builder/txt"
@@ -32,12 +32,13 @@ class Lifer::Collection
32
32
  .select { |candidate| Lifer::Entry.supported? candidate }
33
33
 
34
34
  entries = entry_glob.select { |entry|
35
- if Lifer.manifest.include? entry
35
+ filenames = Lifer.entry_manifest.map(&:file)
36
+
37
+ if filenames.include? entry
36
38
  false
37
39
  elsif Lifer.ignoreable? entry.gsub("#{directory}/", "")
38
40
  false
39
41
  else
40
- Lifer.manifest << entry
41
42
  true
42
43
  end
43
44
  }.map { |entry| Lifer::Entry.generate file: entry, collection: }
@@ -89,6 +90,8 @@ class Lifer::Collection
89
90
  #
90
91
  # @param name [*Symbol] A list of symbols that map to a nested Lifer
91
92
  # setting (for the current collection).
93
+ # @param strict [boolean] Choose whether to strictly return the collection
94
+ # setting or to fallback to the Lifer root and default settings.
92
95
  # @return [String, Nil] The setting as set in the Lifer project's
93
96
  # configuration file.
94
97
  def setting(*name, strict: false)
data/lib/lifer/config.rb CHANGED
@@ -1,5 +1,3 @@
1
- require_relative "utilities"
2
-
3
1
  # This class is responsible for reading the Lifer configuration YAML file. This
4
2
  # file should provided by the user, but the Lifer Ruby gem does provide a default
5
3
  # file, as well.
@@ -2,8 +2,6 @@ require "date"
2
2
  require "kramdown"
3
3
  require "time"
4
4
 
5
- require_relative "../utilities"
6
-
7
5
  # We should initialize each Markdown file in a Lifer project as a
8
6
  # `Lifer::Entry::Markdown` object. This class contains convenience methods for
9
7
  # parsing a Markdown file with frontmatter as a weblog post or article. Of
data/lib/lifer/entry.rb CHANGED
@@ -29,6 +29,11 @@ module Lifer
29
29
  require_relative "entry/markdown"
30
30
  require_relative "entry/txt"
31
31
 
32
+ # If assets are represented in YAML frontmatter as a string, they're split on
33
+ # commas and/or spaces.
34
+ #
35
+ ASSET_DELIMITER_REGEX = /[,\s]+/
36
+
32
37
  # We provide a default date for entries that have no date and entry types that
33
38
  # otherwise could not have a date due to no real way of getting that metadata.
34
39
  #
@@ -58,12 +63,15 @@ module Lifer
58
63
  # @param file [String] The absolute filename of an entry file.
59
64
  # @param collection [Lifer::Collection] The collection for the entry.
60
65
  # @return [Lifer::Entry] An entry.
61
- def generate(file:, collection:)
66
+ def generate(file:, collection:, dependencies: [:assets, :authors, :tags])
62
67
  error!(file) unless File.exist?(file)
63
68
 
64
69
  if (new_entry = subclass_for(file)&.new(file:, collection:))
65
70
  Lifer.entry_manifest << new_entry
66
- new_entry.tags
71
+
72
+ dependencies.each do |dependency|
73
+ new_entry.public_send dependency
74
+ end
67
75
  end
68
76
 
69
77
  new_entry
@@ -124,6 +132,14 @@ module Lifer
124
132
  @collection = collection
125
133
  end
126
134
 
135
+ # Locates and returns all assets defined in the entry.
136
+ #
137
+ # @return [Array<Lifer::Asset>] The entry's assets.
138
+ def assets
139
+ @assets ||= candidate_asset_names
140
+ .map { Lifer::Asset.build_or_update(url: _1, entries: [self]) }
141
+ end
142
+
127
143
  # Given the entry's frontmatter, we should be able to get a list of authors.
128
144
  # We always prefer authors (as opposed to a singular author) because it makes
129
145
  # handling both cases easier in the long run.
@@ -133,7 +149,20 @@ module Lifer
133
149
  #
134
150
  # @return [Array<String>] An array of authors's names.
135
151
  def authors
136
- Array(frontmatter[:author] || frontmatter[:authors]).compact
152
+ list = Array(frontmatter[:author] || frontmatter[:authors]).compact
153
+ if list.any? && list.all? { _1.is_a? Array }
154
+ list = [list.to_h]
155
+ end
156
+
157
+ list.map {
158
+ attributes = Lifer::Utilities.symbolize_keys(
159
+ case _1
160
+ when Hash then _1
161
+ when String then {name: _1}
162
+ end
163
+ )
164
+ Lifer::Author.build_or_update **attributes.merge(entries: [self])
165
+ }
137
166
  end
138
167
 
139
168
  # This method returns the full text of the entry, only removing the
@@ -176,6 +205,12 @@ module Lifer
176
205
  @full_text ||= File.readlines(file).join if file
177
206
  end
178
207
 
208
+ # The file extension to be used when at the entry's build time. For
209
+ # example, a Markdown file should be built to HTML.
210
+ #
211
+ # @return [String]
212
+ def output_extension = self.class.output_extension
213
+
179
214
  # Using the current Lifer configuration, we can calculate the expected
180
215
  # permalink for the entry. For example:
181
216
  #
@@ -284,6 +319,21 @@ module Lifer
284
319
 
285
320
  private
286
321
 
322
+ # Similar to tags, users may represent assets as a string or a list of
323
+ # strings instead of YAML-style arrays. So let's parse for all of them.
324
+ #
325
+ # @return [Array<String>] An array of candidate asset URLs.
326
+ def candidate_asset_names
327
+ candidate_frontmatter_fields = [:banner_image, :image, :images]
328
+ candidate_frontmatter_fields.flat_map {
329
+ case frontmatter[_1]
330
+ when Array then frontmatter[_1].map(&:to_s)
331
+ when String then frontmatter[_1].split(ASSET_DELIMITER_REGEX)
332
+ else []
333
+ end.uniq
334
+ }.compact
335
+ end
336
+
287
337
  # It is conventional for users to use spaces or commas to delimit tags in
288
338
  # other systems, so let's support that. But let's also support YAML-style
289
339
  # arrays.
@@ -6,7 +6,7 @@
6
6
  # detected Ruby files are dynamically loaded when `Lifer::Brain` is initialized.
7
7
  #
8
8
  # Implementing a selection is simple. Just implement the `#entries` method and
9
- # rovide a name. The `#entries` method can be used to filter down
9
+ # provide a name. The `#entries` method can be used to filter down
10
10
  # `Lifer.entry_manifest` in whichever way one needs. To see examples of this,
11
11
  # check out the source code of any of the included selections.
12
12
  #
@@ -25,7 +25,9 @@ class Lifer::Selection < Lifer::Collection
25
25
  #
26
26
  # @return [Lifer::Selection]
27
27
  def generate
28
- new(name: name, directory: nil)
28
+ selection = new(name: name, directory: nil)
29
+ Lifer.register_settings name
30
+ selection
29
31
  end
30
32
 
31
33
  private
data/lib/lifer/tag.rb CHANGED
@@ -43,11 +43,22 @@ module Lifer
43
43
 
44
44
  attr_accessor :name
45
45
 
46
- attr_reader :entries
47
-
48
46
  def initialize(name:, entries:)
49
47
  @name = name
50
48
  @entries = entries
51
49
  end
50
+
51
+ # Returns the tag's associated entries in order.
52
+ #
53
+ # @param order [Symbol] Either :latest (descending) or :oldest (ascending).
54
+ # @return [Array<Lifer::Entry>] The entries for the current tag.
55
+ def entries(order: :latest)
56
+ case order
57
+ when :latest
58
+ @entries.sort_by { |entry| entry.published_at }.reverse
59
+ when :oldest
60
+ @entries.sort_by { |entry| entry.published_at }
61
+ end
62
+ end
52
63
  end
53
64
  end
@@ -8,6 +8,7 @@ author: Admin
8
8
  entries:
9
9
  default_title: Untitled Entry
10
10
 
11
+ json_feed: false
11
12
  rss: false
12
13
  uri_strategy: simple
13
14
 
@@ -33,12 +34,24 @@ uri_strategy: simple
33
34
  ###
34
35
  ### layout_file: path/to/my_other_layout.html.erb
35
36
  ###
36
- ### my_with_fine_grained_rss_settings:
37
- ### rss
37
+ ### my_collection_with_fine_grained_rss_settings:
38
+ ### json_feed:
39
+ ### authors:
40
+ ### - name: Hal Human
41
+ ### avatar: /images/avatars/hal.png
42
+ ### url: https://hals-website.local
43
+ ### content_format: html
44
+ ### count: 99
45
+ ### expired: false
46
+ ### home_page_url: https://example.com/my-collection
47
+ ### url: /location-of-the-feed.json
48
+ ###
49
+ ### rss:
38
50
  ### count: 99
39
51
  ### format: rss
40
52
  ### managing_editor: editor@example.com (Managing Editor)
41
53
  ### url: custom.xml
54
+ ###
42
55
 
43
56
  # Selections
44
57
  #
@@ -79,6 +92,7 @@ global:
79
92
  build:
80
93
  - html
81
94
  - rss
95
+ - json_feed
82
96
  - txt
83
97
  host: https://example.com
84
98
  output_directory: _build
@@ -59,7 +59,7 @@ class Lifer::URIStrategy
59
59
 
60
60
  private
61
61
 
62
- def file_extension(entry) = entry.class.output_extension
62
+ def file_extension(entry) = entry.output_extension
63
63
 
64
64
  self.name = :uri_strategy
65
65
  end
@@ -4,6 +4,8 @@ require "parallel"
4
4
  # Ensure that these are actually useful globally, though. :-)
5
5
  #
6
6
  module Lifer::Utilities
7
+ class AmbiguousURIError < StandardError; end
8
+
7
9
  class << self
8
10
  # Output a string using bold escape sequences to the output TTY text.
9
11
  #
@@ -129,6 +131,23 @@ module Lifer::Utilities
129
131
  symbolized_hash
130
132
  end
131
133
 
134
+ def uri_from(string, host:, object_type:)
135
+ uri = string && URI.parse(string.strip)
136
+
137
+ if uri && uri.relative? && uri.to_s.start_with?("/")
138
+ "%s%s" % [host, uri]
139
+ elsif uri && uri.relative? && !uri.to_s.start_with?("/")
140
+ raise AmbiguousURIError
141
+ elsif uri&.absolute?
142
+ uri.to_s
143
+ end
144
+ rescue AmbiguousURIError
145
+ Lifer::Message.error "utilities.ambiguous_uri_error",
146
+ object_type: object_type.inspect,
147
+ uri: uri&.to_s
148
+ nil
149
+ end
150
+
132
151
  private
133
152
 
134
153
  def camelize(string)
data/lib/lifer/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Lifer
2
- VERSION = "0.12.4"
2
+ VERSION = "0.14.0"
3
3
  end
data/lib/lifer.rb CHANGED
@@ -29,6 +29,22 @@ module Lifer
29
29
  FRONTMATTER_REGEX = /^---\n(.*?)---\n/m
30
30
 
31
31
  class << self
32
+ # All of the assets represented in Lifer entries for the current project.
33
+ #
34
+ # @return [Array<Lifer::Asset>] The complete list of assets.
35
+ def asset_manifest = brain.asset_manifest
36
+
37
+ # All of the authors represented in Lifer entries for the current project.
38
+ #
39
+ # @return [Array<Lifer::Author>] The complete list of authors.
40
+ def authors = brain.authors
41
+
42
+ # A set of all authors added to the project. Prefer using the `#authors`
43
+ # method for author queries.
44
+ #
45
+ # @return [Set<Lifer::Author>] The complete set of author.
46
+ def author_manifest = brain.author_manifest
47
+
32
48
  # The first time `Lifer.brain` is referenced, we build a new `Lifer::Brain`
33
49
  # object that is used and reused until the current process has ended.
34
50
  #
@@ -65,9 +81,21 @@ module Lifer
65
81
  # @return [Pathname] The path to the current Lifer config file.
66
82
  def config_file = brain.config.file
67
83
 
68
- # A set of all entries currently in the project.
69
- #
70
- # @fixme Do we need this as well as `Lifer.manifest`?
84
+ # Uses the entry manifest to return entries in the specified order.
85
+ #
86
+ # @param order [Symbol] Either :latest (descending) or :oldest (ascending).
87
+ # @return [Set] All entries in the specified order.
88
+ def entries(order: :latest)
89
+ case order
90
+ when :latest
91
+ entry_manifest.sort_by { |entry| entry.published_at }.reverse
92
+ when :oldest
93
+ entry_manifest.sort_by { |entry| entry.published_at }
94
+ end
95
+ end
96
+
97
+ # Allows for getting the entry manifest or shovelling new entries to the
98
+ # entry manifest.
71
99
  #
72
100
  # @return [Set] All entries.
73
101
  def entry_manifest = brain.entry_manifest
@@ -87,13 +115,6 @@ module Lifer
87
115
  directory_or_file.match?(/#{IGNORE_PATTERNS.join("|")}/)
88
116
  end
89
117
 
90
- # A set of all entries currently in the project.
91
- #
92
- # @fixme Do we need this as well as `Lifer.manifest`?
93
- #
94
- # @return [Set] All entries.
95
- def manifest = brain.manifest
96
-
97
118
  # The build directory for the Lifer project.
98
119
  #
99
120
  # @return [Pathname] The absolute path to the directory where the Lifer
@@ -173,11 +194,14 @@ require "i18n"
173
194
  I18n.load_path += Dir["%s/locales/*.yml" % Lifer.gem_root]
174
195
  I18n.available_locales = [:en]
175
196
 
176
- # `Lifer::Shared` contains modules that that may or may not be included on other
177
- # classes required below.
197
+ # `Lifer::Shared` and `Lifer::Utilities` contain modules and methods that
198
+ # are used by the main models required below.
178
199
  #
179
200
  require_relative "lifer/shared"
201
+ require_relative "lifer/utilities"
180
202
 
203
+ require_relative "lifer/asset"
204
+ require_relative "lifer/author"
181
205
  require_relative "lifer/brain"
182
206
  require_relative "lifer/builder"
183
207
  require_relative "lifer/collection"
data/locales/en.yml CHANGED
@@ -49,5 +49,6 @@ en:
49
49
  finder_methods:
50
50
  unknown_class: no class with name "%{name}"
51
51
  utilities:
52
+ ambiguous_uri_error: "An %{object_type} contains an ambiguous URI: \"%{uri}\". Use an absolute URI or relative from the project root instead."
52
53
  classify_error: >
53
54
  could not find constant for path "%{string_constant}" (%{camel_cased_string_constant})
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lifer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.4
4
+ version: 0.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - benjamin wil
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-06-17 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: base64
@@ -168,7 +169,10 @@ files:
168
169
  - bin/console
169
170
  - bin/lifer
170
171
  - bin/setup
172
+ - guix.scm
171
173
  - lib/lifer.rb
174
+ - lib/lifer/asset.rb
175
+ - lib/lifer/author.rb
172
176
  - lib/lifer/brain.rb
173
177
  - lib/lifer/builder.rb
174
178
  - lib/lifer/builder/html.rb
@@ -176,6 +180,7 @@ files:
176
180
  - lib/lifer/builder/html/from_erb.rb
177
181
  - lib/lifer/builder/html/from_liquid.rb
178
182
  - lib/lifer/builder/html/from_liquid/drops.rb
183
+ - lib/lifer/builder/html/from_liquid/drops/authors_drop.rb
179
184
  - lib/lifer/builder/html/from_liquid/drops/collection_drop.rb
180
185
  - lib/lifer/builder/html/from_liquid/drops/collections_drop.rb
181
186
  - lib/lifer/builder/html/from_liquid/drops/entry_drop.rb
@@ -185,6 +190,7 @@ files:
185
190
  - lib/lifer/builder/html/from_liquid/drops/tags_drop.rb
186
191
  - lib/lifer/builder/html/from_liquid/filters.rb
187
192
  - lib/lifer/builder/html/from_liquid/liquid_env.rb
193
+ - lib/lifer/builder/json_feed.rb
188
194
  - lib/lifer/builder/rss.rb
189
195
  - lib/lifer/builder/txt.rb
190
196
  - lib/lifer/cli.rb
@@ -223,9 +229,10 @@ licenses:
223
229
  - MIT
224
230
  metadata:
225
231
  allowed_push_host: https://rubygems.org
226
- homepage_uri: https://github.com/benjaminwil/lifer/blob/v0.12.4/README.md
227
- source_code_uri: https://github.com/benjaminwil/lifer/tree/v0.12.4
232
+ homepage_uri: https://github.com/benjaminwil/lifer/blob/v0.14.0/README.md
233
+ source_code_uri: https://github.com/benjaminwil/lifer/tree/v0.14.0
228
234
  changelog_uri: https://github.com/benjaminwil/lifer/blob/main/CHANGELOG.md
235
+ post_install_message:
229
236
  rdoc_options: []
230
237
  require_paths:
231
238
  - lib
@@ -240,7 +247,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
240
247
  - !ruby/object:Gem::Version
241
248
  version: '0'
242
249
  requirements: []
243
- rubygems_version: 3.6.9
250
+ rubygems_version: 3.5.22
251
+ signing_key:
244
252
  specification_version: 4
245
253
  summary: Minimal static weblog generator.
246
254
  test_files: []