From e8fb1b4347210dd8601415d15581640240cdca33 Mon Sep 17 00:00:00 2001 From: eelco Date: Sat, 28 Feb 2026 17:36:10 +0700 Subject: [PATCH 1/2] Add lambda filtering and custom class support to sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This extends the sources/[programmatic content](https://perron.railsdesigner.com/docs/programmatic-content-creation/) feature First off lamda filtering for sources, like so: ```ruby class Content::Product sources, :countries, products: -> (products) { products.select(&:featured?) } end ``` Then pulling data from external APIs. This has been on my list almost since I first launched Perron. 😊 Initially I thought of adding first-party support for API handling. But instead I opted for a more maintainable and flexible option of simply support adding a class to the sources feature. This is as simple as it is elegant (at least I think it is). Syntax looks like this: ```ruby class Content::Product sources repos: { class: GitHubRepo, primary_key: :name } end ``` More complete example implementation that pulls GitHub repositories (using the [Active Resource gem](https://github.com/rails/activeresource): ```ruby class GitHubRepo < ActiveResource::Base self.site = "https://api.github.com/" def self.all find(:all, from: "/users/Rails-Designer/repos", params: { per_page: 5 }) end end Then the typical template setup: ```ruby class Content::Project < Perron::Resource sources repos: { class: GitHubRepo, primary_key: :name } def self.source_template(sources) <<~TEMPLATE --- title: #{sources.repos.name} description: #{sources.repos.description} language: #{sources.repos.language} stars: #{sources.repos.stargazers_count} --- #{sources.repos.description} TEMPLATE end end ``` Above would pull the repo data and create (and update if changes) erb files into `app/content/projects` just like it does with local files. ✨ --- lib/perron/resource/sourceable.rb | 28 +++++++++++++++++--- test/perron/resource/sourceable_test.rb | 35 +++++++++++++++++++++++++ test/support/data_api_source.rb | 16 +++++++++++ 3 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 test/support/data_api_source.rb diff --git a/lib/perron/resource/sourceable.rb b/lib/perron/resource/sourceable.rb index 351c87a..457b209 100644 --- a/lib/perron/resource/sourceable.rb +++ b/lib/perron/resource/sourceable.rb @@ -15,6 +15,18 @@ def source_definitions @source_definitions || {} end + def resolve(name) + definition = source_definitions[name] + + data = if definition[:class] + definition[:class].all + else + Perron::DataSource.new(name.to_s).to_a + end + + definition[:scope] ? definition[:scope].call(data) : data + end + def source_names = source_definitions.keys def generate_from_sources! @@ -36,11 +48,20 @@ def source_backed? = source_names.any? def parsed(*arguments) return {} if arguments.empty? - arguments.flat_map { it.is_a?(Hash) ? it.to_a : [[it, {primary_key: :id}]] }.to_h + arguments.flat_map do |argument| + case argument + when Hash + argument.to_a + when Proc + [[SecureRandom.hex(8).to_sym, {scope: argument, primary_key: :id}]] + else + [[argument, {primary_key: :id}]] + end + end.to_h end def combinations - datasets = source_names.map { Perron::DataSource.new(it.to_s) } + datasets = source_names.map { resolve it } datasets.first.product(*datasets[1..]) end @@ -72,7 +93,8 @@ def sources singular_name = name.to_s.singularize identifier = frontmatter["#{singular_name}_#{primary_key}"] - hash[name] = Perron::DataSource.new(name.to_s).find { it.public_send(primary_key).to_s == identifier.to_s } + dataset = self.class.send(:resolve, name) + hash[name] = dataset.find { it.public_send(primary_key).to_s == identifier.to_s } end Source.new(data) diff --git a/test/perron/resource/sourceable_test.rb b/test/perron/resource/sourceable_test.rb index 2ded5de..36a06b0 100644 --- a/test/perron/resource/sourceable_test.rb +++ b/test/perron/resource/sourceable_test.rb @@ -1,4 +1,5 @@ require "test_helper" +require "support/data_api_source" class Perron::Resource::SourceableTest < ActiveSupport::TestCase setup do @@ -74,4 +75,38 @@ class Perron::Resource::SourceableTest < ActiveSupport::TestCase assert resource.source_backed? end + + test ".sources with custom class uses class.all method" do + test_class = Class.new(Perron::Resource) do + sources products: { class: DataApiSource } + + def self.source_template(sources) + "test template" + end + end + + combinations = test_class.send(:combinations) + + assert_equal 2, combinations.length + assert_equal "product-1", combinations.first.first.id + assert_equal "product-2", combinations.last.first.id + end + + test ".sources with custom class and scope combines both" do + test_class = Class.new(Perron::Resource) do + sources products: { + class: DataApiSource, + scope: -> (products) { products.select(&:active) } + } + + def self.source_template(sources) + "test template" + end + end + + combinations = test_class.send(:combinations) + + assert_equal 1, combinations.length + assert_equal "product-1", combinations.first.first.id + end end diff --git a/test/support/data_api_source.rb b/test/support/data_api_source.rb new file mode 100644 index 0000000..017a806 --- /dev/null +++ b/test/support/data_api_source.rb @@ -0,0 +1,16 @@ +class DataApiProduct + attr_reader :id, :name, :active + + def initialize(id:, name:, active:) + @id, @name, @active = id, name, active + end +end + +class DataApiSource + def self.all + [ + DataApiProduct.new(id: "product-1", name: "API Product 1", active: true), + DataApiProduct.new(id: "product-2", name: "API Product 2", active: false) + ] + end +end From c8f8d2b79a766f21e496fe4f3a65bea42f666949 Mon Sep 17 00:00:00 2001 From: eelco Date: Sun, 1 Mar 2026 11:30:08 +0700 Subject: [PATCH 2/2] Replace `sources` with `source` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I think this reads better instead of `sources.products`: ``` class Content::Product < Perron::Resource sources :countries, :products def self.source_template(source) <<~TEMPLATE --- product_code: #{source.products.code} country_id: #{source.countries.id} title: #{source.products.name} in #{source.countries.name} slug: #{source.products.slug}-#{source.countries.code.downcase} --- # … end ``` Aliased to `sources` so you could still use if you like that syntax better. --- .../rails/content/templates/model.rb.tt | 2 +- lib/perron/resource/sourceable.rb | 11 ++++++----- test/dummy/app/models/content/product.rb | 16 ++++++++-------- test/generators/perron/content_generator_test.rb | 2 +- test/perron/resource/sourceable_test.rb | 4 ++-- 5 files changed, 18 insertions(+), 17 deletions(-) diff --git a/lib/generators/rails/content/templates/model.rb.tt b/lib/generators/rails/content/templates/model.rb.tt index 92df3d4..f520b63 100644 --- a/lib/generators/rails/content/templates/model.rb.tt +++ b/lib/generators/rails/content/templates/model.rb.tt @@ -2,7 +2,7 @@ class Content::<%= class_name %> < Perron::Resource <% if data_sources? -%> sources <%= data_sources.map { ":#{it}" }.join(", ") %> - def self.source_template(sources) + def self.source_template(source) <<~MARKDOWN --- --- diff --git a/lib/perron/resource/sourceable.rb b/lib/perron/resource/sourceable.rb index 457b209..f8a8b61 100644 --- a/lib/perron/resource/sourceable.rb +++ b/lib/perron/resource/sourceable.rb @@ -68,9 +68,9 @@ def combinations def content_with(combo) data = source_names.each.with_index.to_h { |name, index| [name, combo[index]] } - sources = Source.new(data) + source = Source.new(data) - source_template(sources) + source_template(source) end def filename_with(combo) @@ -86,8 +86,8 @@ def output_dir = Perron.configuration.input.join(model_name.collection) def source_backed? = self.class.source_backed? - def sources - @sources ||= begin + def source + @source ||= begin data = self.class.source_definitions.each_with_object({}) do |(name, options), hash| primary_key = options[:primary_key] singular_name = name.to_s.singularize @@ -100,8 +100,9 @@ def sources Source.new(data) end end + alias_method :sources, :source - def source_template(sources) + def source_template(source) raise NotImplementedError, "#{self.class.name} must implement #source_template" end diff --git a/test/dummy/app/models/content/product.rb b/test/dummy/app/models/content/product.rb index a7a0334..6194a84 100644 --- a/test/dummy/app/models/content/product.rb +++ b/test/dummy/app/models/content/product.rb @@ -1,20 +1,20 @@ class Content::Product < Perron::Resource sources :countries, products: { primary_key: :code } - def self.source_template(sources) + def self.source_template(source) <<~TEMPLATE --- - product_code: #{sources.products.code} - country_id: #{sources.countries.id} - title: #{sources.products.name} in #{sources.countries.name} - slug: #{sources.products.slug}-#{sources.countries.code.downcase} + product_code: #{source.products.code} + country_id: #{source.countries.id} + title: #{source.products.name} in #{source.countries.name} + slug: #{source.products.slug}-#{source.countries.code.downcase} --- - # #{sources.products.name} + # #{source.products.name} - Available in #{sources.countries.name} (#{sources.countries.code}) for $#{sources.products.price}. + Available in #{source.countries.name} (#{source.countries.code}) for $#{source.products.price}. - Product code: #{sources.products.code} + Product code: #{source.products.code} TEMPLATE end end diff --git a/test/generators/perron/content_generator_test.rb b/test/generators/perron/content_generator_test.rb index afef8ff..751a16c 100644 --- a/test/generators/perron/content_generator_test.rb +++ b/test/generators/perron/content_generator_test.rb @@ -112,7 +112,7 @@ class ContentGeneratorTest < Rails::Generators::TestCase assert_file "app/content/data/products.yml" assert_file "app/models/content/product.rb", /sources :countries, :products/ - assert_file "app/models/content/product.rb", /def self\.source_template\(sources\)/ + assert_file "app/models/content/product.rb", /def self\.source_template\(source\)/ end test "--data flag creates data source files with custom extensions" do diff --git a/test/perron/resource/sourceable_test.rb b/test/perron/resource/sourceable_test.rb index 36a06b0..588c622 100644 --- a/test/perron/resource/sourceable_test.rb +++ b/test/perron/resource/sourceable_test.rb @@ -80,7 +80,7 @@ class Perron::Resource::SourceableTest < ActiveSupport::TestCase test_class = Class.new(Perron::Resource) do sources products: { class: DataApiSource } - def self.source_template(sources) + def self.source_template(source) "test template" end end @@ -99,7 +99,7 @@ def self.source_template(sources) scope: -> (products) { products.select(&:active) } } - def self.source_template(sources) + def self.source_template(source) "test template" end end