perron 0.17.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +1 -1
  3. data/Gemfile.lock +25 -2
  4. data/app/controllers/perron/concierge_controller.rb +13 -0
  5. data/app/controllers/perron/searches_controller.rb +48 -0
  6. data/app/helpers/perron/feeds_helper.rb +7 -0
  7. data/app/helpers/perron/markdown_helper.rb +3 -3
  8. data/app/helpers/perron/meta_tags_helper.rb +17 -0
  9. data/app/views/perron/concierge/show.html.erb +271 -0
  10. data/bin/release +19 -4
  11. data/lib/generators/rails/content/USAGE +45 -26
  12. data/lib/generators/rails/content/content_generator.rb +16 -13
  13. data/lib/generators/rails/content/templates/controller.rb.tt +7 -5
  14. data/lib/generators/rails/content/templates/model.rb.tt +4 -4
  15. data/lib/perron/assets/icon.png +0 -0
  16. data/lib/perron/assets/icon.svg +1 -0
  17. data/lib/perron/collection.rb +10 -1
  18. data/lib/perron/configuration.rb +13 -4
  19. data/lib/perron/content/data.rb +6 -2
  20. data/lib/perron/data_source/class_methods.rb +66 -0
  21. data/lib/perron/data_source/helper_context.rb +20 -0
  22. data/lib/perron/data_source/item.rb +37 -0
  23. data/lib/perron/{data → data_source}/proxy.rb +1 -1
  24. data/lib/perron/data_source.rb +140 -0
  25. data/lib/perron/development_feed_server.rb +69 -0
  26. data/lib/perron/engine.rb +41 -1
  27. data/lib/perron/errors.rb +2 -0
  28. data/lib/perron/feeds.rb +4 -3
  29. data/lib/perron/html_processor/absolute_urls.rb +27 -0
  30. data/lib/perron/html_processor/base.rb +2 -2
  31. data/lib/perron/html_processor.rb +7 -11
  32. data/lib/perron/install/README.md.tt +67 -0
  33. data/lib/{generators/perron/templates → perron/install}/initializer.rb.tt +8 -4
  34. data/lib/perron/install.rb +23 -0
  35. data/lib/perron/markdown.rb +2 -2
  36. data/lib/perron/output_server.rb +16 -2
  37. data/lib/perron/relation.rb +51 -0
  38. data/lib/perron/resource/adjacency.rb +70 -0
  39. data/lib/perron/resource/associations.rb +3 -3
  40. data/lib/perron/resource/class_methods.rb +10 -0
  41. data/lib/perron/resource/configuration.rb +16 -14
  42. data/lib/perron/resource/core.rb +11 -0
  43. data/lib/perron/resource/metadata.rb +10 -1
  44. data/lib/perron/resource/publishable.rb +2 -0
  45. data/lib/perron/resource/related/stop_words.rb +20 -20
  46. data/lib/perron/resource/related.rb +76 -54
  47. data/lib/perron/resource/scopes.rb +29 -0
  48. data/lib/perron/resource/searchable.rb +19 -0
  49. data/lib/perron/resource/sourceable.rb +39 -9
  50. data/lib/perron/resource/sweeper.rb +45 -0
  51. data/lib/perron/resource/table_of_content.rb +0 -18
  52. data/lib/perron/resource.rb +32 -20
  53. data/lib/perron/site/builder/assets.rb +1 -1
  54. data/lib/perron/site/builder/feeds/atom.erb +44 -0
  55. data/lib/perron/site/builder/feeds/atom.rb +41 -0
  56. data/lib/perron/site/builder/feeds/json.erb +19 -0
  57. data/lib/perron/site/builder/feeds/json.rb +7 -33
  58. data/lib/perron/site/builder/feeds/rss.erb +28 -0
  59. data/lib/perron/site/builder/feeds/rss.rb +6 -28
  60. data/lib/perron/site/builder/feeds/template.rb +63 -0
  61. data/lib/perron/site/builder/feeds.rb +8 -3
  62. data/lib/perron/site/builder/paths.rb +58 -14
  63. data/lib/perron/site/builder/route_resources.rb +79 -0
  64. data/lib/perron/site/builder/sitemap.rb +71 -20
  65. data/lib/perron/site/builder.rb +1 -1
  66. data/lib/perron/site/validate.rb +1 -2
  67. data/lib/perron/site.rb +10 -3
  68. data/lib/perron/tasks/build.rake +6 -0
  69. data/lib/perron/tasks/install.rake +12 -0
  70. data/lib/perron/version.rb +1 -1
  71. data/lib/perron.rb +1 -0
  72. data/perron.gemspec +1 -0
  73. metadata +45 -10
  74. data/app/helpers/feeds_helper.rb +0 -5
  75. data/app/helpers/meta_tags_helper.rb +0 -15
  76. data/lib/generators/perron/install_generator.rb +0 -32
  77. data/lib/generators/perron/templates/README.md.tt +0 -45
  78. data/lib/perron/data.rb +0 -180
  79. data/lib/perron/html_processor/syntax_highlight.rb +0 -30
