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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/app/controllers/perron/concierge_controller.rb +13 -0
  4. data/app/helpers/perron/markdown_helper.rb +2 -2
  5. data/app/views/perron/concierge/show.html.erb +271 -0
  6. data/lib/generators/rails/content/USAGE +21 -4
  7. data/lib/generators/rails/content/content_generator.rb +16 -12
  8. data/lib/generators/rails/content/templates/controller.rb.tt +6 -0
  9. data/lib/generators/rails/content/templates/model.rb.tt +1 -1
  10. data/lib/perron/assets/icon.png +0 -0
  11. data/lib/perron/assets/icon.svg +1 -0
  12. data/lib/perron/configuration.rb +4 -0
  13. data/lib/perron/data_source/class_methods.rb +8 -0
  14. data/lib/perron/data_source.rb +14 -29
  15. data/lib/perron/development_feed_server.rb +69 -0
  16. data/lib/perron/engine.rb +29 -1
  17. data/lib/perron/errors.rb +2 -0
  18. data/lib/perron/feeds.rb +4 -3
  19. data/lib/perron/html_processor/absolute_urls.rb +27 -0
  20. data/lib/perron/html_processor/base.rb +2 -2
  21. data/lib/perron/html_processor.rb +7 -11
  22. data/lib/{generators/perron/templates → perron/install}/README.md.tt +7 -9
  23. data/lib/perron/install.rb +23 -0
  24. data/lib/perron/markdown.rb +2 -2
  25. data/lib/perron/output_server.rb +9 -0
  26. data/lib/perron/resource/adjacency.rb +70 -0
  27. data/lib/perron/resource/associations.rb +1 -1
  28. data/lib/perron/resource/configuration.rb +9 -4
  29. data/lib/perron/resource/metadata.rb +10 -1
  30. data/lib/perron/resource/publishable.rb +2 -0
  31. data/lib/perron/resource/related.rb +32 -31
  32. data/lib/perron/resource/sourceable.rb +39 -9
  33. data/lib/perron/resource.rb +2 -0
  34. data/lib/perron/site/builder/assets.rb +1 -1
  35. data/lib/perron/site/builder/feeds/atom.erb +44 -0
  36. data/lib/perron/site/builder/feeds/atom.rb +41 -0
  37. data/lib/perron/site/builder/feeds/json.erb +19 -0
  38. data/lib/perron/site/builder/feeds/json.rb +7 -33
  39. data/lib/perron/site/builder/feeds/rss.erb +28 -0
  40. data/lib/perron/site/builder/feeds/rss.rb +6 -28
  41. data/lib/perron/site/builder/feeds/template.rb +63 -0
  42. data/lib/perron/site/builder/feeds.rb +8 -3
  43. data/lib/perron/site/builder/paths.rb +58 -14
  44. data/lib/perron/site/builder/route_resources.rb +79 -0
  45. data/lib/perron/site/builder/sitemap.rb +71 -20
  46. data/lib/perron/site/builder.rb +1 -1
  47. data/lib/perron/site/validate.rb +1 -2
  48. data/lib/perron/site.rb +7 -0
  49. data/lib/perron/tasks/build.rake +6 -7
  50. data/lib/perron/tasks/install.rake +12 -0
  51. data/lib/perron/version.rb +1 -1
  52. metadata +18 -5
  53. data/lib/generators/perron/install_generator.rb +0 -32
  54. data/lib/perron/html_processor/syntax_highlight.rb +0 -32
  55. /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
@@ -10,6 +10,8 @@ module Perron
10
10
 
11
11
  class DataParseError < StandardError; end
12
12
 
13
+ class DataSourceNotFoundError < StandardError; end
14
+
13
15
  class ProcessorNotFoundError < StandardError; end
14
16
 
15
17
  class InvalidProcessorError < StandardError; end
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.upcase} Feed"
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
- rss: "application/rss+xml",
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
@@ -3,8 +3,8 @@
3
3
  module Perron
4
4
  class HtmlProcessor
5
5
  class Base
6
- def initialize(html)
7
- @html = html
6
+ def initialize(html, resource: nil)
7
+ @html, @resource = html, resource
8
8
  end
9
9
 
10
10
  def process
@@ -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
- }.tap do |processors|
25
- require "rouge"
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
- <%% Content::Data::Features.all.each do |feature| %>
11
- <h4><%%= feature.name %></h4>
12
- <p><%%= feature.description %></p>
13
- <%% end %>
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
- <%%= render Content::Data::Features.all %>
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><%%= feature.name %></h4>
45
- <p><%%= feature.description %></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"
@@ -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
 
@@ -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
@@ -13,7 +13,7 @@ module Perron
13
13
  foreign_key = foreign_key_for(association_name, **options)
14
14
  identifier = metadata[foreign_key]
15
15
 
16
- identifier ? associated_class.find(identifier) : nil
16
+ identifier ? associated_class.find!(identifier) : nil
17
17
  end
18
18
  end
19
19
  end
@@ -12,16 +12,21 @@ module Perron
12
12
 
13
13
  config.feeds = Options.new
14
14
 
15
- config.feeds.rss = 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
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
- **Perron.configuration.default_url_options
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
@@ -7,6 +7,8 @@ module Perron
7
7
 
8
8
  included do
9
9
  def published?
10
+ return ENV["VIEW_UNPUBLISHED"] == "true" if ENV["VIEW_UNPUBLISHED"]
11
+
10
12
  return true if Perron.configuration.view_unpublished
11
13
 
12
14
  return false if frontmatter.draft == true
@@ -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
- @collection_caches[collection_name] ||= Cache.new(nil, nil, content_fingerprint(collection_name))
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 != content_fingerprint(collection_name)
31
+ @collection_caches[collection_name]&.fingerprint != fingerprinted(collection_name)
30
32
  end
31
33
 
32
- def self.content_fingerprint(collection_name)
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 = @cache.resources ||= @collection.resources
58
+ def resources
59
+ @cache.resources ||= @collection.resources
60
+ end
56
61
 
57
- def similarity_matrix = @cache.similarity_matrix ||= build_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
- slugs = vectors.keys
64
- slugs.each_with_index do |slug_a, i|
65
- next if vectors[slug_a].empty?
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
- slugs[(i + 1)..].each do |slug_b|
68
- next if vectors[slug_b].empty?
75
+ slugs[(index + 1)..].each do |slug_b|
76
+ next if vectors[slug_b].empty?
69
77
 
70
- score = dot_product(vectors[slug_a], vectors[slug_b])
71
- matrix[slug_a][slug_b] = score
72
- matrix[slug_b][slug_a] = score
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 = tokenize(resource)
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 tokenize(resource)
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
- doc_frequency = Hash.new(0)
112
- resources.each { tokenize(it).uniq.each { doc_frequency[it] += 1 } }
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
- doc_frequency.transform_values { Math.log(total / (1 + it)) }
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 { it.is_a?(Hash) ? it.to_a : [[it, {primary_key: :id}]] }.to_h
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 { Perron::DataSource.new(it.to_s) }
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
- datasets.first.product(*datasets[1..])
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
- sources = Source.new(data)
78
+ source = Source.new(data)
51
79
 
52
- source_template(sources)
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 sources
69
- @sources ||= begin
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
- hash[name] = Perron::DataSource.new(name.to_s).find { it.public_send(primary_key).to_s == identifier.to_s }
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(sources)
112
+ def source_template(source)
83
113
  raise NotImplementedError, "#{self.class.name} must implement #source_template"
84
114
  end
85
115
 
@@ -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, err: 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"