Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ PATH
csv
json
mata (~> 0.8.0)
parallel (~> 1.24)
psych
rails (>= 7.2.0)

Expand Down
3 changes: 3 additions & 0 deletions lib/perron/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ def initialize

@config.mode = :standalone

@config.parallel_rendering = false
@config.build_threads = nil

@config.live_reload = false
@config.live_reload_watch_paths = %w[app/content app/views app/assets]
@config.live_reload_skip_paths = %w[app/assets/builds]
Expand Down
86 changes: 73 additions & 13 deletions lib/perron/site/builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
require "perron/site/builder/paths"
require "perron/site/builder/additional_routes"
require "perron/site/builder/page"
require "perron/site/builder/benchmark"
require "parallel"

module Perron
module Site
Expand All @@ -16,24 +18,52 @@ def initialize
end

def build
@benchmark = Benchmark.new
@benchmark.start

if Perron.configuration.mode.standalone?
puts "🧹 Cleaning previous build…"
@benchmark.phase("Clean") do
puts "🧹 Cleaning previous build…"
FileUtils.rm_rf(Dir.glob("#{@output_path}/*"))
end

FileUtils.rm_rf(Dir.glob("#{@output_path}/*"))
@benchmark.phase("Assets") do
Perron::Site::Builder::Assets.new.prepare
end

Perron::Site::Builder::Assets.new.prepare
Perron::Site::Builder::PublicFiles.new.copy
@benchmark.phase("Public files") do
Perron::Site::Builder::PublicFiles.new.copy
end
end

puts "\n📝 Generating collections…"

paths.each { render_page(it) }
@benchmark.phase("Collect paths") do
@paths = paths
end

Perron::Site::Builder::Sitemap.new(@output_path).generate
Perron::Site::Builder::Feeds.new(@output_path).generate
if Perron.configuration.parallel_rendering
@benchmark.phase("Page rendering (parallel)") do
render_pages_in_parallel
end
else
@benchmark.phase("Page rendering (sequential)") do
render_pages_sequentially
end
end

@benchmark.phase("Sitemap") do
Perron::Site::Builder::Sitemap.new(@output_path).generate
end

@benchmark.phase("Feeds") do
Perron::Site::Builder::Feeds.new(@output_path).generate
end

output_preview_urls

@benchmark.summary

puts "\n✅ Build complete"
end

Expand All @@ -46,16 +76,46 @@ def paths
end
end

def render_page(path) = Perron::Site::Builder::Page.new(path).render
def render_pages_sequentially
@paths.each do |path|
result = Page.new(path, benchmark: @benchmark).render

display_error(result)
end
end

def render_pages_in_parallel
print_mutex = Mutex.new

results = Parallel.map(@paths.to_a, threads: thread_count) do |path|
result = Page.new(path, benchmark: @benchmark).render

print_mutex.synchronize { print "\e[32m.\e[0m" }

result
end

results.each { |result| display_error(result) if result }
end

def display_error(result)
return if result.success

puts "\n ❌ ERROR: Failed to generate page for '#{result.path}'. Details: #{result.error}"
end

def thread_count
Perron.configuration.build_threads || Parallel.processor_count
end

def output_preview_urls
previewable_resources = Perron::Site.collections.flat_map { it.send(:load_resources) }.select(&:previewable?)

if previewable_resources.any?
puts "\n🔒 Preview URLs:"
previewable_resources.each do |resource|
puts " #{Rails.application.routes.url_helpers.polymorphic_url(resource, **Perron.configuration.default_url_options)}"
end
return unless previewable_resources.any?

puts "\n🔒 Preview URLs:"
previewable_resources.each do |resource|
puts " #{Rails.application.routes.url_helpers.polymorphic_url(resource, **Perron.configuration.default_url_options)}"
end
end
end
Expand Down
95 changes: 95 additions & 0 deletions lib/perron/site/builder/benchmark.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# frozen_string_literal: true

module Perron
module Site
class Builder
class Benchmark
def initialize
@phases = {}
@page_times = []
@start_time = nil
end

