perron 0.18.0 → 1.1.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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +11 -15
  3. data/app/controllers/perron/concierge_controller.rb +16 -0
  4. data/app/helpers/perron/markdown_helper.rb +3 -2
  5. data/app/helpers/perron/meta_tags_helper.rb +3 -9
  6. data/app/helpers/perron/paginate_helper.rb +40 -0
  7. data/app/views/perron/concierge/show.html.erb +271 -0
  8. data/lib/generators/rails/content/USAGE +21 -4
  9. data/lib/generators/rails/content/content_generator.rb +16 -12
  10. data/lib/generators/rails/content/templates/controller.rb.tt +6 -0
  11. data/lib/generators/rails/content/templates/model.rb.tt +1 -1
  12. data/lib/perron/assets/icon.png +0 -0
  13. data/lib/perron/assets/icon.svg +1 -0
  14. data/lib/perron/collection.rb +2 -1
  15. data/lib/perron/configuration.rb +27 -2
  16. data/lib/perron/data_source/class_methods.rb +8 -0
  17. data/lib/perron/data_source.rb +20 -33
  18. data/lib/perron/development_feed_server.rb +69 -0
  19. data/lib/perron/engine.rb +32 -1
  20. data/lib/perron/errors.rb +2 -0
  21. data/lib/perron/feeds.rb +4 -3
  22. data/lib/perron/html_processor/absolute_urls.rb +27 -0
  23. data/lib/perron/html_processor/base.rb +2 -2
  24. data/lib/perron/html_processor.rb +7 -11
  25. data/lib/{generators/perron/templates → perron/install}/README.md.tt +7 -9
  26. data/lib/perron/install/deploy.yml +15 -0
  27. data/lib/perron/install.rb +26 -0
  28. data/lib/perron/markdown.rb +2 -2
  29. data/lib/perron/output_server.rb +9 -0
  30. data/lib/perron/paginate.rb +58 -0
  31. data/lib/perron/relation.rb +24 -6
  32. data/lib/perron/resource/adjacency.rb +70 -0
  33. data/lib/perron/resource/associations.rb +1 -1
  34. data/lib/perron/resource/class_methods.rb +6 -0
  35. data/lib/perron/resource/configuration.rb +12 -4
  36. data/lib/perron/resource/metadata.rb +19 -4
  37. data/lib/perron/resource/publishable.rb +2 -0
  38. data/lib/perron/resource/related.rb +32 -31
  39. data/lib/perron/resource/sourceable.rb +98 -16
  40. data/lib/perron/resource.rb +8 -0
  41. data/lib/perron/site/builder/assets.rb +1 -1
  42. data/lib/perron/site/builder/feeds/atom.erb +44 -0
  43. data/lib/perron/site/builder/feeds/atom.rb +41 -0
  44. data/lib/perron/site/builder/feeds/json.erb +19 -0
  45. data/lib/perron/site/builder/feeds/json.rb +7 -33
  46. data/lib/perron/site/builder/feeds/rss.erb +28 -0
  47. data/lib/perron/site/builder/feeds/rss.rb +6 -28
  48. data/lib/perron/site/builder/feeds/template.rb +63 -0
  49. data/lib/perron/site/builder/feeds.rb +8 -3
  50. data/lib/perron/site/builder/page.rb +19 -4
  51. data/lib/perron/site/builder/paths.rb +75 -13
  52. data/lib/perron/site/builder/route_resources.rb +79 -0
  53. data/lib/perron/site/builder/sitemap.rb +71 -20
  54. data/lib/perron/site/builder.rb +25 -1
  55. data/lib/perron/site/validate.rb +19 -7
  56. data/lib/perron/site.rb +7 -0
  57. data/lib/perron/tasks/build.rake +6 -7
  58. data/lib/perron/tasks/deploy.rake +58 -0
  59. data/lib/perron/tasks/install.rake +12 -0
  60. data/lib/perron/version.rb +1 -1
  61. data/lib/perron.rb +1 -0
  62. data/perron.gemspec +1 -1
  63. metadata +25 -8
  64. data/lib/generators/perron/install_generator.rb +0 -32
  65. data/lib/perron/html_processor/syntax_highlight.rb +0 -32
  66. /data/lib/{generators/perron/templates → perron/install}/initializer.rb.tt +0 -0
@@ -20,6 +20,20 @@ module Perron
20
20
  super(records)
21
21
  end
22
22
 
