Files
homebrew-tap/scripts/build_site.rb
2025-09-21 23:31:15 +03:00

334 lines
9.0 KiB
Ruby

#!/usr/bin/env ruby
# frozen_string_literal: true
require 'json'
require 'fileutils'
require 'erb'
require 'pathname'
require 'time'
# Simple static site generator for homebrew tap documentation
class SiteBuilder
include ERB::Util
class PartialContext
include ERB::Util
def initialize(builder, locals)
@builder = builder
locals.each do |key, value|
define_singleton_method(key) { value }
end
end
def render_partial(name, locals = {})
@builder.render_partial(name, locals)
end
def format_relative_time(timestamp)
@builder.format_relative_time(timestamp)
end
def format_date(timestamp)
@builder.format_date(timestamp)
end
def get_binding
binding
end
end
DOCS_DIR = File.expand_path('../docs', __dir__)
DATA_DIR = File.join(DOCS_DIR, '_data')
OUTPUT_DIR = DOCS_DIR
THEME_SOURCE_DIR = File.expand_path('../theme', __dir__)
TEMPLATES_DIR = THEME_SOURCE_DIR
def self.build
new.generate_site
end
def generate_site
puts "🏗️ Building static site..."
setup_directories
load_data
generate_assets
generate_pages
puts "✅ Site built successfully in #{OUTPUT_DIR}"
puts "🌐 Open #{File.join(OUTPUT_DIR, 'index.html')} in your browser"
end
def render_partial(name, locals = {})
partial_path = File.join(TEMPLATES_DIR, "_#{name}.html.erb")
unless File.exist?(partial_path)
raise ArgumentError, "Partial not found: #{partial_path}"
end
context = PartialContext.new(self, locals)
ERB.new(File.read(partial_path)).result(context.get_binding)
end
def format_relative_time(timestamp)
return '' unless timestamp
begin
time = Time.parse(timestamp)
now = Time.now
diff = now - time
case diff
when 0..59
'just now'
when 60..3599
mins = (diff / 60).to_i
"#{mins} minute#{mins == 1 ? '' : 's'} ago"
when 3600..86399
hours = (diff / 3600).to_i
"#{hours} hour#{hours == 1 ? '' : 's'} ago"
when 86400..604799
days = (diff / 86400).to_i
"#{days} day#{days == 1 ? '' : 's'} ago"
when 604800..2419199
weeks = (diff / 604800).to_i
"#{weeks} week#{weeks == 1 ? '' : 's'} ago"
when 2419200..31535999
months = (diff / 2419200).to_i
"#{months} month#{months == 1 ? '' : 's'} ago"
else
years = (diff / 31536000).to_i
"#{years} year#{years == 1 ? '' : 's'} ago"
end
rescue
''
end
end
def format_date(timestamp)
return '' unless timestamp
begin
Time.parse(timestamp).strftime('%b %d, %Y')
rescue
''
end
end
private
def setup_directories
FileUtils.mkdir_p(File.join(OUTPUT_DIR, 'formula'))
unless templates_exist?
puts "⚠️ Templates not found in #{TEMPLATES_DIR}. Please ensure theme/*.html.erb files exist."
exit 1
end
end
def load_data
formulae_file = File.join(DATA_DIR, 'formulae.json')
@data = File.exist?(formulae_file) ? JSON.parse(File.read(formulae_file)) : default_data
end
def generate_assets
copy_assets
generate_css
minify_js
end
def generate_pages
generate_index_page
generate_formulae_pages
end
def copy_assets
assets_source_dir = File.join(THEME_SOURCE_DIR, 'assets')
assets_output_dir = File.join(OUTPUT_DIR, 'assets')
# Create the output assets directory if it doesn't exist
FileUtils.mkdir_p(assets_output_dir)
# Check if source assets directory exists
if Dir.exist?(assets_source_dir)
# Copy all files recursively, preserving directory structure
Dir.glob(File.join(assets_source_dir, '**', '*')).each do |source_file|
next if File.directory?(source_file)
# Calculate relative path from source assets dir
relative_path = Pathname.new(source_file).relative_path_from(Pathname.new(assets_source_dir))
output_file = File.join(assets_output_dir, relative_path)
# Create parent directories if needed
FileUtils.mkdir_p(File.dirname(output_file))
# Copy the file
FileUtils.cp(source_file, output_file)
end
asset_count = Dir.glob(File.join(assets_source_dir, '**', '*')).reject { |f| File.directory?(f) }.size
puts "📁 Copied #{asset_count} asset files to #{assets_output_dir}"
else
puts "⚠️ Assets source directory not found: #{assets_source_dir}"
end
end
def generate_css
css_source_path = File.join(THEME_SOURCE_DIR, 'style.css')
css_output_path = File.join(OUTPUT_DIR, 'style.css')
if File.exist?(css_source_path)
css_content = File.read(css_source_path)
minified_css = minify_css(css_content)
File.write(css_output_path, minified_css)
puts "📄 Generated and minified CSS (#{minified_css.length} bytes)"
else
puts "⚠️ CSS source file not found: #{css_source_path}"
end
end
def minify_css(css)
css
.gsub(/\/\*.*?\*\//m, '') # Remove comments
.gsub(/\s+/, ' ') # Collapse whitespace
.gsub(/\s*{\s*/, '{') # Remove spaces around braces
.gsub(/\s*}\s*/, '}')
.gsub(/\s*:\s*/, ':') # Remove spaces around colons
.gsub(/\s*;\s*/, ';') # Remove spaces around semicolons
.gsub(/\s*,\s*/, ',') # Remove spaces around commas
.strip
end
def minify_js
js_source_path = File.join(THEME_SOURCE_DIR, 'main.js')
js_output_path = File.join(OUTPUT_DIR, 'main.js')
if File.exist?(js_source_path)
js_content = File.read(js_source_path)
minified_js = minify_js_content(js_content)
File.write(js_output_path, minified_js)
puts "📄 Generated and minified JavaScript (#{minified_js.length} bytes)"
else
puts "⚠️ JavaScript source file not found: #{js_source_path}"
end
end
def minify_js_content(js)
# Simple minification that preserves string literals
# This is a basic approach that handles most cases
result = []
in_string = false
in_template = false
string_char = nil
i = 0
while i < js.length
char = js[i]
prev_char = i > 0 ? js[i - 1] : nil
# Handle string and template literal boundaries
if !in_string && !in_template && (char == '"' || char == "'" || char == '`')
in_string = true if char != '`'
in_template = true if char == '`'
string_char = char
result << char
elsif (in_string || in_template) && char == string_char && prev_char != '\\'
in_string = false
in_template = false
string_char = nil
result << char
elsif in_string || in_template
# Preserve everything inside strings and template literals
result << char
else
# Outside strings, apply minification
if char == '/' && i + 1 < js.length
next_char = js[i + 1]
if next_char == '/'
# Skip single-line comment
i += 1 while i < js.length && js[i] != "\n"
next
elsif next_char == '*'
# Skip multi-line comment
i += 2
while i < js.length - 1
break if js[i] == '*' && js[i + 1] == '/'
i += 1
end
i += 1 # Skip the closing /
next
else
result << char
end
elsif char =~ /\s/
# Only add space if needed between identifiers
if result.last && result.last =~ /[a-zA-Z0-9_$]/ &&
i + 1 < js.length && js[i + 1] =~ /[a-zA-Z0-9_$]/
result << ' '
end
else
result << char
end
end
i += 1
end
result.join.strip
end
def generate_index_page
template = load_template('index.html.erb')
content = template.result(binding)
File.write(File.join(OUTPUT_DIR, 'index.html'), content)
end
def generate_formulae_pages
@data['formulae'].each do |formula|
generate_formula_page(formula)
end
# Generate formulae index
template = load_template('formulae.html.erb')
content = template.result(binding)
File.write(File.join(OUTPUT_DIR, 'formulae.html'), content)
end
def generate_formula_page(formula)
@formula = formula
template = load_template('formula.html.erb')
content = template.result(binding)
formula_dir = File.join(OUTPUT_DIR, 'formula')
FileUtils.mkdir_p(formula_dir)
File.write(File.join(formula_dir, "#{formula['name']}.html"), content)
end
def load_template(name)
template_path = File.join(TEMPLATES_DIR, name)
template_content = File.read(template_path)
ERB.new(template_content)
end
def templates_exist?
%w[index.html.erb formulae.html.erb formula.html.erb].all? do |template|
File.exist?(File.join(TEMPLATES_DIR, template))
end
end
def default_data
{
'tap_name' => 'ivuorinen/homebrew-tap',
'generated_at' => Time.now.strftime('%Y-%m-%dT%H:%M:%S%z'),
'formulae_count' => 0,
'formulae' => []
}
end
end
# Allow running this script directly
if __FILE__ == $PROGRAM_NAME
SiteBuilder.build
end