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