perron 0.17.0 → 1.0.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.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +1 -1
  3. data/Gemfile.lock +25 -2
  4. data/app/controllers/perron/concierge_controller.rb +13 -0
  5. data/app/controllers/perron/searches_controller.rb +48 -0
  6. data/app/helpers/perron/feeds_helper.rb +7 -0
  7. data/app/helpers/perron/markdown_helper.rb +3 -3
  8. data/app/helpers/perron/meta_tags_helper.rb +17 -0
  9. data/app/views/perron/concierge/show.html.erb +271 -0
  10. data/bin/release +19 -4
  11. data/lib/generators/rails/content/USAGE +45 -26
  12. data/lib/generators/rails/content/content_generator.rb +16 -13
  13. data/lib/generators/rails/content/templates/controller.rb.tt +7 -5
  14. data/lib/generators/rails/content/templates/model.rb.tt +4 -4
  15. data/lib/perron/assets/icon.png +0 -0
  16. data/lib/perron/assets/icon.svg +1 -0
  17. data/lib/perron/collection.rb +10 -1
  18. data/lib/perron/configuration.rb +13 -4
  19. data/lib/perron/content/data.rb +6 -2
  20. data/lib/perron/data_source/class_methods.rb +66 -0
  21. data/lib/perron/data_source/helper_context.rb +20 -0
  22. data/lib/perron/data_source/item.rb +37 -0
  23. data/lib/perron/{data → data_source}/proxy.rb +1 -1
  24. data/lib/perron/data_source.rb +140 -0
  25. data/lib/perron/development_feed_server.rb +69 -0
  26. data/lib/perron/engine.rb +41 -1
  27. data/lib/perron/errors.rb +2 -0
  28. data/lib/perron/feeds.rb +4 -3
  29. data/lib/perron/html_processor/absolute_urls.rb +27 -0
  30. data/lib/perron/html_processor/base.rb +2 -2
  31. data/lib/perron/html_processor.rb +7 -11
  32. data/lib/perron/install/README.md.tt +67 -0
  33. data/lib/{generators/perron/templates → perron/install}/initializer.rb.tt +8 -4
  34. data/lib/perron/install.rb +23 -0
  35. data/lib/perron/markdown.rb +2 -2
  36. data/lib/perron/output_server.rb +16 -2
  37. data/lib/perron/relation.rb +51 -0
  38. data/lib/perron/resource/adjacency.rb +70 -0
  39. data/lib/perron/resource/associations.rb +3 -3
  40. data/lib/perron/resource/class_methods.rb +10 -0
  41. data/lib/perron/resource/configuration.rb +16 -14
  42. data/lib/perron/resource/core.rb +11 -0
  43. data/lib/perron/resource/metadata.rb +10 -1
  44. data/lib/perron/resource/publishable.rb +2 -0
  45. data/lib/perron/resource/related/stop_words.rb +20 -20
  46. data/lib/perron/resource/related.rb +76 -54
  47. data/lib/perron/resource/scopes.rb +29 -0
  48. data/lib/perron/resource/searchable.rb +19 -0
  49. data/lib/perron/resource/sourceable.rb +39 -9
  50. data/lib/perron/resource/sweeper.rb +45 -0
  51. data/lib/perron/resource/table_of_content.rb +0 -18
  52. data/lib/perron/resource.rb +32 -20
  53. data/lib/perron/site/builder/assets.rb +1 -1
  54. data/lib/perron/site/builder/feeds/atom.erb +44 -0
  55. data/lib/perron/site/builder/feeds/atom.rb +41 -0
  56. data/lib/perron/site/builder/feeds/json.erb +19 -0
  57. data/lib/perron/site/builder/feeds/json.rb +7 -33
  58. data/lib/perron/site/builder/feeds/rss.erb +28 -0
  59. data/lib/perron/site/builder/feeds/rss.rb +6 -28
  60. data/lib/perron/site/builder/feeds/template.rb +63 -0
  61. data/lib/perron/site/builder/feeds.rb +8 -3
  62. data/lib/perron/site/builder/paths.rb +58 -14
  63. data/lib/perron/site/builder/route_resources.rb +79 -0
  64. data/lib/perron/site/builder/sitemap.rb +71 -20
  65. data/lib/perron/site/builder.rb +1 -1
  66. data/lib/perron/site/validate.rb +1 -2
  67. data/lib/perron/site.rb +10 -3
  68. data/lib/perron/tasks/build.rake +6 -0
  69. data/lib/perron/tasks/install.rake +12 -0
  70. data/lib/perron/version.rb +1 -1
  71. data/lib/perron.rb +1 -0
  72. data/perron.gemspec +1 -0
  73. metadata +45 -10
  74. data/app/helpers/feeds_helper.rb +0 -5
  75. data/app/helpers/meta_tags_helper.rb +0 -15
  76. data/lib/generators/perron/install_generator.rb +0 -32
  77. data/lib/generators/perron/templates/README.md.tt +0 -45
  78. data/lib/perron/data.rb +0 -180
  79. data/lib/perron/html_processor/syntax_highlight.rb +0 -30
