perron 0.8.0 → 0.9.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9605385eb10b149f1f679d423a2367ed7f35684d381699692d329a1d05a2f6ea
4
- data.tar.gz: e3803d65512af0de01c0cee99afdbbd2a8fed6d54afb823a9b64716469ea0fc6
3
+ metadata.gz: 2f225e6b75be66a9fd394e0c87b3c63c878042664e3acfeaa7ff68cc449af1b1
4
+ data.tar.gz: a963c546505ffa227310b7e0d8cba7a40446d0a70b86b8491f7baa761bf6e0cb
5
5
  SHA512:
6
- metadata.gz: '090e644895f9f3fabb0b1ca3839592838864c5c6cb0a1c31acd83dbe2ef8c0ee2cbad5a13308e8986928f660d54c342f494858fcd7eb33038dc57d32834b03f8'
7
- data.tar.gz: 1647b01aed3aa31f8aaac047ffe7880f58fe47530d81445077652c15635efdc99eeaedfdc7e12af87ab914ae3e4e80edda0eeec42404bdb5d50813527ac642a0
6
+ metadata.gz: 3f9926aba1d4c88204131fc61564173334a059bcc832c7457f0c31a276b1e05d1466c196afc8a25f54091402436618bee7ae97c6397fdb0660a1da50dbb80231
7
+ data.tar.gz: 54f74446e210fb7ac299b0dc5496bcce18676128fdf9af994403513f8f4ddff37aee8933e58cc5d4e3cbf7316667b0b19860863ddaec6da78ca79c9ac81d3fd6
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- perron (0.8.0)
4
+ perron (0.9.0)
5
5
  csv
6
6
  json
7
7
  psych
data/README.md CHANGED
@@ -160,6 +160,51 @@ feature[:name]
160
160
  ```
161
161
 
162
162
 
163
+ ## Feeds
164
+
165
+ The `feeds` helper automatically generates HTML `<link>` tags for your site's RSS and JSON feeds.
166
+
167
+
168
+ ### Usage
169
+
170
+ In your layout (e.g., `app/views/layouts/application.html.erb`), add the helper to the `<head>` section:
171
+ ```erb
172
+ <head>
173
+
174
+ <%= feeds %>
175
+
176
+ </head>
177
+ ```
178
+
179
+ To render feeds for specific collections, such as `posts`:
180
+ ```erb
181
+ <%= feeds only: %w[posts] %>
182
+ ```
183
+
184
+ Similarly, you can exclude collections:
185
+ ```erb
186
+ <%= feeds except: %w[pages] %>
187
+ ```
188
+
189
+
190
+ ### Configuration
191
+
192
+ Feeds are configured within the `Resource` class corresponding to a collection:
193
+ ```ruby
194
+ # app/models/content/post.rb
195
+ class Content::Post < Perron::Resource
196
+ configure do |config|
197
+ config.feeds.rss.enabled = true
198
+ # config.feeds.rss.path = "path-to-feed.xml"
199
+ # config.feeds.rss.max_items = 25
200
+ config.feeds.json.enabled = true
201
+ # config.feeds.json.max_items = 15
202
+ # config.feeds.json.path = "path-to-feed.json"
203
+ end
204
+ end
205
+ ```
206
+
207
+
163
208
  ## Metatags
164
209
 
165
210
  The `meta_tags` helper automatically generates SEO and social sharing meta tags for your pages.
@@ -219,7 +264,19 @@ author: Kendall
219
264
  Your content here…
220
265
  ```
221
266
 
222
- #### 3. Default Values
267
+ #### 3. Collection configuration
268
+
269
+ Set site-wide defaults in the initializer:
270
+ ```ruby
271
+ class Content::Post < Perron::Resource
272
+ # …
273
+
274
+ config.metadata.description = "AI-powered tool to keep your knowledge base articles images/screenshots and content up-to-date"
275
+ config.metadata.author = "Rails Designer"
276
+ end
277
+ ```
278
+
279
+ #### 4. Default Values
223
280
 
224
281
  Set site-wide defaults in the initializer:
