perron 0.18.0 → 1.1.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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +11 -15
  3. data/app/controllers/perron/concierge_controller.rb +16 -0
  4. data/app/helpers/perron/markdown_helper.rb +3 -2
  5. data/app/helpers/perron/meta_tags_helper.rb +3 -9
  6. data/app/helpers/perron/paginate_helper.rb +40 -0
  7. data/app/views/perron/concierge/show.html.erb +271 -0
  8. data/lib/generators/rails/content/USAGE +21 -4
  9. data/lib/generators/rails/content/content_generator.rb +16 -12
  10. data/lib/generators/rails/content/templates/controller.rb.tt +6 -0
  11. data/lib/generators/rails/content/templates/model.rb.tt +1 -1
  12. data/lib/perron/assets/icon.png +0 -0
  13. data/lib/perron/assets/icon.svg +1 -0
  14. data/lib/perron/collection.rb +2 -1
  15. data/lib/perron/configuration.rb +27 -2
  16. data/lib/perron/data_source/class_methods.rb +8 -0
  17. data/lib/perron/data_source.rb +20 -33
  18. data/lib/perron/development_feed_server.rb +69 -0
  19. data/lib/perron/engine.rb +32 -1
  20. data/lib/perron/errors.rb +2 -0
  21. data/lib/perron/feeds.rb +4 -3
  22. data/lib/perron/html_processor/absolute_urls.rb +27 -0
  23. data/lib/perron/html_processor/base.rb +2 -2
  24. data/lib/perron/html_processor.rb +7 -11
  25. data/lib/{generators/perron/templates → perron/install}/README.md.tt +7 -9
  26. data/lib/perron/install/deploy.yml +15 -0
  27. data/lib/perron/install.rb +26 -0
  28. data/lib/perron/markdown.rb +2 -2
  29. data/lib/perron/output_server.rb +9 -0
  30. data/lib/perron/paginate.rb +58 -0
  31. data/lib/perron/relation.rb +24 -6
  32. data/lib/perron/resource/adjacency.rb +70 -0
  33. data/lib/perron/resource/associations.rb +1 -1
  34. data/lib/perron/resource/class_methods.rb +6 -0
  35. data/lib/perron/resource/configuration.rb +12 -4
  36. data/lib/perron/resource/metadata.rb +19 -4
  37. data/lib/perron/resource/publishable.rb +2 -0
  38. data/lib/perron/resource/related.rb +32 -31
  39. data/lib/perron/resource/sourceable.rb +98 -16
  40. data/lib/perron/resource.rb +8 -0
  41. data/lib/perron/site/builder/assets.rb +1 -1
  42. data/lib/perron/site/builder/feeds/atom.erb +44 -0
  43. data/lib/perron/site/builder/feeds/atom.rb +41 -0
  44. data/lib/perron/site/builder/feeds/json.erb +19 -0
  45. data/lib/perron/site/builder/feeds/json.rb +7 -33
  46. data/lib/perron/site/builder/feeds/rss.erb +28 -0
  47. data/lib/perron/site/builder/feeds/rss.rb +6 -28
  48. data/lib/perron/site/builder/feeds/template.rb +63 -0
  49. data/lib/perron/site/builder/feeds.rb +8 -3
  50. data/lib/perron/site/builder/page.rb +19 -4
  51. data/lib/perron/site/builder/paths.rb +75 -13
  52. data/lib/perron/site/builder/route_resources.rb +79 -0
  53. data/lib/perron/site/builder/sitemap.rb +71 -20
  54. data/lib/perron/site/builder.rb +25 -1
  55. data/lib/perron/site/validate.rb +19 -7
  56. data/lib/perron/site.rb +7 -0
  57. data/lib/perron/tasks/build.rake +6 -7
  58. data/lib/perron/tasks/deploy.rake +58 -0
  59. data/lib/perron/tasks/install.rake +12 -0
  60. data/lib/perron/version.rb +1 -1
  61. data/lib/perron.rb +1 -0
  62. data/perron.gemspec +1 -1
  63. metadata +25 -8
  64. data/lib/generators/perron/install_generator.rb +0 -32
  65. data/lib/perron/html_processor/syntax_highlight.rb +0 -32
  66. /data/lib/{generators/perron/templates → perron/install}/initializer.rb.tt +0 -0
@@ -12,20 +12,28 @@ module Perron
12
12
 
13
13
  config.feeds = Options.new
14
14
 
