perron 0.0.1 → 0.6.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +17 -0
  3. data/Gemfile.lock +273 -0
  4. data/README.md +221 -0
  5. data/Rakefile +9 -0
  6. data/app/helpers/meta_tags_helper.rb +17 -0
  7. data/app/helpers/perron/markdown_helper.rb +9 -0
  8. data/bin/console +10 -0
  9. data/bin/rails +17 -0
  10. data/bin/release +19 -0
  11. data/bin/setup +8 -0
  12. data/lib/generators/content/content_generator.rb +45 -0
  13. data/lib/generators/content/templates/controller.rb.tt +17 -0
  14. data/lib/generators/content/templates/index.html.erb.tt +11 -0
  15. data/lib/generators/content/templates/model.rb.tt +2 -0
  16. data/lib/generators/content/templates/root.erb.tt +5 -0
  17. data/lib/generators/content/templates/show.html.erb.tt +7 -0
  18. data/lib/generators/perron/install_generator.rb +16 -0
  19. data/lib/generators/perron/templates/README.md.tt +30 -0
  20. data/lib/generators/perron/templates/initializer.rb.tt +22 -0
  21. data/lib/perron/configuration.rb +63 -0
  22. data/lib/perron/engine.rb +13 -0
  23. data/lib/perron/errors.rb +13 -0
  24. data/lib/perron/html_processor/target_blank.rb +23 -0
  25. data/lib/perron/html_processor.rb +28 -0
  26. data/lib/perron/markdown.rb +54 -0
  27. data/lib/perron/metatags.rb +82 -0
  28. data/lib/perron/refinements/delete_suffixes.rb +12 -0
  29. data/lib/perron/root.rb +13 -0
  30. data/lib/perron/site/builder/assets.rb +67 -0
  31. data/lib/perron/site/builder/page.rb +51 -0
  32. data/lib/perron/site/builder/paths.rb +40 -0
  33. data/lib/perron/site/builder/public_files.rb +40 -0
  34. data/lib/perron/site/builder.rb +44 -0
  35. data/lib/perron/site/collection.rb +28 -0
  36. data/lib/perron/site/data.rb +144 -0
  37. data/lib/perron/site/resource/class_methods.rb +43 -0
  38. data/lib/perron/site/resource/core.rb +15 -0
  39. data/lib/perron/site/resource/publishable.rb +51 -0
  40. data/lib/perron/site/resource/separator.rb +31 -0
  41. data/lib/perron/site/resource/slug.rb +20 -0
  42. data/lib/perron/site/resource.rb +62 -0
  43. data/lib/perron/site.rb +34 -0
  44. data/lib/perron/tasks/perron.rake +12 -0
  45. data/lib/perron/version.rb +3 -0
  46. data/lib/perron.rb +11 -1
  47. data/perron.gemspec +26 -0
  48. metadata +112 -6