23
+ def self.all
24
+ identifier = name.to_s.split("::").drop(2).map { it.underscore }.join("/")
25
+ identifier = name.demodulize.underscore if identifier.empty?
26
+
27
+ return cached(identifier) if Perron.configuration.cache_data_sources
28
+
29
+ new(identifier)
30
+ end
31
+
32
+ def self.cached(identifier)
33
+ @_data_sources ||= {}
34
+ @_data_sources[identifier] ||= new(identifier)
35
+ end
36
+
23
37
  def each(&block) = @records.each(&block)
24
38
 
25
39
  def count = @records.count
@@ -52,23 +66,15 @@ module Perron
52
66
  end
53
67
 
54
68
  data.map.with_index do |item, index|
55
- unless item.is_a?(Hash)
56
- raise Errors::DataParseError, "Item at index #{index} in `#{@file_path}` must be a hash/object, got #{item.class}"
69
+ if item.is_a?(Hash)
70
+ Item.new(item, identifier: @identifier)
71
+ elsif item.is_a?(String)
72
+ item
73
+ else
74
+ raise Errors::DataParseError, "Item at index #{index} in `#{@file_path}` must be a hash/object or string, got #{item.class}"
57
75
  end
58
-
59
- Item.new(item, identifier: @identifier)
60
76
  end
61
77
  end
62
- # def records
63
- # content = rendered_from(@file_path)
64
- # data = parsed_from(content, @file_path)
65
-
66
- # unless data.is_a?(Array)
67
- # raise Errors::DataParseError, "Data in `#{@file_path}` must be an array of objects."
68
- # end
69
-
70
- # data.map { Item.new(it, identifier: @identifier) }
71
- # end
72
78
 
73
79
  def rendered_from(path)
74
80
  raw_content = File.read(path)
@@ -86,16 +92,6 @@ module Perron
86
92
 
87
93
  send(parser_method, content, path)
88
94
  end
89
- # def parsed_from(content, path)
90
- # extension = File.extname(path)
91
- # parser_method = PARSER_METHODS.fetch(extension) do
92
- # raise Errors::UnsupportedDataFormatError, "Unsupported data format: #{extension}"
93
- # end
94
-
95
- # send(parser_method, content)
96
- # rescue Psych::SyntaxError, JSON::ParserError, CSV::MalformedCSVError => error
97
- # raise Errors::DataParseError, "Failed to parse data format in `#{path}`: (#{error.class}) #{error.message}"
98
- # end
99
95
 
100
96
  def render_erb(content) = ERB.new(content).result(HelperContext.instance.get_binding)
101
97
 
@@ -107,9 +103,6 @@ module Perron
107
103
 
108
104
  raise Errors::DataParseError, "Invalid YAML syntax in `#{path}`#{line_info}#{column_info}: #{error.problem}"
109
105
  end
110
- # def parse_yaml(content)
111
- # YAML.safe_load(content, permitted_classes: [Symbol, Time], aliases: true)
112
- # end
113
106
 
114
107
  def parse_json(content, path)
115
108
  JSON.parse(content, symbolize_names: true)
@@ -119,9 +112,6 @@ module Perron
119
112
 
120
113
  raise Errors::DataParseError, "Invalid JSON syntax in `#{path}`#{line_info}: #{error.message}"
121
114
  end
122
- # def parse_json(content)
123
- # JSON.parse(content, symbolize_names: true)
124
- # end
125
115
 
126
116
  def parse_csv(content, path)
127
117
  expected_headers = nil
@@ -148,8 +138,5 @@ module Perron
148
138
 
149
139
  raise Errors::DataParseError, "Malformed CSV in `#{path}`#{line_info}: #{error.message}"
150
140
  end
151
- # def parse_csv(content)
152
- # CSV.new(content, headers: true, header_converters: :symbol).to_a.map(&:to_h)
153
- # end
154
141
  end
155
142
  end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Perron
