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.
- checksums.yaml +4 -4
- data/Gemfile +1 -1
- data/Gemfile.lock +25 -2
- data/app/controllers/perron/concierge_controller.rb +13 -0
- data/app/controllers/perron/searches_controller.rb +48 -0
- data/app/helpers/perron/feeds_helper.rb +7 -0
- data/app/helpers/perron/markdown_helper.rb +3 -3
- data/app/helpers/perron/meta_tags_helper.rb +17 -0
- data/app/views/perron/concierge/show.html.erb +271 -0
- data/bin/release +19 -4
- data/lib/generators/rails/content/USAGE +45 -26
- data/lib/generators/rails/content/content_generator.rb +16 -13
- data/lib/generators/rails/content/templates/controller.rb.tt +7 -5
- data/lib/generators/rails/content/templates/model.rb.tt +4 -4
- data/lib/perron/assets/icon.png +0 -0
- data/lib/perron/assets/icon.svg +1 -0
- data/lib/perron/collection.rb +10 -1
- data/lib/perron/configuration.rb +13 -4
- data/lib/perron/content/data.rb +6 -2
- data/lib/perron/data_source/class_methods.rb +66 -0
- data/lib/perron/data_source/helper_context.rb +20 -0
- data/lib/perron/data_source/item.rb +37 -0
- data/lib/perron/{data → data_source}/proxy.rb +1 -1
- data/lib/perron/data_source.rb +140 -0
- data/lib/perron/development_feed_server.rb +69 -0
- data/lib/perron/engine.rb +41 -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/perron/install/README.md.tt +67 -0
- data/lib/{generators/perron/templates → perron/install}/initializer.rb.tt +8 -4
- data/lib/perron/install.rb +23 -0
- data/lib/perron/markdown.rb +2 -2
- data/lib/perron/output_server.rb +16 -2
- data/lib/perron/relation.rb +51 -0
- data/lib/perron/resource/adjacency.rb +70 -0
- data/lib/perron/resource/associations.rb +3 -3
- data/lib/perron/resource/class_methods.rb +10 -0
- data/lib/perron/resource/configuration.rb +16 -14
- data/lib/perron/resource/core.rb +11 -0
- data/lib/perron/resource/metadata.rb +10 -1
- data/lib/perron/resource/publishable.rb +2 -0
- data/lib/perron/resource/related/stop_words.rb +20 -20
- data/lib/perron/resource/related.rb +76 -54
- data/lib/perron/resource/scopes.rb +29 -0
- data/lib/perron/resource/searchable.rb +19 -0
- data/lib/perron/resource/sourceable.rb +39 -9
- data/lib/perron/resource/sweeper.rb +45 -0
- data/lib/perron/resource/table_of_content.rb +0 -18
- data/lib/perron/resource.rb +32 -20
- 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 +10 -3
- data/lib/perron/tasks/build.rake +6 -0
- data/lib/perron/tasks/install.rake +12 -0
- data/lib/perron/version.rb +1 -1
- data/lib/perron.rb +1 -0
- data/perron.gemspec +1 -0
- metadata +45 -10
- data/app/helpers/feeds_helper.rb +0 -5
- data/app/helpers/meta_tags_helper.rb +0 -15
- data/lib/generators/perron/install_generator.rb +0 -32
- data/lib/generators/perron/templates/README.md.tt +0 -45
- data/lib/perron/data.rb +0 -180
- 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(
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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, "
|
|
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")
|
|
141
|
-
|
|
142
|
-
|
|
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(
|
|
7
|
-
<<~
|
|
5
|
+
def self.source_template(source)
|
|
6
|
+
<<~MARKDOWN
|
|
8
7
|
---
|
|
9
8
|
---
|
|
10
|
-
|
|
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>
|
data/lib/perron/collection.rb
CHANGED
|
@@ -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
|
data/lib/perron/configuration.rb
CHANGED
|
@@ -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.
|
|
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
|
data/lib/perron/content/data.rb
CHANGED
|
@@ -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::
|
|
7
|
-
def self.const_missing(nested_name) = const_set(nested_name, Class.new(Perron::
|
|
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
|
|
@@ -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
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
|