perron 0.13.3 → 0.15.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/README.md +1 -1
  4. data/lib/generators/perron/install_generator.rb +1 -4
  5. data/lib/generators/perron/templates/README.md.tt +5 -17
  6. data/lib/generators/perron/templates/initializer.rb.tt +3 -3
  7. data/lib/generators/rails/content/USAGE +41 -0
  8. data/lib/generators/rails/content/content_generator.rb +126 -0
  9. data/lib/perron/collection.rb +12 -7
  10. data/lib/perron/engine.rb +1 -0
  11. data/lib/perron/markdown.rb +51 -21
  12. data/lib/perron/resource/associations.rb +69 -0
  13. data/lib/perron/resource/metadata.rb +7 -3
  14. data/lib/perron/resource/previewable.rb +25 -0
  15. data/lib/perron/resource/publishable.rb +8 -0
  16. data/lib/perron/resource/reading_time.rb +36 -0
  17. data/lib/perron/resource/slug.rb +3 -2
  18. data/lib/perron/resource/sourceable.rb +102 -0
  19. data/lib/perron/resource.rb +8 -0
  20. data/lib/perron/site/builder/feeds/json.rb +1 -1
  21. data/lib/perron/site/builder/feeds/rss.rb +1 -1
  22. data/lib/perron/site/builder/paths.rb +1 -1
  23. data/lib/perron/site/builder.rb +13 -0
  24. data/lib/perron/site/validate.rb +3 -1
  25. data/lib/perron/tasks/sync_sources.rake +12 -0
  26. data/lib/perron/tasks/validate.rake +1 -1
  27. data/lib/perron/version.rb +1 -1
  28. metadata +14 -9
  29. data/lib/generators/content/USAGE +0 -16
  30. data/lib/generators/content/content_generator.rb +0 -72
  31. /data/lib/generators/{content → rails/content}/templates/controller.rb.tt +0 -0
  32. /data/lib/generators/{content → rails/content}/templates/index.html.erb.tt +0 -0
  33. /data/lib/generators/{content → rails/content}/templates/model.rb.tt +0 -0
  34. /data/lib/generators/{content → rails/content}/templates/root.erb.tt +0 -0
  35. /data/lib/generators/{content → rails/content}/templates/show.html.erb.tt +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ebff05f1548f6e133cde9cdfc40ba692d65a52d548cdf47df78b16b52d072b49
4
- data.tar.gz: 8fcf6d0a11a8ed4b7426c11473d1d8d791a329cd47417d072434b8a1d28e8c6c
3
+ metadata.gz: f86f6e49cb1954ed51dc8aa0c2b6cfdaf3f1fcc3c78141b36c837f0eef26a9c8
4
+ data.tar.gz: efaff555c00579430812ca06e3ee6a87376db403321ab302762b393356d7607a
5
5
  SHA512:
6
- metadata.gz: fcb6b264a9d159852991f67803bf9f8e666d535a87dc8f12cd82e87362ce326ec3cfa47c0681bfa13e83889e7374fbfe3289d86674b7f8c120971c34969928a0
7
- data.tar.gz: 45f0d1fd94a3152d396e64ef8178639fffdd527d8a7fc3c50318578cc71684587fa4622aa99f8b3cc347cf8729460d0d6853e22e0d07122c3b88de01c18c444d
6
+ metadata.gz: c7e7c64d77fac8aadb06e3fc3541db5f8593a571938c12020dcbcb03e3f968dd326eede32b06342843df4b0e84e8722b4beb95b5d202283539b763ba3db01ada
7
+ data.tar.gz: 1a3f470b3412fa9187bca282ee1ac051cd1674c34a19a761b6cdedf901f0e5e2e6bb2c1a025ffadf1e2f478fcf13b9f8a90ab29579d46ae0cf3594099b13fb5d
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.15.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
 
@@ -11,10 +11,7 @@ module Perron
11
11
  end
12
12
 
13
13
  def create_data_directory
14
- data_directory = Rails.root.join("app", "content", "data")
15
- empty_directory data_directory
16
-
17
- template "README.md.tt", File.join(data_directory, "README.md")
14
+ template "README.md.tt", "app/content/data/README.md"
18
15
  end
19
16
 
20
17
  def add_markdown_gems
