perron 0.8.0 → 0.9.1
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 +1 -1
- data/README.md +115 -2
- data/app/helpers/feeds_helper.rb +5 -0
- data/app/helpers/meta_tags_helper.rb +5 -7
- data/lib/generators/perron/install_generator.rb +1 -1
- data/lib/generators/perron/templates/README.md.tt +13 -3
- data/lib/perron/configuration.rb +8 -0
- data/lib/perron/feeds.rb +38 -0
- data/lib/perron/metatags.rb +13 -11
- data/lib/perron/site/builder/feeds/json.rb +52 -0
- data/lib/perron/site/builder/feeds/rss.rb +55 -0
- data/lib/perron/site/builder/feeds.rb +41 -0
- data/lib/perron/site/builder/sitemap.rb +0 -4
- data/lib/perron/site/builder.rb +2 -0
- data/lib/perron/site/data.rb +39 -73
- data/lib/perron/site/resource/configuration.rb +9 -1
- data/lib/perron/site/resource/related/stop_words.rb +34 -0
- data/lib/perron/site/resource/related.rb +99 -0
- data/lib/perron/site/resource.rb +4 -0
- data/lib/perron/version.rb +1 -1
- data/lib/perron.rb +1 -0
- metadata +8 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: cce790442290a0fedc4e4db328e3358b60a7edafafc6a0c980cec22d6f02fba5
|
4
|
+
data.tar.gz: c0a48eecc48c7a3230de6c36bf3a9180c9125b389282b0f311c02638b2db41cf
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e3f7bce10120b2feaf667051c8b67efd58adffb83a91d7d853b79b408bfa04cf5e004f40e99357059871c51f42aaf8ee26d32e4b36b776fcb9936309d1a7e94a
|
7
|
+
data.tar.gz: 9324e15f3d236809cf9422da8e8500d302e64a980352c5a9163734d908f8ecd1774cd1350d762487e9795df887d026858e3bd3bcab440c8f9fb1c547913295d9
|
data/Gemfile.lock
CHANGED
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.
|
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
|
@@ -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
|
-
|
8
|
+
Resource = Data.define(:path, :collection, :metadata, :published_at)
|
11
9
|
|
12
|
-
def
|
13
|
-
return
|
10
|
+
def resource
|
11
|
+
return Resource.new(request.path, nil, @metadata, nil) if @metadata.present?
|
14
12
|
|
15
|
-
@resource ||
|
13
|
+
@resource || Resource.new(request.path, nil, {}, 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", "
|
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,
|
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::
|
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
|
data/lib/perron/configuration.rb
CHANGED
@@ -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, ...)
|
data/lib/perron/feeds.rb
ADDED
@@ -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
|
data/lib/perron/metatags.rb
CHANGED
@@ -28,17 +28,19 @@ module Perron
|
|
28
28
|
def tags
|
29
29
|
@tags ||= begin
|
30
30
|
frontmatter = @resource&.metadata&.stringify_keys || {}
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
locale = frontmatter["locale"] ||
|
40
|
-
|
41
|
-
|
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
|
data/lib/perron/site/builder.rb
CHANGED
@@ -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"
|
data/lib/perron/site/data.rb
CHANGED
@@ -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 =
|
33
|
-
|
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
|
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.
|
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
|
data/lib/perron/site/resource.rb
CHANGED
@@ -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?
|
data/lib/perron/version.rb
CHANGED
data/lib/perron.rb
CHANGED
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.
|
4
|
+
version: 0.9.1
|
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
|