diff --git a/lib/perron/feeds.rb b/lib/perron/feeds.rb index a3eacf4..8605496 100644 --- a/lib/perron/feeds.rb +++ b/lib/perron/feeds.rb @@ -1,8 +1,11 @@ # frozen_string_literal: true +require "perron/feeds/split" + module Perron class Feeds include ActionView::Helpers::TagHelper + include Perron::Feeds::Split def render(options = {}) html_tags = [] @@ -22,6 +25,16 @@ def render(options = {}) title = "#{collection.name.humanize} #{type.to_s.humanize} Feed" html_tags << tag(:link, rel: "alternate", type: MIME_TYPES[type], title: title, href: absolute_url) + + next unless feed[:split_by] + + split_values(collection.resources, feed[:split_by][:extractor]).each do |value| + split_path = split_path_for(feed, value) + split_url = URI.join(url.root_url, split_path).to_s + split_title = "#{collection.name.humanize}: #{value.to_s.humanize} #{type.to_s.humanize} Feed" + + html_tags << tag(:link, rel: "alternate", type: MIME_TYPES[type], title: split_title, href: split_url) + end end end end diff --git a/lib/perron/feeds/split.rb b/lib/perron/feeds/split.rb new file mode 100644 index 0000000..be58913 --- /dev/null +++ b/lib/perron/feeds/split.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Perron + class Feeds + module Split + module_function + + def grouped_resources(resources, extractor) + resources + .flat_map { |resource| extract(resource, extractor).map { [it, resource] } } + .group_by(&:first) + .transform_values { it.map(&:last) } + end + + def split_values(resources, extractor) + resources + .flat_map { extract(it, extractor) } + .uniq + end + + def extract(resource, extractor) + value = case extractor + when Symbol then resource.public_send(extractor) + when Proc then extractor.call(resource) + end + + Array(value) + end + + def split_path_for(type_config, value) + split_by = type_config[:split_by] + + if split_by[:path] + split_by[:path].gsub(":value", value.to_s.parameterize) + else + extension = File.extname(type_config.path) + base_path = type_config.path.delete_suffix(extension) + field = split_by[:extractor] + field_name = field.is_a?(Symbol) ? field.to_s : "custom" + + "#{base_path}/#{field_name}/#{value.to_s.parameterize}#{extension}" + end + end + end + end +end diff --git a/lib/perron/resource/configuration.rb b/lib/perron/resource/configuration.rb index 1d22f20..26fa55f 100644 --- a/lib/perron/resource/configuration.rb +++ b/lib/perron/resource/configuration.rb @@ -12,17 +12,17 @@ def configuration config.feeds = Options.new - config.feeds.atom = ActiveSupport::OrderedOptions.new + config.feeds.atom = FeedTypeConfig.new config.feeds.atom.enabled = false config.feeds.atom.path = "feeds/#{collection.name.demodulize.parameterize}.atom" config.feeds.atom.max_items = 20 - config.feeds.json = ActiveSupport::OrderedOptions.new + config.feeds.json = FeedTypeConfig.new config.feeds.json.enabled = false config.feeds.json.path = "feeds/#{collection.name.demodulize.parameterize}.json" config.feeds.json.max_items = 20 - config.feeds.rss = ActiveSupport::OrderedOptions.new + config.feeds.rss = FeedTypeConfig.new config.feeds.rss.enabled = false config.feeds.rss.path = "feeds/#{collection.name.demodulize.parameterize}.xml" config.feeds.rss.max_items = 20 @@ -58,6 +58,15 @@ def respond_to_missing?(name, include_private = false) end end private_constant :Options + + class FeedTypeConfig < ActiveSupport::OrderedOptions + def split_by(method_or_lambda = nil, path: nil, &block) + extractor = method_or_lambda || block + + self[:split_by] = {extractor: extractor} + self[:split_by][:path] = path if path + end + end end end end diff --git a/lib/perron/site/builder/feeds.rb b/lib/perron/site/builder/feeds.rb index f5e9c3d..ca255fe 100644 --- a/lib/perron/site/builder/feeds.rb +++ b/lib/perron/site/builder/feeds.rb @@ -4,10 +4,14 @@ require "perron/site/builder/feeds/json" require "perron/site/builder/feeds/rss" +require "perron/feeds/split" + module Perron module Site class Builder class Feeds + include Perron::Feeds::Split + def initialize(output_path) @output_path = output_path end @@ -18,21 +22,39 @@ def generate config = collection.configuration.feeds - if config.atom.enabled - create_file at: config.atom.path, with: Atom.new(collection: collection).generate - end + generate_feed(collection: collection, type_config: config.atom, generator_class: Atom) if config.atom.enabled + generate_feed(collection: collection, type_config: config.json, generator_class: Json) if config.json.enabled + generate_feed(collection: collection, type_config: config.rss, generator_class: Rss) if config.rss.enabled + end + end + + private + + def generate_feed(collection:, type_config:, generator_class:) + generator = generator_class.new(collection: collection) + content = generator.generate - if config.json.enabled - create_file at: config.json.path, with: Json.new(collection: collection).generate - end + create_file(at: type_config.path, with: content) if content.present? - if config.rss.enabled - create_file at: config.rss.path, with: Rss.new(collection: collection).generate - end + return unless type_config[:split_by] + + grouped_resources(collection.resources, type_config[:split_by][:extractor]).each do |value, group| + path = split_path_for(type_config, value) + config = split_config(type_config, value, path) + content = generator_class.new(collection: collection, resources: group, feed_config: config).generate + + create_file(at: path, with: content) if content.present? end end - private + def split_config(type_config, value, split_path) + ActiveSupport::OrderedOptions.new.tap do |config| + type_config.each { |key, value| config[key] = value unless key == :split_by } + + config.path = split_path + config.title = "#{type_config.title.presence || Perron.configuration.site_name}: #{value.to_s.humanize}" + end + end def create_file(at:, with:) return if with.blank? diff --git a/lib/perron/site/builder/feeds/atom.rb b/lib/perron/site/builder/feeds/atom.rb index 332ba58..db8e18c 100644 --- a/lib/perron/site/builder/feeds/atom.rb +++ b/lib/perron/site/builder/feeds/atom.rb @@ -11,8 +11,10 @@ class Atom include Feeds::Author include Feeds::Template - def initialize(collection:) + def initialize(collection:, resources: nil, feed_config: nil) @collection = collection + @resources = resources + @feed_config = feed_config @configuration = Perron.configuration end @@ -28,12 +30,16 @@ def generate private def resources - @resource ||= @collection.resources + (@resources || @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 feed_configuration + @feed_config || super + end end end end diff --git a/lib/perron/site/builder/feeds/json.rb b/lib/perron/site/builder/feeds/json.rb index 7f35155..945d24c 100644 --- a/lib/perron/site/builder/feeds/json.rb +++ b/lib/perron/site/builder/feeds/json.rb @@ -11,8 +11,10 @@ class Json include Feeds::Author include Feeds::Template - def initialize(collection:) + def initialize(collection:, resources: nil, feed_config: nil) @collection = collection + @resources = resources + @feed_config = feed_config @configuration = Perron.configuration end @@ -28,12 +30,16 @@ def generate private def resources - @resource ||= @collection.resources + (@resources || @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 feed_configuration + @feed_config || super + end end end end diff --git a/lib/perron/site/builder/feeds/rss.rb b/lib/perron/site/builder/feeds/rss.rb index 7225d8d..636d534 100644 --- a/lib/perron/site/builder/feeds/rss.rb +++ b/lib/perron/site/builder/feeds/rss.rb @@ -11,8 +11,10 @@ class Rss include Feeds::Author include Feeds::Template - def initialize(collection:) + def initialize(collection:, resources: nil, feed_config: nil) @collection = collection + @resources = resources + @feed_config = feed_config @configuration = Perron.configuration end @@ -28,12 +30,16 @@ def generate private def resources - @resource ||= @collection.resources + (@resources || @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 feed_configuration + @feed_config || super + end end end end diff --git a/test/perron/feeds_test.rb b/test/perron/feeds_test.rb index 1737b64..6404186 100644 --- a/test/perron/feeds_test.rb +++ b/test/perron/feeds_test.rb @@ -8,9 +8,14 @@ class Perron::FeedsTest < ActionDispatch::IntegrationTest end teardown do - Content::Post.configure { it.feeds.rss.enabled = false } - Content::Post.configure { it.feeds.atom.enabled = false } - Content::Post.configure { it.feeds.json.enabled = false } + Content::Post.configure do |config| + config.feeds.rss.enabled = false + config.feeds.atom.enabled = false + config.feeds.json.enabled = false + + config.feeds.atom[:split_by] = nil + end + Content::Page.configure { it.feeds.rss.enabled = false } end @@ -39,6 +44,32 @@ class Perron::FeedsTest < ActionDispatch::IntegrationTest assert_select document, 'link[href*="feeds/posts.xml"]', count: 0, message: "Posts feed should be excluded" end + test "renders split feed link tags when split_by is configured" do + Content::Post.configure { it.feeds.atom.split_by :category } + + document = rendered_document + + assert_select document, 'link[rel="alternate"][type="application/atom+xml"][href*="feeds/posts.atom"]', count: 1 + assert_select document, 'link[rel="alternate"][type="application/atom+xml"][href*="feeds/posts/category/news.atom"][title*="News"]', count: 1 + assert_select document, 'link[rel="alternate"][type="application/atom+xml"][href*="feeds/posts/category/tutorial.atom"][title*="Tutorial"]', count: 1 + end + + test "does not render split feed links when split_by is not configured" do + document = rendered_document + + assert_select document, 'link[href*="category/"]', count: 0 + end + + test "renders split feed link tags respect :only and :except options" do + Content::Post.configure { it.feeds.atom.split_by :category } + + document = rendered_document(only: [:posts]) + assert_select document, 'link[href*="feeds/posts/category/"]', count: 2 + + document = rendered_document(only: [:pages]) + assert_select document, 'link[href*="feeds/posts/category/"]', count: 0 + end + test "returns an empty document if no matching feeds are found" do document = rendered_document(only: [:nonexistent_collection]) diff --git a/test/perron/site/builder/feeds_test.rb b/test/perron/site/builder/feeds_test.rb index 3762e21..65efb7a 100644 --- a/test/perron/site/builder/feeds_test.rb +++ b/test/perron/site/builder/feeds_test.rb @@ -23,8 +23,21 @@ class Perron::Site::Builder::FeedsTest < ActiveSupport::TestCase %w[rss.erb atom.erb json.erb].each do |file| path = Rails.root.join("app/views/content/posts/#{file}") + FileUtils.rm_f(path) end + + Content::Post.configure do |config| + config.feeds.rss.enabled = false + config.feeds.atom.enabled = false + config.feeds.json.enabled = false + + config.feeds.atom.path = "feeds/posts.atom" + + config.feeds.atom[:split_by] = nil + config.feeds.rss[:split_by] = nil + config.feeds.json[:split_by] = nil + end end test "does not instantiate any builders if feeds are disabled" do @@ -147,6 +160,70 @@ class Perron::Site::Builder::FeedsTest < ActiveSupport::TestCase assert_equal "posts:4:default", output end + test "generates split feeds grouped by metadata field" do + Content::Post.configure do |config| + config.feeds.atom.enabled = true + config.feeds.atom.path = "feeds/posts.atom" + config.feeds.atom.split_by :category + end + + File.write(Rails.root.join("app/views/content/posts/atom.erb"), "<%= resources.count %>") + + Perron::Site::Builder::Feeds.new(@output_path).generate + + main_feed = @output_path.join("feeds/posts.atom") + news_feed = @output_path.join("feeds/posts/category/news.atom") + tutorial_feed = @output_path.join("feeds/posts/category/tutorial.atom") + + assert File.exist?(main_feed) + assert_equal "4", File.read(main_feed) + + assert File.exist?(news_feed) + assert_equal "1", File.read(news_feed) + + assert File.exist?(tutorial_feed) + assert_equal "1", File.read(tutorial_feed) + end + + test "split feed uses custom path template when provided" do + Content::Post.configure do |config| + config.feeds.atom.enabled = true + config.feeds.atom.path = "feeds/posts.atom" + + config.feeds.atom.split_by :category, path: "feeds/by-cat/:value.atom" + end + + File.write(Rails.root.join("app/views/content/posts/atom.erb"), "<%= config.path %>") + + Perron::Site::Builder::Feeds.new(@output_path).generate + + news_feed = @output_path.join("feeds/by-cat/news.atom") + tutorial_feed = @output_path.join("feeds/by-cat/tutorial.atom") + + assert File.exist?(news_feed), "News split feed at custom path should exist" + assert File.exist?(tutorial_feed), "Tutorial split feed at custom path should exist" + assert_equal "feeds/by-cat/news.atom", File.read(news_feed) + assert_equal "feeds/by-cat/tutorial.atom", File.read(tutorial_feed) + end + + test "split feed does not create files for resources without the split field" do + Content::Post.configure do |config| + config.feeds.atom.enabled = true + config.feeds.atom.path = "feeds/posts.atom" + + config.feeds.atom.split_by :category + end + + Perron::Site::Builder::Feeds.new(@output_path).generate + + feeds_dir = @output_path.join("feeds/posts/category") + generated_categories = Dir.glob("#{feeds_dir}/*.atom").map { |f| File.basename(f, ".atom") } + + assert_includes generated_categories, "news" + assert_includes generated_categories, "tutorial" + refute_includes generated_categories, "custom" + end + test "falls back to default generation when no custom template exists" do posts = Perron::Site.collection('posts') @@ -186,13 +263,4 @@ class Perron::Site::Builder::FeedsTest < ActiveSupport::TestCase assert_match(/#{Regexp.escape(feed_url)}<\/id>/, output, "Feed id should contain actual URL") assert_match(/]*rel="self"/, output, "Self link should contain actual URL") end - - teardown do - Content::Post.configure do |config| - config.feeds.rss.enabled = false - config.feeds.atom.enabled = false - config.feeds.json.enabled = false - config.feeds.atom.path = "feeds/posts.atom" - end - end end