chore: fixes, several improvements and refactorings (#2)

* chore: fixes

* chore: rubocop fixes, linting, etc.

* chore: switching to use `brew style` only

* chore: use `brew style` for linting, skip example formulae in ci.yml

* chore(lint): fixes, additions and tweaks
This commit is contained in:
2025-09-23 11:29:53 +03:00
committed by GitHub
parent 310ac4946a
commit 6dc9a170cc
16 changed files with 678 additions and 472 deletions

View File

@@ -49,8 +49,16 @@ jobs:
- name: Run brew test-bot (tap syntax) - name: Run brew test-bot (tap syntax)
run: brew test-bot --only-tap-syntax run: brew test-bot --only-tap-syntax
- name: Check for real formulae (non-examples)
id: check-formulae
run: |
# Count formulae that are not example formulae
REAL_FORMULAE=$(find Formula -name "*.rb" ! -name "example-*.rb" | wc -l | tr -d ' ')
echo "real_formulae_count=$REAL_FORMULAE" >> "$GITHUB_OUTPUT"
echo "Found $REAL_FORMULAE real formulae (excluding examples)"
- name: Run brew test-bot (formulae) - name: Run brew test-bot (formulae)
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request' && steps.check-formulae.outputs.real_formulae_count > 0
run: brew test-bot --only-formulae run: brew test-bot --only-formulae
- name: Upload bottles as artifact - name: Upload bottles as artifact

5
.gitignore vendored
View File

@@ -2,6 +2,7 @@
Gemfile.lock Gemfile.lock
.bundle/ .bundle/
vendor/bundle/ vendor/bundle/
.ruby-lsp/
# Generated site files # Generated site files
docs/**/*.html docs/**/*.html
@@ -27,3 +28,7 @@ docs/assets/*
# Other # Other
AGENTS.md AGENTS.md
.rubocop_todo.yml
.serena
CLAUDE.md
node_modules/

29
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,29 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- id: check-json
- id: check-merge-conflict
- id: detect-private-key
- repo: local
hooks:
- id: brew-style
name: Brew Style
entry: brew style --fix --reset-cache .
language: system
types: [ruby]
pass_filenames: false
require_serial: true
exclude: ^(docs/|vendor/|bin/|tmp/|\.git/|node_modules/)
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v4.0.0-alpha.8
hooks:
- id: prettier
types_or: [css, javascript, json, yaml]
exclude: "^docs/"

View File

@@ -1,41 +0,0 @@
AllCops:
TargetRubyVersion: 3.4
NewCops: enable
Exclude:
- 'vendor/**/*'
- 'docs/**/*'
- '.bundle/**/*'
Layout/LineLength:
Max: 120
AllowedPatterns:
- '^\s*#'
- 'url "'
Layout/IndentationStyle:
EnforcedStyle: spaces
Layout/IndentationWidth:
Width: 2
Style/StringLiterals:
EnforcedStyle: double_quotes
Style/FrozenStringLiteralComment:
Enabled: true
EnforcedStyle: always
Naming/FileName:
Exclude:
- 'Formula/**/*.rb'
Metrics/MethodLength:
Max: 30
Metrics/ClassLength:
Max: 150
Metrics/BlockLength:
Exclude:
- 'spec/**/*'
- '*.gemspec'

View File

@@ -1 +1 @@
3.3.6 3.4.5

View File

@@ -1,7 +1,10 @@
# frozen_string_literal: true
# Homebrew formula for ExampleTool - a demonstration tool for this tap
class ExampleTool < Formula class ExampleTool < Formula
desc "An example tool to demonstrate the tap functionality" desc "Imaginery tool to demonstrate the tap functionality"
homepage "https://github.com/ivuorinen/example-tool" homepage "https://github.com/ivuorinen/example-tool"
url "https://github.com/ivuorinen/example-tool/archive/v1.0.0.tar.gz" url "https://github.com/ivuorinen/example-tool/refs/tags/v1.0.0.tar.gz"
sha256 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" sha256 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
license "MIT" license "MIT"

View File

@@ -1,7 +1,10 @@
# frozen_string_literal: true
# Homebrew formula for ExampleTool2 - a second demonstration tool for this tap
class ExampleTool2 < Formula class ExampleTool2 < Formula
desc "A second example tool to demonstrate the tap functionality" desc "Second example tool to demonstrate the tap functionality"
homepage "https://github.com/ivuorinen/example-tool2" homepage "https://github.com/ivuorinen/example-tool2"
url "https://github.com/ivuorinen/example-tool2/archive/v2.0.0.tar.gz" url "https://github.com/ivuorinen/example-tool2/refs/tags/v2.0.0.tar.gz"
sha256 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" sha256 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
license "MIT" license "MIT"

10
Gemfile
View File

@@ -1,13 +1,7 @@
# frozen_string_literal: true
source "https://rubygems.org" source "https://rubygems.org"
ruby "3.4.5" ruby "3.4.5"
gem "parser", "~> 3.3"
gem "json", "~> 2.7" gem "json", "~> 2.7"
group :development, :test do
gem "rubocop", "~> 1.69"
gem "rubocop-rspec", "~> 3.6"
gem "rubocop-performance", "~> 1.25"
gem "rspec", "~> 3.13"
end

View File

@@ -1,7 +1,7 @@
# Homebrew Tap Makefile # Homebrew Tap Makefile
# Provides convenient commands for building and managing the tap documentation # Provides convenient commands for building and managing the tap documentation
.PHONY: help build serve parse clean test install dev setup check .PHONY: help build serve parse clean test install dev setup check lint update
# Default target # Default target
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
@@ -87,6 +87,35 @@ install: ## Install development dependencies (if Gemfile exists)
echo " No Gemfile found, skipping dependency installation"; \ echo " No Gemfile found, skipping dependency installation"; \
fi fi
update: ## Update all gems to their latest versions (respecting Gemfile constraints)
@if [ -f Gemfile ]; then \
echo "🔄 Updating Ruby dependencies..."; \
echo "📦 Running bundle update..."; \
bundle update; \
echo "✅ All gems updated to latest versions!"; \
echo ""; \
echo "💡 Current gem versions:"; \
bundle list | grep -E "^\s*\*" | head -10; \
else \
echo "❌ No Gemfile found"; \
exit 1; \
fi
update-bundler: ## Update bundler itself to the latest version
@echo "🔄 Updating Bundler..."
@gem update bundler
@echo "✅ Bundler updated!"
@echo "Current version: $$(bundle --version)"
outdated: ## Show outdated gems
@if [ -f Gemfile ]; then \
echo "📋 Checking for outdated gems..."; \
bundle outdated || true; \
else \
echo "❌ No Gemfile found"; \
exit 1; \
fi
watch: ## Watch for file changes and auto-rebuild (alias for serve) watch: ## Watch for file changes and auto-rebuild (alias for serve)
@$(MAKE) serve @$(MAKE) serve
@@ -116,6 +145,10 @@ tap-install: ## Install this tap locally for testing
@echo "🍺 Installing tap locally..." @echo "🍺 Installing tap locally..."
@brew tap $$(pwd) @brew tap $$(pwd)
lint: ## Lint all Ruby scripts
@echo "🔍 Linting Ruby scripts..."
@brew style --fix --reset-cache .
formula-new: ## Create a new formula template (usage: make formula-new NAME=myformula) formula-new: ## Create a new formula template (usage: make formula-new NAME=myformula)
@if [ -z "$(NAME)" ]; then \ @if [ -z "$(NAME)" ]; then \
echo "❌ Please provide a formula name: make formula-new NAME=myformula"; \ echo "❌ Please provide a formula name: make formula-new NAME=myformula"; \

View File

546
scripts/build_site.rb Normal file → Executable file
View File

@@ -1,15 +1,311 @@
#!/usr/bin/env ruby #!/usr/bin/env ruby
# typed: strict
# frozen_string_literal: true # frozen_string_literal: true
require 'json' require "json"
require 'fileutils' require "fileutils"
require 'erb' require "erb"
require 'pathname' require "pathname"
require 'time' require "time"
# Simple polyfill for Homebrew extensions
class Array
def exclude?(item)
!include?(item)
end
end
# Simple static site generator for homebrew tap documentation # Simple static site generator for homebrew tap documentation
# Module for formatting timestamps and dates
module TimeFormatter
SECONDS_PER_MINUTE = 60
SECONDS_PER_HOUR = 3600
SECONDS_PER_DAY = 86_400
SECONDS_PER_WEEK = 604_800
SECONDS_PER_MONTH = 2_419_200
SECONDS_PER_YEAR = 31_536_000
def format_relative_time(timestamp)
return "" unless timestamp
begin
diff = calculate_time_difference(timestamp)
return "just now" if diff < SECONDS_PER_MINUTE
format_time_by_category(diff)
rescue
""
end
end
def format_date(timestamp)
return "" unless timestamp
begin
Time.parse(timestamp).strftime("%b %d, %Y")
rescue
""
end
end
private
def calculate_time_difference(timestamp)
time = Time.parse(timestamp)
Time.now - time
end
def format_time_by_category(diff)
case diff
when SECONDS_PER_MINUTE...SECONDS_PER_HOUR
format_time_unit(diff / SECONDS_PER_MINUTE, "minute")
when SECONDS_PER_HOUR...SECONDS_PER_DAY
format_time_unit(diff / SECONDS_PER_HOUR, "hour")
when SECONDS_PER_DAY...SECONDS_PER_WEEK
format_time_unit(diff / SECONDS_PER_DAY, "day")
when SECONDS_PER_WEEK...SECONDS_PER_MONTH
format_time_unit(diff / SECONDS_PER_WEEK, "week")
when SECONDS_PER_MONTH...SECONDS_PER_YEAR
format_time_unit(diff / SECONDS_PER_MONTH, "month")
else
format_time_unit(diff / SECONDS_PER_YEAR, "year")
end
end
def format_time_unit(value, unit)
count = value.to_i
"#{count} #{unit}#{"s" if count != 1} ago"
end
end
# Module for processing and copying assets
module AssetProcessor
DOCS_DIR = File.expand_path("../docs", __dir__).freeze
OUTPUT_DIR = DOCS_DIR
THEME_SOURCE_DIR = File.expand_path("../theme", __dir__).freeze
def copy_assets
copy_asset_files
end
def generate_css
css_path = File.join(THEME_SOURCE_DIR, "style.css")
output_path = File.join(OUTPUT_DIR, "style.css")
return unless File.exist?(css_path)
css_content = File.read(css_path)
minified_css = minify_css(css_content)
File.write(output_path, minified_css)
puts "📄 Generated CSS file: #{output_path}"
end
def minify_js
js_path = File.join(THEME_SOURCE_DIR, "main.js")
output_path = File.join(OUTPUT_DIR, "main.js")
return unless File.exist?(js_path)
js_content = File.read(js_path)
minified_js = JavaScriptMinifier.minify(js_content)
File.write(output_path, minified_js)
puts "🔧 Generated JS file: #{output_path}"
end
private
def copy_asset_files
assets_source_dir = File.join(THEME_SOURCE_DIR, "assets")
assets_output_dir = File.join(OUTPUT_DIR, "assets")
FileUtils.mkdir_p(assets_output_dir)
return handle_missing_assets(assets_source_dir) unless Dir.exist?(assets_source_dir)
copy_files_recursively(assets_source_dir, assets_output_dir)
end
def handle_missing_assets(assets_source_dir)
puts "⚠️ Assets source directory not found: #{assets_source_dir}"
end
def copy_files_recursively(source_dir, output_dir)
asset_files = Dir.glob(File.join(source_dir, "**", "*")).reject { |f| File.directory?(f) }
asset_files.each do |source_file|
copy_single_asset(source_file, source_dir, output_dir)
end
puts "📁 Copied #{asset_files.count} asset files to #{output_dir}"
end
def copy_single_asset(source_file, source_dir, output_dir)
relative_path = Pathname.new(source_file).relative_path_from(Pathname.new(source_dir))
output_file = File.join(output_dir, relative_path)
FileUtils.mkdir_p(File.dirname(output_file))
FileUtils.cp(source_file, output_file)
end
def minify_css(css)
css.gsub(%r{/\*.*?\*/}m, "")
.gsub(/\s+/, " ")
.gsub(/;\s*}/, "}")
.strip
end
end
# Helper module for JavaScript character handling
module JavaScriptCharacters
NEWLINE_CHARS = ["\n", "\r"].freeze
WHITESPACE_CHARS = [" ", "\t"].freeze
SPECIAL_CHARS = [";", "{", "}", "(", ")", ",", ":", "=", "+", "-", "*", "/", "%", "!", "&", "|", "^", "~", "<",
">", "?"].freeze
end
# Helper module for JavaScript comment and string processing
module JavaScriptProcessor
include JavaScriptCharacters
private
def skip_line_comment(index)
index += 1 while index < javascript.length && javascript[index] != "\n"
index
end
def skip_block_comment(index)
index += 2
while index < javascript.length - 1
break if javascript[index] == "*" && javascript[index + 1] == "/"
index += 1
end
index + 2
end
def process_string_content(result, quote_char, index)
while index < javascript.length && javascript[index] != quote_char
result += javascript[index]
index += 1 if javascript[index] == "\\"
index += 1
end
index
end
def append_closing_quote(result, index)
result << javascript[index] if index < javascript.length
end
def skip_to_next_line(index)
index += 1 while index < javascript.length && NEWLINE_CHARS.include?(javascript[index])
index
end
def skip_whitespace(index)
index += 1 while index < javascript.length && WHITESPACE_CHARS.include?(javascript[index])
index
end
def preserve_space?(result)
return false if result.empty?
last_char = result[-1]
[";", "{", "}", "(", ")", ",", ":", "=", "+", "-", "*", "/", "%", "!", "&", "|", "^", "~", "<", ">",
"?"].exclude?(last_char)
end
attr_reader :javascript
end
# Class for JavaScript minification
class JavaScriptMinifier
include JavaScriptProcessor
def self.minify(javascript)
new(javascript).minify
end
def initialize(javascript)
@javascript = javascript
end
def minify
remove_comments_and_whitespace
end
private
def remove_comments_and_whitespace
result = ""
i = 0
while i < javascript.length
char = javascript[i]
case char
when "/"
i = handle_slash(result, i)
when '"', "'"
i = handle_string_literal(result, char, i)
when "\n", "\r"
i = handle_newline(result, i)
when " ", "\t"
i = handle_whitespace(result, i)
else
result += char
i += 1
end
end
result
end
def handle_slash(_result, index)
if index + 1 < javascript.length
next_char = javascript[index + 1]
case next_char
when "/"
skip_line_comment(index)
when "*"
skip_block_comment(index)
else
javascript[index]
index + 1
end
else
javascript[index]
index + 1
end
end
def handle_string_literal(result, quote_char, index)
result += javascript[index]
index += 1
index = process_string_content(result, quote_char, index)
append_closing_quote(result, index)
index + 1
end
def handle_newline(result, index)
result << " " if preserve_space?(result)
skip_to_next_line(index)
end
def handle_whitespace(result, index)
result << " " if preserve_space?(result)
skip_whitespace(index)
end
end
# Static site generator for homebrew tap documentation
class SiteBuilder class SiteBuilder
include ERB::Util include ERB::Util
include TimeFormatter
include AssetProcessor
# Context class for rendering ERB partials with access to builder methods and local variables
class PartialContext class PartialContext
include ERB::Util include ERB::Util
@@ -32,14 +328,14 @@ class SiteBuilder
@builder.format_date(timestamp) @builder.format_date(timestamp)
end end
def get_binding def binding_context
binding binding
end end
end end
DOCS_DIR = File.expand_path('../docs', __dir__) DOCS_DIR = File.expand_path("../docs", __dir__).freeze
DATA_DIR = File.join(DOCS_DIR, '_data') DATA_DIR = File.join(DOCS_DIR, "_data").freeze
OUTPUT_DIR = DOCS_DIR OUTPUT_DIR = DOCS_DIR
THEME_SOURCE_DIR = File.expand_path('../theme', __dir__) THEME_SOURCE_DIR = File.expand_path("../theme", __dir__).freeze
TEMPLATES_DIR = THEME_SOURCE_DIR TEMPLATES_DIR = THEME_SOURCE_DIR
def self.build def self.build
@@ -55,76 +351,29 @@ class SiteBuilder
generate_pages generate_pages
puts "✅ Site built successfully in #{OUTPUT_DIR}" puts "✅ Site built successfully in #{OUTPUT_DIR}"
puts "🌐 Open #{File.join(OUTPUT_DIR, 'index.html')} in your browser" puts "🌐 Open #{File.join(OUTPUT_DIR, "index.html")} in your browser"
end end
def render_partial(name, locals = {}) def render_partial(name, locals = {})
partial_path = File.join(TEMPLATES_DIR, "_#{name}.html.erb") partial_path = File.join(TEMPLATES_DIR, "_#{name}.html.erb")
unless File.exist?(partial_path) raise ArgumentError, "Partial not found: #{partial_path}" unless File.exist?(partial_path)
raise ArgumentError, "Partial not found: #{partial_path}"
end
context = PartialContext.new(self, locals) context = PartialContext.new(self, locals)
ERB.new(File.read(partial_path)).result(context.get_binding) ERB.new(File.read(partial_path)).result(context.binding_context)
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 end
private private
def setup_directories def setup_directories
FileUtils.mkdir_p(File.join(OUTPUT_DIR, 'formula')) FileUtils.mkdir_p(File.join(OUTPUT_DIR, "formula"))
unless templates_exist? return if templates_exist?
puts "⚠️ Templates not found in #{TEMPLATES_DIR}. Please ensure theme/*.html.erb files exist."
exit 1 puts "⚠️ Templates not found in #{TEMPLATES_DIR}. Please ensure theme/*.html.erb files exist."
end exit 1
end end
def load_data def load_data
formulae_file = File.join(DATA_DIR, 'formulae.json') formulae_file = File.join(DATA_DIR, "formulae.json")
@data = File.exist?(formulae_file) ? JSON.parse(File.read(formulae_file)) : default_data @data = File.exist?(formulae_file) ? JSON.parse(File.read(formulae_file)) : default_data
end end
@@ -139,170 +388,31 @@ class SiteBuilder
generate_formulae_pages generate_formulae_pages
end 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 def generate_index_page
template = load_template('index.html.erb') template = load_template("index.html.erb")
content = template.result(binding) content = template.result(binding)
File.write(File.join(OUTPUT_DIR, 'index.html'), content) File.write(File.join(OUTPUT_DIR, "index.html"), content)
end end
def generate_formulae_pages def generate_formulae_pages
@data['formulae'].each do |formula| @data["formulae"].each do |formula|
generate_formula_page(formula) generate_formula_page(formula)
end end
# Generate formulae index # Generate formulae index
template = load_template('formulae.html.erb') template = load_template("formulae.html.erb")
content = template.result(binding) content = template.result(binding)
File.write(File.join(OUTPUT_DIR, 'formulae.html'), content) File.write(File.join(OUTPUT_DIR, "formulae.html"), content)
end end
def generate_formula_page(formula) def generate_formula_page(formula)
@formula = formula @formula = formula
template = load_template('formula.html.erb') template = load_template("formula.html.erb")
content = template.result(binding) content = template.result(binding)
formula_dir = File.join(OUTPUT_DIR, 'formula') formula_dir = File.join(OUTPUT_DIR, "formula")
FileUtils.mkdir_p(formula_dir) FileUtils.mkdir_p(formula_dir)
File.write(File.join(formula_dir, "#{formula['name']}.html"), content) File.write(File.join(formula_dir, "#{formula["name"]}.html"), content)
end end
def load_template(name) def load_template(name)
@@ -319,15 +429,13 @@ class SiteBuilder
def default_data def default_data
{ {
'tap_name' => 'ivuorinen/homebrew-tap', "tap_name" => "ivuorinen/homebrew-tap",
'generated_at' => Time.now.strftime('%Y-%m-%dT%H:%M:%S%z'), "generated_at" => Time.now.strftime("%Y-%m-%dT%H:%M:%S%z"),
'formulae_count' => 0, "formulae_count" => 0,
'formulae' => [] "formulae" => [],
} }
end end
end end
# Allow running this script directly # Allow running this script directly
if __FILE__ == $PROGRAM_NAME SiteBuilder.build if __FILE__ == $PROGRAM_NAME
SiteBuilder.build
end