@@ -13,6 +13,7 @@ module Rails
13
13
  desc: "Create a new content file from template instead of generating scaffold"
14
14
  class_option :data, type: :array, default: [], banner: "source1(.ext) source2(.ext)",
15
15
  desc: "Specify data sources with optional extensions (defaults to .yml)"
16
+ class_option :inline, type: :boolean, default: false, desc: "Render show action inline instead of using a view template"
16
17
 
17
18
  argument :actions, type: :array, default: %w[index show], banner: "actions", desc: "Specify which actions to generate (index/show)"
18
19
 
@@ -53,6 +54,8 @@ module Rails
53
54
  empty_directory view_directory
54
55
 
55
56
  actions.each do |action|
57
+ next if action == "show" && options[:inline]
58
+
56
59
  template "#{action}.html.erb.tt", File.join(view_directory, "#{action}.html.erb")
57
60
  end
58
61
  end
@@ -76,7 +79,7 @@ module Rails
76
79
  options[:data].each do |source|
77
80
  name, extension = source.split(".", 2)
78
81
 
79
- create_file File.join(content_directory, "#{name}.#{extension || "yml"}"), ""
82
+ create_file File.join("app", "content", "data", "#{name}.#{extension || "yml"}"), ""
80
83
  end
81
84
  end
82
85
 
@@ -84,16 +87,12 @@ module Rails
84
87
  return if @content_mode
85
88
  return unless should_include_root?
86
89
 
87
- inject_into_class "app/controllers/content/#{plural_file_name}_controller.rb", "Content::#{plural_class_name}Controller" do
88
- <<-RUBY
90
+ controller_file = "app/controllers/content/#{plural_file_name}_controller.rb"
91
+ return unless File.exist?(File.join(destination_root, controller_file))
89
92
 
90
- def root
91
- @resource = Content::#{class_name}.root
93
+ root_action = " def root\n @resource = Content::#{class_name}.root\n\n render :show\n end\n\n"
92
94
 
93
- render :show
94
- end
95
- RUBY
96
- end
95
+ inject_into_file controller_file, root_action, after: "class Content::#{plural_class_name}Controller < ApplicationController\n"
97
96
  end
98
97
 
99
98
  def create_root_content_file
@@ -130,16 +129,20 @@ module Rails
130
129
  def pages_controller? = plural_file_name == "pages"
131
130
 
132
131
  def template_file
133
- @template_file ||= Dir.glob(File.join(content_directory, "{YYYY-MM-DD-,}template.*.tt")).first
132
+ @template_file ||= Dir.glob(File.join(content_directory, "*.tt")).first
134
133
  end
135
134
 
136
135
  def filename_from_template
137
136
  @filename_from_template ||= begin
138
137
  return "untitled.md" unless template_file
139
138
 
140
- File.basename(template_file, ".tt").tap do |name|
141
- name.gsub!("YYYY-MM-DD", Time.current.strftime("%Y-%m-%d"))
142
- name.sub!("template", @content_title ? @content_title.parameterize : "untitled")
139
+ name = File.basename(template_file, ".tt")
140
+ name = Time.current.strftime(name)
141
+
142
+ if name.include?("title")
143
+ name.sub("title", @content_title ? @content_title.parameterize : "untitled")
144
+ else
145
+ name
143
146
  end
144
147
  end
145
148
  end
@@ -1,17 +1,19 @@
1
1
  class Content::<%= plural_class_name %>Controller < ApplicationController