@@ -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
@@ -0,0 +1,41 @@
1
+ Description:
2
+ Generates content model scaffold (controller, views, routes) or creates
3
+ new content files from templates.
4
+
5
+ Examples:
6
+ Generate content scaffold:
7
+ rails generate content Post
8
+ rails generate content Post index
9
+ rails generate content Post index show
10
+
11
+ This will create:
12
+ app/content/posts/
13
+ app/models/content/post.rb
14
+ app/controllers/content/posts_controller.rb
15
+ app/views/content/posts/index.html.erb
16
+ app/views/content/posts/show.html.erb
17
+
18
+ And adds: resources :posts, module: :content, only: %w[index show]
19
+
20
+ Create new content file from template:
21
+ rails generate content Post --new
22
+ rails generate content Post --new "My First Post"
23
+
24
+ This will create a new content file in app/content/posts/ using:
25
+ - YYYY-MM-DD-template.*.tt (if exists, with date prefix)
26
+ - template.*.tt (if exists, without date prefix)
27
+ - Empty file with frontmatter dashes (if no template)
28
+
29
+ Template files support ERB:
30
+ ---
31
+ title: <%= @title %>
32
+ published_at: <%= Time.current %>
33
+ ---
34
+
35
+ Arguments:
36
+ NAME: Name of the content model (singular or plural)
37
+ actions: Actions to generate (default: index show)
38
+
39
+ Options:
40
+ --new [TITLE]: Create new content file instead of scaffold
41
+ --force-plural: Use plural form for model name and class
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module Rails
6
+ module Generators
7
+ class ContentGenerator < Rails::Generators::NamedBase
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ class_option :force_plural, type: :boolean, default: false, desc: "Forces the use of a plural model name and class"
11
+ class_option :new, type: :string, default: nil, banner: "TITLE",
12
+ desc: "Create a new content file from template instead of generating scaffold"
13
+
14
+ argument :actions, type: :array, default: %w[index show], banner: "actions", desc: "Specify which actions to generate (index/show)"
15
+
16
+ def initialize(*args)
17
+ super
18
+
19
+ @content_mode = !options[:new].nil?
20
+ @content_title = options[:new].presence
21
+ end
22
+
23
+ def create_content_file
24
+ return unless @content_mode
25
+
26
+ @title = @content_title
27
+
28
+ if template_file
29
+ create_file File.join(content_directory, filename_from_template), ERB.new(File.read(template_file)).result(binding)
30
+ else
31
+ create_file File.join(content_directory, filename_from_template), "---\n---\n"
32
+ end
33
+ end
34
+
35
+ def create_model
36
+ return if @content_mode
37
+
38
+ template "model.rb.tt", File.join("app/models/content", "#{file_name}.rb")
39
+ end
40
+
41
+ def create_controller
42
+ return if @content_mode
43
+
44
+ template "controller.rb.tt", File.join("app/controllers/content", "#{plural_file_name}_controller.rb")
45
+ end
46
+
47
+ def create_views
48
+ return if @content_mode
49
+
50
+ empty_directory view_directory
51
+
52
+ actions.each do |action|
53
+ template "#{action}.html.erb.tt", File.join(view_directory, "#{action}.html.erb")
54
+ end
55
+ end
56
+
57
+ def create_content_directory
58
+ return if @content_mode
59
+
60
+ FileUtils.mkdir_p(content_directory)
61
+ end
62
+
63
+ def create_pages_root
64
+ return if @content_mode
65
+ return unless pages_controller?
66
+
67
+ template "root.erb.tt", File.join(content_directory, "root.erb")
68
+ end
69
+
70
+ def add_content_route
71
+ return if @content_mode
72
+
73
+ route "resources :#{plural_file_name}, module: :content, only: %w[#{actions.join(" ")}]"
74
+ end
75
+
76
+ def add_root_route
77
+ return if @content_mode
78
+ return unless pages_controller?
79
+ return if root_route_exists?
80
+
81
+ inject_into_file "config/routes.rb", " root to: \"content/pages#root\"\n", before: /^\s*end\s*$/
82
+ end
83
+
84
+ private
85
+
86
+ def file_name
87
+ options[:force_plural] ? super.pluralize : super.singularize
88
+ end
89
+
90
+ def class_name
91
+ options[:force_plural] ? super.pluralize : super.singularize
92
+ end
93
+
94
+ def view_directory = File.join(destination_root, "app", "views", "content", plural_file_name)
95
+
96
+ def content_directory = File.join(destination_root, "app", "content", plural_file_name)
97
+
98
+ def plural_class_name = plural_name.camelize
99
+
100
+ def pages_controller? = plural_file_name == "pages"
101
+
102
+ def root_route_exists?
103
+ routes = File.join(destination_root, "config", "routes.rb")
104
+
105
+ return false unless File.exist?(routes)
106
+
107
+ File.read(routes).match?(/\broot\s+to:/)
108
+ end
109
+
110
+ def template_file
111
+ @template_file ||= Dir.glob(File.join(content_directory, "{YYYY-MM-DD-,}template.*.tt")).first
112
+ end
113
+
114
+ def filename_from_template
115
+ @filename_from_template ||= begin
116
+ return "untitled.md" unless template_file
117
+
118
+ File.basename(template_file, ".tt").tap do |name|
119
+ name.gsub!("YYYY-MM-DD", Time.current.strftime("%Y-%m-%d"))
120
+ name.sub!("template", @content_title ? @content_title.parameterize : "untitled")
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -16,17 +16,12 @@ module Perron
16
16
  end
