From 6dc9a170cc03d252f483c8b6903b4072f7f5efb6 Mon Sep 17 00:00:00 2001 From: Ismo Vuorinen Date: Tue, 23 Sep 2025 11:29:53 +0300 Subject: [PATCH] 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 --- .github/workflows/ci.yml | 10 +- .gitignore | 5 + .pre-commit-config.yaml | 29 + .rubocop.yml | 41 -- .ruby-version | 2 +- .../e/{example-tool.rbx => example-tool.rb} | 7 +- .../e/{example-tool2.rbx => example-tool2.rb} | 7 +- Gemfile | 10 +- Makefile | 35 +- docs/formula/.gitkeep | 0 scripts/build_site.rb | 546 +++++++++++------- scripts/make.rb | 49 +- scripts/parse_formulas.rb | 95 +-- scripts/serve.rb | 247 ++++---- theme/main.js | 11 +- theme/style.css | 56 +- 16 files changed, 678 insertions(+), 472 deletions(-) create mode 100644 .pre-commit-config.yaml delete mode 100644 .rubocop.yml rename Formula/e/{example-tool.rbx => example-tool.rb} (62%) rename Formula/e/{example-tool2.rbx => example-tool2.rb} (61%) delete mode 100644 docs/formula/.gitkeep mode change 100644 => 100755 scripts/build_site.rb mode change 100644 => 100755 scripts/make.rb mode change 100644 => 100755 scripts/serve.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a19da3..8c53815 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,8 +49,16 @@ jobs: - name: Run brew test-bot (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) - 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 - name: Upload bottles as artifact diff --git a/.gitignore b/.gitignore index 79aae00..a2ec12e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ Gemfile.lock .bundle/ vendor/bundle/ +.ruby-lsp/ # Generated site files docs/**/*.html @@ -27,3 +28,7 @@ docs/assets/* # Other AGENTS.md +.rubocop_todo.yml +.serena +CLAUDE.md +node_modules/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..2054248 --- /dev/null +++ b/.pre-commit-config.yaml @@ -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/" diff --git a/.rubocop.yml b/.rubocop.yml deleted file mode 100644 index 79b476d..0000000 --- a/.rubocop.yml +++ /dev/null @@ -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' diff --git a/.ruby-version b/.ruby-version index 9c25013..4f5e697 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.3.6 +3.4.5 diff --git a/Formula/e/example-tool.rbx b/Formula/e/example-tool.rb similarity index 62% rename from Formula/e/example-tool.rbx rename to Formula/e/example-tool.rb index 83206d6..de223d5 100644 --- a/Formula/e/example-tool.rbx +++ b/Formula/e/example-tool.rb @@ -1,7 +1,10 @@ +# frozen_string_literal: true + +# Homebrew formula for ExampleTool - a demonstration tool for this tap 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" - 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" license "MIT" diff --git a/Formula/e/example-tool2.rbx b/Formula/e/example-tool2.rb similarity index 61% rename from Formula/e/example-tool2.rbx rename to Formula/e/example-tool2.rb index dfd6e64..c287db4 100644 --- a/Formula/e/example-tool2.rbx +++ b/Formula/e/example-tool2.rb @@ -1,7 +1,10 @@ +# frozen_string_literal: true + +# Homebrew formula for ExampleTool2 - a second demonstration tool for this tap 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" - 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" license "MIT" diff --git a/Gemfile b/Gemfile index 8ef1852..6f0f716 100644 --- a/Gemfile +++ b/Gemfile @@ -1,13 +1,7 @@ +# frozen_string_literal: true + source "https://rubygems.org" ruby "3.4.5" -gem "parser", "~> 3.3" 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 diff --git a/Makefile b/Makefile index dbb471d..aad9bbc 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # Homebrew Tap Makefile # 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_GOAL := help @@ -87,6 +87,35 @@ install: ## Install development dependencies (if Gemfile exists) echo "โ„น๏ธ No Gemfile found, skipping dependency installation"; \ 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) @$(MAKE) serve @@ -116,6 +145,10 @@ tap-install: ## Install this tap locally for testing @echo "๐Ÿบ Installing tap locally..." @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) @if [ -z "$(NAME)" ]; then \ echo "โŒ Please provide a formula name: make formula-new NAME=myformula"; \ diff --git a/docs/formula/.gitkeep b/docs/formula/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/scripts/build_site.rb b/scripts/build_site.rb old mode 100644 new mode 100755 index 2cca69a..fdb7ce4 --- a/scripts/build_site.rb +++ b/scripts/build_site.rb @@ -1,15 +1,311 @@ #!/usr/bin/env ruby +# typed: strict # frozen_string_literal: true -require 'json' -require 'fileutils' -require 'erb' -require 'pathname' -require 'time' +require "json" +require "fileutils" +require "erb" +require "pathname" +require "time" + +# Simple polyfill for Homebrew extensions +class Array + def exclude?(item) + !include?(item) + end +end # 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 include ERB::Util + include TimeFormatter + include AssetProcessor + + # Context class for rendering ERB partials with access to builder methods and local variables class PartialContext include ERB::Util @@ -32,14 +328,14 @@ class SiteBuilder @builder.format_date(timestamp) end - def get_binding + def binding_context binding end end - DOCS_DIR = File.expand_path('../docs', __dir__) - DATA_DIR = File.join(DOCS_DIR, '_data') + DOCS_DIR = File.expand_path("../docs", __dir__).freeze + DATA_DIR = File.join(DOCS_DIR, "_data").freeze 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 def self.build @@ -55,76 +351,29 @@ class SiteBuilder generate_pages 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 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 + raise ArgumentError, "Partial not found: #{partial_path}" unless File.exist?(partial_path) 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 + ERB.new(File.read(partial_path)).result(context.binding_context) 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 + FileUtils.mkdir_p(File.join(OUTPUT_DIR, "formula")) + return if templates_exist? + + puts "โš ๏ธ Templates not found in #{TEMPLATES_DIR}. Please ensure theme/*.html.erb files exist." + exit 1 end 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 end @@ -139,170 +388,31 @@ class SiteBuilder 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') + template = load_template("index.html.erb") content = template.result(binding) - File.write(File.join(OUTPUT_DIR, 'index.html'), content) + File.write(File.join(OUTPUT_DIR, "index.html"), content) end def generate_formulae_pages - @data['formulae'].each do |formula| + @data["formulae"].each do |formula| generate_formula_page(formula) end # Generate formulae index - template = load_template('formulae.html.erb') + template = load_template("formulae.html.erb") content = template.result(binding) - File.write(File.join(OUTPUT_DIR, 'formulae.html'), content) + File.write(File.join(OUTPUT_DIR, "formulae.html"), content) end def generate_formula_page(formula) @formula = formula - template = load_template('formula.html.erb') + template = load_template("formula.html.erb") content = template.result(binding) - formula_dir = File.join(OUTPUT_DIR, 'formula') + formula_dir = File.join(OUTPUT_DIR, "formula") 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 def load_template(name) @@ -319,15 +429,13 @@ class SiteBuilder def default_data { - 'tap_name' => 'ivuorinen/homebrew-tap', - 'generated_at' => Time.now.strftime('%Y-%m-%dT%H:%M:%S%z'), - 'formulae_count' => 0, - 'formulae' => [] + "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 +SiteBuilder.build if __FILE__ == $PROGRAM_NAME diff --git a/scripts/make.rb b/scripts/make.rb old mode 100644 new mode 100755 index e1b9a61..eec38bc --- a/scripts/make.rb +++ b/scripts/make.rb @@ -1,16 +1,17 @@ #!/usr/bin/env ruby +# typed: strict # frozen_string_literal: true -require 'fileutils' +require "fileutils" # Simple make-style command runner for homebrew tap class Make COMMANDS = { - 'build' => 'Build the static site', - 'serve' => 'Start development server', - 'parse' => 'Parse formulae and generate JSON data', - 'clean' => 'Clean generated files', - 'help' => 'Show this help message' + "build" => "Build the static site", + "serve" => "Start development server", + "parse" => "Parse formulae and generate JSON data", + "clean" => "Clean generated files", + "help" => "Show this help message", }.freeze def self.run(command = nil) @@ -19,15 +20,15 @@ class Make def execute(command) case command&.downcase - when 'build' + when "build" build - when 'serve' + when "serve" serve - when 'parse' + when "parse" parse - when 'clean' + when "clean" clean - when 'help', nil + when "help", nil help else puts "โŒ Unknown command: #{command}" @@ -41,10 +42,10 @@ class Make def build 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 - success = system('ruby', script_path('build_site.rb')) + success = system("ruby", script_path("build_site.rb")) exit 1 unless success puts "โœ… Build complete!" @@ -52,17 +53,17 @@ class Make def serve port = ARGV[1]&.to_i || 4000 - host = ARGV[2] || 'localhost' + host = ARGV[2] || "localhost" 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 def parse puts "๐Ÿ“‹ Parsing formulae..." - success = system('ruby', script_path('parse_formulas.rb')) + success = system("ruby", script_path("parse_formulas.rb")) exit 1 unless success puts "โœ… Formulae parsing complete!" @@ -72,13 +73,13 @@ class Make puts "๐Ÿงน Cleaning generated files..." files_to_clean = [ - docs_path('index.html'), - docs_path('formulae.html'), - docs_path('formula'), - docs_path('_templates'), - docs_path('_data', 'formulae.json'), - docs_path('style.css'), - docs_path('main.js') + docs_path("index.html"), + docs_path("formulae.html"), + docs_path("formula"), + docs_path("_templates"), + docs_path("_data", "formulae.json"), + docs_path("style.css"), + docs_path("main.js"), ] files_to_clean.each do |path| @@ -113,7 +114,7 @@ class Make end def docs_path(*parts) - File.join(__dir__, '..', 'docs', *parts) + File.join(__dir__, "..", "docs", *parts) end end diff --git a/scripts/parse_formulas.rb b/scripts/parse_formulas.rb index 0c0f595..440f866 100755 --- a/scripts/parse_formulas.rb +++ b/scripts/parse_formulas.rb @@ -1,27 +1,36 @@ #!/usr/bin/env ruby +# typed: strict # frozen_string_literal: true -require 'json' -require 'fileutils' -require 'pathname' -require 'date' +require "json" +require "fileutils" +require "pathname" +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 class FormulaParser - FORMULA_DIR = File.expand_path('../Formula', __dir__) - OUTPUT_DIR = File.expand_path('../docs/_data', __dir__) - OUTPUT_FILE = File.join(OUTPUT_DIR, 'formulae.json') + FORMULA_DIR = File.expand_path("../Formula", __dir__).freeze + OUTPUT_DIR = File.expand_path("../docs/_data", __dir__).freeze + OUTPUT_FILE = File.join(OUTPUT_DIR, "formulae.json").freeze # Regex patterns for safe extraction without code evaluation PATTERNS = { class_name: /^class\s+(\w+)\s+<\s+Formula/, - desc: /^\s*desc\s+["']([^"']+)["']/, - homepage: /^\s*homepage\s+["']([^"']+)["']/, - url: /^\s*url\s+["']([^"']+)["']/, - version: /^\s*version\s+["']([^"']+)["']/, - sha256: /^\s*sha256\s+["']([a-f0-9]{64})["']/i, - license: /^\s*license\s+["']([^"']+)["']/, - depends_on: /^\s*depends_on\s+["']([^"']+)["']/ + desc: /^\s*desc\s+["']([^"']+)["']/, + homepage: /^\s*homepage\s+["']([^"']+)["']/, + url: /^\s*url\s+["']([^"']+)["']/, + version: /^\s*version\s+["']([^"']+)["']/, + sha256: /^\s*sha256\s+["']([a-f0-9]{64})["']/i, + license: /^\s*license\s+["']([^"']+)["']/, + depends_on: /^\s*depends_on\s+["']([^"']+)["']/, }.freeze def self.run @@ -42,41 +51,49 @@ class FormulaParser end 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 def formula_files - Dir.glob(File.join(FORMULA_DIR, '**', '*.rb')) + Dir.glob(File.join(FORMULA_DIR, "**", "*.rb")) end def parse_formula(file_path) content = File.read(file_path) 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) - return nil if formula_name.nil? || formula_name.empty? + return if formula_name.blank? - { - 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: 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 + build_formula_metadata(content, file_path, formula_name, class_name) + rescue => e warn "โš ๏ธ Error parsing #{file_path}: #{e.message}" nil 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) match = content.match(PATTERNS[pattern_key]) match&.[](1) @@ -88,7 +105,7 @@ class FormulaParser return explicit if explicit url = extract_value(content, :url) - return nil unless url + return unless url # Common version patterns in URLs url.match(/v?(\d+(?:\.\d+)+)/)&.[](1) @@ -99,25 +116,25 @@ class FormulaParser end def convert_class_name_to_formula_name(class_name) - return nil unless class_name + return unless class_name # Convert CamelCase to kebab-case class_name - .gsub(/([a-z\d])([A-Z])/, '\1-\2') + .gsub(/([a-z\d])([A-Z])/, "\1-\2") .downcase end def format_time_iso8601(time) # 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 def write_json_output(formulae) output = { - tap_name: 'ivuorinen/tap', - generated_at: format_time_iso8601(Time.now), + tap_name: "ivuorinen/tap", + generated_at: format_time_iso8601(Time.now), formulae_count: formulae.length, - formulae: formulae + formulae: formulae, } File.write(OUTPUT_FILE, JSON.pretty_generate(output)) diff --git a/scripts/serve.rb b/scripts/serve.rb old mode 100644 new mode 100755 index 93feddd..c2b29e0 --- a/scripts/serve.rb +++ b/scripts/serve.rb @@ -1,22 +1,146 @@ #!/usr/bin/env ruby +# typed: strict # frozen_string_literal: true -require 'webrick' -require 'fileutils' -require_relative 'parse_formulas' -require_relative 'build_site' +require "webrick" +require "fileutils" +require_relative "parse_formulas" +require_relative "build_site" # 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 - def self.serve(port: 4000, host: 'localhost') + include FileWatcher + + def self.serve(port: 4000, host: "localhost") new(port: port, host: host).start end - def initialize(port: 4000, host: 'localhost') + def initialize(port: 4000, host: "localhost") @port = port @host = host - @site_dir = File.expand_path('../docs', __dir__) - @docs_dir = File.expand_path('../docs', __dir__) + @site_dir = File.expand_path("../docs", __dir__) + @docs_dir = File.expand_path("../docs", __dir__) end def start @@ -43,128 +167,45 @@ class DevServer def start_server server = WEBrick::HTTPServer.new( - Port: @port, - Host: @host, + Port: @port, + Host: @host, DocumentRoot: @site_dir, - Logger: WEBrick::Log.new($stderr, WEBrick::Log::INFO), - AccessLog: [[ + Logger: WEBrick::Log.new($stderr, WEBrick::Log::INFO), + AccessLog: [[ $stderr, - WEBrick::AccessLog::COMBINED_LOG_FORMAT - ]] + WEBrick::AccessLog::COMBINED_LOG_FORMAT, + ]], ) # Handle Ctrl+C gracefully - trap('INT') do + trap("INT") do puts "\n๐Ÿ‘‹ Stopping server..." server.shutdown end # 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) start_file_watcher server.start 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 # Command line interface if __FILE__ == $PROGRAM_NAME # Check for --list-watched flag - if ARGV.include?('--list-watched') + if ARGV.include?("--list-watched") 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:" - files.sort.each { |f| puts " - #{f.sub(File.expand_path('..', __dir__) + '/', '')}" } + files.sort.each { |f| puts " - #{f.sub("#{File.expand_path("..", __dir__)}/", "")}" } exit 0 end port = ARGV[0]&.to_i || 4000 - host = ARGV[1] || 'localhost' + host = ARGV[1] || "localhost" DevServer.serve(port: port, host: host) end diff --git a/theme/main.js b/theme/main.js index 98bd76c..15fd338 100644 --- a/theme/main.js +++ b/theme/main.js @@ -19,7 +19,9 @@ } function getSystemTheme() { - return window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + return window.matchMedia?.("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; } function getCurrentTheme() { @@ -137,8 +139,11 @@ const cards = document.querySelectorAll(".formula-card"); cards.forEach((card) => { - const text = `${card.querySelector("h3")?.textContent || ""} ${card.querySelector("p")?.textContent || ""}`; - card.style.display = !searchTerm || fuzzyMatch(searchTerm, text) ? "" : "none"; + const text = `${card.querySelector("h3")?.textContent || ""} ${ + card.querySelector("p")?.textContent || "" + }`; + card.style.display = + !searchTerm || fuzzyMatch(searchTerm, text) ? "" : "none"; }); } searchInput.addEventListener("input", () => { diff --git a/theme/style.css b/theme/style.css index 288e048..6a353b5 100644 --- a/theme/style.css +++ b/theme/style.css @@ -1,43 +1,43 @@ @font-face { - font-family: "Monaspace Argon"; - src: - url("assets/MonaspaceArgonVar.woff2") format("woff2"), - url("assets/MonaspaceArgonVar.woff") format("woff"); - font-weight: 100 900; - font-style: normal; - font-display: swap; + font-family: "Monaspace Argon"; + src: + url("assets/MonaspaceArgonVar.woff2") format("woff2"), + url("assets/MonaspaceArgonVar.woff") format("woff"); + font-weight: 100 900; + font-style: normal; + font-display: swap; } :root { - color-scheme: light dark; + color-scheme: light dark; } html { - font-family: - "Monaspace Argon", - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - Helvetica, - Arial, - sans-serif; + font-family: + "Monaspace Argon", + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Helvetica, + Arial, + sans-serif; } body { - font-family: - "Monaspace Argon", - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - Helvetica, - Arial, - sans-serif; + font-family: + "Monaspace Argon", + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Helvetica, + Arial, + sans-serif; } code, pre { - font-family: "Monaspace Argon", monospace; - font-feature-settings: - "calt", "ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", "ss09", "ss10", - "liga"; + font-family: "Monaspace Argon", monospace; + font-feature-settings: + "calt", "ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", + "ss09", "ss10", "liga"; }