perron 0.18.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.lock +1 -1
- data/app/controllers/perron/concierge_controller.rb +13 -0
- data/app/helpers/perron/markdown_helper.rb +2 -2
- 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/configuration.rb +4 -0
- data/lib/perron/data_source/class_methods.rb +8 -0
- data/lib/perron/data_source.rb +14 -29
- data/lib/perron/development_feed_server.rb +69 -0
- data/lib/perron/engine.rb +29 -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.rb +23 -0
- data/lib/perron/markdown.rb +2 -2
- data/lib/perron/output_server.rb +9 -0
- data/lib/perron/resource/adjacency.rb +70 -0
- data/lib/perron/resource/associations.rb +1 -1
- data/lib/perron/resource/configuration.rb +9 -4
- data/lib/perron/resource/metadata.rb +10 -1
- data/lib/perron/resource/publishable.rb +2 -0
- data/lib/perron/resource/related.rb +32 -31
- data/lib/perron/resource/sourceable.rb +39 -9
- data/lib/perron/resource.rb +2 -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/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 +7 -0
- data/lib/perron/tasks/build.rake +6 -7
- data/lib/perron/tasks/install.rake +12 -0
- data/lib/perron/version.rb +1 -1
- metadata +18 -5
- 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
data/lib/perron/engine.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "perron/output_server"
|
|
4
|
+
require "perron/development_feed_server"
|
|
4
5
|
require "mata"
|
|
5
6
|
|
|
6
7
|
module Perron
|
|
@@ -10,7 +11,11 @@ module Perron
|
|
|
10
11
|
end
|
|
11
12
|
|
|
12
13
|
initializer "perron.output_server" do |app|
|
|
13
|
-
app.middleware.use Perron::OutputServer
|
|
14
|
+
app.middleware.use Perron::OutputServer if Rails.env.development?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
initializer "perron.development_feed_server" do |app|
|
|
18
|
+
app.middleware.use Perron::DevelopmentFeedServer if Rails.env.development?
|
|
14
19
|
end
|
|
15
20
|
|
|
16
21
|
initializer "perron.configure_hmr", after: :load_config_initializers do |app|
|
|
@@ -24,9 +29,32 @@ module Perron
|
|
|
24
29
|
end
|
|
25
30
|
end
|
|
26
31
|
|
|
32
|
+
initializer "perron.concierge", before: :add_builtin_route do |app|
|
|
33
|
+
app.config.after_initialize do
|
|
34
|
+
app.routes.append do
|
|
35
|
+
namespace :perron do
|
|
36
|
+
post :run_command, to: "concierge#run_command"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
root to: "perron/concierge#show" unless app.routes.named_routes.key?(:root)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
app.routes.finalize!
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
initializer "perron.inflections" do
|
|
47
|
+
ActiveSupport::Inflector.inflections(:en) do |inflect|
|
|
48
|
+
inflect.acronym "RSS"
|
|
49
|
+
inflect.acronym "Atom"
|
|
50
|
+
inflect.acronym "Json"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
27
54
|
rake_tasks do
|
|
28
55
|
load File.expand_path("../tasks/build.rake", __FILE__)
|
|
29
56
|
load File.expand_path("../tasks/clobber.rake", __FILE__)
|
|
57
|
+
load File.expand_path("../tasks/install.rake", __FILE__)
|
|
30
58
|
load File.expand_path("../tasks/sync_sources.rake", __FILE__)
|
|
31
59
|
load File.expand_path("../tasks/validate.rake", __FILE__)
|
|
32
60
|
end
|
data/lib/perron/errors.rb
CHANGED
data/lib/perron/feeds.rb
CHANGED
|
@@ -19,7 +19,7 @@ module Perron
|
|
|
19
19
|
next unless feed.enabled && feed.path && MIME_TYPES.key?(type)
|
|
20
20
|
|
|
21
21
|
absolute_url = URI.join(url.root_url, feed.path).to_s
|
|
22
|
-
title = "#{collection.name.humanize} #{type.to_s.
|
|
22
|
+
title = "#{collection.name.humanize} #{type.to_s.humanize} Feed"
|
|
23
23
|
|
|
24
24
|
html_tags << tag(:link, rel: "alternate", type: MIME_TYPES[type], title: title, href: absolute_url)
|
|
25
25
|
end
|
|
@@ -32,8 +32,9 @@ module Perron
|
|
|
32
32
|
private
|
|
33
33
|
|
|
34
34
|
MIME_TYPES = {
|
|
35
|
-
|
|
36
|
-
json: "application/json"
|
|
35
|
+
atom: "application/atom+xml",
|
|
36
|
+
json: "application/json",
|
|
37
|
+
rss: "application/rss+xml"
|
|
37
38
|
}
|
|
38
39
|
end
|
|
39
40
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Perron
|
|
4
|
+
class HtmlProcessor
|
|
5
|
+
class AbsoluteUrls < HtmlProcessor::Base
|
|
6
|
+
def process
|
|
7
|
+
@html.css("img").each do |image|
|
|
8
|
+
src = image["src"]
|
|
9
|
+
|
|
10
|
+
next if src.blank? || absolute_url?(src)
|
|
11
|
+
|
|
12
|
+
image["src"] = base_url + src
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def absolute_url?(src)
|
|
19
|
+
src.start_with?("http://", "https://", "//")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def base_url
|
|
23
|
+
Perron.configuration.url.delete_suffix("/")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -2,17 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
require "perron/html_processor/target_blank"
|
|
4
4
|
require "perron/html_processor/lazy_load_images"
|
|
5
|
+
require "perron/html_processor/absolute_urls"
|
|
5
6
|
|
|
6
7
|
module Perron
|
|
7
8
|
class HtmlProcessor
|
|
8
|
-
def initialize(html, processors: [])
|
|
9
|
+
def initialize(html, processors: [], resource: nil)
|
|
9
10
|
@html = html
|
|
11
|
+
@resource = resource
|
|
10
12
|
@processors = processors.map { find_by(it) }
|
|
11
13
|
end
|
|
12
14
|
|
|
13
15
|
def process
|
|
14
16
|
Nokogiri::HTML::DocumentFragment.parse(@html).tap do |document|
|
|
15
|
-
@processors.each { it.new(document).process }
|
|
17
|
+
@processors.each { it.new(document, resource: @resource).process }
|
|
16
18
|
end.to_html
|
|
17
19
|
end
|
|
18
20
|
|
|
@@ -20,14 +22,9 @@ module Perron
|
|
|
20
22
|
|
|
21
23
|
BUILT_IN = {
|
|
22
24
|
"target_blank" => Perron::HtmlProcessor::TargetBlank,
|
|
23
|
-
"lazy_load_images" => Perron::HtmlProcessor::LazyLoadImages
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
require "perron/html_processor/syntax_highlight"
|
|
27
|
-
|
|
28
|
-
processors["syntax_highlight"] = Perron::HtmlProcessor::SyntaxHighlight
|
|
29
|
-
rescue LoadError
|
|
30
|
-
end
|
|
25
|
+
"lazy_load_images" => Perron::HtmlProcessor::LazyLoadImages,
|
|
26
|
+
"absolute_urls" => Perron::HtmlProcessor::AbsoluteUrls
|
|
27
|
+
}
|
|
31
28
|
|
|
32
29
|
def find_by(identifier)
|
|
33
30
|
case identifier
|
|
@@ -47,7 +44,6 @@ module Perron
|
|
|
47
44
|
|
|
48
45
|
return processor if processor
|
|
49
46
|
|
|
50
|
-
raise Perron::Errors::ProcessorNotFoundError, "The `syntax_highlight` processor requires `rouge`. Run `bundle add rouge` to add it to your Gemfile." if name.inquiry.syntax_highlight?
|
|
51
47
|
raise Perron::Errors::ProcessorNotFoundError, "Could not find processor `#{name}`. It is not a Perron-included processor and the constant `#{name.camelize}` could not be found."
|
|
52
48
|
end
|
|
53
49
|
end
|
|
@@ -7,10 +7,10 @@ Perron can consume structured data from YML, JSON or CSV files, making them avai
|
|
|
7
7
|
|
|
8
8
|
Access data sources using the `Content::Data` namespace with the class name matching the file's basename:
|
|
9
9
|
```erb
|
|
10
|
-
|
|
11
|
-
<h4
|
|
12
|
-
<p
|
|
13
|
-
|
|
10
|
+
<% Content::Data::Features.all.each do |feature| %>
|
|
11
|
+
<h4><%= feature.name %></h4>
|
|
12
|
+
<p><%= feature.description %></p>
|
|
13
|
+
<% end %>
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
Look up a single entry with `Content::Data::Features.find("advanced-search")`, where `"advanced-search"` matches the value of the entry's `id` field.
|
|
@@ -34,15 +34,15 @@ feature[:name]
|
|
|
34
34
|
|
|
35
35
|
Render data collections directly using Rails-like partial rendering:
|
|
36
36
|
```erb
|
|
37
|
-
|
|
37
|
+
<%= render Content::Data::Features.all %>
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
This expects a partial at `app/views/content/features/_feature.html.erb` that will be rendered once for each item in `Content::Data::Features.all`. The individual record is made available as a local variable matching the singular form of the collection name.
|
|
41
41
|
```erb
|
|
42
42
|
<!-- app/views/content/features/_feature.html.erb -->
|
|
43
43
|
<div class="feature">
|
|
44
|
-
<h4
|
|
45
|
-
<p
|
|
44
|
+
<h4><%= feature.name %></h4>
|
|
45
|
+
<p><%= feature.description %></p>
|
|
46
46
|
</div>
|
|
47
47
|
```
|
|
48
48
|
|
|
@@ -64,6 +64,4 @@ Data resources must contain an array of objects. Each record should include an `
|
|
|
64
64
|
|
|
65
65
|
## Enumerable methods
|
|
66
66
|
|
|
67
|
-
[!label v0.17.0+]
|
|
68
|
-
|
|
69
67
|
All data objects support enumerable methods like `select`, `sort_by`, `first` and `count`. See [Enumerable methods](/docs/rendering/#enumerable-methods) for the full list of available methods.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
say "Install Perron in your Rails app"
|
|
2
|
+
|
|
3
|
+
say "Create Perron initializer"
|
|
4
|
+
copy_file "#{__dir__}/install/initializer.rb", "config/initializers/perron.rb"
|
|
5
|
+
|
|
6
|
+
say "Create content data directory"
|
|
7
|
+
copy_file "#{__dir__}/install/README.md", "app/content/data/README.md"
|
|
8
|
+
|
|
9
|
+
say "Add Markdown gem options to Gemfile"
|
|
10
|
+
append_to_file "Gemfile", <<~RUBY
|
|
11
|
+
|
|
12
|
+
# Perron supports Markdown rendering using one of the following gems.
|
|
13
|
+
# Uncomment your preferred choice and run `bundle install`
|
|
14
|
+
# gem "commonmarker"
|
|
15
|
+
# gem "kramdown"
|
|
16
|
+
# gem "redcarpet"
|
|
17
|
+
RUBY
|
|
18
|
+
|
|
19
|
+
copy_file "#{__dir__}/assets/icon.png", "public/icon.png", force: true
|
|
20
|
+
copy_file "#{__dir__}/assets/icon.svg", "public/icon.svg", force: true
|
|
21
|
+
|
|
22
|
+
say "Add output folder to .gitignore"
|
|
23
|
+
append_to_file ".gitignore", "/#{Perron.configuration.output}/\n"
|
data/lib/perron/markdown.rb
CHANGED
|
@@ -5,9 +5,9 @@ require "perron/html_processor"
|
|
|
5
5
|
module Perron
|
|
6
6
|
class Markdown
|
|
7
7
|
class << self
|
|
8
|
-
def render(text, processors: [])
|
|
8
|
+
def render(text, processors: [], resource: nil)
|
|
9
9
|
parser.parse(text)
|
|
10
|
-
.then { Perron::HtmlProcessor.new(it, processors: processors).process }
|
|
10
|
+
.then { Perron::HtmlProcessor.new(it, processors: processors, resource: resource).process }
|
|
11
11
|
.html_safe
|
|
12
12
|
end
|
|
13
13
|
|
data/lib/perron/output_server.rb
CHANGED
|
@@ -8,6 +8,7 @@ module Perron
|
|
|
8
8
|
|
|
9
9
|
def call(environment)
|
|
10
10
|
return @app.call(environment) if disabled?
|
|
11
|
+
return not_found if !static_file(environment) && Perron.configuration.output_server_strict
|
|
11
12
|
|
|
12
13
|
static_file(environment).then do |file|
|
|
13
14
|
file ? serve(file) : @app.call(environment)
|
|
@@ -41,6 +42,14 @@ module Perron
|
|
|
41
42
|
]
|
|
42
43
|
end
|
|
43
44
|
|
|
45
|
+
def not_found
|
|
46
|
+
[
|
|
47
|
+
404,
|
|
48
|
+
{"Content-Type" => "text/plain"},
|
|
49
|
+
["Not Found"]
|
|
50
|
+
]
|
|
51
|
+
end
|
|
52
|
+
|
|
44
53
|
def enabled? = Dir.exist?(output_path)
|
|
45
54
|
|
|
46
55
|
def inject_preview_indicator(content)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Perron
|
|
4
|
+
class Resource
|
|
5
|
+
module Adjacency
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
class_methods do
|
|
9
|
+
def adjacent_by(position_method, within: {})
|
|
10
|
+
return unless within.present?
|
|
11
|
+
|
|
12
|
+
grouping_method = within.is_a?(Hash) ? within.keys.first : within
|
|
13
|
+
grouping_order = within.is_a?(Hash) ? within.values.first : nil
|
|
14
|
+
|
|
15
|
+
define_method(:next) do
|
|
16
|
+
resources = self.class.all.sort_by do |resource|
|
|
17
|
+
group_value = resource.public_send(grouping_method)
|
|
18
|
+
|
|
19
|
+
group_order = if grouping_order
|
|
20
|
+
grouping_order.index(group_value.to_s) || grouping_order.index(group_value.to_sym) || Float::INFINITY
|
|
21
|
+
else
|
|
22
|
+
group_value.to_s
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
[group_order, resource.public_send(position_method)]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
return if (position = resources.index { it.id == id }).nil? || position >= resources.size - 1
|
|
29
|
+
|
|
30
|
+
resources[position + 1]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
define_method(:previous) do
|
|
34
|
+
resources = self.class.all.sort_by do |resource|
|
|
35
|
+
group_value = resource.public_send(grouping_method)
|
|
36
|
+
|
|
37
|
+
group_order = if grouping_order
|
|
38
|
+
grouping_order.index(group_value.to_s) || grouping_order.index(group_value.to_sym) || Float::INFINITY
|
|
39
|
+
else
|
|
40
|
+
group_value.to_s
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
[group_order, resource.public_send(position_method)]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
return if (position = resources.index { it.id == id }).nil? || position <= 0
|
|
47
|
+
|
|
48
|
+
resources[position - 1]
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
included do
|
|
54
|
+
define_method(:next) do
|
|
55
|
+
resources = self.class.all
|
|
56
|
+
return if (position = resources.index { it.id == id }).nil? || position >= resources.size - 1
|
|
57
|
+
|
|
58
|
+
resources[position + 1]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
define_method(:previous) do
|
|
62
|
+
resources = self.class.all
|
|
63
|
+
return if (position = resources.index { it.id == id }).nil? || position <= 0
|
|
64
|
+
|
|
65
|
+
resources[position - 1]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -12,16 +12,21 @@ 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
|
|
@@ -25,6 +25,8 @@ module Perron
|
|
|
25
25
|
|
|
26
26
|
to[:canonical_url] ||= canonical_url
|
|
27
27
|
|
|
28
|
+
to[:image] = absolute_url(to[:image]) if to[:image]
|
|
29
|
+
|
|
28
30
|
to[:og_image] ||= to[:image]
|
|
29
31
|
to[:twitter_image] ||= to[:og_image]
|
|
30
32
|
|
|
@@ -52,13 +54,20 @@ module Perron
|
|
|
52
54
|
begin
|
|
53
55
|
Rails.application.routes.url_helpers.polymorphic_url(
|
|
54
56
|
@resource,
|
|
55
|
-
|
|
57
|
+
**Perron.configuration.default_url_options
|
|
56
58
|
)
|
|
57
59
|
rescue
|
|
58
60
|
false
|
|
59
61
|
end
|
|
60
62
|
end
|
|
61
63
|
|
|
64
|
+
def absolute_url(path)
|
|
65
|
+
return path if path.blank?
|
|
66
|
+
return path if path.start_with?("http://", "https://", "//")
|
|
67
|
+
|
|
68
|
+
Perron.configuration.url.delete_suffix("/") + path
|
|
69
|
+
end
|
|
70
|
+
|
|
62
71
|
def site_data
|
|
63
72
|
@config.metadata.except(:title_separator, :title_suffix).deep_symbolize_keys || {}
|
|
64
73
|
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
|
|
@@ -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
|
|
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
|
|
@@ -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"
|