2
- <%- if pages_controller? -%>
3
- include Perron::Root
4
- <%- end -%>
5
-
6
2
  <%- if actions.include?("index") -%>
7
3
  def index
8
4
  @resources = Content::<%= class_name %>.all
9
5
  end
6
+ <%- if actions.include?("show") -%>
10
7
 
8
+ <%- end -%>
11
9
  <%- end -%>
12
10
  <%- if actions.include?("show") -%>
13
11
  def show
14
- @resource = Content::<%= class_name %>.find(params[:id])
12
+ @resource = Content::<%= class_name %>.find!(params[:id])
13
+ <%- if options[:inline] -%>
14
+
15
+ render @resource.inline
16
+ <%- end -%>
15
17
  end
16
18
  <%- end -%>
17
19
  end
@@ -1,13 +1,13 @@
1
1
  class Content::<%= class_name %> < Perron::Resource
2
2
  <% if data_sources? -%>
3
-
4
3
  sources <%= data_sources.map { ":#{it}" }.join(", ") %>
5
4
 
6
- def self.source_template(sources)
7
- <<~TEMPLATE
5
+ def self.source_template(source)
6
+ <<~MARKDOWN
8
7
  ---
9
8
  ---
10
- TEMPLATE
9
+
10
+ MARKDOWN
11
11
  end
12
12
  <% end -%>
13
13
  end
Binary file
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="400" height="400" fill="none" viewBox="0 0 400 400"><path fill="#f97316" d="M177.101 221.582a8.59 8.59 0 0 1 8.587-8.587h68.524q23.93 0 40.424-16.458 16.728-16.697 16.728-46.99 0-22.66-17.425-40.549-17.424-18.129-39.727-18.128H126.636c-8.836 0-16 7.163-16 16v244.766a8.364 8.364 0 1 1-16.727 0V89.696c0-8.837 7.164-16 16-16h144.303q29.272 0 51.576 23.137 22.303 23.136 22.303 52.714 0 36.495-21.606 58.678-21.606 21.944-52.273 21.944h-68.524a8.587 8.587 0 0 1-8.587-8.587m20 34.348a8.59 8.59 0 0 1 8.587-8.587h48.524q24.162 0 44.606-11.926 20.678-12.165 33.222-34.825 12.778-22.66 12.778-51.045 0-36.733-27.414-64.88-27.182-28.145-63.192-28.145H93.182c-8.837 0-16 7.163-16 16v299.114a8.364 8.364 0 1 1-16.727 0V55.348c0-8.837 7.163-16 16-16h177.757q28.111 0 52.97 15.266t39.495 40.788q14.868 25.522 14.868 54.145 0 32.44-14.868 59.155-14.87 26.477-39.495 41.265-24.394 14.55-52.97 14.55h-48.524a8.59 8.59 0 0 1-8.587-8.587m20 34.348a8.587 8.587 0 0 1 8.587-8.587h28.524q24.86 0 47.626-10.018 22.768-10.257 39.495-27.431 16.96-17.412 26.95-41.981 9.99-24.806 9.99-52.714 0-33.393-16.96-62.732-16.959-29.578-45.768-46.99-28.575-17.65-61.333-17.651H59.727c-8.836 0-16 7.163-16 16v353.462a8.364 8.364 0 0 1-16.727 0V21c0-8.837 7.163-16 16-16h211.212q37.172 0 69.697 20.036 32.526 19.799 51.808 53.192Q395 111.62 395 149.547q0 33.871-11.384 62.494-11.383 28.385-30.667 47.228-19.283 18.844-44.838 29.339-25.555 10.257-53.899 10.257h-28.524a8.59 8.59 0 0 1-8.587-8.587m-73.01 41.358a8.364 8.364 0 0 1-16.727 0V124.043c0-8.836 7.163-16 16-16h110.848q16.495 0 28.344 12.404 12.08 12.404 12.08 29.1 0 46.274-40.424 46.274h-88.524a8.587 8.587 0 1 1 0-17.174h88.524q11.152 0 17.424-6.44t6.273-22.66q0-10.495-6.97-17.412-6.737-6.918-16.727-6.918h-94.121c-8.837 0-16 7.164-16 16z" style="mix-blend-mode:multiply"/></svg>
@@ -16,11 +16,20 @@ module Perron
16
16
  end
17
17
 
18
18
  def all(resource_class = "Content::#{name.classify}".safe_constantize)