@@ -5,93 +5,115 @@ require "perron/resource/related/stop_words"
5
5
  module Perron
6
6
  module Site
7
7
  class Resource
8
+ # Finds related resources using TF-IDF cosine similarity.
9
+ #
10
+ # Pre-normalizes vectors so cosine similarity reduces to a dot product,
11
+ # then builds a symmetric similarity matrix once per collection.
12
+ #
13
+ # Results are cached at the class level so the O(n²) comparison
14
+ # is paid once, not once per resource.
8
15
  class Related
16
+ Cache = Struct.new(:resources, :similarity_matrix, :fingerprint)
17
+
18
+ @collection_caches = {}
19
+
20
+ def self.cache_for(collection_name)
21
+ clear_cache!(collection_name) if stale?(collection_name)
22
+
23
+ @collection_caches[collection_name] ||= Cache.new(nil, nil, fingerprinted(collection_name))
24
+ end
25
+
26
+ def self.clear_cache!(collection_name)
27
+ @collection_caches.delete(collection_name)
28
+ end
29
+
30
+ def self.stale?(collection_name)
31
+ @collection_caches[collection_name]&.fingerprint != fingerprinted(collection_name)
32
+ end
33
+
34
+ def self.fingerprinted(collection_name)
35
+ path = File.join(Perron.configuration.input, collection_name)
36
+ files = Dir.glob(File.join(path, "**", "*.*"))
37
+
38
+ [files.size, files.map { File.mtime(it) }.max]
39
+ end
40
+
9
41
  def initialize(resource)
10
42
  @resource = resource
11
43
  @collection = resource.collection
44
+ @cache = self.class.cache_for(@collection.name)
12
45
  end
13
46
 
14
47
  def find(limit: 5)
15
- @collection.resources
48
+ scores = similarity_matrix[@resource.slug] || {}
49
+
50
+ resources
16
51
  .reject { it.slug == @resource.slug }
17
- .map { [it, cosine_similarities_for(@resource, it)] }
18
- .sort_by { |_, score| -score }
19
- .map(&:first)
52
+ .sort_by { -(scores[it.slug] || 0.0) }
20
53
  .first(limit)
21
54
  end
22
55
 
23
56
  private
24
57
 
25
- def cosine_similarities_for(resource_one, resource_two)
26
- first_vector = tfidf_vector_for(resource_one)
27
- second_vector = tfidf_vector_for(resource_two)
28
-
29
- return 0.0 if first_vector.empty? || second_vector.empty?
30
-
31
- dot_product = 0.0
32
-
33
- first_vector.each_key { dot_product += first_vector[it] * second_vector[it] if second_vector.key?(it) }
34
-
35
- first_magnitude = Math.sqrt(first_vector.values.sum { it**2 })
36
- second_magnitude = Math.sqrt(second_vector.values.sum { it**2 })
37
- denominator = first_magnitude * second_magnitude
38
-
39
- return 0.0 if denominator.zero?
58
+ def resources
59
+ @cache.resources ||= @collection.resources
60
+ end
40
61
 
41
- dot_product / denominator
62
+ def similarity_matrix
63
+ @cache.similarity_matrix ||= build_similarity_matrix
42
64
  end
43
65
 