@@ -0,0 +1,5 @@
1
+ ---
2
+ slug: "/"
3
+ ---
4
+
5
+ Find me in `app/content/pages/root.erb`
@@ -0,0 +1,7 @@
1
+ <article>
2
+ <h1>
3
+ <%%= @resource.filename %>
4
+ </h1>
5
+
6
+ <%%= markdownify @resource.content %>
7
+ </article>
@@ -0,0 +1,16 @@
1
+ module Perron
2
+ class InstallGenerator < Rails::Generators::Base
3
+ source_root File.expand_path("templates", __dir__)
4
+
5
+ def copy_initializer
6
+ template "initializer.rb.tt", "config/initializers/perron.rb"
7
+ end
8
+
9
+ def create_data_directory
10
+ data_directory = Rails.root.join("app", "views", "content", "data")
11
+ empty_directory data_directory
12
+
13
+ template "README.md.tt", File.join(data_directory, "README.md")
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,30 @@
1
+ # Data
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.
5
+
6
+
7
+ ## Usage
8
+
9
+ To use a data file, instantiate `Perron::Data` with the basename of the file and iterate over the result.
10
+ ```erb
11
+ <% Perron::Data.new("features").each do |feature| %>
12
+ <h4><%= feature.name %></h4>
13
+
14
+ <p><%= feature.description %></p>
15
+ <% end %>
16
+ ```
17
+
18
+ ## File Location and Formats
19
+
20
+ By default, Perron looks up `app/content/data/` for files with a `.yml`, `.json`, or `.csv` extension.
21
+ 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.
22
+
23
+
24
+ ## Accessing Data
25
+
26
+ The wrapper object provides flexible, read-only access to each record's attributes. Both dot notation and hash-like key access are supported.
27
+ ```ruby
28
+ feature.name
29
+ feature[:name]
30
+ ```
@@ -0,0 +1,22 @@
1
+ Perron.configure do |config|
2
+ # config.output = "output"
3
+
4
+ # config.site_name = "AppRefresher"
5
+
6
+ # The build mode for Perron. Can be :standalone or :integrated.
7
+ # config.mode = :standalone
8
+
9
+ # In `integrated` mode, the root is skipped by default. Set to `true` to enable.
10
+ # config.include_root = false
11
+
12
+ # config.default_url_options = {host: "apprefresher.com", protocol: "https", trailing_slash: true}
13
+
14
+ # Override the defaults (meta) title suffix
15
+ # Default: `— Perron.configuration.site_name`
16
+ # config.title_suffix = nil
17
+
18
+ # Set default meta values
19
+ # Examples:
20
+ # - `config.metadata.description = "AI-powered tool to keep your knowledge base articles images/screenshots and content up-to-date"`
21
+ # - `config.metadata.author = "Rails Designer"`
22
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Perron
4
+ def self.configuration
5
+ @configuration ||= Configuration.new
6
+ end
7
+
8
+ def self.configure
9
+ yield(configuration)
10
+ end
11
+
12
+ def self.reset_configuration!
13
+ @configuration = Configuration.new
14
+ end
15
+
16
+ class Configuration
17
+ def initialize
18
+ @config = ActiveSupport::OrderedOptions.new
19
+
20
+ @config.output = "output"
21
+
22
+ @config.mode = :standalone
23
+ @config.include_root = false
24
+
25
+ @config.site_name = nil
26
+ @config.title_suffix = nil
27
+
28
+ @config.allowed_extensions = [".erb", ".md"]
29
+ @config.exclude_from_public = %w[assets storage]
30
+ @config.excluded_assets = %w[action_cable actioncable actiontext activestorage rails-ujs trix turbo]
31
+
32
+ @config.default_url_options = {
33
+ host: ENV.fetch("PERRON_HOST", "localhost:3000"),
34
+ protocol: ENV.fetch("PERRON_PROTOCOL", "http"),
35
+ trailing_slash: ENV.fetch("PERRON_TRAILING_SLASH", "true") == "true"
36
+ }
37
+
38
+ @config.metadata = ActiveSupport::OrderedOptions.new
39
+ end
40
+
41
+ def input = "app/content"
42
+
43
+ def output
44
+ mode.integrated? ? "public" : @config.output
45
+ end
46
+
47
+ def mode = @config.mode.to_s.inquiry
48
+
49
+ def exclude_root? = !@config.include_root
50
+
51
+ def method_missing(method_name, ...)
52
+ if @config.respond_to?(method_name)
53
+ @config.send(method_name, ...)
54
+ else
55
+ super
56
+ end
57
+ end
58
+
59
+ def respond_to_missing?(method_name)
60
+ @config.respond_to?(method_name) || super
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Perron
4
+ class Engine < Rails::Engine
5
+ initializer "perron.default_url_options" do |app|
6
+ app.config.action_controller.default_url_options = Perron.configuration.default_url_options
7
+ end
8
+
9
+ rake_tasks do
10
+ load File.expand_path("../tasks/perron.rake", __FILE__)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ module Perron
2
+ module Errors
3
+ class CollectionNotFoundError < StandardError; end
4
+
5
+ class FileNotFoundError < StandardError; end
6
+
7
+ class ResourceNotFoundError < StandardError; end
8
+
9
+ class UnsupportedDataFormatError < StandardError; end
10
+
11
+ class DataParseError < StandardError; end
12
+ end
13
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Perron
4
+ class HtmlProcessor
5
+ class TargetBlank
6
+ def initialize(html)
7
+ @html = html
8
+ end
9
+
10
+ def process
11
+ @html.css("a").each do |link|
12
+ href = link["href"]
13
+
14
+ next unless href
15
+ next if href.start_with?("/", "#", "mailto:")
16
+
17
+ link["target"] = "_blank"
18
+ link["rel"] = "noopener"
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "perron/html_processor/target_blank"
4
+
5
+ module Perron
6
+ class HtmlProcessor
7
+ def initialize(html)
8
+ @html = html
9
+ end
10
+
11
+ def process
12
+ document = Nokogiri::HTML::DocumentFragment.parse(@html)
13
+
14
+ PROCESSORS.each do |processor|
15
+ processor.new(document).process
16
+ end
17
+
18
+ document.to_html
19
+ end
20
+
21
+ private
22
+
23
+ # TODO: should be a configuration option
24
+ PROCESSORS = [
25
+ Perron::HtmlProcessor::TargetBlank
26
+ ]
27
+ end
28
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "perron/html_processor"
4
+
5
+ module Perron
6
+ class Markdown
7
+ class << self
8
+ def render(text)
9
+ parser.parse(text)
10
+ .then { Perron::HtmlProcessor.new(it).process }
11
+ .html_safe
12
+ end
13
+
14
+ private
15
+
16
+ def parser
17
+ @parser ||= markdown_parser
18
+ end
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
27
+ else
28
+ PlainTextParser.new
29
+ end
30
+ end
31
+ end
32
+
33
+ class CommonMarkerParser
34
+ def parse(text) = CommonMarker.render_html(text, :DEFAULT)
35
+ end
36
+
37
+ class KramdownParser
38
+ def parse(text) = Kramdown::Document.new(text).to_html
39
+ end
40
+
41
+ class RedcarpetParser
42
+ def parse(text)
43
+ renderer = Redcarpet::Render::HTML.new(filter_html: true)
44
+ markdown = Redcarpet::Markdown.new(renderer, autolink: true, tables: true)
45
+
46
+ markdown.render(text)
47
+ end
48
+ end
49
+
50
+ class PlainTextParser
51
+ def parse(text) = text.to_s
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Perron
4
+ class Metatags
5
+ include ActionView::Helpers::TagHelper
6
+
7
+ def initialize(resource)
8
+ @resource = resource
9
+ @config = Perron.configuration
10
+ end
11
+
12
+ def render(options = {})
13
+ keys = tags.keys
14
+ .then { options[:only] ? it & options[:only].map(&:to_sym) : it }
15
+ .then { options[:except] ? it - options[:except].map(&:to_sym) : it }
16
+
17
+ safe_join(keys.filter_map { tags[it].presence }, "\n")
18
+ end
19
+
20
+ private
21
+
22
+ FRONTMATTER_KEY_MAP = {
23
+ "image" => %w[og:image twitter:image],
24
+ "author" => %w[og:author]
25
+ }.freeze
26
+
27
+ def tags
28
+ @tags ||= begin
29
+ frontmatter = @resource&.metadata&.stringify_keys || {}
30
+ defaults = @config.metadata
31
+
32
+ title = frontmatter["title"] || defaults["title"] || @config.site_name
33
+ description = frontmatter["description"] || defaults["description"]
34
+ author = frontmatter["author"] || defaults["author"]
35
+ image = frontmatter["image"] || defaults["image"]
36
+ og_image = frontmatter["og:image"] || image
37
+ twitter_image = frontmatter["twitter:image"] || og_image
38
+
39
+ {
40
+ title: title_tag(title),
41
+ description: meta_tag(name: "description", content: description),
42
+
43
+ og_type: meta_tag(property: "og:type", content: frontmatter["og:type"] || "article"),
44
+ og_title: meta_tag(property: "og:title", content: frontmatter["og:title"] || title),
45
+ og_description: meta_tag(property: "og:description", content: frontmatter["og:description"] || description),
46
+ og_site_name: meta_tag(property: "og:site_name", content: @config.site_name),
47
+ og_image: meta_tag(property: "og:image", content: og_image),
48
+ og_author: meta_tag(property: "og:author", content: frontmatter["og:author"] || author),
49
+
50
+ twitter_card: meta_tag(name: "twitter:card", content: frontmatter["twitter:card"] || "summary_large_image"),
51
+ twitter_title: meta_tag(name: "twitter:title", content: frontmatter["twitter:title"] || title),
52
+ twitter_description: meta_tag(name: "twitter:description", content: frontmatter["twitter:description"] || description),
53
+ twitter_image: meta_tag(name: "twitter:image", content: twitter_image),
54
+ article_published: meta_tag(property: "article:published_time", content: @resource&.published_at),
55
+
56
+ og_url: meta_tag(property: "og:url", content: canonical_url)
57
+ }
58
+ end
59
+ end
60
+
61
+ def title_tag(content)
62
+ tag.title(
63
+ content.then { (it == @config.site_name) ? it : "#{it} #{@config.title_suffix || "— #{@config.site_name}"}" }
64
+ )
65
+ end
66
+
67
+ def meta_tag(attributes)
68
+ return if attributes[:content].blank?
69
+
70
+ tag.meta(**attributes)
71
+ end
72
+
73
+ def canonical_url
74
+ url_options = @config.default_url_options
75
+ base_url = "#{url_options[:protocol]}://#{url_options[:host]}"
76
+ url = URI.join(base_url, @resource&.path).to_s
77
+ has_extension = URI(url).path.split("/").last&.include?(".")
78
+
79
+ url.then { (url_options[:trailing_slash] && !it.end_with?("/") && !has_extension) ? "#{it}/" : it }
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,12 @@
1
+ module Perron
2
+ module SuffixStripping
3
+ refine String do
4
+ def delete_suffixes(suffixes)
5
+ suffixes
6
+ .sort_by(&:length)
7
+ .reverse_each
8
+ .reduce(self, :delete_suffix)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Perron
4
+ module Root
5
+ include ActiveSupport::Concern
6
+
7
+ def root
8
+ @resource = Content::Page.find("/")
9
+
10
+ render :show
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Perron
4
+ module Site
5
+ class Builder
6
+ class Assets
7
+ def initialize
8
+ @output_path = Rails.root.join(Perron.configuration.output)
9
+ end
10
+
11
+ def prepare
12
+ puts "📦 Precompiling and copying assets…"
13
+
14
+ success = system("bundle exec rails assets:precompile", out: File::NULL, err: File::NULL)
15
+
16
+ unless success
17
+ puts "❌ ERROR: Asset precompilation failed"
18
+
19
+ exit(1)
20
+ end
21
+
22
+ source = Rails.root.join("public", "assets")
23
+ destination = @output_path.join("assets")
24
+
25
+ unless Dir.exist?(source)
26
+ puts "⚠️ WARNING: No assets found in `#{source}` to copy"
27
+
28
+ return
29
+ end
30
+
31
+ FileUtils.mkdir_p(destination)
32
+ FileUtils.cp_r(Dir.glob("#{source}/*"), destination)
33
+
34
+ puts " ✅ Copied assets to `#{destination.relative_path_from(Rails.root)}`"
35
+
36
+ prune_excluded_assets from: destination
37
+ end
38
+
39
+ private
40
+
41
+ def prune_excluded_assets(from:)
42
+ return if exclusions.empty?
43
+
44
+ puts "Pruning excluded assets…"
45
+
46
+ pattern = /^(#{exclusions.join("|")})(\.esm|\.min)?-[a-f0-9]{8,}/
47
+
48
+ Dir.glob("#{from}/**/*").each do |path|
49
+ next if File.directory?(path)
50
+
51
+ filename = File.basename(path)
52
+
53
+ if filename.match?(pattern)
54
+ FileUtils.rm(path)
55
+
56
+ map_file = "#{path}.map"
57
+
58
+ FileUtils.rm(map_file) if File.exist?(map_file)
59
+ end
60
+ end
61
+ end
62
+
63
+ def exclusions = Perron.configuration.excluded_assets
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/mock"
4
+
5
+ module Perron
6
+ module Site
7
+ class Builder
8
+ class Page
9
+ def initialize(path)
10
+ @output_path, @path = Rails.root.join(Perron.configuration.output), path
11
+ end
12
+
13
+ def render
14
+ action = route_info[:action]
15
+ request = ActionDispatch::Request.new(env)
16
+ response = ActionDispatch::Response.new
17
+
18
+ request.path_parameters = route_info
19
+
20
+ controller.dispatch(action, request, response)
21
+
22
+ return puts " ❌ ERROR: Request failed for '#{@path}' (Status: #{response.status})" unless response.successful?
23
+
24
+ save_html(response.body)
25
+ rescue => error
26
+ puts " ❌ ERROR: Failed to generate page for '#{@path}'. Details: #{error.class} - #{error.message}\n#{error.backtrace.first(3).join("\n")}"
27
+ end
28
+
29
+ private
30
+
31
+ def save_html(html)
32
+ directory_path = @output_path.join(@path.delete_prefix("/"))
33
+ file_path = directory_path.join("index.html")
34
+
35
+ FileUtils.mkdir_p(directory_path)
36
+ File.write(file_path, html)
37
+
38
+ puts "✅ Generated: #{@path} -> #{file_path.relative_path_from(@output_path)}"
39
+ end
40
+
41
+ def route_info
42
+ @route_info ||= Rails.application.routes.recognize_path(@path)
43
+ end
44
+
45
+ def env = Rack::MockRequest.env_for(@path, "HTTP_HOST" => Perron.configuration.default_url_options[:host])
46
+
47
+ def controller = "#{route_info[:controller]}_controller".classify.constantize.new
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Perron
4
+ module Site
5
+ class Builder
6
+ class Paths
7
+ def initialize(collection, paths)
8
+ @collection, @paths = collection, paths
9
+ end
10
+
11
+ def get
12
+ @paths << routes.public_send(index_path) if routes.respond_to?(index_path)
13
+
14
+ if routes.respond_to?(show_path)
15
+ @collection.all.each do |resource|
16
+ root = resource.slug == "/"
17
+
18
+ next if skip? root
19
+
20
+ @paths << (root ? routes.root_path : routes.public_send(show_path, resource))
21
+ end
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def skip?(root)
28
+ root &&
29
+ Perron.configuration.mode.integrated? && Perron.configuration.exclude_root?
30
+ end
31
+
32
+ def routes = Rails.application.routes.url_helpers
33
+
34
+ def index_path = "#{@collection.name}_path"
35
+
36
+ def show_path = "#{@collection.name.singularize}_path"
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Perron
4
+ module Site
5
+ class Builder
6
+ class PublicFiles
7
+ def initialize
8
+ @output_path = Rails.root.join(Perron.configuration.output)
9
+ @public_dir = Rails.root.join("public")
10
+ end
11
+
12
+ def copy
13
+ puts "📂 Copying public files…"
14
+
15
+ return unless Dir.exist?(@public_dir)
16
+
17
+ if paths.empty?
18
+ puts " - No public files to copy"
19
+
20
+ return
21
+ end
22
+
23
+ paths.each do |path|
24
+ FileUtils.cp_r(path, @output_path)
25
+
26
+ puts " ✅ Copied: #{File.basename(path)}"
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def paths
33
+ @paths ||= Dir.glob(File.join(@public_dir, "*")).reject do |path|
34
+ Set.new(Perron.configuration.exclude_from_public + %w[. ..]).include?(File.basename(path))
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "perron/site/builder/assets"
4
+ require "perron/site/builder/public_files"
5
+ require "perron/site/builder/paths"
6
+ require "perron/site/builder/page"
7
+
8
+ module Perron
9
+ module Site
10
+ class Builder
11
+ def initialize
12
+ @output_path = Rails.root.join(Perron.configuration.output)
13
+ end
14
+
15
+ def build
16
+ if Perron.configuration.mode.standalone?
17
+ puts "🧹 Cleaning previous build…"
18
+ FileUtils.rm_rf(Dir.glob("#{@output_path}/*"))
19
+
20
+ Perron::Site::Builder::Assets.new.prepare
21
+ Perron::Site::Builder::PublicFiles.new.copy
22
+ end
23
+
24
+ puts "🚀 Starting site build…"
25
+ puts "-" * 15
26
+
27
+ paths.each { render_page(it) }
28
+
29
+ puts "-" * 15
30
+ puts "✅ Build complete"
31
+ end
32
+
33
+ private
34
+
35
+ def paths
36
+ Set.new.tap do |paths|
37
+ Perron::Site.collections.each { Perron::Site::Builder::Paths.new(it, paths).get }
38
+ end.to_a
39
+ end
40
+
41
+ def render_page(path) = Perron::Site::Builder::Page.new(path).render
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Perron
4
+ class Collection
5
+ attr_reader :name
6
+
7
+ def initialize(name)
8
+ @name = name
9
+ @collection_path = File.join(Rails.root, Perron.configuration.input, name)
10
+
11
+ raise Errors::CollectionNotFoundError, "No such collection: #{name}" unless File.exist?(@collection_path) && File.directory?(@collection_path)
12
+ end
13
+
14
+ def all(resource_class = "Content::#{name.classify}".safe_constantize)
15
+ @all ||= Dir.glob("#{@collection_path}/**/*.*").map do |file_path|
16
+ resource_class.new(file_path)
17
+ end.select(&:published?)
18
+ end
19
+
20
+ def find(slug, resource_class = Resource)
21
+ resource = all(resource_class).find { it.slug == slug }
22
+
23
+ return resource if resource
24
+
25
+ raise Errors::ResourceNotFoundError, "Resource not found with slug: #{slug}"
26
+ end
27
+ end
28
+ end