19
- load_resources(resource_class).select(&:published?)
19
+ Perron::Relation.new(load_resources(resource_class).select(&:published?))
20
20
  end
21
21
  alias_method :resources, :all
22
22
 
23
23
  def find(slug, resource_class = Resource)
24
+ Perron.deprecator.deprecation_warning(
25
+ :find,
26
+ "Collection#find will return nil instead of raising in the next major version. Use #find! to raise an error."
27
+ )
28
+
29
+ find!(slug, resource_class)
30
+ end
31
+
32
+ def find!(slug, resource_class = Resource)
24
33
  resource = load_resources(resource_class).find { it.slug == slug }
25
34
 
26
35
  return resource if resource
@@ -13,17 +13,19 @@ module Perron
13
13
  def initialize
14
14
  @config = ActiveSupport::OrderedOptions.new
15
15
 
16
- @config.site_name = nil
17
- @config.site_description = nil
18
-
19
16
  @config.output = "output"
20
17
 
18
+ @config.output_server_strict = true
19
+
21
20
  @config.mode = :standalone
22
21
 
23
- @config.allowed_extensions = %w[erb md]
22
+ @config.live_reload = false
23
+ @config.live_reload_watch_paths = %w[app/content app/views app/assets]
24
+ @config.live_reload_skip_paths = %w[app/assets/builds]
24
25
 
25
26
  @config.exclude_from_public = %w[assets storage]
26
27
  @config.excluded_assets = %w[action_cable actioncable actiontext activestorage rails-ujs trix turbo]
28
+ @config.allowed_extensions = %w[erb md]
27
29
 
28
30
  @config.view_unpublished = Rails.env.development?
29
31
 
@@ -35,11 +37,18 @@ module Perron
35
37
 
36
38
  @config.markdown_options = {}
37
39
 
40
+ @config.search_scope = []
41
+
42
+ @config.cache_data_sources = false
43
+
38
44
  @config.sitemap = ActiveSupport::OrderedOptions.new
39
45
  @config.sitemap.enabled = false
40
46
  @config.sitemap.priority = 0.5
41
47
  @config.sitemap.change_frequency = :monthly
42
48
 
49
+ @config.site_name = nil
50
+ @config.site_description = nil
51
+
43
52
  @config.metadata = ActiveSupport::OrderedOptions.new
44
53
  @config.metadata.title_separator = " — "
45
54
  end
@@ -2,9 +2,13 @@
2
2
 
3
3
  module Content
4
4
  module Data
5
+ def self.new(identifier)
6
+ Perron::DataSource.new(identifier)
7
+ end
8
+
5
9
  def self.const_missing(name)
6
- klass = Class.new(Perron::Data) do
7
- def self.const_missing(nested_name) = const_set(nested_name, Class.new(Perron::Data))
10
+ klass = Class.new(Perron::DataSource) do
11
+ def self.const_missing(nested_name) = const_set(nested_name, Class.new(Perron::DataSource))
8
12
  end
9
13
 