44
- def tfidf_vector_for(target_resource)
45
- @tfidf_vectors ||= {}
66
+ def build_similarity_matrix
67
+ vectors = resources.to_h { [it.slug, normalize(tfidf_vector_for(it))] }
46
68
 
47
- return @tfidf_vectors[target_resource] if @tfidf_vectors.key?(target_resource)
69
+ Hash.new { |hash, key| hash[key] = {} }.tap do |matrix|
70
+ slugs = vectors.keys
48
71
 
49
- tokens = tokenize_content(target_resource)
50
- token_count = tokens.size
72
+ slugs.each_with_index do |slug_a, index|
73
+ next if vectors[slug_a].empty?
51
74
 
52
- return {} if token_count.zero?
75
+ slugs[(index + 1)..].each do |slug_b|
76
+ next if vectors[slug_b].empty?
53
77
 
54
- term_count = Hash.new(0)
78
+ matrix[slug_a][slug_b] = dot_product(vectors[slug_a], vectors[slug_b])
79
+ matrix[slug_b][slug_a] = matrix[slug_a][slug_b]
80
+ end
81
+ end
82
+ end
83
+ end
55
84
 
56
- tokens.each { |token| term_count[token] += 1 }
85
+ def normalize(vector)
86
+ magnitude = Math.sqrt(vector.values.sum { it**2 })
87
+ return {} if vector.empty? || magnitude.zero?
57
88
 
58
- tfidf_vector = {}
89
+ vector.transform_values { it / magnitude }
90
+ end
59
91
 
60
- term_count.each do |term, count|
61
- terms = count.to_f / token_count
92
+ def tfidf_vector_for(resource)
93
+ tokens = tokenized(resource)
94
+ return {} if tokens.empty?
62
95
 
63
- tfidf_vector[term] = terms * inverse_document_frequency[term]
64
- end
65
-
66
- @tfidf_vectors[target_resource] = tfidf_vector
96
+ token_count = tokens.size.to_f
97
+ tokens.tally.to_h { |term, count| [term, (count / token_count) * inverse_document_frequency[term]] }
67
98
  end
68
99
 
69
- def tokenize_content(target_resource)
70
- @tokenized_content ||= {}
71
-
72
- return @tokenized_content[target_resource] if @tokenized_content.key?(target_resource)
73
- return [] if target_resource.content.blank?
100
+ def dot_product(vector_a, vector_b)
101
+ vector_a.sum { |term, value| value * (vector_b[term] || 0) }
102
+ end
74
103
 
75
- content = target_resource.content.gsub(/<[^>]*>/, " ")
76
- tokens = content.downcase.scan(/\w+/).reject { StopWords.all.include?(it) || it.length < 3 }
104
+ def tokenized(resource)
105
+ return [] if resource.content.blank?
77
106
 
78
- @tokenized_content[target_resource] = tokens
107
+ resource.content.gsub(/<[^>]*>/, " ").downcase.scan(/\w+/).reject { StopWords.all.include?(it) || it.length < 3 }
79
108
  end
80
109
 
81
110
  def inverse_document_frequency
82
111
  @inverse_document_frequency ||= begin
83
- resource_frequency = Hash.new(0)
84
-
85
- @collection.resources.each { tokenize_content(it).uniq.each { resource_frequency[it] += 1 } }
86
-
87
- frequencies = {}
88
- total_resources = @collection.resources.size
89
-
90
- resource_frequency.each do |term, frequency|
91
- frequencies[term] = Math.log(total_resources.to_f / (1 + frequency))
92
- end
112
+ document_frequency = Hash.new(0)
113
+ resources.each { tokenized(it).uniq.each { document_frequency[it] += 1 } }
93
114
 
94
- frequencies
115
+ total = resources.size.to_f
116
+ document_frequency.transform_values { Math.log(total / (1 + it)) }
95
117
  end
96
118
  end
