pressroom 0.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 (82) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +22 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +172 -0
  5. data/Rakefile +8 -0
  6. data/app/assets/javascripts/pressroom/application.js +18 -0
  7. data/app/assets/javascripts/pressroom/controllers/editor_controller.js +146 -0
  8. data/app/assets/javascripts/pressroom/vendor/code.js +181 -0
  9. data/app/assets/javascripts/pressroom/vendor/delimiter.js +98 -0
  10. data/app/assets/javascripts/pressroom/vendor/editorjs.js +11217 -0
  11. data/app/assets/javascripts/pressroom/vendor/header.js +321 -0
  12. data/app/assets/javascripts/pressroom/vendor/image.js +1103 -0
  13. data/app/assets/javascripts/pressroom/vendor/inline-code.js +99 -0
  14. data/app/assets/javascripts/pressroom/vendor/list.js +2091 -0
  15. data/app/assets/javascripts/pressroom/vendor/marker.js +95 -0
  16. data/app/assets/javascripts/pressroom/vendor/quote.js +990 -0
  17. data/app/assets/javascripts/pressroom/vendor/stimulus.js +2563 -0
  18. data/app/assets/javascripts/pressroom/vendor/turbo.js +7256 -0
  19. data/app/assets/stylesheets/pressroom/admin.css +216 -0
  20. data/app/assets/stylesheets/pressroom/content.css +18 -0
  21. data/app/controllers/pressroom/application_controller.rb +41 -0
  22. data/app/controllers/pressroom/categories_controller.rb +50 -0
  23. data/app/controllers/pressroom/posts_controller.rb +98 -0
  24. data/app/controllers/pressroom/tags_controller.rb +39 -0
  25. data/app/controllers/pressroom/uploads_controller.rb +52 -0
  26. data/app/helpers/pressroom/application_helper.rb +4 -0
  27. data/app/helpers/pressroom/blocks_helper.rb +13 -0
  28. data/app/jobs/pressroom/application_job.rb +4 -0
  29. data/app/mailers/pressroom/application_mailer.rb +6 -0
  30. data/app/models/concerns/pressroom/authorable.rb +23 -0
  31. data/app/models/concerns/pressroom/sluggable.rb +66 -0
  32. data/app/models/pressroom/application_record.rb +7 -0
  33. data/app/models/pressroom/category.rb +54 -0
  34. data/app/models/pressroom/post.rb +90 -0
  35. data/app/models/pressroom/tag.rb +30 -0
  36. data/app/models/pressroom/tagging.rb +12 -0
  37. data/app/views/layouts/pressroom/admin.html.erb +34 -0
  38. data/app/views/layouts/pressroom/application.html.erb +17 -0
  39. data/app/views/pressroom/blocks/_code.html.erb +2 -0
  40. data/app/views/pressroom/blocks/_delimiter.html.erb +1 -0
  41. data/app/views/pressroom/blocks/_header.html.erb +5 -0
  42. data/app/views/pressroom/blocks/_image.html.erb +18 -0
  43. data/app/views/pressroom/blocks/_list.html.erb +6 -0
  44. data/app/views/pressroom/blocks/_list_items.html.erb +13 -0
  45. data/app/views/pressroom/blocks/_paragraph.html.erb +1 -0
  46. data/app/views/pressroom/blocks/_quote.html.erb +6 -0
  47. data/app/views/pressroom/categories/_form.html.erb +31 -0
  48. data/app/views/pressroom/categories/edit.html.erb +3 -0
  49. data/app/views/pressroom/categories/index.html.erb +34 -0
  50. data/app/views/pressroom/categories/new.html.erb +3 -0
  51. data/app/views/pressroom/posts/_form.html.erb +49 -0
  52. data/app/views/pressroom/posts/_post_row.html.erb +17 -0
  53. data/app/views/pressroom/posts/edit.html.erb +34 -0
  54. data/app/views/pressroom/posts/index.html.erb +38 -0
  55. data/app/views/pressroom/posts/new.html.erb +3 -0
  56. data/app/views/pressroom/tags/edit.html.erb +24 -0
  57. data/app/views/pressroom/tags/index.html.erb +27 -0
  58. data/config/editorjs_manifest.yml +53 -0
  59. data/config/importmap.rb +18 -0
  60. data/config/locales/pressroom.en.yml +16 -0
  61. data/config/locales/transliteration.ru.yml +70 -0
  62. data/config/locales/transliteration.uk.yml +70 -0
  63. data/config/routes.rb +17 -0
  64. data/db/migrate/20260821000001_create_pressroom_posts.rb +42 -0
  65. data/db/migrate/20260821000002_create_pressroom_taxonomy.rb +33 -0
  66. data/lib/pressroom/blocks/definition.rb +103 -0
  67. data/lib/pressroom/blocks/registry.rb +56 -0
  68. data/lib/pressroom/blocks/renderer.rb +97 -0
  69. data/lib/pressroom/blocks/sanitizer.rb +36 -0
  70. data/lib/pressroom/blocks/schema.rb +247 -0
  71. data/lib/pressroom/blocks/text_extractor.rb +46 -0
  72. data/lib/pressroom/built_in_blocks.rb +97 -0
  73. data/lib/pressroom/configuration.rb +64 -0
  74. data/lib/pressroom/engine.rb +33 -0
  75. data/lib/pressroom/errors.rb +11 -0
  76. data/lib/pressroom/feed.rb +69 -0
  77. data/lib/pressroom/transliteration.rb +213 -0
  78. data/lib/pressroom/version.rb +3 -0
  79. data/lib/pressroom.rb +70 -0
  80. data/lib/tasks/pressroom_tasks.rake +4 -0
  81. data/lib/tasks/pressroom_vendor.rake +31 -0
  82. metadata +171 -0
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pressroom
4
+ module Blocks
5
+ # Declarative description of one block's data payload.
6
+ #
7
+ # The schema exists for security first and validation second: it records
8
+ # which fields carry HTML from Editor.js inline tools, so the renderer
9
+ # knows exactly what to run through the sanitizer and what to escape.
10
+ class Schema
11
+ Field = Struct.new(:name, :type, :required, :default, :options, :schema,
12
+ keyword_init: true)
13
+
14
+ SCALAR_TYPES = %i[string html integer boolean enum].freeze
15
+
16
+ # Guards against a hand-crafted document nesting a list deep enough
17
+ # to exhaust the stack during coercion or rendering.
18
+ MAX_TREE_DEPTH = 8
19
+
20
+ def self.build(&block)
21
+ schema = new
22
+ schema.instance_eval(&block) if block
23
+ schema
24
+ end
25
+
26
+ attr_reader :fields
27
+
28
+ def initialize
29
+ @fields = {}
30
+ end
31
+
32
+ def string(name, required: false, default: nil)
33
+ add(name, :string, required, default)
34
+ end
35
+
36
+ def html(name, required: false, default: nil)
37
+ add(name, :html, required, default)
38
+ end
39
+
40
+ def integer(name, required: false, default: nil)
41
+ add(name, :integer, required, default)
42
+ end
43
+
44
+ def boolean(name, required: false, default: false)
45
+ add(name, :boolean, required, default)
46
+ end
47
+
48
+ # +in:+ is a Ruby keyword and cannot be a parameter name, so it is
49
+ # collected through the options hash.
50
+ def enum(name, required: false, default: nil, **options)
51
+ values = Array(options.fetch(:in)).map(&:to_s)
52
+ add(name, :enum, required, default, { values: values })
53
+ end
54
+
55
+ def array(name, of:, required: false, default: nil)
56
+ add(name, :array, required, default || [], { of: of.to_sym })
57
+ end
58
+
59
+ def nested(name, required: false, &block)
60
+ add(name, :nested, required, nil, {}, Schema.build(&block))
61
+ end
62
+
63
+ # A recursive list, as produced by @editorjs/list 2.x:
64
+ # [{ "content" => "a", "items" => [{ "content" => "a1", "items" => [] }] }]
65
+ #
66
+ # Plain strings are accepted too, so documents written by the 1.x
67
+ # tool keep rendering after an upgrade.
68
+ def tree(name, content: :html, required: false)
69
+ add(name, :tree, required, [], { content: content.to_sym })
70
+ end
71
+
72
+ # Names of fields whose values must be sanitized before rendering.
73
+ def html_fields
74
+ fields.each_value.filter_map do |field|
75
+ next field.name if field.type == :html
76
+ next field.name if field.type == :array && field.options[:of] == :html
77
+ next field.name if field.type == :tree && field.options[:content] == :html
78
+
79
+ nil
80
+ end
81
+ end
82
+
83
+ # Applies the sanitizer to every field that can carry markup.
84
+ #
85
+ # This lives on the schema rather than the renderer because only the
86
+ # schema knows the shape of each field: a flat string, an array, a
87
+ # nested object, or a recursive tree.
88
+ def sanitize(data, sanitizer)
89
+ result = data.dup
90
+
91
+ fields.each do |name, field|
92
+ case field.type
93
+ when :html
94
+ result[name] = sanitizer.sanitize(result[name])
95
+ when :array
96
+ next unless field.options[:of] == :html
97
+
98
+ result[name] = Array(result[name]).map { |item| sanitizer.sanitize(item) }
99
+ when :tree
100
+ next unless field.options[:content] == :html
101
+
102
+ result[name] = sanitize_tree(Array(result[name]), sanitizer)
103
+ when :nested
104
+ result[name] = field.schema.sanitize(result[name] || {}, sanitizer)
105
+ end
106
+ end
107
+
108
+ result
109
+ end
110
+
111
+ # Returns [normalized_data, errors]. Never raises: a malformed payload
112
+ # degrades to defaults so that one bad article cannot take a page down.
113
+ def coerce(data)
114
+ data = data.is_a?(Hash) ? stringify(data) : {}
115
+ errors = []
116
+ result = {}
117
+
118
+ fields.each do |name, field|
119
+ if !data.key?(name) || data[name].nil?
120
+ errors << "#{name} is required" if field.required
121
+ result[name] = dup_default(field)
122
+ next
123
+ end
124
+
125
+ value, error = cast(field, data[name])
126
+ errors << "#{name}: #{error}" if error
127
+ result[name] = error ? dup_default(field) : value
128
+ end
129
+
130
+ [result, errors]
131
+ end
132
+
133
+ private
134
+
135
+ def add(name, type, required, default, options = {}, schema = nil)
136
+ key = name.to_s
137
+ fields[key] = Field.new(name: key, type: type, required: required,
138
+ default: default, options: options, schema: schema)
139
+ key
140
+ end
141
+
142
+ def stringify(hash)
143
+ hash.each_with_object({}) { |(k, v), memo| memo[k.to_s] = v }
144
+ end
145
+
146
+ def dup_default(field)
147
+ default = field.default
148
+ default.respond_to?(:dup) && !default.frozen? ? default.dup : default
149
+ end
150
+
151
+ def cast(field, value)
152
+ case field.type
153
+ when :string, :html
154
+ [value.to_s, nil]
155
+ when :integer
156
+ [Integer(value), nil]
157
+ when :boolean
158
+ [cast_boolean(value), nil]
159
+ when :enum
160
+ cast_enum(field, value)
161
+ when :array
162
+ cast_array(field, value)
163
+ when :tree
164
+ cast_tree(field, value)
165
+ when :nested
166
+ cast_nested(field, value)
167
+ else
168
+ [nil, "unknown field type #{field.type}"]
169
+ end
170
+ rescue ArgumentError, TypeError
171
+ [nil, "cannot be cast to #{field.type}"]
172
+ end
173
+
174
+ def cast_boolean(value)
175
+ return false if value == false || value.nil?
176
+ return false if value.to_s.strip.downcase == "false"
177
+ return false if value.to_s.strip == "0"
178
+ return false if value.to_s.strip.empty?
179
+
180
+ true
181
+ end
182
+
183
+ def cast_enum(field, value)
184
+ candidate = value.to_s
185
+ return [candidate, nil] if field.options[:values].include?(candidate)
186
+
187
+ [nil, "must be one of #{field.options[:values].join(', ')}"]
188
+ end
189
+
190
+ def cast_array(field, value)
191
+ return [nil, "must be an array"] unless value.is_a?(Array)
192
+
193
+ element = Field.new(name: field.name, type: field.options[:of],
194
+ required: false, default: nil, options: {})
195
+ [value.map { |item| cast(element, item).first }, nil]
196
+ end
197
+
198
+ def cast_nested(field, value)
199
+ return [nil, "must be an object"] unless value.is_a?(Hash)
200
+
201
+ nested_data, nested_errors = field.schema.coerce(value)
202
+ [nested_data, nested_errors.empty? ? nil : nested_errors.join("; ")]
203
+ end
204
+
205
+ def cast_tree(field, value)
206
+ return [nil, "must be an array"] unless value.is_a?(Array)
207
+
208
+ [build_tree(field, value, 0), nil]
209
+ end
210
+
211
+ def build_tree(field, items, depth)
212
+ return [] if depth >= MAX_TREE_DEPTH
213
+
214
+ items.filter_map do |item|
215
+ node = normalize_tree_node(item)
216
+ next nil if node.nil?
217
+
218
+ content, = cast(content_field(field), node[:content])
219
+ { "content" => content.to_s,
220
+ "items" => build_tree(field, Array(node[:children]), depth + 1) }
221
+ end
222
+ end
223
+
224
+ def normalize_tree_node(item)
225
+ case item
226
+ when Hash
227
+ { content: item["content"] || item[:content],
228
+ children: item["items"] || item[:items] }
229
+ when nil then nil
230
+ else { content: item, children: [] }
231
+ end
232
+ end
233
+
234
+ def content_field(field)
235
+ Field.new(name: field.name, type: field.options[:content],
236
+ required: false, default: nil, options: {})
237
+ end
238
+
239
+ def sanitize_tree(items, sanitizer)
240
+ items.map do |item|
241
+ { "content" => sanitizer.sanitize(item["content"]),
242
+ "items" => sanitize_tree(Array(item["items"]), sanitizer) }
243
+ end
244
+ end
245
+ end
246
+ end
247
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pressroom
4
+ module Blocks
5
+ # Produces plain text for the host application's search index.
6
+ #
7
+ # Never raises: it runs inside a save callback, and a block type the
8
+ # registry has not seen must not prevent an author from saving a draft.
9
+ class TextExtractor
10
+ def initialize(registry:)
11
+ @registry = registry
12
+ end
13
+
14
+ def extract(document)
15
+ blocks_of(document)
16
+ .filter_map { |block| text_for_block(block) }
17
+ .join(" ")
18
+ .gsub(/\s+/, " ")
19
+ .strip
20
+ end
21
+
22
+ private
23
+
24
+ def blocks_of(document)
25
+ case document
26
+ when Array then document.select { |item| item.is_a?(Hash) }
27
+ when Hash then Array(document["blocks"] || document[:blocks])
28
+ .select { |item| item.is_a?(Hash) }
29
+ else []
30
+ end
31
+ end
32
+
33
+ def text_for_block(block)
34
+ type = (block["type"] || block[:type]).to_s
35
+ definition = @registry.fetch(type)
36
+ return nil unless definition
37
+
38
+ data, = definition.schema.coerce(block["data"] || block[:data])
39
+ text = definition.text_for(data).to_s.strip
40
+ text.empty? ? nil : text
41
+ rescue StandardError
42
+ nil
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pressroom
4
+ # The seven core block types. Anything richer -- tables, checklists,
5
+ # embeds, attachments -- belongs in the host application or in a
6
+ # companion gem, not here: every built-in block is support surface
7
+ # forever.
8
+ module BuiltInBlocks
9
+ module_function
10
+
11
+ def register!(registry)
12
+ paragraph(registry)
13
+ header(registry)
14
+ list(registry)
15
+ quote(registry)
16
+ code(registry)
17
+ image(registry)
18
+ delimiter(registry)
19
+ end
20
+
21
+ def paragraph(registry)
22
+ registry.register(:paragraph) do |b|
23
+ b.tool "Paragraph"
24
+ b.partial "pressroom/blocks/paragraph"
25
+ b.schema { html :text }
26
+ end
27
+ end
28
+
29
+ def header(registry)
30
+ registry.register(:header) do |b|
31
+ b.tool "Header", config: { levels: [2, 3, 4], defaultLevel: 2 }
32
+ b.partial "pressroom/blocks/header"
33
+ b.schema do
34
+ html :text
35
+ integer :level, default: 2
36
+ end
37
+ end
38
+ end
39
+
40
+ def list(registry)
41
+ registry.register(:list) do |b|
42
+ b.tool "List"
43
+ b.partial "pressroom/blocks/list"
44
+ b.schema do
45
+ enum :style, in: %w[ordered unordered], default: "unordered"
46
+ tree :items, content: :html
47
+ end
48
+ end
49
+ end
50
+
51
+ def quote(registry)
52
+ registry.register(:quote) do |b|
53
+ b.tool "Quote"
54
+ b.partial "pressroom/blocks/quote"
55
+ b.schema do
56
+ html :text
57
+ html :caption
58
+ enum :alignment, in: %w[left center], default: "left"
59
+ end
60
+ end
61
+ end
62
+
63
+ def code(registry)
64
+ registry.register(:code) do |b|
65
+ b.tool "CodeTool"
66
+ b.partial "pressroom/blocks/code"
67
+ # Deliberately a string, not html: code is escaped, never sanitized.
68
+ b.schema { string :code }
69
+ end
70
+ end
71
+
72
+ def image(registry)
73
+ registry.register(:image) do |b|
74
+ b.tool "ImageTool"
75
+ b.partial "pressroom/blocks/image"
76
+ b.schema do
77
+ nested :file do
78
+ string :url
79
+ end
80
+ html :caption
81
+ boolean :withBorder
82
+ boolean :stretched
83
+ boolean :withBackground
84
+ end
85
+ b.searchable { |data| data["caption"] }
86
+ end
87
+ end
88
+
89
+ def delimiter(registry)
90
+ registry.register(:delimiter) do |b|
91
+ b.tool "Delimiter"
92
+ b.partial "pressroom/blocks/delimiter"
93
+ b.schema { }
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pressroom
4
+ # Host-application settings. Every value has a safe default; the one
5
+ # deliberate exception is +authenticate_with+, which defaults to nil so
6
+ # that an unconfigured admin refuses access instead of granting it.
7
+ class Configuration
8
+ DEFAULT_SANITIZER_TAGS = %w[b strong i em u s mark code br a].freeze
9
+ DEFAULT_SANITIZER_ATTRIBUTES = %w[href target rel class].freeze
10
+
11
+ # Drops unknown characters instead of replacing them with "?", so that
12
+ # a fully non-Latin title yields an empty string the slug generator can
13
+ # detect and fall back from. See Pressroom::Transliteration for why this
14
+ # does not simply call I18n.transliterate.
15
+ DEFAULT_TRANSLITERATOR = lambda do |string, locale|
16
+ Pressroom::Transliteration.call(string, locale)
17
+ end
18
+
19
+ attr_accessor :parent_controller, :author_class,
20
+ :author_name_method, :comments, :transliteration_locale,
21
+ :transliterator, :sanitizer_tags, :sanitizer_attributes,
22
+ :unknown_block_strategy, :admin_per_page,
23
+ :image_content_types, :max_image_bytes
24
+
25
+ attr_writer :authenticate_with
26
+
27
+ def initialize
28
+ @parent_controller = "ApplicationController"
29
+ @authenticate_with = nil
30
+ @author_class = nil
31
+ @author_name_method = :name
32
+ @comments = false
33
+ @transliteration_locale = nil
34
+ @transliterator = DEFAULT_TRANSLITERATOR
35
+ @sanitizer_tags = DEFAULT_SANITIZER_TAGS.dup
36
+ @sanitizer_attributes = DEFAULT_SANITIZER_ATTRIBUTES.dup
37
+ @unknown_block_strategy = nil
38
+ @admin_per_page = 25
39
+ @image_content_types = %w[image/png image/jpeg image/gif image/webp]
40
+ @max_image_bytes = 5 * 1024 * 1024
41
+ end
42
+
43
+ # Reader and block-form writer in one, so both spellings work:
44
+ #
45
+ # config.authenticate_with { redirect_to main_app.root_path unless ... }
46
+ # config.authenticate_with = ->(*) { ... }
47
+ def authenticate_with(&block)
48
+ @authenticate_with = block if block
49
+ @authenticate_with
50
+ end
51
+
52
+ # Explicit configuration wins; otherwise fail loudly where a developer
53
+ # will see it and degrade quietly where users would.
54
+ def resolved_unknown_block_strategy
55
+ return unknown_block_strategy if unknown_block_strategy
56
+
57
+ defined?(Rails) && Rails.env.local? ? :raise : :skip
58
+ end
59
+
60
+ def author_class_object
61
+ author_class&.constantize
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pressroom
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Pressroom
6
+
7
+ # Registration happens once at boot, not in to_prepare: the registry is
8
+ # not reloadable, and re-registering would raise on a code reload.
9
+ initializer "pressroom.blocks" do
10
+ Pressroom::BuiltInBlocks.register!(Pressroom.registry)
11
+ end
12
+
13
+ initializer "pressroom.helpers" do
14
+ ActiveSupport.on_load(:action_view) do
15
+ include Pressroom::BlocksHelper
16
+ end
17
+ end
18
+
19
+ initializer "pressroom.assets" do |app|
20
+ next unless app.config.respond_to?(:assets)
21
+
22
+ app.config.assets.paths << root.join("app/assets/javascripts")
23
+ app.config.assets.paths << root.join("app/assets/stylesheets")
24
+ end
25
+
26
+ initializer "pressroom.importmap", before: "importmap" do |app|
27
+ next unless app.config.respond_to?(:importmap)
28
+
29
+ app.config.importmap.paths << root.join("config/importmap.rb")
30
+ app.config.importmap.cache_sweepers << root.join("app/assets/javascripts")
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pressroom
4
+ class Error < StandardError; end
5
+
6
+ # Raised when the host application has not configured a required hook.
7
+ class ConfigurationError < Error; end
8
+
9
+ # Raised when a stored block has no matching definition in the registry.
10
+ class UnknownBlockError < Error; end
11
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rss"
4
+
5
+ module Pressroom
6
+ # Builds RSS 2.0 and Atom 1.0 documents from a collection of posts.
7
+ #
8
+ # The gem does not own public routes, so the caller supplies a block that
9
+ # turns a post into its public URL.
10
+ class Feed
11
+ def initialize(posts, title:, url:, description:, author: nil, &link_builder)
12
+ @posts = posts
13
+ @title = title
14
+ @url = url
15
+ @description = description
16
+ @author = author || title
17
+ @link_builder = link_builder || ->(post) { "#{url}/#{post.slug}" }
18
+ end
19
+
20
+ def to_rss
21
+ RSS::Maker.make("2.0") do |maker|
22
+ maker.channel.title = @title
23
+ maker.channel.link = @url
24
+ maker.channel.description = @description
25
+ maker.channel.updated = updated_at.to_s
26
+
27
+ add_items(maker)
28
+ end.to_s
29
+ end
30
+
31
+ def to_atom
32
+ RSS::Maker.make("atom") do |maker|
33
+ maker.channel.id = @url
34
+ maker.channel.title = @title
35
+ maker.channel.link = @url
36
+ maker.channel.description = @description
37
+ maker.channel.author = @author
38
+ maker.channel.updated = updated_at.to_s
39
+
40
+ add_items(maker)
41
+ end.to_s
42
+ end
43
+
44
+ private
45
+
46
+ def add_items(maker)
47
+ @posts.each do |post|
48
+ link = @link_builder.call(post)
49
+
50
+ maker.items.new_item do |item|
51
+ item.id = link
52
+ item.title = post.title
53
+ item.link = link
54
+ item.description = post.excerpt.to_s
55
+ item.updated = timestamp_for(post).to_s
56
+ item.pubDate = timestamp_for(post).to_s
57
+ end
58
+ end
59
+ end
60
+
61
+ def timestamp_for(post)
62
+ post.published_at || post.updated_at || Time.current
63
+ end
64
+
65
+ def updated_at
66
+ @posts.filter_map { |post| timestamp_for(post) }.max || Time.current
67
+ end
68
+ end
69
+ end