225
282
  ```ruby
@@ -232,6 +289,61 @@ end
232
289
  ```
233
290
 
234
291
 
292
+ ## Related Resources
293
+
294
+ The `related_resources` method allows to find and display a list of similar resources
295
+ from the same collection. Similarity is calculated using the **TF-IDF** algorithm on the content of each resource.
296
+
297
+
298
+ ### Basic Usage
299
+
300
+ To get a list of the 5 most similar resources, call the method on any resource instance.
301
+ ```ruby
302
+ # app/views/content/posts/show.html.erb
303
+ @resource.related_resources
304
+
305
+ # Just the 3 most similar resources
306
+ @resource.related_resources(limit: 3)
307
+ ```
308
+
309
+
310
+ ## XML Sitemap
311
+
312
+ A sitemap is an XML file that lists all the pages of a website to help search engines discover and index content more efficiently, typically containing URLs, last modification dates, change frequency, and priority values.
313
+
314
+ Enable it with the following line in the Perron configuration:
315
+ ```ruby
316
+ Perron.configure do |config|
317
+ # …
318
+ config.sitemap.enabled = true
319
+ # config.sitemap.priority = 0.8
320
+ # config.sitemap.change_frequency = :monthly
321
+ # …
322
+ end
323
+ ```
324
+
325
+ Values can be overridden per collection…
326
+ ```ruby
327
+ # app/models/content/post.rb
328
+ class Content::Post < Perron::Resource
329
+ configure do |config|
330
+ config.sitemap.enabled = false
331
+ config.sitemap.priority = 0.5
332
+ config.sitemap.change_frequency = :weekly
333
+ end
334
+ end
335
+ ```
336
+
337
+ …or on a resource basis:
338
+ ```ruby
339
+ # app/content/posts/my-first-post.md
340
+ ---
341
+ sitemap_priority: 0.25
342
+ sitemap_change_frequency: :daily
343
+ ---
344
+ ```
345
+
346
+
235
347
  ## Building Your Static Site
236
348
 
237
349
  When in `standalone` mode and you're ready to generate your static site, run:
@@ -248,9 +360,10 @@ Sites that use Perron.
248
360
 
249
361
  ### Standalone (as a SSG)