97
119
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Perron
4
+ class Resource
5
+ module Scopes
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ def scope(name, body)
10
+ unless body.respond_to?(:call)
11
+ raise ArgumentError, "The scope body needs to be callable."
12
+ end
13
+
14
+ if respond_to?(name, true)
15
+ raise ArgumentError, "Cannot define scope :#{name} because it already exists."
16
+ end
17
+
18
+ singleton_class.define_method(name) do |*arguments|
19
+ instance_exec(*arguments, &body)
20
+ end
21
+
22
+ Perron::Relation.define_method(name) do |*arguments|
23
+ instance_exec(*arguments, &body)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Perron
4
+ class Resource
5
+ module Searchable
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ class_attribute :search_fields_list, default: []
10
+ end
11
+
12
+ class_methods do
13
+ def search_fields(*fields)
14
+ self.search_fields_list = fields
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -15,6 +15,18 @@ module Perron
15
15
  @source_definitions || {}
16
16
  end
17
17
 
18
+ def resolve(name)
19
+ definition = source_definitions[name]
20
+
21
+ data = if definition[:class]
22
+ definition[:class].all
23
+ else
24
+ Perron::DataSource.new(name.to_s).to_a
25
+ end
26
+
27
+ definition[:scope] ? definition[:scope].call(data) : data
28
+ end
29
+
18
30
  def source_names = source_definitions.keys
19
31
 
20
32
  def generate_from_sources!
@@ -36,20 +48,36 @@ module Perron
36
48
  def parsed(*arguments)
37
49
  return {} if arguments.empty?
38
50
 
39
- arguments.flat_map { it.is_a?(Hash) ? it.to_a : [[it, {primary_key: :id}]] }.to_h
51
+ arguments.flat_map do |argument|
52
+ case argument
53
+ when Hash
54
+ argument.to_a
55
+ when Proc
56
+ [[SecureRandom.hex(8).to_sym, {scope: argument, primary_key: :id}]]
57
+ else
58
+ [[argument, {primary_key: :id}]]
59
+ end
60
+ end.to_h
40
61
  end
41
62
 
42
63
  def combinations
43
- datasets = source_names.map { Perron::Data.new(it.to_s) }
64
+ datasets = source_names.map { resolve it }
65
+
66
+ datasets.first.product(*datasets[1..]).each do |combo|
67
+ combo.each_with_index do |item, index|
68
+ name = source_names[index]
69
+ primary_key = source_definitions[name][:primary_key] || :id
44
70
 
45
- datasets.first.product(*datasets[1..])
71
+ raise Errors::DataParseError, "Primary key `#{primary_key}` is nil for row in source `#{name}`" if item.public_send(primary_key).nil?
72
+ end
73
+ end
46
74
  end
47
75
 
48
76
  def content_with(combo)
49
77
  data = source_names.each.with_index.to_h { |name, index| [name, combo[index]] }
50
- sources = Source.new(data)
78
+ source = Source.new(data)
51
79
 
52
- source_template(sources)
80
+ source_template(source)
53
81
  end
54
82
 
55
83
  def filename_with(combo)
@@ -65,21 +93,23 @@ module Perron
65
93
 
66
94
  def source_backed? = self.class.source_backed?
67
95
 
68
- def sources
69
- @sources ||= begin
96
+ def source
97
+ @source ||= begin
70
98
  data = self.class.source_definitions.each_with_object({}) do |(name, options), hash|
71
99
  primary_key = options[:primary_key]
72
100
  singular_name = name.to_s.singularize
73
101
  identifier = frontmatter["#{singular_name}_#{primary_key}"]
74
102
 
75
- hash[name] = Perron::Data.new(name.to_s).find { it.public_send(primary_key).to_s == identifier.to_s }
103
+ dataset = self.class.send(:resolve, name)
104
+ hash[name] = dataset.find { it.public_send(primary_key).to_s == identifier.to_s }
76
105
  end
77
106
 
78
107
  Source.new(data)
79
108
  end
80
109
  end
110
+ alias_method :sources, :source
81
111
 
82
- def source_template(sources)
112
+ def source_template(source)
83
113
  raise NotImplementedError, "#{self.class.name} must implement #source_template"
84
114
  end