17
17
 
18
18
  def all(resource_class = "Content::#{name.classify}".safe_constantize)
19
- allowed_extensions = Perron.configuration.allowed_extensions.map { ".#{it}" }.to_set
20
-
21
- Dir.glob("#{@collection_path}/**/*.*")
22
- .select { allowed_extensions.include?(File.extname(it)) }
23
- .map { resource_class.new(it) }
24
- .select(&:published?)
19
+ load_resources(resource_class).select(&:published?)
25
20
  end
26
21
  alias_method :resources, :all
27
22
 
28
23
  def find(slug, resource_class = Resource)
29
- resource = all(resource_class).find { it.slug == slug }
24
+ resource = load_resources(resource_class).find { it.slug == slug }
30
25
 
31
26
  return resource if resource
32
27
 
@@ -40,5 +35,15 @@ module Perron
40
35
  end
41
36
 
42
37
  def validate = Perron::Site::Validate.new(collections: [self]).validate
38
+
39
+ private
40
+
41
+ def load_resources(resource_class = "Content::#{name.classify}".safe_constantize)
42
+ allowed_extensions = Perron.configuration.allowed_extensions.map { ".#{it}" }.to_set
43
+
44
+ Dir.glob("#{@collection_path}/**/*.*")
45
+ .select { allowed_extensions.include?(File.extname(it)) }
46
+ .map { resource_class.new(it) }
47
+ end
43
48
  end
44
49
  end
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,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Perron
4
+ class Resource
5
+ module Previewable
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ def previewable?
10
+ frontmatter.preview.present? && (draft? || scheduled?)
11
+ end
12
+
13
+ def preview_token
14
+ return nil unless previewable?
15
+
16
+ @preview_token ||= if frontmatter.preview == true
17
+ Digest::SHA256.hexdigest(file_path)[0..11]
18
+ else
19
+ frontmatter.preview
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -16,8 +16,16 @@ module Perron
16
16
  true
17
17
  end
18
18
 
19
+ def buildable?
20
+ published? || previewable?
21
+ end
22
+
19
23
  def scheduled? = publication_date&.after?(Time.current)
20
24
 
25
+ def draft?
26
+ frontmatter.draft == true || frontmatter.published == false
27
+ end
28
+
21
29
  def publication_date
22
30
  @publication_date ||= begin
23
31
  from_meta = frontmatter.published_at.present? ? begin
@@ -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
@@ -15,8 +15,9 @@ module Perron
15
15
  def create
16
16
  return "/" if Perron.configuration.allowed_extensions.any? { @resource.filename == "root.#{it}" }
17
17
 
18
- @frontmatter.slug.presence ||
19
- @resource.filename.sub(/^[\d-]+-/, "").delete_suffixes(dot_prepended_allowed_extensions)
18
+ base_slug = @frontmatter.slug.presence || @resource.filename.sub(/^[\d-]+-/, "").delete_suffixes(dot_prepended_allowed_extensions)
19
+
20
+ [base_slug, @resource.preview_token].compact.join("-")
20
21
  end