15
- config.feeds.rss = ActiveSupport::OrderedOptions.new
16
- config.feeds.rss.enabled = false
17
- config.feeds.rss.path = "feeds/#{collection.name.demodulize.parameterize}.xml"
18
- config.feeds.rss.max_items = 20
15
+ config.feeds.atom = ActiveSupport::OrderedOptions.new
16
+ config.feeds.atom.enabled = false
17
+ config.feeds.atom.path = "feeds/#{collection.name.demodulize.parameterize}.atom"
18
+ config.feeds.atom.max_items = 20
19
19
 
20
20
  config.feeds.json = ActiveSupport::OrderedOptions.new
21
21
  config.feeds.json.enabled = false
22
22
  config.feeds.json.path = "feeds/#{collection.name.demodulize.parameterize}.json"
23
23
  config.feeds.json.max_items = 20
24
24
 
25
+ config.feeds.rss = ActiveSupport::OrderedOptions.new
26
+ config.feeds.rss.enabled = false
27
+ config.feeds.rss.path = "feeds/#{collection.name.demodulize.parameterize}.xml"
28
+ config.feeds.rss.max_items = 20
29
+
25
30
  config.related_posts = ActiveSupport::OrderedOptions.new
26
31
  config.related_posts.enabled = false
27
32
  config.related_posts.max = 5
28
33
 
34
+ config.pagination = ActiveSupport::OrderedOptions.new
35
+ config.pagination.path_template = "/page/:page/"
36
+
29
37
  config.sitemap = ActiveSupport::OrderedOptions.new
30
38
  config.sitemap.exclude = false
31
39
  end
@@ -3,28 +3,36 @@
3
3
  module Perron
4
4
  class Resource
5
5
  class Metadata
6
- def initialize(resource:, frontmatter:, collection:)
6
+ def initialize(resource:, frontmatter:, collection:, controller_metadata: {})
7
7
  @resource = resource
8
8
  @frontmatter = frontmatter&.deep_symbolize_keys || {}
9
9
  @collection = collection
10
+ @controller_metadata = controller_metadata
10
11
  @config = Perron.configuration
11
12
  end
12
13
 
13
14
  def data
14
15
  @data ||= ActiveSupport::OrderedOptions
15
16
  .new
16
- .merge(apply_fallbacks_and_defaults(to: merged_site_collection_resource_frontmatter))
17
+ .merge(apply_fallbacks_and_defaults(to: merged_metadata))
17
18
  end
18
19
 
19
20
  private
20
21
 
21
- def merged_site_collection_resource_frontmatter = site_data.merge(collection_data).merge(@frontmatter)
22
+ def merged_metadata
23
+ site_data
24
+ .merge(collection_data)
25
+ .merge(@controller_metadata)
26
+ .merge(@frontmatter)
27
+ end
22
28
 
23
29
  def apply_fallbacks_and_defaults(to:)
24
30
  to[:title] ||= @config.site_name || Rails.application.name.underscore.camelize
25
31
 
26
32
  to[:canonical_url] ||= canonical_url
27
33
 
34
+ to[:image] = absolute_url(to[:image]) if to[:image]
35
+
28
36
  to[:og_image] ||= to[:image]
29
37
  to[:twitter_image] ||= to[:og_image]
30
38
 
@@ -52,13 +60,20 @@ module Perron
52
60
  begin
53
61
  Rails.application.routes.url_helpers.polymorphic_url(
54
62
  @resource,
55
- **Perron.configuration.default_url_options
63
+ **Perron.configuration.default_url_options
56
64
  )
57
65
  rescue
58
66
  false
59
67
  end
60
68
  end
61
69
 
70
+ def absolute_url(path)
71
+ return path if path.blank?
72
+ return path if path.start_with?("http://", "https://", "//")
73
+
74
+ Perron.configuration.url.delete_suffix("/") + path
75
+ end
76
+
62
77
  def site_data
63
78
  @config.metadata.except(:title_separator, :title_suffix).deep_symbolize_keys || {}
64
79
  end
@@ -7,6 +7,8 @@ module Perron
7
7
 
8
8
  included do
9
9
  def published?
10
+ return ENV["VIEW_UNPUBLISHED"] == "true" if ENV["VIEW_UNPUBLISHED"]
11
+
10
12
  return true if Perron.configuration.view_unpublished
11
13
 
12
14
  return false if frontmatter.draft == true
@@ -9,6 +9,7 @@ module Perron
9
9
  #
10
10
  # Pre-normalizes vectors so cosine similarity reduces to a dot product,
11
11
  # then builds a symmetric similarity matrix once per collection.
12
+ #
12
13
  # Results are cached at the class level so the O(n²) comparison
13
14
  # is paid once, not once per resource.
14
15
  class Related
@@ -18,7 +19,8 @@ module Perron
18
19
 
