perron 0.13.3 → 0.14.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ebff05f1548f6e133cde9cdfc40ba692d65a52d548cdf47df78b16b52d072b49
4
- data.tar.gz: 8fcf6d0a11a8ed4b7426c11473d1d8d791a329cd47417d072434b8a1d28e8c6c
3
+ metadata.gz: '080352c09efb475419f74de6ee7cf01a491d4cc349f6cf0d6e23d35132c83953'
4
+ data.tar.gz: 156c0419afc4c3aa77411ac7f7508cbea56de584d164624be86b4d2615baf7f5
5
5
  SHA512:
6
- metadata.gz: fcb6b264a9d159852991f67803bf9f8e666d535a87dc8f12cd82e87362ce326ec3cfa47c0681bfa13e83889e7374fbfe3289d86674b7f8c120971c34969928a0
7
- data.tar.gz: 45f0d1fd94a3152d396e64ef8178639fffdd527d8a7fc3c50318578cc71684587fa4622aa99f8b3cc347cf8729460d0d6853e22e0d07122c3b88de01c18c444d
6
+ metadata.gz: c75e1453c34bf8a8b8a4345d9167110745c1c47d8094aea3345e85fb46de51b0d326c904a7132c20e058ae89156c48dac5b9311121fc0359ad1d348ccde7d27f
7
+ data.tar.gz: a3acd15ee6f0224c032b31dac7272539e6ed6e6c9451a4b22d47c83c108f8fa374f5b6b7ceab97ae7bf4d6cc1296bbac8cd9c8788b28c7187f50c29c4621ee01
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- perron (0.13.3)
4
+ perron (0.14.0)
5
5
  csv
6
6
  json
7
7
  psych
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Perron
2
2
 
3
- A Rails-based static site generator.
3
+ Static Site Generator for Ruby on Rails. Build with Rails. Deploy static sites.
4
4
 