4
+ class DevelopmentFeedServer
5
+ def initialize(app)
6
+ @app = app
7
+ end
8
+
9
+ def call(environment)
10
+ request = Rack::Request.new(environment)
11
+
12
+ if build_only_path?(request.path_info)
13
+ render_message(request.path_info)
14
+ else
15
+ @app.call(environment)
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def build_only_path?(path)
22
+ sitemap?(path) || feed?(path)
23
+ end
24
+
25
+ def render_message(path)
26
+ content_type = path.end_with?(".json") ? "application/json" : "application/xml"
27
+
28
+ [
29
+ 200,
30
+
31
+ {
32
+ "Content-Type" => "#{content_type}; charset=utf-8",
33
+ "Content-Length" => message(path).bytesize.to_s
34
+ },
35
+
36
+ [message(path)]
37
+ ]
38
+ end
39
+
40
+ def sitemap?(path)
41
+ path.match?(/\/sitemap\.xml$/)
42
+ end
43
+
44
+ def feed?(path)
45
+ feed_paths.any? { path.end_with?("/#{it}") || path == "/#{it}" }
46
+ end
47
+
48
+ def message(path)
49
+ if path.end_with?(".json")
50
+ "{ \"message\": \"This feed is generated during build\" }"
51
+ elsif sitemap?(path)
52
+ "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n <!-- Sitemap is generated during build -->\n</urlset>"
53
+ else
54
+ "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<feed xmlns=\"http://www.w3.org/2005/Atom\">\n <!-- Feed is generated during build -->\n</feed>"
55
+ end
56
+ end
57
+
58
+ def feed_paths
59
+ @feed_paths ||= Perron::Site.collections.flat_map do |collection|
60
+ config = collection.configuration
61
+ next [] unless config && config[:feeds]
62
+
63
+ config[:feeds].values.filter_map do |feed_config|
64
+ feed_config[:path] if feed_config[:enabled]
65
+ end
66
+ end.compact
67
+ end
68
+ end
69
+ end
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,11 +29,37 @@ module Perron
24
29
  end
25
30
  end
26
31
 
32
+ initializer "perron.concierge", before: :add_builtin_route do |app|
33
+ if Rails.env.development?
34
+ app.config.after_initialize do
35
+ app.routes.append do
36
+ namespace :perron do
37
+ post :run_command, to: "concierge#run_command"
38
+ end
39
+
40
+ root to: "perron/concierge#show" unless app.routes.named_routes.key?(:root)
41
+ end
42
+ end
43
+
44
+ app.routes.finalize!
45
+ end
46
+ end
47
+
48
+ initializer "perron.inflections" do
49
+ ActiveSupport::Inflector.inflections(:en) do |inflect|
50
+ inflect.acronym "RSS"
51
+ inflect.acronym "Atom"
52
+ inflect.acronym "Json"
53
+ end
54
+ end
55
+
27
56
  rake_tasks do
28
57
  load File.expand_path("../tasks/build.rake", __FILE__)
29
58
  load File.expand_path("../tasks/clobber.rake", __FILE__)
59
+ load File.expand_path("../tasks/install.rake", __FILE__)
30
60
  load File.expand_path("../tasks/sync_sources.rake", __FILE__)
31
61
  load File.expand_path("../tasks/validate.rake", __FILE__)
62
+ load File.expand_path("../tasks/deploy.rake", __FILE__)
32
63
  end
33
64
  end
34
65
  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,15 @@