19
20
  def self.cache_for(collection_name)
20
21
  clear_cache!(collection_name) if stale?(collection_name)
21
- @collection_caches[collection_name] ||= Cache.new(nil, nil, content_fingerprint(collection_name))
22
+
23
+ @collection_caches[collection_name] ||= Cache.new(nil, nil, fingerprinted(collection_name))
22
24
  end
23
25
 
24
26
  def self.clear_cache!(collection_name)
@@ -26,12 +28,13 @@ module Perron
26
28
  end
27
29
 
28
30
  def self.stale?(collection_name)
29
- @collection_caches[collection_name]&.fingerprint != content_fingerprint(collection_name)
31
+ @collection_caches[collection_name]&.fingerprint != fingerprinted(collection_name)
30
32
  end
31
33
 
32
- def self.content_fingerprint(collection_name)
34
+ def self.fingerprinted(collection_name)
33
35
  path = File.join(Perron.configuration.input, collection_name)
34
36
  files = Dir.glob(File.join(path, "**", "*.*"))
37
+
35
38
  [files.size, files.map { File.mtime(it) }.max]
36
39
  end
37
40
 
@@ -52,55 +55,53 @@ module Perron
52
55
 
53
56
  private
54
57
 
55
- def resources = @cache.resources ||= @collection.resources
58
+ def resources
59
+ @cache.resources ||= @collection.resources
60
+ end
56
61
 
57
- def similarity_matrix = @cache.similarity_matrix ||= build_similarity_matrix
62
+ def similarity_matrix
63
+ @cache.similarity_matrix ||= build_similarity_matrix
64
+ end
58
65
 
59
66
  def build_similarity_matrix
60
67
  vectors = resources.to_h { [it.slug, normalize(tfidf_vector_for(it))] }
61
- matrix = Hash.new { |h, k| h[k] = {} }
62
68
 
63
- slugs = vectors.keys
64
- slugs.each_with_index do |slug_a, i|
65
- next if vectors[slug_a].empty?
69
+ Hash.new { |hash, key| hash[key] = {} }.tap do |matrix|
70
+ slugs = vectors.keys
71
+
72
+ slugs.each_with_index do |slug_a, index|
73
+ next if vectors[slug_a].empty?
66
74
 
67
- slugs[(i + 1)..].each do |slug_b|
68
- next if vectors[slug_b].empty?
75
+ slugs[(index + 1)..].each do |slug_b|
76
+ next if vectors[slug_b].empty?
69
77
 
70
- score = dot_product(vectors[slug_a], vectors[slug_b])
71
- matrix[slug_a][slug_b] = score
72
- matrix[slug_b][slug_a] = score
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
73
81
  end
74
82
  end
75
-
76
- matrix
77
- end
78
-
79
- def dot_product(vec_a, vec_b)
80
- score = 0.0
81
- vec_a.each_key { score += vec_a[it] * vec_b[it] if vec_b.key?(it) }
82
- score
83
83
  end
84
84
 
85
85
  def normalize(vector)
86
- return {} if vector.empty?
87
-
88
86
  magnitude = Math.sqrt(vector.values.sum { it**2 })
89
- return {} if magnitude.zero?
87
+ return {} if vector.empty? || magnitude.zero?
90
88
 
91
89
  vector.transform_values { it / magnitude }
92
90
  end
93
91
 
94
92
  def tfidf_vector_for(resource)
95
- tokens = tokenize(resource)
93
+ tokens = tokenized(resource)
96
94
  return {} if tokens.empty?
97
95
 
98
96
  token_count = tokens.size.to_f
99
-
100
97
  tokens.tally.to_h { |term, count| [term, (count / token_count) * inverse_document_frequency[term]] }
101
98
  end
102
99
 
103
- def tokenize(resource)
100
+ def dot_product(vector_a, vector_b)
101
+ vector_a.sum { |term, value| value * (vector_b[term] || 0) }
102
+ end
103
+
104
+ def tokenized(resource)
104
105
  return [] if resource.content.blank?
105
106
 
106
107
  resource.content.gsub(/<[^>]*>/, " ").downcase.scan(/\w+/).reject { StopWords.all.include?(it) || it.length < 3 }
@@ -108,11 +109,11 @@ module Perron
108
109
 
109
110
  def inverse_document_frequency
110
111
  @inverse_document_frequency ||= begin
111
- doc_frequency = Hash.new(0)
112
- resources.each { tokenize(it).uniq.each { doc_frequency[it] += 1 } }
112
+ document_frequency = Hash.new(0)
113
+ resources.each { tokenized(it).uniq.each { document_frequency[it] += 1 } }
113
114
 
