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.
- checksums.yaml +4 -4
- data/Gemfile.lock +11 -15
- data/app/controllers/perron/concierge_controller.rb +16 -0
- data/app/helpers/perron/markdown_helper.rb +3 -2
- data/app/helpers/perron/meta_tags_helper.rb +3 -9
- data/app/helpers/perron/paginate_helper.rb +40 -0
- data/app/views/perron/concierge/show.html.erb +271 -0
- data/lib/generators/rails/content/USAGE +21 -4
- data/lib/generators/rails/content/content_generator.rb +16 -12
- data/lib/generators/rails/content/templates/controller.rb.tt +6 -0
- data/lib/generators/rails/content/templates/model.rb.tt +1 -1
- data/lib/perron/assets/icon.png +0 -0
- data/lib/perron/assets/icon.svg +1 -0
- data/lib/perron/collection.rb +2 -1
- data/lib/perron/configuration.rb +27 -2
- data/lib/perron/data_source/class_methods.rb +8 -0
- data/lib/perron/data_source.rb +20 -33
- data/lib/perron/development_feed_server.rb +69 -0
- data/lib/perron/engine.rb +32 -1
- data/lib/perron/errors.rb +2 -0
- data/lib/perron/feeds.rb +4 -3
- data/lib/perron/html_processor/absolute_urls.rb +27 -0
- data/lib/perron/html_processor/base.rb +2 -2
- data/lib/perron/html_processor.rb +7 -11
- data/lib/{generators/perron/templates → perron/install}/README.md.tt +7 -9
- data/lib/perron/install/deploy.yml +15 -0
- data/lib/perron/install.rb +26 -0
- data/lib/perron/markdown.rb +2 -2
- data/lib/perron/output_server.rb +9 -0
- data/lib/perron/paginate.rb +58 -0
- data/lib/perron/relation.rb +24 -6
- data/lib/perron/resource/adjacency.rb +70 -0
- data/lib/perron/resource/associations.rb +1 -1
- data/lib/perron/resource/class_methods.rb +6 -0
- data/lib/perron/resource/configuration.rb +12 -4
- data/lib/perron/resource/metadata.rb +19 -4
- data/lib/perron/resource/publishable.rb +2 -0
- data/lib/perron/resource/related.rb +32 -31
- data/lib/perron/resource/sourceable.rb +98 -16
- data/lib/perron/resource.rb +8 -0
- data/lib/perron/site/builder/assets.rb +1 -1
- data/lib/perron/site/builder/feeds/atom.erb +44 -0
- data/lib/perron/site/builder/feeds/atom.rb +41 -0
- data/lib/perron/site/builder/feeds/json.erb +19 -0
- data/lib/perron/site/builder/feeds/json.rb +7 -33
- data/lib/perron/site/builder/feeds/rss.erb +28 -0
- data/lib/perron/site/builder/feeds/rss.rb +6 -28
- data/lib/perron/site/builder/feeds/template.rb +63 -0
- data/lib/perron/site/builder/feeds.rb +8 -3
- data/lib/perron/site/builder/page.rb +19 -4
- data/lib/perron/site/builder/paths.rb +75 -13
- data/lib/perron/site/builder/route_resources.rb +79 -0
- data/lib/perron/site/builder/sitemap.rb +71 -20
- data/lib/perron/site/builder.rb +25 -1
- data/lib/perron/site/validate.rb +19 -7
- data/lib/perron/site.rb +7 -0
- data/lib/perron/tasks/build.rake +6 -7
- data/lib/perron/tasks/deploy.rake +58 -0
- data/lib/perron/tasks/install.rake +12 -0
- data/lib/perron/version.rb +1 -1
- data/lib/perron.rb +1 -0
- data/perron.gemspec +1 -1
- metadata +25 -8
- data/lib/generators/perron/install_generator.rb +0 -32
- data/lib/perron/html_processor/syntax_highlight.rb +0 -32
- /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.
|
|
16
|
-
config.feeds.
|
|
17
|
-
config.feeds.
|
|
18
|
-
config.feeds.
|
|
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:
|
|
17
|
+
.merge(apply_fallbacks_and_defaults(to: merged_metadata))
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
private
|
|
20
21
|
|
|
21
|
-
def
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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 !=
|
|
31
|
+
@collection_caches[collection_name]&.fingerprint != fingerprinted(collection_name)
|
|
30
32
|
end
|
|
31
33
|
|
|
32
|
-
def self.
|
|
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
|
|
58
|
+
def resources
|
|
59
|
+
@cache.resources ||= @collection.resources
|
|
60
|
+
end
|
|
56
61
|
|
|
57
|
-
def 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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
75
|
+
slugs[(index + 1)..].each do |slug_b|
|
|
76
|
+
next if vectors[slug_b].empty?
|
|
69
77
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
112
|
-
resources.each {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
43
|
-
datasets = source_names.map {
|
|
74
|
+
def derive
|
|
75
|
+
datasets = source_names.map { resolve it }
|
|
44
76
|
|
|
45
|
-
|
|
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 =
|
|
50
|
-
|
|
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(
|
|
121
|
+
source_template(source)
|
|
53
122
|
end
|
|
54
123
|
|
|
55
124
|
def filename_with(combo)
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
|
69
|
-
@
|
|
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
|
-
|
|
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(
|
|
164
|
+
def source_template(source)
|
|
83
165
|
raise NotImplementedError, "#{self.class.name} must implement #source_template"
|
|
84
166
|
end
|
|
85
167
|
|
data/lib/perron/resource.rb
CHANGED
|
@@ -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
|
|
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
|
|
20
|
+
return if resources.empty?
|
|
20
21
|
|
|
21
|
-
|
|
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
|
-
|
|
25
|
+
render(template, feed_configuration)
|
|
42
26
|
end
|
|
43
27
|
|
|
44
28
|
private
|
|
45
29
|
|
|
46
30
|
def resources
|
|
47
|
-
@
|
|
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>
|