10
14
  const_set(name, klass)
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Perron
4
+ class DataSource < SimpleDelegator
5
+ module ClassMethods
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ def all
10
+ parts = name.to_s.split("::").drop(2)
11
+ identifier = parts.empty? ? name.demodulize.underscore : parts.map(&:underscore).join("/")
12
+
13
+ new(identifier)
14
+ end
15
+
16
+ def find(id)
17
+ all.find { it[:id] == id || it["id"] == id }
18
+ end
19
+
20
+ def find!(id)
21
+ data_source = all.find { it[:id] == id || it["id"] == id }
22
+
23
+ return data_source if data_source
24
+
25
+ raise Errors::DataSourceNotFoundError, "Row not found with id: #{id}"
26
+ end
27
+
28
+ def count = all.size
29
+
30
+ def first = all.first
31
+
32
+ def second = all[1]
33
+
34
+ def third = all[2]
35
+
36
+ def fourth = all[3]
37
+
38
+ def fifth = all[4]
39
+
40
+ def forty_two = all[41]
41
+
42
+ def last = all.last
43
+
44
+ def take(n) = all.first(n)
45
+
46
+ def path_for(identifier)
47
+ path = Pathname.new(identifier)
48
+
49
+ return path.to_s if path.file? && path.absolute?
50
+
51
+ base_path = Rails.root.join("app", "content", "data")
52
+
53
+ SUPPORTED_EXTENSIONS.lazy.map { base_path.join("#{identifier}#{it}") }.find(&:exist?)&.to_s
54
+ end
55
+
56
+ def path_for!(identifier)
57
+ path_for(identifier).tap do |path|
58
+ raise Errors::FileNotFoundError, "No data file found for `#{identifier}`" unless path
59
+ end
60
+ end
61
+
62
+ def directory?(identifier) = Dir.exist?(Rails.root.join("app", "content", "data", identifier))
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Perron
4
+ class DataSource < SimpleDelegator
5
+ class HelperContext
6
+ include Singleton
7
+
8
+ def initialize
9
+ self.class.include ActionView::Helpers::AssetUrlHelper
10
+ self.class.include ActionView::Helpers::DateHelper
11
+ self.class.include Rails.application.routes.url_helpers
12
+ end
13
+
14
+ def get_binding = binding
15
+
16
+ def default_url_options = Perron.configuration.default_url_options || {}
17
+ end
18
+ private_constant :HelperContext
19
+ end
20
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Perron
4
+ class DataSource < SimpleDelegator
5
+ class Item
6
+ def initialize(attributes, identifier:)
7
+ @attributes = attributes.transform_keys(&:to_sym)
8
+ @identifier = identifier
9
+ end
10
+
11
+ def [](key) = @attributes[key.to_sym]
12
+
13
+ def association_value(key) = self[key]
14
+
15
+ def to_partial_path
16
+ @to_partial_path ||= begin
17
+ identifier = @identifier.to_s
18
+ collection = File.extname(identifier).present? ? File.basename(identifier, ".*") : identifier
19
+ element = ActiveSupport::Inflector.underscore(ActiveSupport::Inflector.singularize(File.basename(collection)))
20
+
21
+ File.join("content", collection, element)
22
+ end
23
+ end
24
+
25
+ def method_missing(method_name, *arguments, &block)
26
+ return super if !@attributes.key?(method_name) || arguments.any? || block
27
+
28
+ @attributes[method_name]
29
+ end
30
+
31
+ def respond_to_missing?(method_name, include_private = false)
32
+ @attributes.key?(method_name) || super
33
+ end
34
+ end
35
+ private_constant :Item
36
+ end
37
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Perron
4
- class Data
4
+ class DataSource
5
5
  class Proxy
6
6
  include Enumerable