1
+ # Deploy configuration for Perron
2
+ # Requires Beam Up (`bundle add beam_up`)
3
+ # See also: https://perron.railsdesigner.com/docs/deploy/
4
+
5
+ # Uncomment and configure your provider:
6
+ # provider: netlify
7
+
8
+ # netlify:
9
+ # api_token: your_token_here
10
+ # site_id: your_site_id
11
+
12
+ before_actions:
13
+ - RAILS_ENV=production bundle exec rails perron:build
14
+ after_actions:
15
+ - bundle exec rails perron:clobber
@@ -0,0 +1,26 @@
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 "Create deploy configuration"
10
+ copy_file "#{__dir__}/install/deploy.yml", "config/deploy.yml"
11
+
12
+ say "Add Markdown gem options to Gemfile"
13
+ append_to_file "Gemfile", <<~RUBY
14
+
15
+ # Perron supports Markdown rendering using one of the following gems.
16
+ # Uncomment your preferred choice and run `bundle install`
17
+ # gem "commonmarker"
18
+ # gem "kramdown"
19
+ # gem "redcarpet"
20
+ RUBY
21
+
22
+ copy_file "#{__dir__}/assets/icon.png", "public/icon.png", force: true
23
+ copy_file "#{__dir__}/assets/icon.svg", "public/icon.svg", force: true
24
+
25
+ say "Add output folder to .gitignore"
26
+ 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,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Perron
4
+ class Paginate
5
+ def initialize(collection, page:, per_page:, base_path: nil, page_path_template: nil, use_query_params: false)
6
+ @collection = collection
7
+ @per_page = per_page
8
+ @base_path = base_path
9
+ @page_path_template = page_path_template || "/page/:page/"
10
+ @use_query_params = use_query_params
11
+
12
+ @total_items = collection.size
13
+ @total_pages = total_items.zero? ? 0 : (total_items.to_f / per_page).ceil
14
+ @current_page = page.clamp(1, total_pages.zero? ? 1 : total_pages)
15
+ end
16
+
17
+ attr_reader :current_page, :total_pages, :total_items, :per_page
18
+
19
+ def items
20
+ offset = (@current_page - 1) * @per_page
21
+
22
+ @collection[offset, @per_page] || []
23
+ end
24
+
25
+ def next? = @current_page < @total_pages
26
+
27
+ def previous? = @current_page > 1
28
+
29
+ def next
30
+ return unless next?
31
+
32
+ page_path(@current_page + 1)
33
+ end
34
+
35
+ def previous
36
+ return unless previous?
37
+
38
+ target = (@current_page == 2) ? 1 : @current_page - 1
39
+
40
+ page_path(target)
41
+ end
42
+
43
+ private
44
+
45
+ attr_reader :use_query_params
46
+
47
+ def page_path(number)
48
+ return if number < 1
49
+ return if number > @total_pages && @total_pages > 0
50
+
51
+ return @base_path if number <= 1
52
+
53
+ return "#{@base_path}?page=#{number}" if @use_query_params
54
+
55
+ @base_path.sub(/\/$/, "") + @page_path_template.sub(":page", number.to_s)
56
+ end
57
+ end
58
+ end
@@ -2,9 +2,12 @@
2
2
 
3
3
  module Perron
4
4
  class Relation < Array
5
- def initialize(resources = [])
6
- super
5
+ def initialize(resources = [], model_class = nil)
6
+ super(resources)
7
+
8
+ @model_class = model_class
7
9
  end
10
+ attr_reader :model_class
8
11
 
9
12
  def where(**conditions)
10
13
  filtered = select do |resource|
@@ -19,12 +22,12 @@ module Perron
19
22
  end
20
23
  end
21
24
 
22
- Relation.new(filtered)
25
+ Relation.new(filtered, @model_class)
23
26
  end
24
27
 
25
- def limit(count) = Relation.new(first(count))
28
+ def limit(count) = Relation.new(first(count), @model_class)
26
29
 
27
- def offset(count) = Relation.new(drop(count))
30
+ def offset(count) = Relation.new(drop(count), @model_class)
28
31
 
29
32
  def order(attribute, direction = :asc)
30
33
  if attribute.is_a?(Hash)
@@ -33,7 +36,7 @@ module Perron
33
36
 
34
37
  sorted = sort_by { it.public_send(attribute) }
35
38
 
36
- Relation.new((direction == :desc) ? sorted.reverse : sorted)
39
+ Relation.new((direction == :desc) ? sorted.reverse : sorted, @model_class)
37
40
  end
38
41
 
39
42
  def pluck(*attributes)
@@ -47,5 +50,20 @@ module Perron
47
50
  end
48
51
  end
49
52
  end
53
+
54
+ def in_order_of(attribute, values, filter: true)
55
+ return Relation.new([]) if values.empty?
56
+
57
+ indexed = values.each_with_index.to_h
58
+
59
+ resources = if filter
60
+ select { indexed.key?(it.public_send(attribute)) }
61
+ .sort_by { indexed[it.public_send(attribute)] }
62
+ else
63
+ sort_by { indexed[it.public_send(attribute)] || Float::INFINITY }
64
+ end
65
+
66
+ Relation.new(resources)
67
+ end
50
68
  end
51
69
  end
@@ -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
@@ -22,6 +22,8 @@ module Perron
22
22
 
23
23
  def order(attribute, direction = :asc) = all.order(attribute, direction)
24
24
 
25
+ def in_order_of(attribute, values, filter: true) = all.in_order_of(attribute, values, filter:)
26
+
25
27
  def first(n = nil)
26
28
  n ? all.first(n) : all[0]
27
29
  end
@@ -44,6 +46,10 @@ module Perron
44
46
 
45
47
  def root = all.find(&:root?)
46
48
 
49
+ def destroy_all
50
+ all.each(&:destroy)
51
+ end
52
+
47
53
  def model_name
48
54
  @model_name ||= ActiveModel::Name.new(self, nil, name.demodulize.to_s)
49
55
  end