21
22
 
22
23
  private
@@ -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,16 @@
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"
8
+ require "perron/resource/previewable"
7
9
  require "perron/resource/publishable"
10
+ require "perron/resource/reading_time"
8
11
  require "perron/resource/related"
9
12
  require "perron/resource/renderer"
10
13
  require "perron/resource/slug"
11
14
  require "perron/resource/separator"
15
+ require "perron/resource/sourceable"
12
16
  require "perron/resource/table_of_content"
13
17
 
14
18
  module Perron
@@ -20,7 +24,11 @@ module Perron
20
24
  include Perron::Resource::Configuration
21
25
  include Perron::Resource::Core
22
26
  include Perron::Resource::ClassMethods
27
+ include Perron::Resource::Associations
28
+ include Perron::Resource::ReadingTime
29
+ include Perron::Resource::Sourceable
23
30
  include Perron::Resource::Publishable
31
+ include Perron::Resource::Previewable
24
32
  include Perron::Resource::TableOfContent
25
33
 
26
34
  attr_reader :file_path, :id
@@ -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)) }
@@ -12,7 +12,7 @@ module Perron
12
12
  @paths << routes.public_send(index_path) if routes.respond_to?(index_path)
13
13
 
14
14
  if routes.respond_to?(show_path)
15
- @collection.all.each do |resource|
15
+ @collection.send(:load_resources).select(&:buildable?).each do |resource|
16
16
  root = resource.slug == "/"
17
17
 
18
18
  next if skip? root
@@ -31,6 +31,8 @@ module Perron
31
31
  Perron::Site::Builder::Sitemap.new(@output_path).generate
32
32
  Perron::Site::Builder::Feeds.new(@output_path).generate
33
33
 
34
+ output_preview_urls
35
+
34
36
  puts "\n✅ Build complete"
35
37
  end
36
38
 
@@ -43,6 +45,17 @@ module Perron
43
45
  end
44
46
 
45
47
  def render_page(path) = Perron::Site::Builder::Page.new(path).render
48
+
49
+ def output_preview_urls
50
+ previewable_resources = Perron::Site.collections.flat_map { it.send(:load_resources) }.select(&:previewable?)
51
+
52
+ if previewable_resources.any?
53
+ puts "\n🔒 Preview URLs:"
54
+ previewable_resources.each do |resource|
55
+ puts " #{Rails.application.routes.url_helpers.polymorphic_url(resource, **Perron.configuration.default_url_options)}"
56
+ end
57
+ end
58
+ end
46
59
  end
47
60
  end
48
61
  end
@@ -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.15.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.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rails Designer Developers
@@ -86,16 +86,16 @@ files:
86
86
  - bin/rails
87
87
  - bin/release
88
88
  - bin/setup
89
- - lib/generators/content/USAGE
90
- - lib/generators/content/content_generator.rb
91
- - lib/generators/content/templates/controller.rb.tt
92
- - lib/generators/content/templates/index.html.erb.tt
93
- - lib/generators/content/templates/model.rb.tt
94
- - lib/generators/content/templates/root.erb.tt
95
- - lib/generators/content/templates/show.html.erb.tt
96
89
  - lib/generators/perron/install_generator.rb
97
90
  - lib/generators/perron/templates/README.md.tt
98
91
  - lib/generators/perron/templates/initializer.rb.tt
92
+ - lib/generators/rails/content/USAGE
93
+ - lib/generators/rails/content/content_generator.rb
94
+ - lib/generators/rails/content/templates/controller.rb.tt
95
+ - lib/generators/rails/content/templates/index.html.erb.tt
96
+ - lib/generators/rails/content/templates/model.rb.tt
97
+ - lib/generators/rails/content/templates/root.erb.tt
98
+ - lib/generators/rails/content/templates/show.html.erb.tt
99
99
  - lib/perron.rb
100
100
  - lib/perron/collection.rb
101
101
  - lib/perron/configuration.rb
@@ -113,16 +113,20 @@ 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
121
+ - lib/perron/resource/previewable.rb
120
122
  - lib/perron/resource/publishable.rb
