diff --git a/lib/perron/site/builder/feeds/atom.erb b/lib/perron/site/builder/feeds/atom.erb new file mode 100644 index 0000000..66e7c65 --- /dev/null +++ b/lib/perron/site/builder/feeds/atom.erb @@ -0,0 +1,44 @@ + + + Perron + <%= current_feed_url %> + <%= config.title.presence || @configuration.site_name %> + <%= config.description.presence || @configuration.site_description %> + + + <%= resources.first&.published_at&.iso8601 || Time.current.iso8601 %> + + <% feed_author = config.author || { name: @configuration.site_name, email: "noreply@#{URI.parse(@configuration.url).host}" } %> + + <% if feed_author[:name] %><%= feed_author[:name] %><% end %> + <% if feed_author[:email] %><%= feed_author[:email] %><% end %> + + + <% resources.each do |resource| %> + + <%= url_for_resource(resource) || "#{@configuration.url}/posts/#{resource.id}" %> + <%= resource.metadata.title %> + + <%= resource.published_at&.iso8601 %> + <%= (resource.metadata.updated_at || resource.published_at)&.iso8601 %> + + <% entry_author = author(resource); if entry_author %> + + <% if entry_author.name %><%= entry_author.name %><% end %> + <% if entry_author.email %><%= entry_author.email %><% end %> + + <% end %> + + <% base_url = url_for_resource(resource) %> + <% if base_url %> + ]]> + <% else %> + ]]> + <% end %> + + <% resource.metadata.tags&.each do |tag| %> + + <% end %> + + <% end %> + diff --git a/lib/perron/site/builder/feeds/atom.rb b/lib/perron/site/builder/feeds/atom.rb index 3b5758e..332ba58 100644 --- a/lib/perron/site/builder/feeds/atom.rb +++ b/lib/perron/site/builder/feeds/atom.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -require "nokogiri" require "perron/site/builder/feeds/author" +require "perron/site/builder/feeds/template" module Perron module Site @@ -9,6 +9,7 @@ class Builder class Feeds class Atom include Feeds::Author + include Feeds::Template def initialize(collection:) @collection = collection @@ -18,58 +19,10 @@ def initialize(collection:) def generate return if resources.empty? - Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml| - xml.feed(xmlns: "http://www.w3.org/2005/Atom") do - xml.generator "Perron", uri: @configuration.url, version: Perron::VERSION - xml.id current_feed_url - xml.title feed_configuration.title.presence || @configuration.site_name - xml.subtitle feed_configuration.description.presence || @configuration.site_description - xml.link href: current_feed_url, rel: "self", type: "application/atom+xml" - xml.link href: @configuration.url, rel: "alternate", type: "text/html" - xml.updated resources.first&.published_at&.iso8601 || Time.current.iso8601 + template = find_template("atom") + return unless template - feed_author = feed_configuration.author || { - name: @configuration.site_name, - email: "noreply@#{URI.parse(@configuration.url).host}" - } - - xml.author do - xml.name feed_author[:name] if feed_author[:name] - xml.email feed_author[:email] if feed_author[:email] - end - - resources.each do |resource| - xml.entry do - xml.title resource.metadata.title - xml.link href: url_for_resource(resource), rel: "alternate", type: "text/html" - xml.published resource.published_at&.iso8601 - xml.updated (resource.metadata.updated_at || resource.published_at)&.iso8601 - xml.id url_for_resource(resource) || "#{@configuration.url}/posts/#{resource.id}" - - if (entry_author = author(resource)) - xml.author do - xml.name entry_author.name if entry_author.name - xml.email entry_author.email if entry_author.email - end - end - - if (base_url = url_for_resource(resource)) - xml.content :type => "html", "xml:base" => base_url do - xml.cdata(Perron::Markdown.render(resource.content)) - end - else - xml.content type: "html" do - xml.cdata(Perron::Markdown.render(resource.content)) - end - end - - resource.metadata.tags&.each do |tag| - xml.category term: tag - end - end - end - end - end.to_xml + render(template, feed_configuration) end private @@ -81,24 +34,6 @@ def resources .reverse .take(feed_configuration.max_items) end - - def url_for_resource(resource) - routes - .polymorphic_url(resource, **@configuration.default_url_options.merge(ref: feed_configuration.ref)) - .delete_suffix("?ref=") - rescue - nil - end - - def current_feed_url - path = feed_configuration.path || "feed.atom" - - URI.join(@configuration.url, path).to_s - end - - def feed_configuration = @collection.configuration.feeds.atom - - def routes = Rails.application.routes.url_helpers end end end diff --git a/lib/perron/site/builder/feeds/json.erb b/lib/perron/site/builder/feeds/json.erb new file mode 100644 index 0000000..be8dac8 --- /dev/null +++ b/lib/perron/site/builder/feeds/json.erb @@ -0,0 +1,19 @@ +<%= { + generator: "Perron (#{Perron::VERSION})", + version: "https://jsonfeed.org/version/1.1", + home_page_url: @configuration.url, + title: config.title.presence || @configuration.site_name, + description: config.description.presence || @configuration.site_description, + + items: resources.map { |resource| + item_author = author.call(resource) + { + id: resource.id, + url: url_for_resource.call(resource), + date_published: resource.published_at&.iso8601, + title: resource.metadata.title, + authors: (item_author && item_author.name ? [{ name: item_author.name, email: item_author.email, url: item_author.url, avatar: item_author.avatar }.compact] : nil), + content_html: Perron::Markdown.render(resource.content) + }.compact + } +}.to_json %> diff --git a/lib/perron/site/builder/feeds/json.rb b/lib/perron/site/builder/feeds/json.rb index be12f39..7f35155 100644 --- a/lib/perron/site/builder/feeds/json.rb +++ b/lib/perron/site/builder/feeds/json.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -require "json" require "perron/site/builder/feeds/author" +require "perron/site/builder/feeds/template" module Perron module Site @@ -9,6 +9,7 @@ class Builder class Feeds class Json include Feeds::Author + include Feeds::Template def initialize(collection:) @collection = collection @@ -16,60 +17,23 @@ def initialize(collection:) end def generate - return nil if resources.empty? + return if resources.empty? - hash = { - generator: "Perron (#{Perron::VERSION})", - version: "https://jsonfeed.org/version/1.1", - home_page_url: @configuration.url, - title: feed_configuration.title.presence || @configuration.site_name, - description: feed_configuration.description.presence || @configuration.site_description, - items: resources.filter_map { jsonify(it) } - } + template = find_template("json") + return unless template - JSON.pretty_generate hash + render(template, feed_configuration) end private def resources - @resources ||= @collection.resources + @resource ||= @collection.resources .reject { it.metadata.feed == false } .sort_by { it.metadata.published_at || it.metadata.updated_at || Time.current } .reverse .take(feed_configuration.max_items) end - - def jsonify(resource) - { - id: resource.id, - url: url_for_resource(resource), - date_published: resource.published_at&.iso8601, - authors: authors(resource), - title: resource.metadata.title, - content_html: Perron::Markdown.render(resource.content) - }.compact - end - - def url_for_resource(resource) - routes - .polymorphic_url(resource, **@configuration.default_url_options.merge(ref: feed_configuration.ref)) - .delete_suffix("?ref=") - rescue - nil - end - - def authors(resource) - author = author(resource) - - return nil unless author&.name - - [{name: author.name, email: author.email, url: author.url, avatar: author.avatar}.compact].presence - end - - def feed_configuration = @collection.configuration.feeds.json - - def routes = Rails.application.routes.url_helpers end end end diff --git a/lib/perron/site/builder/feeds/rss.erb b/lib/perron/site/builder/feeds/rss.erb new file mode 100644 index 0000000..1cdec1f --- /dev/null +++ b/lib/perron/site/builder/feeds/rss.erb @@ -0,0 +1,28 @@ + + + + Perron (<%= Perron::VERSION %>) + <%= @configuration.url %> + <%= config.title.presence || @configuration.site_name %> + <%= config.description.presence || @configuration.site_description %> + + <% resources.each do |resource| %> + + <%= resource.id %> + + <% resource_url = url_for_resource(resource) %> + <% if resource_url %> + <%= resource_url %> + <% end %> + + <%= resource.published_at&.rfc822 %> + <% author = author(resource); if author && author.email %> + <%= author.name ? "#{author.email} (#{author.name})" : author.email %> + <% end %> + <%= resource.metadata.title %> + + ]]> + + <% end %> + + diff --git a/lib/perron/site/builder/feeds/rss.rb b/lib/perron/site/builder/feeds/rss.rb index 78bf199..7225d8d 100644 --- a/lib/perron/site/builder/feeds/rss.rb +++ b/lib/perron/site/builder/feeds/rss.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -require "nokogiri" require "perron/site/builder/feeds/author" +require "perron/site/builder/feeds/template" module Perron module Site @@ -9,6 +9,7 @@ class Builder class Feeds class Rss include Feeds::Author + include Feeds::Template def initialize(collection:) @collection = collection @@ -18,35 +19,10 @@ def initialize(collection:) def generate return if resources.empty? - Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml| - xml.rss(:version => "2.0", "xmlns:atom" => "http://www.w3.org/2005/Atom") do - xml.channel do - xml.generator "Perron (#{Perron::VERSION})" - xml.title feed_configuration.title.presence || @configuration.site_name - xml.description feed_configuration.description.presence || @configuration.site_description - xml.link @configuration.url + template = find_template("rss") + return unless template - resources.each do |resource| - xml.item do - xml.guid resource.id, isPermaLink: false - - if (resource_url = url_for_resource(resource)) - xml.link resource_url - end - - xml.pubDate(resource.published_at&.rfc822) - - if (author = author(resource)) && author.email - xml.author author.name ? "#{author.email} (#{author.name})" : author.email - end - - xml.title resource.metadata.title - xml.description { xml.cdata(Perron::Markdown.render(resource.content)) } - end - end - end - end - end.to_xml + render(template, feed_configuration) end private @@ -58,18 +34,6 @@ def resources .reverse .take(feed_configuration.max_items) end - - def url_for_resource(resource) - routes - .polymorphic_url(resource, **@configuration.default_url_options.merge(ref: feed_configuration.ref)) - .delete_suffix("?ref=") - rescue - nil - end - - def feed_configuration = @collection.configuration.feeds.rss - - def routes = Rails.application.routes.url_helpers end end end diff --git a/lib/perron/site/builder/feeds/template.rb b/lib/perron/site/builder/feeds/template.rb new file mode 100644 index 0000000..6497b67 --- /dev/null +++ b/lib/perron/site/builder/feeds/template.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Perron + module Site + class Builder + class Feeds + module Template + def find_template(type) + collection_name = @collection.name.to_s.pluralize + + user_path = Rails.root.join("app/views/content/#{collection_name}/#{type}.erb") + return user_path if File.exist?(user_path) + + default_path = Pathname.new(__dir__).join("#{type}.erb") + return default_path if File.exist?(default_path) + + nil + end + + def render(template_path, feed_config) + template = File.read(template_path) + b = binding + + b.local_variable_set(:collection, @collection) + b.local_variable_set(:resources, resources) + b.local_variable_set(:config, feed_config) + b.local_variable_set(:routes, routes) + b.local_variable_set(:author, method(:author)) + b.local_variable_set(:url_for_resource, method(:url_for_resource)) + b.local_variable_set(:current_feed_url, method(:current_feed_url)) + + ERB.new(template).result(b) + end + + def url_for_resource(resource) + routes + .polymorphic_url(resource, **@configuration.default_url_options.merge(ref: feed_configuration.ref)) + .delete_suffix("?ref=") + rescue + nil + end + + def current_feed_url + path = feed_configuration.path || "feed.atom" + URI.join(@configuration.url, path).to_s + end + + def routes + Rails.application.routes.url_helpers + end + + def feed_configuration + case self.class.name.demodulize + when "Rss" then @collection.configuration.feeds.rss + when "Atom" then @collection.configuration.feeds.atom + when "Json" then @collection.configuration.feeds.json + end + end + end + end + end + end +end diff --git a/test/perron/site/builder/feeds_test.rb b/test/perron/site/builder/feeds_test.rb index de64dc4..5d57f2c 100644 --- a/test/perron/site/builder/feeds_test.rb +++ b/test/perron/site/builder/feeds_test.rb @@ -10,14 +10,28 @@ class Perron::Site::Builder::FeedsTest < ActiveSupport::TestCase FileUtils.rm_rf(@output_path) FileUtils.mkdir_p(@output_path) + + Content::Post.configure do |config| + config.feeds.rss.enabled = false + config.feeds.atom.enabled = false + config.feeds.json.enabled = false + end end teardown do FileUtils.rm_rf(@output_path) + + %w[rss.erb atom.erb json.erb].each do |file| + path = Rails.root.join("app/views/content/posts/#{file}") + FileUtils.rm_f(path) + end end test "does not instantiate any builders if feeds are disabled" do - Content::Post.configure { |it| it.feeds.rss.enabled = false; it.feeds.json.enabled = false } + Content::Post.configure do |it| + it.feeds.rss.enabled = false + it.feeds.json.enabled = false + end rss_never_called = -> { flunk "Rss.new should not have been called" } json_never_called = -> { flunk "Json.new should not have been called" } @@ -74,4 +88,73 @@ class Perron::Site::Builder::FeedsTest < ActiveSupport::TestCase json_builder_stub.verify end + + test "uses custom RSS template when present" do + posts = Perron::Site.collection("posts") + + posts.configuration.feeds.rss.enabled = true + posts.configuration.feeds.rss.path = "feeds/posts.xml" + + File.write(Rails.root.join("app/views/content/posts/rss.erb"), "Custom RSS: <%= resources.map(&:id).join(',') %>") + + rss = Perron::Site::Builder::Feeds::Rss.new(collection: posts) + output = rss.generate + + assert output.start_with?("Custom RSS: ") + end + + test "uses custom Atom template when present" do + posts = Perron::Site.collection("posts") + + posts.configuration.feeds.atom.enabled = true + posts.configuration.feeds.atom.path = "feeds/posts.atom" + + File.write(Rails.root.join("app/views/content/posts/atom.erb"), "Custom Atom: <%= resources.count %>") + + atom = Perron::Site::Builder::Feeds::Atom.new(collection: posts) + output = atom.generate + + assert_equal "Custom Atom: 4", output + end + + test "uses custom JSON template when present" do + posts = Perron::Site.collection("posts") + + posts.configuration.feeds.json.enabled = true + posts.configuration.feeds.json.path = "feeds/posts.json" + + File.write(Rails.root.join("app/views/content/posts/json.erb"), '{"custom": true, "items": <%= resources.count %>}') + + json = Perron::Site::Builder::Feeds::Json.new(collection: posts) + output = json.generate + + assert_equal '{"custom": true, "items": 4}', output + end + + test "template has access to collection, resources, and config" do + posts = Perron::Site.collection("posts") + + posts.configuration.feeds.rss.enabled = true + + File.write( + Rails.root.join("app/views/content/posts/rss.erb"), + "<%= collection.name %>:<%= resources.count %>:<%= config.title || 'default' %>" + ) + + rss = Perron::Site::Builder::Feeds::Rss.new(collection: posts) + output = rss.generate + + assert_equal "posts:4:default", output + end + + test "falls back to default generation when no custom template exists" do + posts = Perron::Site.collection('posts') + + posts.configuration.feeds.rss.enabled = true + + rss = Perron::Site::Builder::Feeds::Rss.new(collection: posts) + output = rss.generate + + assert output.start_with?("