85
115
 
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Perron
4
+ class Resource
5
+ module Sweeper
6
+ extend ActiveSupport::Concern
7
+
8
+ def extracted_headings
9
+ extract_heading_texts(from: rendered_document)
10
+ end
11
+
12
+ def sweeped_content
13
+ ActionView::Base.full_sanitizer.sanitize(
14
+ rendered_document.text.gsub(/\s+/, " ").strip
15
+ )
16
+ end
17
+
18
+ private
19
+
20
+ def rendered_document
21
+ @rendered_document ||= Nokogiri::HTML::DocumentFragment.parse(Markdown.render(content))
22
+ end
23
+
24
+ def extract_heading_texts(from:, levels: "h1, h2, h3, h4, h5, h6")
25
+ from.css(levels).map { it.text.strip }.compact_blank
26
+ end
27
+
28
+ def extract_headings(from:, levels:)
29
+ from.css(levels).each_with_object([]) do |heading, headings|
30
+ heading_text = heading.text.strip
31
+ id = heading["id"] || heading.at("a")&.[]("id")
32
+
33
+ next if heading_text.empty? || id.blank?
34
+
35
+ headings << TableOfContent::Item.new(
36
+ id: id,
37
+ text: heading_text,
38
+ level: heading.name[1..].to_i,
39
+ children: []
40
+ )
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -20,24 +20,6 @@ module Perron
20
20
 
21
21
  Item = ::Data.define(:id, :text, :level, :children)
22
22
 
23
- def extract_headings(from:, levels:)
24
- from.css(levels).each_with_object([]) do |heading, headings|
25
- heading.tap do |node|
26
- heading_text = node.text.strip
27
- id = node["id"] || node.at("a")&.[]("id")
28
-
29
- next if heading_text.empty? || id.blank?
30
-
31
- headings << Item.new(
32
- id: id,
33
- text: heading_text,
34
- level: node.name[1..].to_i,
35
- children: []
36
- )
37
- end
38
- end
39
- end
40
-
41
23
  class Builder
42
24
  def build(headings)
43
25
  parents = {0 => {children: []}}
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "perron/relation"
3
4
  require "perron/resource/configuration"
4
5
  require "perron/resource/core"
5
6
  require "perron/resource/class_methods"
@@ -10,26 +11,32 @@ require "perron/resource/publishable"
10
11
  require "perron/resource/reading_time"
11
12
  require "perron/resource/related"
12
13
  require "perron/resource/renderer"
14
+ require "perron/resource/scopes"
13
15
  require "perron/resource/slug"
16
+ require "perron/resource/searchable"
14
17
  require "perron/resource/separator"
15
18
  require "perron/resource/sourceable"
19
+ require "perron/resource/sweeper"
20
+ require "perron/resource/adjacency"
16
21
  require "perron/resource/table_of_content"
17
22
 
18
23
  module Perron
19
24
  class Resource
20
- ID_LENGTH = 8
21
-
22
25
  include ActiveModel::Validations
23
26
 
24
- include Perron::Resource::Configuration
25
- include Perron::Resource::Core
26
- include Perron::Resource::ClassMethods
27
- include Perron::Resource::Associations
28
- include Perron::Resource::ReadingTime
29
- include Perron::Resource::Sourceable
30
- include Perron::Resource::Publishable
31
- include Perron::Resource::Previewable
32
- include Perron::Resource::TableOfContent
27
+ include Configuration
28
+ include Core
29
+ include ClassMethods
30
+ include Associations
31
+ include ReadingTime
32
+ include Searchable
33
+ include Sourceable
34
+ include Publishable
35
+ include Previewable
36
+ include Scopes
37
+ include Sweeper
38
+ include Adjacency
39
+ include TableOfContent
33
40
 
34
41
  attr_reader :file_path, :id
35
42
 
@@ -41,6 +48,16 @@ module Perron
41
48
  raise Errors::FileNotFoundError, "No such file: #{file_path}" unless File.exist?(file_path)
42
49
  end
43
50
 
51
+ def pluck(*attributes)
52
+ raise ArgumentError, "wrong number of arguments (given 0, expected 1+)" if attributes.empty?
53
+
54
+ if attributes.size == 1
55
+ public_send(attributes.first)
56
+ else
57
+ attributes.map { |attr| public_send(attr) }
58
+ end
59
+ end
60
+
44
61
  def filename = File.basename(@file_path)
45
62
 
46
63
  def slug = Perron::Resource::Slug.new(self, frontmatter).create