123
+ - lib/perron/resource/reading_time.rb
121
124
  - lib/perron/resource/related.rb
122
125
  - lib/perron/resource/related/stop_words.rb
123
126
  - lib/perron/resource/renderer.rb
124
127
  - lib/perron/resource/separator.rb
125
128
  - lib/perron/resource/slug.rb
129
+ - lib/perron/resource/sourceable.rb
126
130
  - lib/perron/resource/table_of_content.rb
127
131
  - lib/perron/root.rb
128
132
  - lib/perron/site.rb
@@ -137,6 +141,7 @@ files:
137
141
  - lib/perron/site/builder/sitemap.rb
138
142
  - lib/perron/site/validate.rb
139
143
  - lib/perron/tasks/build.rake
144
+ - lib/perron/tasks/sync_sources.rake
140
145
  - lib/perron/tasks/validate.rake
141
146
  - lib/perron/version.rb
142
147
  - perron.gemspec
@@ -160,7 +165,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
160
165
  - !ruby/object:Gem::Version
161
166
  version: '0'
162
167
  requirements: []
163
- rubygems_version: 3.6.9
168
+ rubygems_version: 4.0.1
164
169
  specification_version: 4
165
170
  summary: Rails-based static site generator
166
171
  test_files: []
@@ -1,16 +0,0 @@
1
- Description:
2
- Creates a new content model with specified actions
3
-
4
- Example:
5
- rails generate content post
6
-
7
- This will create:
8
- - app/models/content/post.rb
9
- - app/controllers/content/posts_controller.rb
10
- - app/views/content/posts/index.html.erb
11
- - app/views/content/posts/show.html.erb
12
- - …and add `resource :posts, module: :content, only: %w[index show]` to `config/routes.rb`
13
-
14
- Arguments:
15
- NAME: Name of the content model
16
- actions: List of actions to generate (index or show)
@@ -1,72 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "rails/generators/base"
4
-
5
- class ContentGenerator < Rails::Generators::NamedBase
6
- source_root File.expand_path("templates", __dir__)
7
-
8
- class_option :force_plural, type: :boolean, default: false, desc: "Forces the use of a plural model name and class"
9
-
10
- argument :actions, type: :array, default: %w[index show], banner: "actions", desc: "Specify which actions to generate (index/show)"
11
-
12
- def create_model
13
- template "model.rb.tt", File.join("app/models/content", "#{file_name}.rb")
14
- end
15
-
16
- def create_controller
17
- template "controller.rb.tt", File.join("app/controllers/content", "#{plural_file_name}_controller.rb")
18
- end
19
-
20
- def create_views
21
- empty_directory view_directory
22
-
23
- actions.each do |action|
24
- template "#{action}.html.erb.tt", File.join(view_directory, "#{action}.html.erb")
25
- end
26
- end
27
-
28
- def create_content_directory = FileUtils.mkdir_p(content_directory)
29
-
30
- def create_pages_root
31
- return unless pages_controller?
32
-
33
- template "root.erb.tt", File.join(content_directory, "root.erb")
34
- end
35
-
36
- def add_content_route
37
- route "resources :#{plural_file_name}, module: :content, only: %w[#{actions.join(" ")}]"
38
- end
39
-
40
- def add_root_route
41
- return unless pages_controller?
42
- return if root_route_exists?
43
-
44
- inject_into_file "config/routes.rb", " root to: \"content/pages#root\"\n", before: /^\s*end\s*$/
45
- end
46
-
47
- private
48
-
49
- def file_name
50
- options[:force_plural] ? super.pluralize : super.singularize
51
- end
52
-
53
- def class_name
54
- options[:force_plural] ? super.pluralize : super.singularize
55
- end
56
-
57
- def view_directory = Rails.root.join("app", "views", "content", plural_file_name)
58
-
59
- def content_directory = Rails.root.join("app", "content", plural_file_name)
60
-
61
- def plural_class_name = plural_name.camelize
62
-
63
- def pages_controller? = plural_file_name == "pages"
64
-
65
- def root_route_exists?
66
- routes = Rails.root.join("config", "routes.rb")
67
-
68
- return false unless File.exist?(routes)
69
-
70
- File.read(routes).match?(/\broot\s+to:/)
71
- end
72
- end