250
362
  - [AppRefresher](https://apprefresher.com)
363
+ - [Helptail](https://helptail.com)
251
364
 
252
365
  ### Integrated (part of a Rails app)
253
- - [Rails Designers (private community for Rails UI engineers](https://railsdesigners.com)
366
+ - [Rails Designers (private community for Rails UI engineers)](https://railsdesigners.com)
254
367
 
255
368
 
256
369
  ## Contributing
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FeedsHelper
4
+ def feeds(options = {}) = Perron::Feeds.new.render(options)
5
+ end
@@ -1,17 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MetaTagsHelper
4
- def meta_tags(options = {})
5
- Perron::Metatags.new(source).render(options)
6
- end
4
+ def meta_tags(options = {}) = Perron::Metatags.new(resource).render(options)
7
5
 
8
6
  private
9
7
 
10
- Source = Data.define(:path, :metadata, :published_at)
8
+ Resource = Data.define(:path, :metadata, :published_at)
11
9
 
12
- def source
10
+ def resource
13
11
  return Source.new(request.path, @metadata, nil) if @metadata.present?
14
12
 
15
- @resource || Source.new(request.path, {}, nil)
13
+ @resource || Resource.new(request.path, {}, nil)
16
14
  end
17
15
  end
@@ -7,7 +7,7 @@ module Perron
7
7
  end
8
8
 
9
9
  def create_data_directory
10
- data_directory = Rails.root.join("app", "views", "content", "data")
10
+ data_directory = Rails.root.join("app", "content", "data")
11
11
  empty_directory data_directory
12
12
 
13
13
  template "README.md.tt", File.join(data_directory, "README.md")
@@ -6,19 +6,29 @@ This is useful for populating features, team members, or any other repeated data
6
6
 
7
7
  ## Usage
8
8
 
9
- To use a data file, instantiate `Perron::Data` with the basename of the file and iterate over the result.
9
+ To use a data file, you can access it through the `Perron::Site.data` object followed by the basename of the file:
10
10
  ```erb
11
- <%% Perron::Data.new("features").each do |feature| %>
11
+ <%% Perron::Site.data.features.each do |feature| %>
12
12
  <h4><%%= feature.name %></h4>
13
13
 
14
14
  <p><%%= feature.description %></p>
15
15
  <%% end %>
16
16
  ```
17
17
 
18
+ This is a convenient shorthand for `Perron::Data.new("features")`, which can also be used directly:
19
+ ```ruby
20
+ 
<%% Perron::Data.new("features").each do |feature| %>
21
+ <h4><%%= feature.name %></h4>
22
+
23
+ <p><%%= feature.description %></p>
24
+ <%% end %>
25
+ ```
26
+
27
+
18
28
  ## File Location and Formats
19
29
 
20
30
  By default, Perron looks up `app/content/data/` for files with a `.yml`, `.json`, or `.csv` extension.
21
- For a `new("features")` call, it would find `features.yml`, `features.json`, or `features.csv`. You can also provide a full, absolute path to any data file.
31
+ For a `new("features")` call, it would find `features.yml`, `features.json`, or `features.csv`. You can also provide a full, absolute path to any data file, like `Perron::Data.new("path-to-some-data-file")`.
22
32
 
23
33
 
24
34
  ## Accessing Data
@@ -19,6 +19,7 @@ module Perron
19
19
  @config.include_root = false
20
20
 
21
21
  @config.site_name = nil
22
+ @config.site_description = nil
22
23
 
23
24
  @config.allowed_extensions = [".erb", ".md"]
24
25
  @config.exclude_from_public = %w[assets storage]
@@ -49,6 +50,13 @@ module Perron
49
50
 
50
51
  def exclude_root? = !@config.include_root
51
52
 
53
+ def url
54
+ options = Perron.configuration.default_url_options
55
+ path = options[:trailing_slash] ? "/" : ""
56
+
57
+ URI.join("#{options[:protocol]}://#{options[:host]}", path).to_s
58
+ end
59
+
52
60
  def method_missing(method_name, ...)
53
61
  if @config.respond_to?(method_name)
54
62
  @config.send(method_name, ...)
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Perron
4
+ class Feeds
5
+ include ActionView::Helpers::TagHelper
6
+
7
+ def render(options = {})
8
+ html_tags = []
9
+
10
+ Rails.application.routes.url_helpers.with_options(Perron.configuration.default_url_options) do |url|
11
+ Perron::Site.collections.each do |collection|
12
+ collection_name = collection.name.to_s
13
+
14
+ next if options[:only]&.map(&:to_s)&.exclude?(collection_name)
15
+ next if options[:except]&.map(&:to_s)&.include?(collection_name)
16
+
17
+ collection.configuration.feeds.each do |type, feed|
18
+ next unless feed.enabled && feed.path && MIME_TYPES.key?(type)
19
+
20
+ absolute_url = URI.join(url.root_url, feed.path).to_s
21
+ title = "#{collection.name.humanize} #{type.to_s.upcase} Feed"
22
+
23
+ html_tags << tag(:link, rel: "alternate", type: MIME_TYPES[type], title: title, href: absolute_url)
24
+ end
25
+ end
26
+ end
27
+
28
+ safe_join(html_tags, "\n")
29
+ end
30
+
31
+ private
32
+
33
+ MIME_TYPES = {
34
+ rss: "application/rss+xml",
35
+ json: "application/json"
36
+ }
37
+ end
38
+ end
@@ -28,17 +28,19 @@ module Perron
28
28
  def tags
29
29
  @tags ||= begin
30
30
  frontmatter = @resource&.metadata&.stringify_keys || {}
31
- defaults = @config.metadata
32
-
33
- title = frontmatter["title"] || defaults["title"] || @config.site_name || Rails.application.name.underscore.camelize
34
- type = frontmatter["type"] || defaults["type"]
35
- description = frontmatter["description"] || defaults["description"]
36
- logo = frontmatter["logo"] || defaults["logo"]
37
- author = frontmatter["author"] || defaults["author"]
38
- image = frontmatter["image"] || defaults["image"]
39
- locale = frontmatter["locale"] || defaults["locale"]
40
- og_image = frontmatter["og:image"] || image
41
- twitter_image = frontmatter["twitter:image"] || og_image
31
+ collection_config = @resource.collection.configuration.metadata
32
+ site_config = @config.metadata
33
+
34
+ title = frontmatter["title"] || collection_config["title"] || site_config["title"] || @config.site_name || Rails.application.name.underscore.camelize
35
+ type = frontmatter["type"] || collection_config["type"] || site_config["type"]
36
+ description = frontmatter["description"] || collection_config["description"] || site_config["description"]
37
+ logo = frontmatter["logo"] || collection_config["logo"] || site_config["logo"]
38
+ author = frontmatter["author"] || collection_config["author"] || site_config["author"]
39
+ locale = frontmatter["locale"] || collection_config["locale"] || site_config["locale"]
40
+
41
+ image = frontmatter["image"] || collection_config["image"] || site_config["image"]
42
+ og_image = frontmatter["og:image"] || collection_config["og:image"] || site_config["og:image"] || image
43
+ twitter_image = frontmatter["twitter:image"] || collection_config["twitter:image"] || site_config["twitter:image"] || og_image
42
44
 
43
45
  {
44
46
  title: title_tag(title),
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Perron
6
+ module Site
7
+ class Builder
8
+ class Feeds
9
+ class Json
10
+ def initialize(collection:)
11
+ @collection = collection
12
+ @configuration = Perron.configuration
13
+ end
14
+
15
+ def generate
16
+ return nil if resources.empty?
17
+
18
+ hash = Rails.application.routes.url_helpers.with_options(@configuration.default_url_options) do |url|
19
+ {
20
+ version: "https://jsonfeed.org/version/1.1",
21
+ title: @configuration.site_name,
22
+ home_page_url: @configuration.url,
23
+ description: @configuration.site_description,
24
+ items: resources.map do |resource|
25
+ {
26
+ id: resource.id,
27
+ url: url.polymorphic_url(resource),
28
+ date_published: (resource.metadata.published_at || resource.metadata.updated_at)&.iso8601,
29
+ title: resource.metadata.title,
30
+ content_html: Perron::Markdown.render(resource.content)
31
+ }
32
+ end
33
+ }
34
+ end
35
+
36
+ JSON.pretty_generate hash
37
+ end
38
+
39
+ private
40
+
41
+ def resources
42
+ @resources ||= @collection.resources
43
+ .reject { it.metadata.feed == false }
44
+ .sort_by { it.metadata.published_at || it.metadata.updated_at || Time.current }
45
+ .reverse
46
+ .take(@collection.configuration.feeds.json.max_items)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Perron
6
+ module Site
7
+ class Builder
8
+ class Feeds
9
+ class Rss
10
+ def initialize(collection:)
11
+ @collection = collection
12
+ @configuration = Perron.configuration
13
+ end
14
+
15
+ def generate
16
+ return if resources.empty?
17
+
18
+ Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml|
19
+ xml.rss(:version => "2.0", "xmlns:atom" => "http://www.w3.org/2005/Atom") do
20
+ xml.channel do
21
+ xml.title @configuration.site_name
22
+ xml.description @configuration.site_description
23
+ xml.link @configuration.url
24
+ xml.generator "Perron (#{Perron::VERSION})"
25
+
26
+ Rails.application.routes.url_helpers.with_options(@configuration.default_url_options) do |url|
27
+ resources.each do |resource|
28
+ xml.item do
29
+ xml.guid resource.id
30
+ xml.link url.polymorphic_url(resource), isPermaLink: true
31
+ xml.pubDate((resource.metadata.published_at || resource.metadata.updated_at)&.rfc822)
32
+ xml.title resource.metadata.title
33
+ xml.description { xml.cdata(Perron::Markdown.render(resource.content)) }
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end.to_xml
40
+ end
41
+
42
+ private
43
+
44
+ def resources
45
+ @resource ||= @collection.resources
46
+ .reject { it.metadata.feed == false }
47
+ .sort_by { it.metadata.published_at || it.metadata.updated_at || Time.current }
48
+ .reverse
49
+ .take(@collection.configuration.feeds.rss.max_items)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "perron/site/builder/feeds/rss"
4
+ require "perron/site/builder/feeds/json"
5
+
6
+ module Perron
7
+ module Site
8
+ class Builder
9
+ class Feeds
10
+ def initialize(output_path)
11
+ @output_path = output_path
12
+ end
13
+
14
+ def generate
15
+ Perron::Site.collections.each do |collection|
16
+ config = collection.configuration.feeds
17
+
18
+ if config.rss.enabled
19
+ create_file at: config.rss.path, with: Rss.new(collection: collection).generate
20
+ end
21
+
22
+ if config.json.enabled
23
+ create_file at: config.json.path, with: Json.new(collection: collection).generate
24
+ end
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def create_file(at:, with:)
31
+ return if with.blank?
32
+
33
+ path = @output_path.join(at)
34
+
35
+ FileUtils.mkdir_p(File.dirname(path))
36
+ File.write(path, with)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -11,8 +11,6 @@ module Perron
11
11
  def generate
12
12
  return if !Perron.configuration.sitemap.enabled
13
13
 
14
- puts "Generating sitemap.xml…"
15
-
16
14
  xml = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |builder|
17
15
  builder.urlset(xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9") do
18
16
  Perron::Site.collections.each do |collection|
@@ -22,8 +20,6 @@ module Perron
22
20
  end.to_xml
23
21
 
24
22
  File.write(@output_path.join("sitemap.xml"), xml)
25
-
26
- puts "Sitemap generated at `#{@output_path.join("sitemap.xml")}`"
27
23
  end
28
24
 
29
25
  private
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "perron/site/builder/assets"
4
4
  require "perron/site/builder/sitemap"
5
+ require "perron/site/builder/feeds"
5
6
  require "perron/site/builder/public_files"
6
7
  require "perron/site/builder/paths"
7
8
  require "perron/site/builder/page"
@@ -29,6 +30,7 @@ module Perron
29
30
  paths.each { render_page(it) }
30
31
 
31
32
  Perron::Site::Builder::Sitemap.new(@output_path).generate
33
+ Perron::Site::Builder::Feeds.new(@output_path).generate
32
34
 
33
35
  puts "-" * 15
34
36
  puts "✅ Build complete"
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "erb"
4
+ require "singleton"
5
+
3
6
  require "csv"
4
7
 
5
8
  module Perron
@@ -29,25 +32,39 @@ module Perron
29
32
  end
30
33
 
31
34
  def records
32
- content = File.read(@file_path)
33
- extension = File.extname(@file_path)
34
- parser = PARSER_METHODS.fetch(extension) do
35
- raise Errors::UnsupportedDataFormatError, "Unsupported data format: #{extension}"
36
- end
37
-
38
- data = send(parser, content)
35
+ content = rendered_from(@file_path)
36
+ data = parsed_from(content, @file_path)
39
37
 
40
38
  unless data.is_a?(Array)
41
39
  raise Errors::DataParseError, "Data in '#{@file_path}' must be an array of objects."
42
40
  end
43
41
 
44
42
  data.map { Item.new(it) }
43
+ end
44
+
45
+ def rendered_from(path)
46
+ raw_content = File.read(path)
47
+
48
+ render_erb(raw_content)
49
+ rescue NameError, ArgumentError, SyntaxError => error
50
+ raise Errors::DataParseError, "Failed to render ERB in `#{path}`: (#{error.class}) #{error.message}"
51
+ end
52
+
53
+ def parsed_from(content, path)
54
+ extension = File.extname(path)
55
+ parser_method = PARSER_METHODS.fetch(extension) do
56
+ raise Errors::UnsupportedDataFormatError, "Unsupported data format: #{extension}"
57
+ end
58
+
59
+ send(parser_method, content)
45
60
  rescue Psych::SyntaxError, JSON::ParserError, CSV::MalformedCSVError => error
46
- raise Errors::DataParseError, "Failed to parse '#{@file_path}': #{error.message}"
61
+ raise Errors::DataParseError, "Failed to parse data format in `#{path}`: (#{error.class}) #{error.message}"
47
62
  end
48
63
 
64
+ def render_erb(content) = ERB.new(content).result(HelperContext.instance.get_binding)
65
+
49
66
  def parse_yaml(content)
50
- YAML.safe_load(content, permitted_classes: [Symbol], aliases: true)
67
+ YAML.safe_load(content, permitted_classes: [Symbol, Time], aliases: true)
51
68
  end
52
69
 
53
70
  def parse_json(content)
@@ -58,6 +75,19 @@ module Perron
58
75
  CSV.new(content, headers: true, header_converters: :symbol).to_a.map(&:to_h)
59
76
  end
60
77
 
78
+ class HelperContext
79
+ include Singleton
80
+
81
+ def initialize
82
+ self.class.include ActionView::Helpers::AssetUrlHelper
83
+ self.class.include ActionView::Helpers::DateHelper
84
+ self.class.include Rails.application.routes.url_helpers
85
+ end
86
+
87
+ def get_binding = binding
88
+ end
89
+ private_constant :HelperContext
90
+
61
91
  class Item
62
92
  def initialize(attributes)
63
93
  @attributes = attributes.transform_keys(&:to_sym)
@@ -78,67 +108,3 @@ module Perron
78
108
  private_constant :Item
79
109
  end
80
110
  end
81
-
82
- # require "csv"
83
-
84
- # module Perron
85
- # class Data
86
- # include Enumerable
87
-
88
- # def initialize(resource)
89
- # @file_path = path_for(resource)
90
- # @data = data
91
- # end
92
-
93
- # def each(&block)
94
- # @data.each(&block)
95
- # end
96
-
97
- # private
98
-
99
- # PARSER_METHODS = {
100
- # ".csv" => :parse_csv,
101
- # ".json" => :parse_json,
102
- # ".yaml" => :parse_yaml,
103
- # ".yml" => :parse_yaml
104
- # }.freeze
105
- # SUPPORTED_EXTENSIONS = PARSER_METHODS.keys.freeze
106
-
107
- # def path_for(identifier)
108
- # path = Pathname.new(identifier)
109
-
110
- # return path.to_s if path.file? && path.absolute?
111
-
112
- # found_path = SUPPORTED_EXTENSIONS.lazy.map do |extension|
113
- # Rails.root.join("app", "content", "data").join("#{identifier}#{extension}")
114
- # end.find(&:exist?)
115
-
116
- # found_path&.to_s or raise Errors::FileNotFoundError, "No data file found for '#{identifier}'"
117
- # end
118
-
119
- # def data
120
- # content = File.read(@file_path)
121
- # extension = File.extname(@file_path)
122
- # parser = PARSER_METHODS.fetch(extension) do
123
- # raise Errors::UnsupportedDataFormatError, "Unsupported data format: #{extension}"
124
- # end
125
-
126
- # raw_data = send(parser, content)
127
-
128
- # unless raw_data.is_a?(Array)
129
- # raise Errors::DataParseError, "Data in '#{@file_path}' must be an array of objects."
130
- # end
131
-
132
- # struct = Struct.new(*raw_data.first.keys, keyword_init: true)
133
- # raw_data.map { struct.new(**it) }
134
- # rescue Psych::SyntaxError, JSON::ParserError, CSV::MalformedCSVError => error
135
- # raise Errors::DataParseError, "Failed to parse '#{@file_path}': #{error.message}"
136
- # end
137
-
138
- # def parse_yaml(content) = YAML.safe_load(content, permitted_classes: [Symbol], aliases: true)
139
-
140
- # def parse_json(content) = JSON.parse(content, symbolize_names: true)
141
-
142
- # def parse_csv(content) = CSV.new(content, headers: true, header_converters: :symbol).to_a.map(&:to_h)
143
- # end
144
- # end
@@ -8,11 +8,19 @@ module Perron
8
8
  class_methods do
9
9
  def configuration
10
10
  @configuration ||= Options.new.tap do |config|
11
+ config.metadata = Options.new
12
+
11
13
  config.feeds = Options.new
12
14
 
13
15
  config.feeds.rss = ActiveSupport::OrderedOptions.new
14
- config.feeds.atom = ActiveSupport::OrderedOptions.new
16
+ config.feeds.rss.enabled = false
17
+ config.feeds.rss.path = "feeds/#{collection.name.demodulize.parameterize}.xml"
18
+ config.feeds.rss.max_items = 20
19
+
15
20
  config.feeds.json = ActiveSupport::OrderedOptions.new
21
+ config.feeds.json.enabled = false
22
+ config.feeds.json.path = "feeds/#{collection.name.demodulize.parameterize}.json"
23
+ config.feeds.json.max_items = 20
16
24
 
17
25
  config.linked_data = ActiveSupport::OrderedOptions.new
18
26
 
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Perron
4
+ module Site
5
+ class Resource
6
+ class Related
7
+ module StopWords
8
+ module_function
9
+
10
+ def all
11
+ Set[
12
+ "a", "about", "above", "after", "again", "against", "all", "am",
13
+ "an", "and", "any", "are", "as", "at", "be", "because", "been",
14
+ "before", "being", "below", "between", "both", "but", "by", "can",
15
+ "did", "do", "does", "doing", "down", "during", "each", "few",
16
+ "for", "from", "further", "had", "has", "have", "having", "he",
17
+ "her", "here", "hers", "herself", "him", "himself", "his", "how",
18
+ "i", "if", "in", "into", "is", "it", "its", "itself", "just",
19
+ "me", "more", "most", "my", "myself", "no", "nor", "not", "now",
20
+ "of", "off", "on", "once", "only", "or", "other", "our", "ours",
21
+ "ourselves", "out", "over", "own", "s", "same", "she", "should",
22
+ "so", "some", "such", "t", "than", "that", "the", "their",
23
+ "theirs", "them", "themselves", "then", "there", "these", "they",
24
+ "this", "those", "through", "to", "too", "under", "until", "up",
25
+ "very", "was", "we", "were", "what", "when", "where", "which",
26
+ "while", "who", "whom", "why", "will", "with", "you", "your",
27
+ "yours", "yourself", "yourselves"
28
+ ]
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "perron/site/resource/related/stop_words"
4
+
5
+ module Perron
6
+ module Site
7
+ class Resource
8
+ class Related
9
+ def initialize(resource)
10
+ @resource = resource
11
+ @collection = resource.collection
12
+ end
13
+
14
+ def find(limit: 5)
15
+ @collection.resources
16
+ .reject { it.slug == @resource.slug }
17
+ .map { [it, cosine_similarities_for(@resource, it)] }
18
+ .sort_by { |_, score| -score }
19
+ .map(&:first)
20
+ .first(limit)
21
+ end
22
+
23
+ private
24
+
25
+ def cosine_similarities_for(resource_one, resource_two)
26
+ first_vector = tfidf_vector_for(resource_one)
27
+ second_vector = tfidf_vector_for(resource_two)
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?
40
+
41
+ dot_product / denominator
42
+ end
43
+
44
+ def tfidf_vector_for(target_resource)
45
+ @tfidf_vectors ||= {}
46
+
47
+ return @tfidf_vectors[target_resource] if @tfidf_vectors.key?(target_resource)
48
+
49
+ tokens = tokenize_content(target_resource)
50
+ token_count = tokens.size
51
+
52
+ return {} if token_count.zero?
53
+
54
+ term_count = Hash.new(0)
55
+
56
+ tokens.each { |token| term_count[token] += 1 }
57
+
58
+ tfidf_vector = {}
59
+
60
+ term_count.each do |term, count|
61
+ terms = count.to_f / token_count
62
+
63
+ tfidf_vector[term] = terms * inverse_document_frequency[term]
64
+ end
65
+
66
+ @tfidf_vectors[target_resource] = tfidf_vector
67
+ end
68
+
69
+ def tokenize_content(target_resource)
70
+ @tokenized_content ||= {}
71
+
72
+ return @tokenized_content[target_resource] if @tokenized_content.key?(target_resource)
73
+
74
+ content = target_resource.content.gsub(/<[^>]*>/, " ")
75
+ tokens = content.downcase.scan(/\w+/).reject { StopWords.all.include?(it) || it.length < 3 }
76
+
77
+ @tokenized_content[target_resource] = tokens
78
+ end
79
+
80
+ def inverse_document_frequency
81
+ @inverse_document_frequency ||= begin
82
+ resource_frequency = Hash.new(0)
83
+
84
+ @collection.resources.each { tokenize_content(it).uniq.each { resource_frequency[it] += 1 } }
85
+
86
+ frequencies = {}
87
+ total_resources = @collection.resources.size
88
+
89
+ resource_frequency.each do |term, frequency|
90
+ frequencies[term] = Math.log(total_resources.to_f / (1 + frequency))
91
+ end
92
+
93
+ frequencies
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -4,6 +4,7 @@ require "perron/site/resource/configuration"
4
4
  require "perron/site/resource/core"
5
5
  require "perron/site/resource/class_methods"
6
6
  require "perron/site/resource/publishable"
7
+ require "perron/site/resource/related"
7
8
  require "perron/site/resource/slug"
8
9
  require "perron/site/resource/separator"
9
10
 
@@ -49,6 +50,9 @@ module Perron
49
50
 
50
51
  def collection = Collection.new(self.class.model_name.collection)
51
52
 
53
+ def related_resources(limit: 5) = Perron::Site::Resource::Related.new(self).find(limit:)
54
+ alias_method :related, :related_resources
55
+
52
56
  private
53
57
 
54
58
  def processable?
@@ -1,3 +1,3 @@
1
1
  module Perron
2
- VERSION = "0.8.0"
2
+ VERSION = "0.9.0"
3
3
  end
data/lib/perron.rb CHANGED
@@ -6,6 +6,7 @@ require "perron/errors"
6
6
  require "perron/root"
7
7
  require "perron/site"
8
8
  require "perron/markdown"
9
+ require "perron/feeds"
9
10
  require "perron/metatags"
10
11
  require "perron/engine"
11
12
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: perron
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rails Designer Developers
@@ -78,6 +78,7 @@ files:
78
78
  - Gemfile.lock
79
79
  - README.md
80
80
  - Rakefile
81
+ - app/helpers/feeds_helper.rb
81
82
  - app/helpers/meta_tags_helper.rb
82
83
  - app/helpers/perron/markdown_helper.rb
83
84
  - bin/console
@@ -97,6 +98,7 @@ files:
97
98
  - lib/perron/configuration.rb
98
99
  - lib/perron/engine.rb
99
100
  - lib/perron/errors.rb
101
+ - lib/perron/feeds.rb
100
102
  - lib/perron/html_processor.rb
101
103
  - lib/perron/html_processor/base.rb
102
104
  - lib/perron/html_processor/lazy_load_images.rb
@@ -108,6 +110,9 @@ files:
108
110
  - lib/perron/site.rb
109
111
  - lib/perron/site/builder.rb
110
112
  - lib/perron/site/builder/assets.rb
113
+ - lib/perron/site/builder/feeds.rb
114
+ - lib/perron/site/builder/feeds/json.rb
115
+ - lib/perron/site/builder/feeds/rss.rb
111
116
  - lib/perron/site/builder/page.rb
112
117
  - lib/perron/site/builder/paths.rb
113
118
  - lib/perron/site/builder/public_files.rb
@@ -120,6 +125,8 @@ files:
120
125
  - lib/perron/site/resource/configuration.rb
121
126
  - lib/perron/site/resource/core.rb
122
127
  - lib/perron/site/resource/publishable.rb
128
+ - lib/perron/site/resource/related.rb
129
+ - lib/perron/site/resource/related/stop_words.rb
123
130
  - lib/perron/site/resource/separator.rb
124
131
  - lib/perron/site/resource/slug.rb
125
132
  - lib/perron/tasks/perron.rake