@@ -66,15 +83,8 @@ module Perron
66
83
  render_inline_erb using: page_content
67
84
  end
68
85
 
69
- def association_value(key) = metadata[key]
70
-
71
- def to_partial_path
72
- @to_partial_path ||= begin
73
- element = ActiveSupport::Inflector.underscore(ActiveSupport::Inflector.demodulize(self.class.model_name))
74
- collection = ActiveSupport::Inflector.tableize(self.class.model_name)
75
-
76
- File.join("content", collection, element)
77
- end
86
+ def inline(layout: "application", **options)
87
+ {html: content, layout: layout}.merge(options)
78
88
  end
79
89
 
80
90
  def collection = Collection.new(self.class.model_name.collection)
@@ -88,6 +98,8 @@ module Perron
88
98
 
89
99
  private
90
100
 
101
+ ID_LENGTH = 8
102
+
91
103
  def frontmatter
92
104
  @frontmatter ||= Perron::Resource::Separator.new(raw_content).frontmatter
93
105
  end
@@ -11,7 +11,7 @@ module Perron
11
11
  def prepare
12
12
  puts "📦 Precompiling and copying assets…"
13
13
 
14
- success = system("bundle exec rails assets:precompile", out: File::NULL, err: File::NULL)
14
+ success = system("bundle exec rails assets:precompile", out: File::NULL)
15
15
 
16
16
  unless success
17
17
  puts "❌ ERROR: Asset precompilation failed"
@@ -0,0 +1,44 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <feed xmlns="http://www.w3.org/2005/Atom">
3
+ <generator uri="https://perron.railsdesigner.com/" version="<%= Perron::VERSION %>">Perron</generator>
4
+ <id><%= current_feed_url.call %></id>
5
+ <title><%= config.title.presence || @configuration.site_name %></title>
6
+ <subtitle><%= config.description.presence || @configuration.site_description %></subtitle>
7
+ <link href="<%= current_feed_url.call %>" rel="self" type="application/atom+xml"/>
8
+ <link href="<%= @configuration.url %>" rel="alternate" type="text/html"/>
9
+ <updated><%= resources.first&.published_at&.iso8601 || Time.current.iso8601 %></updated>
10
+
11
+ <% feed_author = config.author || { name: @configuration.site_name, email: "noreply@#{URI.parse(@configuration.url).host}" } %>
12
+ <author>
13
+ <% if feed_author[:name] %><name><%= feed_author[:name] %></name><% end %>
14
+ <% if feed_author[:email] %><email><%= feed_author[:email] %></email><% end %>
15
+ </author>
16
+
17
+ <% resources.each do |resource| %>
18
+ <entry>
19
+ <id><%= url_for_resource.call(resource) || "#{@configuration.url}/posts/#{resource.id}" %></id>
20
+ <title><%= resource.metadata.title %></title>
21
+ <link href="<%= url_for_resource.call(resource) %>" rel="alternate" type="text/html"/>
22
+ <published><%= resource.published_at&.iso8601 %></published>
23
+ <updated><%= (resource.metadata.updated_at || resource.published_at)&.iso8601 %></updated>
24
+
25
+ <% entry_author = author.call(resource); if entry_author %>
26
+ <author>
27
+ <% if entry_author.name %><name><%= entry_author.name %></name><% end %>
28
+ <% if entry_author.email %><email><%= entry_author.email %></email><% end %>
29
+ </author>
30
+ <% end %>
31
+
32
+ <% base_url = url_for_resource.call(resource) %>
33
+ <% if base_url %>
34
+ <content type="html" xml:base="<%= base_url %>"><![CDATA[<%= Perron::Markdown.render(resource.content, processors: ["absolute_urls"]) %>]]></content>
35
+ <% else %>
36
+ <content type="html"><![CDATA[<%= Perron::Markdown.render(resource.content, processors: ["absolute_urls"]) %>]]></content>
37
+ <% end %>
38
+
39
+ <% resource.metadata.tags&.each do |tag| %>
40
+ <category term="<%= tag %>"/>
41
+ <% end %>
42
+ </entry>
43
+ <% end %>
44
+ </feed>
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "perron/site/builder/feeds/author"
4
+ require "perron/site/builder/feeds/template"
5
+
6
+ module Perron
7
+ module Site
8
+ class Builder
9
+ class Feeds
10
+ class Atom
11
+ include Feeds::Author
12
+ include Feeds::Template
13
+
14
+ def initialize(collection:)
15
+ @collection = collection
16
+ @configuration = Perron.configuration
17
+ end
18
+
19
+ def generate
20
+ return if resources.empty?
21
+
22
+ template = find_template("atom")
23
+ return unless template
24
+
25
+ render(template, feed_configuration)
26
+ end
27
+
28
+ private
29
+
30
+ def resources
31
+ @resource ||= @collection.resources
32
+ .reject { it.metadata.feed == false }
33
+ .sort_by { it.metadata.published_at || it.metadata.updated_at || Time.current }
34
+ .reverse
35
+ .take(feed_configuration.max_items)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,19 @@
1
+ <%= {
2
+ generator: "Perron (#{Perron::VERSION})",
3
+ version: "https://jsonfeed.org/version/1.1",
4
+ home_page_url: @configuration.url,
5
+ title: config.title.presence || @configuration.site_name,
6
+ description: config.description.presence || @configuration.site_description,
7
+
8
+ items: resources.map { |resource|
9
+ item_author = author.call(resource)
10
+ {
11
+ id: resource.id,
12
+ url: url_for_resource.call(resource),
13
+ date_published: resource.published_at&.iso8601,
14
+ title: resource.metadata.title,
15
+ authors: (item_author && item_author.name ? [{ name: item_author.name, email: item_author.email, url: item_author.url, avatar: item_author.avatar }.compact] : nil),
16
+ content_html: Perron::Markdown.render(resource.content, processors: ["absolute_urls"])
17
+ }.compact
18
+ }
19
+ }.to_json %>
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
3
  require "perron/site/builder/feeds/author"