7
7
 
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ require "perron/data_source/class_methods"
6
+ require "perron/data_source/item"
7
+ require "perron/data_source/helper_context"
8
+
9
+ module Perron
10
+ class DataSource < SimpleDelegator
11
+ include Enumerable
12
+
13
+ include Perron::DataSource::ClassMethods
14
+
15
+ def initialize(identifier)
16
+ @identifier = identifier
17
+ @file_path = self.class.path_for!(identifier)
18
+ @records = records
19
+
20
+ super(records)
21
+ end
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
+
37
+ def each(&block) = @records.each(&block)
38
+
39
+ def count = @records.count
40
+
41
+ def first(n = nil)
42
+ n ? @records.first(n) : @records.first
43
+ end
44
+
45
+ def last = @records.last
46
+
47
+ def [](index) = @records[index]
48
+
49
+ def size = @records.size
50
+ alias_method :length, :size
51
+
52
+ private
53
+
54
+ PARSER_METHODS = {
55
+ ".yml" => :parse_yaml, ".yaml" => :parse_yaml,
56
+ ".json" => :parse_json, ".csv" => :parse_csv
57
+ }.freeze
58
+ SUPPORTED_EXTENSIONS = PARSER_METHODS.keys
59
+
60
+ def records
61
+ content = rendered_from(@file_path)
62
+ data = parsed_from(content, @file_path)
63
+
64
+ unless data.is_a?(Array)
65
+ raise Errors::DataParseError, "Data in `#{@file_path}` must be an array of objects."
66
+ end
67
+
68
+ data.map.with_index do |item, index|
69
+ unless item.is_a?(Hash)
70
+ raise Errors::DataParseError, "Item at index #{index} in `#{@file_path}` must be a hash/object, got #{item.class}"
71
+ end
72
+
73
+ Item.new(item, identifier: @identifier)
74
+ end
75
+ end
76
+
77
+ def rendered_from(path)
78
+ raw_content = File.read(path)
79
+
80
+ render_erb(raw_content)
81
+ rescue NameError, ArgumentError, SyntaxError => error
82
+ raise Errors::DataParseError, "Failed to render ERB in `#{path}`: (#{error.class}) #{error.message}"
83
+ end
84
+
85
+ def parsed_from(content, path)
86
+ extension = File.extname(path)
87
+ parser_method = PARSER_METHODS.fetch(extension) do
88
+ raise Errors::UnsupportedDataFormatError, "Unsupported data format: #{extension}. Supported formats: #{SUPPORTED_EXTENSIONS.join(", ")}"
89
+ end
90
+
91
+ send(parser_method, content, path)
92
+ end
93
+
94
+ def render_erb(content) = ERB.new(content).result(HelperContext.instance.get_binding)
95
+
96
+ def parse_yaml(content, path)
97
+ YAML.safe_load(content, permitted_classes: [Symbol, Time], aliases: true)
98
+ rescue Psych::SyntaxError => error
99
+ line_info = error.line ? " at line #{error.line}" : ""
100
+ column_info = error.column ? ", column #{error.column}" : ""
101
+
102
+ raise Errors::DataParseError, "Invalid YAML syntax in `#{path}`#{line_info}#{column_info}: #{error.problem}"
103
+ end
104
+
105
+ def parse_json(content, path)
106
+ JSON.parse(content, symbolize_names: true)
107
+ rescue JSON::ParserError => error
108
+ line_match = error.message.match(/at line (\d+)/)
109
+ line_info = line_match ? " at line #{line_match[1]}" : ""
110
+
111
+ raise Errors::DataParseError, "Invalid JSON syntax in `#{path}`#{line_info}: #{error.message}"
112
+ end
113
+
114
+ def parse_csv(content, path)
115
+ expected_headers = nil
116
+
117
+ CSV.new(content, headers: true, header_converters: :symbol).map.with_index do |row, index|
118
+ expected_headers ||= row.headers
119
+
120
+ if row.headers != expected_headers
121
+ missing = expected_headers - row.headers
122
+ extra = row.headers - expected_headers
123
+
124
+ error_parts = []
125
+ error_parts << "missing columns: #{missing.join(", ")}" if missing.any?
126
+ error_parts << "extra columns: #{extra.join(", ")}" if extra.any?
127
+
128
+ raise Errors::DataParseError, "Column mismatch in `#{path}` at row #{index + 2} (#{error_parts.join("; ")}). Expected: #{expected_headers.join(", ")}"
129
+ end
130
+
131
+ row.to_h
132
+ end
133
+ rescue CSV::MalformedCSVError => error
134
+ line_match = error.message.match(/line (\d+)/)
135
+ line_info = line_match ? " at line #{line_match[1]}" : ""
136
+
137
+ raise Errors::DataParseError, "Malformed CSV in `#{path}`#{line_info}: #{error.message}"
138
+ end
139
+ end
140
+ 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,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "perron/output_server"
4
+ require "perron/development_feed_server"
5
+ require "mata"
4
6
 
5
7
  module Perron
6
8
  class Engine < Rails::Engine
@@ -9,12 +11,50 @@ module Perron
9
11
  end
10
12
 
11
13
  initializer "perron.output_server" do |app|
12
- 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?
19
+ end
20
+
21
+ initializer "perron.configure_hmr", after: :load_config_initializers do |app|
22
+ if Rails.env.development? && Perron.configuration.live_reload
23
+ app.config.middleware.insert_before(
24
+ ActionDispatch::Static,
25
+ Mata,
26
+ watch: Perron.configuration.live_reload_watch_paths,
27
+ skip: Perron.configuration.live_reload_skip_paths
28
+ )
29
+ end
30
+ end
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
13
52
  end
14
53
 
15
54
  rake_tasks do
16
55
  load File.expand_path("../tasks/build.rake", __FILE__)
17
56
  load File.expand_path("../tasks/clobber.rake", __FILE__)
57
+ load File.expand_path("../tasks/install.rake", __FILE__)
18
58
  load File.expand_path("../tasks/sync_sources.rake", __FILE__)
19
59
  load File.expand_path("../tasks/validate.rake", __FILE__)
20
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