114
115
  total = resources.size.to_f
115
- doc_frequency.transform_values { Math.log(total / (1 + it)) }
116
+ document_frequency.transform_values { Math.log(total / (1 + it)) }
116
117
  end
117
118
  end
118
119
  end
@@ -5,6 +5,11 @@ module Perron
5
5
  module Sourceable
6
6
  extend ActiveSupport::Concern
7
7
 
8
+ MODES = {
9
+ single: ->(dataset) { dataset.zip },
10
+ combinations: ->(dataset) { dataset.to_a.combination(2).to_a }
11
+ }
12
+
8
13
  class_methods do
9
14
  def sources(*arguments)
10
15
  @source_definitions = parsed(*arguments)
@@ -15,12 +20,24 @@ module Perron
15
20
  @source_definitions || {}
16
21
  end
17
22
 
23
+ def resolve(name)
24
+ definition = source_definitions[name]
25
+
26
+ data = if definition[:class]
27
+ definition[:class].all
28
+ else
29
+ Perron::DataSource.new(name.to_s).to_a
30
+ end
31
+
32
+ definition[:scope] ? definition[:scope].call(data) : data
33
+ end
34
+
18
35
  def source_names = source_definitions.keys
19
36
 
20
37
  def generate_from_sources!
21
38
  return unless source_backed?
22
39
 
23
- combinations.each do |combo|
40
+ derive.each do |combo|
24
41
  content = content_with combo
25
42
  filename = filename_with combo
26
43
 
@@ -36,28 +53,91 @@ module Perron
36
53
  def parsed(*arguments)
37
54
  return {} if arguments.empty?
38
55
 
39
- arguments.flat_map { it.is_a?(Hash) ? it.to_a : [[it, {primary_key: :id}]] }.to_h
56
+ definitions = arguments.flat_map do |argument|
57
+ case argument
58
+ when Hash
59
+ argument.to_a
60
+ when Proc
61
+ [[SecureRandom.hex(8).to_sym, {scope: argument, primary_key: :id}]]
62
+ else
63
+ [[argument, {primary_key: :id}]]
64
+ end
65
+ end.to_h
66
+
67
+ if definitions.values.any? { it[:mode] }
68
+ raise ArgumentError, "mode is only supported for single-source definitions" if definitions.size > 1
69
+ end
70
+
71
+ definitions
40
72
  end
41
73
 
42
- def combinations
43
- datasets = source_names.map { Perron::DataSource.new(it.to_s) }
74
+ def derive
75
+ datasets = source_names.map { resolve it }
44
76
 
45
- datasets.first.product(*datasets[1..])
77
+ if single_source_with_mode?
78
+ derive_from_single_source(datasets.first)
79
+ else
80
+ derive_from_multiple_sources(datasets)
81
+ end
82
+ end
83
+
84
+ def derive_from_single_source(dataset)
85
+ source_name = source_names.first
86
+ mode = source_definitions[source_name][:mode] || :single
87
+ method = MODES[mode.to_sym] || raise(ArgumentError, "Unknown mode: #{mode}")
88
+
89
+ method.call(dataset).each do |combo|
90
+ primary_key = source_definitions[source_name][:primary_key] || :id
91
+
92
+ combo.each do |item|
93
+ raise Errors::DataParseError, "Primary key `#{primary_key}` is nil for row" if item.public_send(primary_key).nil?
94
+ end
95
+ end
96
+ end
97
+
98
+ def derive_from_multiple_sources(datasets)
99
+ datasets.first.product(*datasets[1..]).each do |combo|
100
+ combo.each_with_index do |item, index|
101
+ name = source_names[index]
102
+ primary_key = source_definitions[name][:primary_key] || :id
103
+
104
+ raise Errors::DataParseError, "Primary key `#{primary_key}` is nil for row in source `#{name}`" if item.public_send(primary_key).nil?
105
+ end
106
+ end
46
107
  end
47
108
 
48
109
  def content_with(combo)
49
- data = source_names.each.with_index.to_h { |name, index| [name, combo[index]] }
50
- sources = Source.new(data)
110
+ data = if single_source_with_mode?
111
+ source_name = source_names.first
112
+ names = source_definitions[source_name][:as]&.map(&:to_sym) || (1..combo.size).map { :"#{source_name}_#{it}" }
113
+
114
+ combo.each_with_index.to_h { |item, index| [names[index], item] }
115
+ else
116
+ source_names.each_with_index.to_h { |name, index| [name, combo[index]] }
117
+ end
118
+
119
+ source = Source.new(data)
51
120
 