5
5
  **Sponsored By [Rails Designer](https://railsdesigner.com/)**
6
6
 
@@ -1,37 +1,25 @@
1
1
  # Data
2
2
 
3
- Perron can consume structured data from YML, JSON, or CSV files, making them available within your templates.
4
- This is useful for populating features, team members, or any other repeated data structure.
3
+ Perron can consume structured data from YML, JSON, or CSV files, making them available within your templates. This is useful for populating features, team members, or any other repeated data structure.
5
4
 
6
5
 
7
6
  ## Usage
8
7
 
9
- To use a data file, you can access it through the `Perron::Site.data` object followed by the basename of the file:
8
+ To use a data file, instantiate `Perron::Site.data` with the basename of the file and iterate over the result.
10
9
  ```erb
11
10
  <%% Perron::Site.data.features.each do |feature| %>
12
11
  <h4><%%= feature.name %></h4>
13
-
14
- <p><%%= feature.description %></p>
15
- <%% end %>
16
- ```
17
-
18
- This is a convenient shorthand for `Perron::Data.new("features")`, which can also be used directly:
19
- ```ruby
20
- <%% Perron::Data.new("features").each do |feature| %>
21
- <h4><%%= feature.name %></h4>
22
-
23
12
  <p><%%= feature.description %></p>
24
13
  <%% end %>
25
14
  ```
26
15
 
27
16
 
28
- ## File Location and Formats
17
+ ## File location and formats
29
18
 
30
- By default, Perron looks up `app/content/data/` for files with a `.yml`, `.json`, or `.csv` extension.
31
- For a `new("features")` call, it would find `features.yml`, `features.json`, or `features.csv`. You can also provide a full, absolute path to any data file, like `Perron::Data.new("path-to-some-data-file")`.
19
+ By default, Perron looks up `app/content/data/` for files with a `.yml`, `.json`, or `.csv` extension. For a `features` call, it would find `features.yml`, `features.json`, or `features.csv`. You can also provide a path to any data file, via `Perron::Data.new("path/to/data.json")`.
32
20
 
33
21
 
34
- ## Accessing Data
22
+ ## Accessing data
35
23
 
36
24
  The wrapper object provides flexible, read-only access to each record's attributes. Both dot notation and hash-like key access are supported.
37
25
  ```ruby
@@ -1,7 +1,7 @@
1
1
  Perron.configure do |config|
2
2
  # config.output = "output"
3
3
 
4
- # config.site_name = "Helptail"
4
+ # config.site_name = "Chirp Form"
5
5
 
6
6
  # The build mode for Perron. Can be :standalone or :integrated
7
7
  # config.mode = :standalone
@@ -16,8 +16,8 @@ Perron.configure do |config|
16
16
 
17
17
  # Set default meta values
18
18
  # Examples:
19
- # - `config.metadata.description = "Put your routine tasks on autopilot"`
20
- # - `config.metadata.author = "Helptail Team"`
19
+ # - `config.metadata.description = "Add forms to any static site. Display responses anywhere."`
20
+ # - `config.metadata.author = "Chirp Form Team"`
21
21
 
22
22
  # Set meta title suffix
23
23
  # config.metadata.title_suffix = nil
data/lib/perron/engine.rb CHANGED
@@ -9,6 +9,7 @@ module Perron
9
9
  rake_tasks do
10
10
  load File.expand_path("../tasks/build.rake", __FILE__)
11
11
  load File.expand_path("../tasks/validate.rake", __FILE__)
12
+ load File.expand_path("../tasks/sync_sources.rake", __FILE__)
12
13
  end
13
14
  end
14
15
  end
@@ -17,39 +17,69 @@ module Perron
17
17
  @parser ||= markdown_parser
18
18
  end
19
19
 
20
- def markdown_parser
21
- if defined?(::Commonmarker)
22
- CommonMarkerParser.new
23
- elsif defined?(::Kramdown)
24
- KramdownParser.new
25
- elsif defined?(::Redcarpet)
26
- RedcarpetParser.new
20
+ def configured_parser
21
+ return unless (parser_name = Perron.configuration.markdown_parser)
22
+ class_name = parser_name.to_s.camelize
23
+ class_name += "Parser" unless class_name.end_with?("Parser")
24
+
25
+ klass = if const_defined?(class_name)
26
+ const_get(class_name)
27
+ elsif Object.const_defined?(class_name)
28
+ Object.const_get(class_name)
27
29
  else
28
- PlainTextParser.new
30
+ raise "Can't find parser #{parser_name} by class name #{class_name}"
31
+ end
32
+
33
+ unless klass.available?
34
+ raise "Parser #{parser_name} #{class_name} is not available (gem not installed?)"
29
35
  end
36
+
37
+ klass
30
38
  end
31
- end
32
39
 
33
- class CommonMarkerParser
34
- def parse(text) = Commonmarker.to_html(text, **Perron.configuration.markdown_options)
40
+ def available_parser = Parser.descendants.find(&:available?) || Parser
41
+
42
+ def markdown_parser
43
+ (configured_parser || available_parser).new(**Perron.configuration.markdown_options)
44
+ end
35
45
  end
36
46
 
37
- class KramdownParser
38
- def parse(text) = Kramdown::Document.new(text, Perron.configuration.markdown_options).to_html
47
+ class Parser
48
+ attr_reader :options
49
+
50
+ def initialize(**options)
51
+ @options = options
52
+ end
53
+
54
+ def parse(text) = text.to_s
55
+
56
+ def self.available? = true
39
57
  end
40
58
 
41
- class RedcarpetParser
42
- def parse(text)
43
- options = Perron.configuration.markdown_options
44
- renderer = Redcarpet::Render::HTML.new(options.fetch(:renderer_options, {}))
45
- markdown = Redcarpet::Markdown.new(renderer, options.fetch(:markdown_options, {}))
59
+ class RedcarpetParser < Parser
60
+ def renderer
61
+ @renderer ||= Redcarpet::Render::HTML.new(options.fetch(:renderer_options, {}))
62
+ end
46
63
 
47
- markdown.render(text)
64
+ def markdown
65
+ @markdown ||= Redcarpet::Markdown.new(renderer, options.fetch(:markdown_options, {}))
48
66
  end
67
+
68
+ def parse(text) = markdown.render(text)
69
+
70
+ def self.available? = defined?(::Redcarpet)
49
71
  end
50
72
 
51
- class PlainTextParser
52
- def parse(text) = text.to_s
73
+ class KramdownParser < Parser
74
+ def parse(text) = Kramdown::Document.new(text, options).to_html
75
+
76
+ def self.available? = defined?(::Kramdown)
77
+ end
78
+
79
+ class CommonMarkerParser < Parser
80
+ def parse(text) = Commonmarker.to_html(text, **options)
81
+
82
+ def self.available? = defined?(::Commonmarker)
53
83
  end
54
84
  end
55
85
  end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Perron
4
+ class Resource
5
+ module Associations
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ def belongs_to(association_name, **options)
10
+ define_method(association_name) do
11
+ cache_belongs_to_association(association_name) do
12
+ associated_class = association_class_for(association_name, **options)
13
+ foreign_key = foreign_key_for(association_name, **options)
14
+ identifier = metadata[foreign_key]
15
+
16
+ identifier ? associated_class.find(identifier) : nil
17
+ end
18
+ end
19
+ end
20
+
21
+ def has_many(association_name, **options)
22
+ define_method(association_name) do
23
+ cache_has_many_association(association_name) do
24
+ associated_class = association_class_for(association_name, singularize: true, **options)
25
+ foreign_key = foreign_key_for(inverse_association_name, **options)
26
+ primary_key_method = options.fetch(:primary_key, :slug)
27
+ lookup_value = public_send(primary_key_method)
28
+
29
+ associated_class.all.select { |record| record.metadata[foreign_key] == lookup_value }
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def cache_belongs_to_association(name)
38
+ @belongs_to_cache ||= {}
39
+ return @belongs_to_cache[name] if @belongs_to_cache.key?(name)
40
+
41
+ @belongs_to_cache[name] = yield
42
+ end
43
+
44
+ def cache_has_many_association(name)
45
+ @has_many_cache ||= {}
46
+ return @has_many_cache[name] if @has_many_cache.key?(name)
47
+
48
+ @has_many_cache[name] = yield
49
+ end
50
+
51
+ def association_class_for(association_name, singularize: false, **options)
52
+ class_name = options[:class_name] || begin
53
+ name = association_name.to_s
54
+ name = name.singularize if singularize
55
+
56
+ "Content::#{name.classify}"
57
+ end
58
+
59
+ class_name.constantize
60
+ end
61
+
62
+ def foreign_key_for(base_name, **options)
63
+ (options[:foreign_key] || "#{base_name}_id").to_s
64
+ end
65
+
66
+ def inverse_association_name = self.class.name.demodulize.underscore
67
+ end
68
+ end
69
+ end
@@ -49,10 +49,14 @@ module Perron
49
49
  return @frontmatter[:canonical_url] if @frontmatter[:canonical_url]
50
50
  return Rails.application.routes.url_helpers.root_url(**Perron.configuration.default_url_options) if @resource.slug == "/"
51
51
 
52
- Rails.application.routes.url_helpers.polymorphic_url(
53
- @resource,
52
+ begin
53
+ Rails.application.routes.url_helpers.polymorphic_url(
54
+ @resource,
54
55
  **Perron.configuration.default_url_options
55
- )
56
+ )
57
+ rescue
58
+ false
59
+ end
56
60
  end
57
61
 
58
62
  def site_data
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Perron
4
+ class Resource
5
+ module ReadingTime
6
+ extend ActiveSupport::Concern
7
+
8
+ def estimated_reading_time(wpm: DEFAULT_WORDS_PER_MINUTE, format: DEFAULT_FORMAT)
9
+ word_count = content.scan(/\b[a-zA-Z]+\b/).size
10
+ total_minutes = [(word_count.to_f / wpm).ceil, 1].max
11
+
12
+ hours = total_minutes / 60
13
+ minutes = total_minutes % 60
14
+ seconds = ((word_count.to_f / wpm) * 60).to_i % 60
15
+
16
+ return total_minutes if format.blank?
17
+
18
+ format % {
19
+ minutes: minutes,
20
+ total_minutes: total_minutes,
21
+ hours: hours,
22
+ seconds: seconds,
23
+ min: minutes,
24
+ h: hours,
25
+ s: seconds
26
+ }
27
+ end
28
+ alias_method :reading_time, :estimated_reading_time
29
+
30
+ private
31
+
32
+ DEFAULT_WORDS_PER_MINUTE = 200
33
+ DEFAULT_FORMAT = "%{minutes} min read"
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Perron
4
+ class Resource
5
+ module Sourceable
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ def sources(*arguments)
10
+ @source_definitions = parsed(*arguments)
11
+ end
12
+ alias_method :source, :sources
13
+
14
+ def source_definitions
15
+ @source_definitions || {}
16
+ end
17
+
18
+ def source_names = source_definitions.keys
19
+
20
+ def generate_from_sources!
21
+ return unless source_backed?
22
+
23
+ combinations.each do |combo|
24
+ content = content_with combo
25
+ filename = filename_with combo
26
+
27
+ FileUtils.mkdir_p(output_dir)
28
+ File.write(output_dir.join("#{filename}.erb"), content)
29
+ end
30
+ end
31
+
32
+ def source_backed? = source_names.any?
33
+
34
+ private
35
+
36
+ def parsed(*arguments)
37
+ return {} if arguments.empty?
38
+
39
+ arguments.flat_map { it.is_a?(Hash) ? it.to_a : [[it, {primary_key: :id}]] }.to_h
40
+ end
41
+
42
+ def combinations
43
+ datasets = source_names.map { Perron::Site.data.public_send(it) }
44
+
45
+ datasets.first.product(*datasets[1..])
46
+ end
47
+
48
+ def content_with(combo)
49
+ data = source_names.each.with_index.to_h { |name, index| [name, combo[index]] }
50
+ sources = Source.new(data)
51
+
52
+ source_template(sources)
53
+ end
54
+
55
+ def filename_with(combo)
56
+ source_names.each_with_index.map do |name, index|
57
+ primary_key = source_definitions[name][:primary_key]
58
+
59
+ combo[index].public_send(primary_key)
60
+ end.join("-")
61
+ end
62
+
63
+ def output_dir = Perron.configuration.input.join(model_name.collection)
64
+ end
65
+
66
+ def source_backed? = self.class.source_backed?
67
+
68
+ def sources
69
+ @sources ||= begin
70
+ data = self.class.source_definitions.each_with_object({}) do |(name, options), hash|
71
+ primary_key = options[:primary_key]
72
+ singular_name = name.to_s.singularize
73
+ identifier = frontmatter["#{singular_name}_#{primary_key}"]
74
+ hash[name] = Perron::Site.data.public_send(name).find { it.public_send(primary_key).to_s == identifier.to_s }
75
+ end
76
+
77
+ Source.new(data)
78
+ end
79
+ end
80
+
81
+ def source_template(sources)
82
+ raise NotImplementedError, "#{self.class.name} must implement #source_template"
83
+ end
84
+
85
+ class Source
86
+ def initialize(data)
87
+ @data = data
88
+ end
89
+
90
+ def method_missing(name, *arguments, &block)
91
+ return super if arguments.any? || block
92
+ return @data[name] if @data.key?(name)
93
+
94
+ super
95
+ end
96
+
97
+ def respond_to_missing?(name, _) = @data.key?(name)
98
+ end
99
+ private_constant :Source
100
+ end
101
+ end
102
+ end
@@ -3,12 +3,15 @@
3
3
  require "perron/resource/configuration"
4
4
  require "perron/resource/core"
5
5
  require "perron/resource/class_methods"
6
+ require "perron/resource/associations"
6
7
  require "perron/resource/metadata"
7
8
  require "perron/resource/publishable"
9
+ require "perron/resource/reading_time"
8
10
  require "perron/resource/related"
9
11
  require "perron/resource/renderer"
10
12
  require "perron/resource/slug"
11
13
  require "perron/resource/separator"
14
+ require "perron/resource/sourceable"
12
15
  require "perron/resource/table_of_content"
13
16
 
14
17
  module Perron
@@ -20,6 +23,9 @@ module Perron
20
23
  include Perron::Resource::Configuration
21
24
  include Perron::Resource::Core
22
25
  include Perron::Resource::ClassMethods
26
+ include Perron::Resource::Associations
27
+ include Perron::Resource::ReadingTime
28
+ include Perron::Resource::Sourceable
23
29
  include Perron::Resource::Publishable
24
30
  include Perron::Resource::TableOfContent
25
31
 
@@ -25,7 +25,7 @@ module Perron
25
25
  items: resources.map do |resource|
26
26
  {
27
27
  id: resource.id,
28
- url: url.polymorphic_url(resource),
28
+ url: url.polymorphic_url(resource, ref: feed_configuration.ref).delete_suffix("?ref="),
29
29
  date_published: resource.published_at&.iso8601,
30
30
  title: resource.metadata.title,
31
31
  content_html: Perron::Markdown.render(resource.content)
@@ -27,7 +27,7 @@ module Perron
27
27
  resources.each do |resource|
28
28
  xml.item do
29
29
  xml.guid resource.id
30
- xml.link url.polymorphic_url(resource), isPermaLink: true
30
+ xml.link url.polymorphic_url(resource, ref: feed_configuration.ref).delete_suffix("?ref="), isPermaLink: true
31
31
  xml.pubDate(resource.published_at&.rfc822)
32
32
  xml.title resource.metadata.title
33
33
  xml.description { xml.cdata(Perron::Markdown.render(resource.content)) }
@@ -19,11 +19,13 @@ module Perron
19
19
 
20
20
  puts [
21
21
  "Validation finished",
22
- (" with #{@failures.count} failures" if @failures.any?),
22
+ (" with #{@failures.count} failures" if failed?),
23
23
  "."
24
24
  ].join
25
25
  end
26
26
 
27
+ def failed? = @failures.any?
28
+
27
29
  private
28
30
 
29
31
  Failure = ::Data.define(:identifier, :errors)
@@ -0,0 +1,12 @@
1
+ namespace :perron do
2
+ desc "Sync source-backed resources"
3
+ task :sync_sources, [:name] => :environment do |_, arguments|
4
+ Rails.application.eager_load!
5
+
6
+ resource_classes = arguments.name ? ["Content::#{arguments.name.classify}".constantize] : Perron::Resource.descendants
7
+
8
+ resource_classes.compact.each do |resource_class|
9
+ resource_class.generate_from_sources! if resource_class.source_backed?
10
+ end
11
+ end
12
+ end
@@ -1,6 +1,6 @@
1
1
  namespace :perron do
2
2
  desc "Validate all site resources"
3
3
  task validate: :environment do
4
- Perron::Site.validate
4
+ abort if Perron::Site::Validate.new.tap(&:validate).failed?
5
5
  end
6
6
  end
@@ -1,3 +1,3 @@
1
1
  module Perron
2
- VERSION = "0.13.3"
2
+ VERSION = "0.14.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: perron
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.3
4
+ version: 0.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rails Designer Developers
@@ -113,16 +113,19 @@ files:
113
113
  - lib/perron/metatags.rb
114
114
  - lib/perron/refinements/delete_suffixes.rb
115
115
  - lib/perron/resource.rb
116
+ - lib/perron/resource/associations.rb
116
117
  - lib/perron/resource/class_methods.rb
117
118
  - lib/perron/resource/configuration.rb
118
119
  - lib/perron/resource/core.rb
119
120
  - lib/perron/resource/metadata.rb
120
121
  - lib/perron/resource/publishable.rb
122
+ - lib/perron/resource/reading_time.rb
121
123
  - lib/perron/resource/related.rb
122
124
  - lib/perron/resource/related/stop_words.rb
123
125
  - lib/perron/resource/renderer.rb
124
126
  - lib/perron/resource/separator.rb
125
127
  - lib/perron/resource/slug.rb
128
+ - lib/perron/resource/sourceable.rb
126
129
  - lib/perron/resource/table_of_content.rb
127
130
  - lib/perron/root.rb
128
131
  - lib/perron/site.rb
@@ -137,6 +140,7 @@ files:
137
140
  - lib/perron/site/builder/sitemap.rb
138
141
  - lib/perron/site/validate.rb
139
142
  - lib/perron/tasks/build.rake
143
+ - lib/perron/tasks/sync_sources.rake
140
144
  - lib/perron/tasks/validate.rake
141
145
  - lib/perron/version.rb
142
146
  - perron.gemspec