def start
@start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
end

def phase(name)
@phases[name] ||= {duration: 0, start: nil}
@phases[name][:start] = Process.clock_gettime(Process::CLOCK_MONOTONIC)

yield
ensure
if @phases[name][:start]
@phases[name][:duration] += Process.clock_gettime(Process::CLOCK_MONOTONIC) - @phases[name][:start]
@phases[name][:start] = nil
end
end

def record_page(path, duration)
@page_times << {path: path, duration: duration}
end

def total_duration
return 0 unless @start_time

Process.clock_gettime(Process::CLOCK_MONOTONIC) - @start_time
end

def summary
total = total_duration

puts "\n"
puts "Build Performance"
puts "=" * 60

@phases.each do |name, data|
percentage = (total > 0) ? (data[:duration] / total * 100) : 0
bar = render_bar(percentage)

puts " #{name.ljust(25)} #{format("%6.2fs", data[:duration])} #{bar} #{percentage.round(1)}%"
end

puts "-" * 60
puts " Total pages: #{@page_times.size}"
puts " Avg per page: #{format("%.3fs", average_page_time)}" if @page_times.any?
puts " Pages/second: #{pages_per_second.round(1)}" if @page_times.any? && total > 0

if @page_times.size > 5
puts "\n Slowest pages:"

sorted = @page_times.sort_by { |entry| entry[:duration] }
sorted.last(5).reverse_each do |entry|
puts " #{entry[:path].ljust(40)} #{format("%6.3fs", entry[:duration])}"
end
end

puts "\n"
puts " TOTAL: #{format("%.2fs", total)}"
puts "=" * 60
end

private

def average_page_time
return 0 unless @page_times.any?

@page_times.sum { |entry| entry[:duration] } / @page_times.size
end

def pages_per_second
return 0 if @page_times.empty? || total_duration.zero?

@page_times.size / total_duration
end

def render_bar(percentage, width: 20)
filled = (percentage / 100 * width).round
empty = width - filled

"#{"█" * filled}#{"░" * empty}"
end
end
end
end
end
26 changes: 22 additions & 4 deletions lib/perron/site/builder/page.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,17 @@ module Perron
module Site
class Builder
class Page
def initialize(path)
@output_path, @path = Rails.root.join(Perron.configuration.output), path
Result = Struct.new(:success, :path, :error, :duration, keyword_init: true)

def initialize(path, benchmark: nil)
@output_path = Rails.root.join(Perron.configuration.output)
@path = path
@benchmark = benchmark
end

def render
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)

action = route_info[:action]
request = ActionDispatch::Request.new(env)
response = ActionDispatch::Response.new
Expand All @@ -19,15 +25,27 @@ def render

controller.dispatch(action, request, response)

return puts " ❌ ERROR: Request failed for '#{@path}' (Status: #{response.status})" unless response.successful?
unless response.successful?
return record_result(success: false, error: "Request failed (Status: #{response.status})", start_time: start_time)
end

save_html(response.body)

record_result(success: true, start_time: start_time)
rescue => error
puts " ❌ ERROR: Failed to generate page for '#{@path}'. Details: #{error.class} - #{error.message}\n#{error.backtrace.first(3).join("\n")}"
record_result(success: false, error: "#{error.class} - #{error.message}", start_time: start_time)
end

private

def record_result(success:, start_time:, error: nil)
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time

@benchmark&.record_page(@path, duration)

Result.new(success: success, path: @path, error: error, duration: duration)
end

def save_html(html)
prefixless_path = @path.delete_prefix("/")

Expand Down
3 changes: 2 additions & 1 deletion perron.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ Gem::Specification.new do |spec|

spec.required_ruby_version = ">= 3.4.0"

spec.add_dependency "rails", ">= 7.2.0"
spec.add_dependency "mata", "~> 0.8.0"
spec.add_dependency "parallel", "~> 1.24"
spec.add_dependency "rails", ">= 7.2.0"

spec.add_runtime_dependency "csv"
spec.add_runtime_dependency "json"
Expand Down
Loading