49
scripts/make.rb Normal file → Executable file
View File

@@ -1,16 +1,17 @@
#!/usr/bin/env ruby #!/usr/bin/env ruby
# typed: strict
# frozen_string_literal: true # frozen_string_literal: true
require 'fileutils' require "fileutils"
# Simple make-style command runner for homebrew tap # Simple make-style command runner for homebrew tap
class Make class Make
COMMANDS = { COMMANDS = {
'build' => 'Build the static site', "build" => "Build the static site",
'serve' => 'Start development server', "serve" => "Start development server",
'parse' => 'Parse formulae and generate JSON data', "parse" => "Parse formulae and generate JSON data",
'clean' => 'Clean generated files', "clean" => "Clean generated files",
'help' => 'Show this help message' "help" => "Show this help message",
}.freeze }.freeze
def self.run(command = nil) def self.run(command = nil)
@@ -19,15 +20,15 @@ class Make
def execute(command) def execute(command)
case command&.downcase case command&.downcase
when 'build' when "build"
build build
when 'serve' when "serve"
serve serve
when 'parse' when "parse"
parse parse
when 'clean' when "clean"
clean clean
when 'help', nil when "help", nil
help help
else else
puts "❌ Unknown command: #{command}" puts "❌ Unknown command: #{command}"
@@ -41,10 +42,10 @@ class Make
def build def build
puts "🏗️ Building homebrew tap documentation..." puts "🏗️ Building homebrew tap documentation..."
success = system('ruby', script_path('parse_formulas.rb')) success = system("ruby", script_path("parse_formulas.rb"))
exit 1 unless success exit 1 unless success
success = system('ruby', script_path('build_site.rb')) success = system("ruby", script_path("build_site.rb"))
exit 1 unless success exit 1 unless success
puts "✅ Build complete!" puts "✅ Build complete!"
@@ -52,17 +53,17 @@ class Make
def serve def serve
port = ARGV[1]&.to_i || 4000 port = ARGV[1]&.to_i || 4000
host = ARGV[2] || 'localhost' host = ARGV[2] || "localhost"
puts "🚀 Starting development server on http://#{host}:#{port}" puts "🚀 Starting development server on http://#{host}:#{port}"
exec('ruby', script_path('serve.rb'), port.to_s, host) exec("ruby", script_path("serve.rb"), port.to_s, host)
end end
def parse def parse
puts "📋 Parsing formulae..." puts "📋 Parsing formulae..."
success = system('ruby', script_path('parse_formulas.rb')) success = system("ruby", script_path("parse_formulas.rb"))
exit 1 unless success exit 1 unless success
puts "✅ Formulae parsing complete!" puts "✅ Formulae parsing complete!"
@@ -72,13 +73,13 @@ class Make
puts "🧹 Cleaning generated files..." puts "🧹 Cleaning generated files..."
files_to_clean = [ files_to_clean = [
docs_path('index.html'), docs_path("index.html"),
docs_path('formulae.html'), docs_path("formulae.html"),
docs_path('formula'), docs_path("formula"),
docs_path('_templates'), docs_path("_templates"),
docs_path('_data', 'formulae.json'), docs_path("_data", "formulae.json"),
docs_path('style.css'), docs_path("style.css"),
docs_path('main.js') docs_path("main.js"),
] ]
files_to_clean.each do |path| files_to_clean.each do |path|
@@ -113,7 +114,7 @@ class Make
end end
def docs_path(*parts) def docs_path(*parts)
File.join(__dir__, '..', 'docs', *parts) File.join(__dir__, "..", "docs", *parts)
end end
end end