4
+ require "perron/site/builder/feeds/template"
5
5
 
6
6
  module Perron
7
7
  module Site
@@ -9,6 +9,7 @@ module Perron
9
9
  class Feeds
10
10
  class Json
11
11
  include Feeds::Author
12
+ include Feeds::Template
12
13
 
13
14
  def initialize(collection:)
14
15
  @collection = collection
@@ -16,50 +17,23 @@ module Perron
16
17
  end
17
18
 
18
19
  def generate
19
- return nil if resources.empty?
20
+ return if resources.empty?
20
21
 
21
- hash = Rails.application.routes.url_helpers.with_options(@configuration.default_url_options) do |url|
22
- {
23
- generator: "Perron (#{Perron::VERSION})",
24
- version: "https://jsonfeed.org/version/1.1",
25
- home_page_url: @configuration.url,
26
- title: feed_configuration.title.presence || @configuration.site_name,
27
- description: feed_configuration.description.presence || @configuration.site_description,
28
- items: resources.map do |resource|
29
- {
30
- id: resource.id,
31
- url: url.polymorphic_url(resource, ref: feed_configuration.ref).delete_suffix("?ref="),
32
- date_published: resource.published_at&.iso8601,
33
- authors: authors(resource),
34
- title: resource.metadata.title,
35
- content_html: Perron::Markdown.render(resource.content)
36
- }
37
- end
38
- }
39
- end
22
+ template = find_template("json")
23
+ return unless template
40
24
 
41
- JSON.pretty_generate hash
25
+ render(template, feed_configuration)
42
26
  end
43
27
 
44
28
  private
45
29
 
46
30
  def resources
47
- @resources ||= @collection.resources
31
+ @resource ||= @collection.resources
48
32
  .reject { it.metadata.feed == false }
49
33
  .sort_by { it.metadata.published_at || it.metadata.updated_at || Time.current }
50
34
  .reverse
51
35
  .take(feed_configuration.max_items)
52
36
  end
53
-
54
- def feed_configuration = @collection.configuration.feeds.json
55
-
56
- def authors(resource)
57
- author = author(resource)
58
-
59
- return nil unless author&.name
60
-
61
- [{name: author.name, email: author.email, url: author.url, avatar: author.avatar}.compact].presence
62
- end
63
37
  end
64
38
  end
65
39
  end