52
- source_template(sources)
121
+ source_template(source)
53
122
  end
54
123
 
55
124
  def filename_with(combo)
56
- source_names.each_with_index.map do |name, index|
57
- primary_key = source_definitions[name][:primary_key]
125
+ if single_source_with_mode?
126
+ source_name = source_names.first
127
+ primary_key = source_definitions[source_name][:primary_key] || :id
128
+
129
+ combo.map { it.public_send(primary_key) }.join("-")
130
+ else
131
+ source_names.each_with_index.map do |name, index|
132
+ primary_key = source_definitions[name][:primary_key]
133
+
134
+ combo[index].public_send(primary_key)
135
+ end.join("-")
136
+ end
137
+ end
58
138
 
59
- combo[index].public_send(primary_key)
60
- end.join("-")
139
+ def single_source_with_mode?
140
+ source_names.one? && source_definitions[source_names.first][:mode]
61
141
  end
62
142
 
63
143
  def output_dir = Perron.configuration.input.join(model_name.collection)
@@ -65,21 +145,23 @@ module Perron
65
145
 
66
146
  def source_backed? = self.class.source_backed?
67
147
 
68
- def sources
69
- @sources ||= begin
148
+ def source
149
+ @source ||= begin
70
150
  data = self.class.source_definitions.each_with_object({}) do |(name, options), hash|
71
151
  primary_key = options[:primary_key]
72
152
  singular_name = name.to_s.singularize
73
153
  identifier = frontmatter["#{singular_name}_#{primary_key}"]
74
154
 
75
- hash[name] = Perron::DataSource.new(name.to_s).find { it.public_send(primary_key).to_s == identifier.to_s }
155
+ dataset = self.class.send(:resolve, name)
156
+ hash[name] = dataset.find { it.public_send(primary_key).to_s == identifier.to_s }
76
157
  end
77
158
 
78
159
  Source.new(data)
79
160
  end
80
161
  end
162
+ alias_method :sources, :source
81
163
 
82
- def source_template(sources)
164
+ def source_template(source)
83
165
  raise NotImplementedError, "#{self.class.name} must implement #source_template"
84
166
  end
85
167
 
@@ -17,6 +17,7 @@ require "perron/resource/searchable"
17
17
  require "perron/resource/separator"
18
18
  require "perron/resource/sourceable"
19
19
  require "perron/resource/sweeper"
20
+ require "perron/resource/adjacency"
20
21
  require "perron/resource/table_of_content"
21
22
 
22
23
  module Perron
@@ -34,6 +35,7 @@ module Perron
34
35
  include Previewable
35
36
  include Scopes
36
37
  include Sweeper
38
+ include Adjacency
37
39
  include TableOfContent
38
40
 
39
41
  attr_reader :file_path, :id
@@ -94,6 +96,10 @@ module Perron
94
96
  slug == "/"
95
97
  end
96
98
 
99
+ def destroy
100
+ File.delete(@file_path) and self
101
+ end
102
+
97
103
  private
98
104
 
99
105
  ID_LENGTH = 8
@@ -115,6 +121,8 @@ module Perron
115
121
  end
116
122
 
117
123
  def erb_processing?
124
+ return false if metadata.erb == false
125
+
118
126
  @file_path.ends_with?(".erb") || metadata.erb == true
119
127
  end
120
128
  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) %></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
@@ -0,0 +1,28 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
3
+ <channel>
4
+ <generator>Perron (<%= Perron::VERSION %>)</generator>
5
+ <link><%= @configuration.url %></link>
6
+ <title><%= config.title.presence || @configuration.site_name %></title>
7
+ <description><%= config.description.presence || @configuration.site_description %></description>
8
+
9
+ <% resources.each do |resource| %>
10
+ <item>
11
+ <guid isPermaLink="false"><%= resource.id %></guid>
12
+
13
+ <% resource_url = url_for_resource.call(resource) %>
14
+ <% if resource_url %>
15
+ <link><%= resource_url %></link>
16
+ <% end %>
17
+
18
+ <pubDate><%= resource.published_at&.rfc822 %></pubDate>
19
+ <% entry_author = author.call(resource); if entry_author && entry_author.email %>
20
+ <author><%= entry_author.name ? "#{entry_author.email} (#{entry_author.name})" : entry_author.email %></author>
21
+ <% end %>
22
+ <title><%= resource.metadata.title %></title>
23
+
24
+ <description><![CDATA[<%= Perron::Markdown.render(resource.content, processors: ["absolute_urls"]) %>]]></description>
25
+ </item>
26
+ <% end %>
27
+ </channel>
28
+ </rss>