View File

@@ -1,27 +1,36 @@
#!/usr/bin/env ruby #!/usr/bin/env ruby
# typed: strict
# frozen_string_literal: true # frozen_string_literal: true
require 'json' require "json"
require 'fileutils' require "fileutils"
require 'pathname' require "pathname"
require 'date' require "date"
# Simple polyfill for Homebrew extensions
class String
def blank?
# Polyfill implementation to avoid external dependencies
nil? || empty? # rubocop:disable Homebrew/Blank
end
end
# Parser class for extracting metadata from Homebrew formulae # Parser class for extracting metadata from Homebrew formulae
class FormulaParser class FormulaParser
FORMULA_DIR = File.expand_path('../Formula', __dir__) FORMULA_DIR = File.expand_path("../Formula", __dir__).freeze
OUTPUT_DIR = File.expand_path('../docs/_data', __dir__) OUTPUT_DIR = File.expand_path("../docs/_data", __dir__).freeze
OUTPUT_FILE = File.join(OUTPUT_DIR, 'formulae.json') OUTPUT_FILE = File.join(OUTPUT_DIR, "formulae.json").freeze
# Regex patterns for safe extraction without code evaluation # Regex patterns for safe extraction without code evaluation
PATTERNS = { PATTERNS = {
class_name: /^class\s+(\w+)\s+<\s+Formula/, class_name: /^class\s+(\w+)\s+<\s+Formula/,
desc: /^\s*desc\s+["']([^"']+)["']/, desc: /^\s*desc\s+["']([^"']+)["']/,
homepage: /^\s*homepage\s+["']([^"']+)["']/, homepage: /^\s*homepage\s+["']([^"']+)["']/,
url: /^\s*url\s+["']([^"']+)["']/, url: /^\s*url\s+["']([^"']+)["']/,
version: /^\s*version\s+["']([^"']+)["']/, version: /^\s*version\s+["']([^"']+)["']/,
sha256: /^\s*sha256\s+["']([a-f0-9]{64})["']/i, sha256: /^\s*sha256\s+["']([a-f0-9]{64})["']/i,
license: /^\s*license\s+["']([^"']+)["']/, license: /^\s*license\s+["']([^"']+)["']/,
depends_on: /^\s*depends_on\s+["']([^"']+)["']/ depends_on: /^\s*depends_on\s+["']([^"']+)["']/,
}.freeze }.freeze
def self.run def self.run
@@ -42,41 +51,49 @@ class FormulaParser
end end
def parse_all_formulae def parse_all_formulae
formula_files.map { |file| parse_formula(file) }.compact.sort_by { |f| f[:name] } formula_files.filter_map { |file| parse_formula(file) }.sort_by { |f| f[:name] }
end end
def formula_files def formula_files
Dir.glob(File.join(FORMULA_DIR, '**', '*.rb')) Dir.glob(File.join(FORMULA_DIR, "**", "*.rb"))
end end
def parse_formula(file_path) def parse_formula(file_path)
content = File.read(file_path) content = File.read(file_path)
class_name = extract_value(content, :class_name) class_name = extract_value(content, :class_name)
return nil unless class_name return unless class_name
formula_name = convert_class_name_to_formula_name(class_name) formula_name = convert_class_name_to_formula_name(class_name)
return nil if formula_name.nil? || formula_name.empty? return if formula_name.blank?
{ build_formula_metadata(content, file_path, formula_name, class_name)
name: formula_name, rescue => e
class_name: class_name,
description: extract_value(content, :desc),
homepage: extract_value(content, :homepage),
url: extract_value(content, :url),
version: extract_version(content),
sha256: extract_value(content, :sha256),
license: extract_value(content, :license),
dependencies: extract_dependencies(content),
file_path: Pathname.new(file_path).relative_path_from(Pathname.new(FORMULA_DIR)).to_s,
last_modified: format_time_iso8601(File.mtime(file_path))
}
rescue StandardError => e
warn "⚠️ Error parsing #{file_path}: #{e.message}" warn "⚠️ Error parsing #{file_path}: #{e.message}"
nil nil
end end
def build_formula_metadata(content, file_path, formula_name, class_name)
{
name: formula_name,
class_name: class_name,
description: extract_value(content, :desc),
homepage: extract_value(content, :homepage),
url: extract_value(content, :url),
version: extract_version(content),
sha256: extract_value(content, :sha256),
license: extract_value(content, :license),
dependencies: extract_dependencies(content),
file_path: calculate_relative_path(file_path),
last_modified: format_time_iso8601(File.mtime(file_path)),
}
end
def calculate_relative_path(file_path)
Pathname.new(file_path).relative_path_from(Pathname.new(FORMULA_DIR)).to_s
end
def extract_value(content, pattern_key) def extract_value(content, pattern_key)
match = content.match(PATTERNS[pattern_key]) match = content.match(PATTERNS[pattern_key])
match&.[](1) match&.[](1)
@@ -88,7 +105,7 @@ class FormulaParser
return explicit if explicit return explicit if explicit
url = extract_value(content, :url) url = extract_value(content, :url)
return nil unless url return unless url
# Common version patterns in URLs # Common version patterns in URLs
url.match(/v?(\d+(?:\.\d+)+)/)&.[](1) url.match(/v?(\d+(?:\.\d+)+)/)&.[](1)
@@ -99,25 +116,25 @@ class FormulaParser
end end
def convert_class_name_to_formula_name(class_name) def convert_class_name_to_formula_name(class_name)
return nil unless class_name return unless class_name
# Convert CamelCase to kebab-case # Convert CamelCase to kebab-case
class_name class_name
.gsub(/([a-z\d])([A-Z])/, '\1-\2') .gsub(/([a-z\d])([A-Z])/, "\1-\2")
.downcase .downcase
end end
def format_time_iso8601(time) def format_time_iso8601(time)
# Format time manually for compatibility # Format time manually for compatibility
time.strftime('%Y-%m-%dT%H:%M:%S%z').gsub(/(\d{2})(\d{2})$/, '\1:\2') time.strftime("%Y-%m-%dT%H:%M:%S%z").gsub(/(\d{2})(\d{2})$/, "\1:\2")
end end
def write_json_output(formulae) def write_json_output(formulae)
output = { output = {
tap_name: 'ivuorinen/tap', tap_name: "ivuorinen/tap",
generated_at: format_time_iso8601(Time.now), generated_at: format_time_iso8601(Time.now),
formulae_count: formulae.length, formulae_count: formulae.length,
formulae: formulae formulae: formulae,
} }
File.write(OUTPUT_FILE, JSON.pretty_generate(output)) File.write(OUTPUT_FILE, JSON.pretty_generate(output))

247
scripts/serve.rb Normal file → Executable file
View File

@@ -1,22 +1,146 @@
#!/usr/bin/env ruby #!/usr/bin/env ruby
# typed: strict
# frozen_string_literal: true # frozen_string_literal: true
require 'webrick' require "webrick"
require 'fileutils' require "fileutils"
require_relative 'parse_formulas' require_relative "parse_formulas"
require_relative 'build_site' require_relative "build_site"
# Simple development server for the homebrew tap documentation # Simple development server for the homebrew tap documentation
# Module for handling file watching and change detection
module FileWatcher
def start_file_watcher
Thread.new do
last_mtime = max_mtime
rebuild_pending = false
watched_files = watched_files_count
puts "👀 Watching #{watched_files} files for changes..."
loop do
sleep 1
current_mtime = max_mtime
next if should_skip_rebuild?(current_mtime, last_mtime, rebuild_pending)
rebuild_pending = true
handle_file_change(last_mtime)
last_mtime = perform_rebuild_with_debounce
rebuild_pending = false
puts "✅ Rebuild complete"
rescue => e
puts "⚠️ File watcher error: #{e.message}"
puts "📍 Backtrace: #{e.backtrace.first(3).join(", ")}"
rebuild_pending = false
sleep 2
puts "🔄 File watcher continuing..."
end
end
end
def watched_files_count
all_watched_files.count { |file| !File.directory?(file) }
end
def find_changed_file(since_mtime)
all_watched_files.find do |file|
File.exist?(file) && !File.directory?(file) && File.mtime(file) > since_mtime
end
end
def all_watched_files
[
formula_files,
theme_files,
template_files,
style_and_script_files,
asset_files,
build_script_files,
config_files,
].flatten.compact.uniq
end
def max_mtime
all_watched_files
.select { |file| File.exist?(file) && !File.directory?(file) }
.map { |file| File.mtime(file) }
.max || Time.at(0)
end
private
def should_skip_rebuild?(current_mtime, last_mtime, rebuild_pending)
current_mtime <= last_mtime || rebuild_pending
end
def handle_file_change(last_mtime)
changed_file = find_changed_file(last_mtime)
puts "📝 Changed: #{changed_file}" if changed_file
puts "🔄 Rebuilding in 1 second..."
end
def perform_rebuild_with_debounce
sleep 1 # Debounce: wait for additional changes
final_mtime = max_mtime
puts "🔨 Building site..."
build_site
final_mtime
end
def formula_files
Dir.glob(File.expand_path("../Formula/**/*.rb", __dir__))
end
def theme_files
Dir.glob(File.expand_path("../theme/**/*", __dir__))
end
def template_files
[
Dir.glob(File.expand_path("../theme/*.erb", __dir__)),
Dir.glob(File.expand_path("../theme/_*.erb", __dir__)),
Dir.glob(File.expand_path("../theme/*.html.erb", __dir__)),
Dir.glob(File.expand_path("../theme/_*.html.erb", __dir__)),
].flatten
end
def style_and_script_files
[
Dir.glob(File.expand_path("../theme/*.css", __dir__)),
Dir.glob(File.expand_path("../theme/*.js", __dir__)),
].flatten
end
def asset_files
Dir.glob(File.expand_path("../theme/assets/**/*", __dir__))
end
def build_script_files
[
File.expand_path("../scripts/parse_formulas.rb", __dir__),
File.expand_path("../scripts/build_site.rb", __dir__),
]
end
def config_files
[File.expand_path("../Makefile", __dir__)]
end
end
# Development server with file watching for homebrew tap documentation
class DevServer class DevServer
def self.serve(port: 4000, host: 'localhost') include FileWatcher
def self.serve(port: 4000, host: "localhost")
new(port: port, host: host).start new(port: port, host: host).start
end end
def initialize(port: 4000, host: 'localhost') def initialize(port: 4000, host: "localhost")
@port = port @port = port
@host = host @host = host
@site_dir = File.expand_path('../docs', __dir__) @site_dir = File.expand_path("../docs", __dir__)
@docs_dir = File.expand_path('../docs', __dir__) @docs_dir = File.expand_path("../docs", __dir__)
end end
def start def start
@@ -43,128 +167,45 @@ class DevServer
def start_server def start_server
server = WEBrick::HTTPServer.new( server = WEBrick::HTTPServer.new(
Port: @port, Port: @port,
Host: @host, Host: @host,
DocumentRoot: @site_dir, DocumentRoot: @site_dir,
Logger: WEBrick::Log.new($stderr, WEBrick::Log::INFO), Logger: WEBrick::Log.new($stderr, WEBrick::Log::INFO),
AccessLog: [[ AccessLog: [[
$stderr, $stderr,
WEBrick::AccessLog::COMBINED_LOG_FORMAT WEBrick::AccessLog::COMBINED_LOG_FORMAT,
]] ]],
) )
# Handle Ctrl+C gracefully # Handle Ctrl+C gracefully
trap('INT') do trap("INT") do
puts "\n👋 Stopping server..." puts "\n👋 Stopping server..."
server.shutdown server.shutdown
end end
# Add custom mime types if needed # Add custom mime types if needed
server.config[:MimeTypes]['json'] = 'application/json' server.config[:MimeTypes]["json"] = "application/json"
# Add auto-rebuild on file changes (simple polling) # Add auto-rebuild on file changes (simple polling)
start_file_watcher start_file_watcher
server.start server.start
end end
def start_file_watcher
Thread.new do
last_mtime = get_max_mtime
rebuild_pending = false
watched_files = get_watched_files_count
puts "👀 Watching #{watched_files} files for changes..."
loop do
sleep 1
current_mtime = get_max_mtime
if current_mtime > last_mtime && !rebuild_pending
rebuild_pending = true
changed_file = find_changed_file(last_mtime)
puts "📝 Changed: #{changed_file}" if changed_file
puts "🔄 Rebuilding in 1 second..."
# Debounce: wait for additional changes
sleep 1
# Check if more changes occurred during debounce period
final_mtime = get_max_mtime
puts "🔨 Building site..."
build_site
last_mtime = final_mtime
rebuild_pending = false
puts "✅ Rebuild complete"
end
end
end
end
def get_watched_files_count
files = get_all_watched_files
files.select { |f| File.exist?(f) && !File.directory?(f) }.count
end
def find_changed_file(since_time)
files = get_all_watched_files
files.select { |f| File.exist?(f) && !File.directory?(f) }
.find { |f| File.mtime(f) > since_time }
&.sub(File.expand_path('..', __dir__) + '/', '')
end
def get_all_watched_files
[
# Watch Formula files for changes
Dir.glob(File.expand_path('../Formula/**/*.rb', __dir__)),
# Watch all theme files including partials
Dir.glob(File.expand_path('../theme/**/*', __dir__)),
# Specifically watch for erb templates and partials
Dir.glob(File.expand_path('../theme/*.erb', __dir__)),
Dir.glob(File.expand_path('../theme/_*.erb', __dir__)),
Dir.glob(File.expand_path('../theme/*.html.erb', __dir__)),
Dir.glob(File.expand_path('../theme/_*.html.erb', __dir__)),
# Watch CSS and JS
Dir.glob(File.expand_path('../theme/*.css', __dir__)),
Dir.glob(File.expand_path('../theme/*.js', __dir__)),
# Watch assets directory
Dir.glob(File.expand_path('../theme/assets/**/*', __dir__)),
# Watch build scripts for changes
[File.expand_path('../scripts/parse_formulas.rb', __dir__)],
[File.expand_path('../scripts/build_site.rb', __dir__)],
# Watch Makefile
[File.expand_path('../Makefile', __dir__)]
].flatten.compact.uniq
end
def get_max_mtime
files_to_watch = get_all_watched_files
# Filter out non-existent files and directories, get modification times
existing_files = files_to_watch.select { |f| File.exist?(f) && !File.directory?(f) }
if existing_files.empty?
Time.at(0)
else
existing_files.map { |f| File.mtime(f) }.max
end
end
end end
# Command line interface # Command line interface
if __FILE__ == $PROGRAM_NAME if __FILE__ == $PROGRAM_NAME
# Check for --list-watched flag # Check for --list-watched flag
if ARGV.include?('--list-watched') if ARGV.include?("--list-watched")
server = DevServer.new server = DevServer.new
files = server.send(:get_all_watched_files).select { |f| File.exist?(f) && !File.directory?(f) } files = server.all_watched_files.select { |f| File.exist?(f) && !File.directory?(f) }
puts "📋 Watching #{files.count} files:" puts "📋 Watching #{files.count} files:"
files.sort.each { |f| puts " - #{f.sub(File.expand_path('..', __dir__) + '/', '')}" } files.sort.each { |f| puts " - #{f.sub("#{File.expand_path("..", __dir__)}/", "")}" }
exit 0 exit 0
end end
port = ARGV[0]&.to_i || 4000 port = ARGV[0]&.to_i || 4000
host = ARGV[1] || 'localhost' host = ARGV[1] || "localhost"
DevServer.serve(port: port, host: host) DevServer.serve(port: port, host: host)
end end

View File

@@ -19,7 +19,9 @@
} }
function getSystemTheme() { function getSystemTheme() {
return window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light"; return window.matchMedia?.("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
} }
function getCurrentTheme() { function getCurrentTheme() {
@@ -137,8 +139,11 @@
const cards = document.querySelectorAll(".formula-card"); const cards = document.querySelectorAll(".formula-card");
cards.forEach((card) => { cards.forEach((card) => {
const text = `${card.querySelector("h3")?.textContent || ""} ${card.querySelector("p")?.textContent || ""}`; const text = `${card.querySelector("h3")?.textContent || ""} ${
card.style.display = !searchTerm || fuzzyMatch(searchTerm, text) ? "" : "none"; card.querySelector("p")?.textContent || ""
}`;
card.style.display =
!searchTerm || fuzzyMatch(searchTerm, text) ? "" : "none";
}); });
} }
searchInput.addEventListener("input", () => { searchInput.addEventListener("input", () => {

View File

@@ -1,43 +1,43 @@
@font-face { @font-face {
font-family: "Monaspace Argon"; font-family: "Monaspace Argon";
src: src:
url("assets/MonaspaceArgonVar.woff2") format("woff2"), url("assets/MonaspaceArgonVar.woff2") format("woff2"),
url("assets/MonaspaceArgonVar.woff") format("woff"); url("assets/MonaspaceArgonVar.woff") format("woff");
font-weight: 100 900; font-weight: 100 900;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
:root { :root {
color-scheme: light dark; color-scheme: light dark;
} }
html { html {
font-family: font-family:
"Monaspace Argon", "Monaspace Argon",
-apple-system, -apple-system,
BlinkMacSystemFont, BlinkMacSystemFont,
"Segoe UI", "Segoe UI",
Helvetica, Helvetica,
Arial, Arial,
sans-serif; sans-serif;
} }
body { body {
font-family: font-family:
"Monaspace Argon", "Monaspace Argon",
-apple-system, -apple-system,
BlinkMacSystemFont, BlinkMacSystemFont,
"Segoe UI", "Segoe UI",
Helvetica, Helvetica,
Arial, Arial,
sans-serif; sans-serif;
} }
code, code,
pre { pre {
font-family: "Monaspace Argon", monospace; font-family: "Monaspace Argon", monospace;
font-feature-settings: font-feature-settings:
"calt", "ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", "ss09", "ss10", "calt", "ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08",
"liga"; "ss09", "ss10", "liga";
} }