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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +22 -0
- data/MIT-LICENSE +20 -0
- data/README.md +172 -0
- data/Rakefile +8 -0
- data/app/assets/javascripts/pressroom/application.js +18 -0
- data/app/assets/javascripts/pressroom/controllers/editor_controller.js +146 -0
- data/app/assets/javascripts/pressroom/vendor/code.js +181 -0
- data/app/assets/javascripts/pressroom/vendor/delimiter.js +98 -0
- data/app/assets/javascripts/pressroom/vendor/editorjs.js +11217 -0
- data/app/assets/javascripts/pressroom/vendor/header.js +321 -0
- data/app/assets/javascripts/pressroom/vendor/image.js +1103 -0
- data/app/assets/javascripts/pressroom/vendor/inline-code.js +99 -0
- data/app/assets/javascripts/pressroom/vendor/list.js +2091 -0
- data/app/assets/javascripts/pressroom/vendor/marker.js +95 -0
- data/app/assets/javascripts/pressroom/vendor/quote.js +990 -0
- data/app/assets/javascripts/pressroom/vendor/stimulus.js +2563 -0
- data/app/assets/javascripts/pressroom/vendor/turbo.js +7256 -0
- data/app/assets/stylesheets/pressroom/admin.css +216 -0
- data/app/assets/stylesheets/pressroom/content.css +18 -0
- data/app/controllers/pressroom/application_controller.rb +41 -0
- data/app/controllers/pressroom/categories_controller.rb +50 -0
- data/app/controllers/pressroom/posts_controller.rb +98 -0
- data/app/controllers/pressroom/tags_controller.rb +39 -0
- data/app/controllers/pressroom/uploads_controller.rb +52 -0
- data/app/helpers/pressroom/application_helper.rb +4 -0
- data/app/helpers/pressroom/blocks_helper.rb +13 -0
- data/app/jobs/pressroom/application_job.rb +4 -0
- data/app/mailers/pressroom/application_mailer.rb +6 -0
- data/app/models/concerns/pressroom/authorable.rb +23 -0
- data/app/models/concerns/pressroom/sluggable.rb +66 -0
- data/app/models/pressroom/application_record.rb +7 -0
- data/app/models/pressroom/category.rb +54 -0
- data/app/models/pressroom/post.rb +90 -0
- data/app/models/pressroom/tag.rb +30 -0
- data/app/models/pressroom/tagging.rb +12 -0
- data/app/views/layouts/pressroom/admin.html.erb +34 -0
- data/app/views/layouts/pressroom/application.html.erb +17 -0
- data/app/views/pressroom/blocks/_code.html.erb +2 -0
- data/app/views/pressroom/blocks/_delimiter.html.erb +1 -0
- data/app/views/pressroom/blocks/_header.html.erb +5 -0
- data/app/views/pressroom/blocks/_image.html.erb +18 -0
- data/app/views/pressroom/blocks/_list.html.erb +6 -0
- data/app/views/pressroom/blocks/_list_items.html.erb +13 -0
- data/app/views/pressroom/blocks/_paragraph.html.erb +1 -0
- data/app/views/pressroom/blocks/_quote.html.erb +6 -0
- data/app/views/pressroom/categories/_form.html.erb +31 -0
- data/app/views/pressroom/categories/edit.html.erb +3 -0
- data/app/views/pressroom/categories/index.html.erb +34 -0
- data/app/views/pressroom/categories/new.html.erb +3 -0
- data/app/views/pressroom/posts/_form.html.erb +49 -0
- data/app/views/pressroom/posts/_post_row.html.erb +17 -0
- data/app/views/pressroom/posts/edit.html.erb +34 -0
- data/app/views/pressroom/posts/index.html.erb +38 -0
- data/app/views/pressroom/posts/new.html.erb +3 -0
- data/app/views/pressroom/tags/edit.html.erb +24 -0
- data/app/views/pressroom/tags/index.html.erb +27 -0
- data/config/editorjs_manifest.yml +53 -0
- data/config/importmap.rb +18 -0
- data/config/locales/pressroom.en.yml +16 -0
- data/config/locales/transliteration.ru.yml +70 -0
- data/config/locales/transliteration.uk.yml +70 -0
- data/config/routes.rb +17 -0
- data/db/migrate/20260821000001_create_pressroom_posts.rb +42 -0
- data/db/migrate/20260821000002_create_pressroom_taxonomy.rb +33 -0
- data/lib/pressroom/blocks/definition.rb +103 -0
- data/lib/pressroom/blocks/registry.rb +56 -0
- data/lib/pressroom/blocks/renderer.rb +97 -0
- data/lib/pressroom/blocks/sanitizer.rb +36 -0
- data/lib/pressroom/blocks/schema.rb +247 -0
- data/lib/pressroom/blocks/text_extractor.rb +46 -0
- data/lib/pressroom/built_in_blocks.rb +97 -0
- data/lib/pressroom/configuration.rb +64 -0
- data/lib/pressroom/engine.rb +33 -0
- data/lib/pressroom/errors.rb +11 -0
- data/lib/pressroom/feed.rb +69 -0
- data/lib/pressroom/transliteration.rb +213 -0
- data/lib/pressroom/version.rb +3 -0
- data/lib/pressroom.rb +70 -0
- data/lib/tasks/pressroom_tasks.rake +4 -0
- data/lib/tasks/pressroom_vendor.rake +31 -0
- 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
|