diff --git a/Readme.md b/Readme.md index 051f258..7b4d84f 100644 --- a/Readme.md +++ b/Readme.md @@ -7,8 +7,7 @@ Source code for [samhuri.net](https://samhuri.net), powered by a Ruby static sit This repository is now a single integrated Ruby project. The legacy Swift generators (`gensite/` and `samhuri.net/`) have been removed. - Generator core: `lib/` -- Build tasks: `bake.rb` -- CLI and utilities: `bin/` +- Build tasks and utility workflows: `bake.rb` - Tests: `spec/` - Config: `site.toml` and `projects.toml` - Content: `posts/` and `public/` @@ -22,12 +21,6 @@ This repository is now a single integrated Ruby project. The legacy Swift genera ## Setup -```bash -bin/bootstrap -``` - -Or manually: - ```bash rbenv install -s "$(cat .ruby-version)" rbenv exec bundle install @@ -55,6 +48,9 @@ Other targets: rbenv exec bundle exec bake mudge rbenv exec bundle exec bake beta rbenv exec bundle exec bake release +rbenv exec bundle exec bake generate . www https://samhuri.net +rbenv exec bundle exec bake watch target=debug +rbenv exec bundle exec bake deploy --test true --delete true rbenv exec bundle exec bake publish_beta rbenv exec bundle exec bake publish ``` @@ -62,8 +58,8 @@ rbenv exec bundle exec bake publish ## Draft Workflow ```bash -bin/new-draft "Post title" -bin/publish-draft public/drafts/post-title.md +rbenv exec bundle exec bake new_draft "Post title" +rbenv exec bundle exec bake publish_draft public/drafts/post-title.md ``` ## Tests And Lint @@ -80,15 +76,15 @@ rbenv exec bundle exec bake test rbenv exec bundle exec bake lint ``` -## Site Generation CLI +## Site Generation ```bash -bin/pressa SOURCE TARGET [URL] -# example -bin/pressa . www https://samhuri.net +rbenv exec bundle exec bake generate SOURCE TARGET [URL] +# example: +rbenv exec bundle exec bake generate . www https://samhuri.net ``` ## Notes -- `bin/watch` is Linux-only and requires `inotifywait`. -- Deployment uses `rsync` to the configured `mudge` host paths in `bake.rb` and `bin/publish`. +- `bake watch` is Linux-only and requires `inotifywait`. +- Deployment uses `rsync` to the configured `mudge` host paths in `bake.rb`. diff --git a/bake.rb b/bake.rb index d189018..393f87d 100644 --- a/bake.rb +++ b/bake.rb @@ -1,5 +1,16 @@ # Build tasks for samhuri.net static site generator +require 'etc' +require 'fileutils' +require 'shellwords' + +DRAFTS_DIR = 'public/drafts'.freeze +PUBLISH_HOST = 'mudge'.freeze +PRODUCTION_PUBLISH_DIR = '/var/www/samhuri.net/public'.freeze +BETA_PUBLISH_DIR = '/var/www/beta.samhuri.net/public'.freeze +WATCHABLE_DIRECTORIES = %w[public posts lib].freeze +BUILD_TARGETS = %w[debug mudge beta release].freeze + # Generate the site in debug mode (localhost:8000) def debug build('http://localhost:8000') @@ -25,29 +36,158 @@ def serve require 'webrick' server = WEBrick::HTTPServer.new(Port: 8000, DocumentRoot: 'www') trap('INT') { server.shutdown } - puts "Server running at http://localhost:8000" + puts 'Server running at http://localhost:8000' server.start end +# Generate a site from an arbitrary source directory into a target directory. +# @parameter source_path [String] Directory containing site sources. +# @parameter target_path [String] Directory to write generated site. +# @parameter url [String] Optional site URL override. +def generate(source_path = '.', target_path = 'www', url = nil) + require_relative 'lib/pressa' + + site = Pressa.create_site(source_path:, url_override: url) + generator = Pressa::SiteGenerator.new(site:) + generator.generate(source_path:, target_path:) + puts "Site built successfully in #{target_path}" +end + +# Install local prerequisites and gem dependencies. +def setup + ruby_version = File.read('.ruby-version').strip + + if RUBY_PLATFORM.include?('linux') + puts '*** installing Linux prerequisites' + unless system('sudo', 'apt', 'install', '-y', + 'build-essential', + 'git', + 'inotify-tools', + 'libffi-dev', + 'libyaml-dev', + 'pkg-config', + 'zlib1g-dev') + abort 'Error: failed to install Linux prerequisites.' + end + end + + if command_available?('rbenv') + puts "*** using rbenv (ruby #{ruby_version})" + abort 'Error: rbenv install failed.' unless system('rbenv', 'install', '-s', ruby_version) + abort 'Error: bundle install failed.' unless system('rbenv', 'exec', 'bundle', 'install') + else + puts '*** rbenv not found, using system Ruby' + abort 'Error: bundle install failed.' unless system('bundle', 'install') + end + + puts '*** done' +end + +# Create a new draft in public/drafts/. +# @parameter title_parts [Array] Optional title words; defaults to Untitled. +def new_draft(*title_parts) + title, filename = + if title_parts.empty? + ['Untitled', next_available_draft] + else + given_title = title_parts.join(' ') + slug = slugify(given_title) + abort 'Error: title cannot be converted to a filename.' if slug.empty? + + filename = "#{slug}.md" + path = draft_path(filename) + abort "Error: draft already exists at #{path}" if File.exist?(path) + + [given_title, filename] + end + + FileUtils.mkdir_p(DRAFTS_DIR) + path = draft_path(filename) + content = render_draft_template(title) + File.write(path, content) + + puts "Created new draft at #{path}" + puts '>>> Contents below <<<' + puts + puts content +end + +# Publish a draft by moving it to posts/YYYY/MM and updating dates. +# @parameter input_path [String] Draft path or filename in public/drafts. +def publish_draft(input_path = nil) + if input_path.nil? || input_path.strip.empty? + puts 'Usage: bake publish_draft ' + puts + puts 'Available drafts:' + drafts = Dir.glob("#{DRAFTS_DIR}/*.md").map { |path| File.basename(path) } + if drafts.empty? + puts ' (no drafts found)' + else + drafts.each { |draft| puts " #{draft}" } + end + abort + end + + draft_path_value, draft_file = resolve_draft_input(input_path) + abort "Error: File not found: #{draft_path_value}" unless File.exist?(draft_path_value) + + now = Time.now + content = File.read(draft_path_value) + content.sub!(/^Date:.*$/, "Date: #{ordinal_date(now)}") + content.sub!(/^Timestamp:.*$/, "Timestamp: #{now.strftime('%Y-%m-%dT%H:%M:%S%:z')}") + + target_dir = "posts/#{now.strftime('%Y/%m')}" + FileUtils.mkdir_p(target_dir) + target_path = "#{target_dir}/#{draft_file}" + + File.write(target_path, content) + FileUtils.rm_f(draft_path_value) + + puts "Published draft: #{draft_path_value} -> #{target_path}" +end + +# Watch content directories and rebuild on every change. +# @parameter target [String] One of debug, mudge, beta, or release. +def watch(target: 'mudge') + unless command_available?('inotifywait') + abort 'inotifywait is required (install inotify-tools).' + end + + loop do + abort 'Error: watch failed.' unless system('inotifywait', '-e', 'modify,create,delete,move', *watch_paths) + puts "changed at #{Time.now}" + sleep 2 + run_build_target(target) + end +end + +# Deploy files via rsync without building first. +# @parameter beta [Boolean] Deploy to beta host path. +# @parameter test [Boolean] Enable rsync --dry-run. +# @parameter delete [Boolean] Enable rsync --delete. +# @parameter paths [Array] Optional local paths; defaults to www/. +def deploy(*paths, beta: false, test: false, delete: false) + publish_dir = truthy?(beta) ? BETA_PUBLISH_DIR : PRODUCTION_PUBLISH_DIR + local_paths = paths.empty? ? ['www/'] : paths + run_rsync(local_paths:, publish_dir:, dry_run: test, delete:) +end + # Publish to beta/staging server def publish_beta beta - puts "Deploying to beta server..." - system('rsync -avz --delete www/ mudge:/var/www/beta.samhuri.net/public') + run_rsync(local_paths: ['www/'], publish_dir: BETA_PUBLISH_DIR, dry_run: false, delete: true) end # Publish to production server def publish release - puts "Deploying to production server..." - system('rsync -avz --delete www/ mudge:/var/www/samhuri.net/public') + run_rsync(local_paths: ['www/'], publish_dir: PRODUCTION_PUBLISH_DIR, dry_run: false, delete: true) end # Clean generated files def clean - require 'fileutils' FileUtils.rm_rf('www') - puts "Cleaned www/ directory" + puts 'Cleaned www/ directory' end # Run RSpec tests @@ -62,7 +202,7 @@ end # List all available drafts def drafts - Dir.glob('public/drafts/*.md').sort.each do |draft| + Dir.glob("#{DRAFTS_DIR}/*.md").sort.each do |draft| puts File.basename(draft) end end @@ -82,11 +222,121 @@ private # Build the site with specified URL # @parameter url [String] The site URL to use def build(url) - require_relative 'lib/pressa' - puts "Building site for #{url}..." - site = Pressa.create_site(source_path: '.', url_override: url) - generator = Pressa::SiteGenerator.new(site:) - generator.generate(source_path: '.', target_path: 'www') - puts "Site built successfully in www/" + generate('.', 'www', url) +end + +def run_rsync(local_paths:, publish_dir:, dry_run:, delete:) + command = ['rsync', '-aKv', '-e', 'ssh -4'] + command << '--dry-run' if truthy?(dry_run) + command << '--delete' if truthy?(delete) + command.concat(local_paths) + command << "#{PUBLISH_HOST}:#{publish_dir}" + + puts "Running: #{Shellwords.join(command)}" + abort 'Error: rsync failed.' unless system(*command) +end + +def run_build_target(target) + target_name = target.to_s + unless BUILD_TARGETS.include?(target_name) + abort "Error: invalid target '#{target_name}'. Use one of: #{BUILD_TARGETS.join(', ')}" + end + + public_send(target_name) +end + +def watch_paths + WATCHABLE_DIRECTORIES.flat_map { |path| ['-r', path] } +end + +def resolve_draft_input(input_path) + if input_path.include?('/') + if input_path.start_with?('posts/') + abort "Error: '#{input_path}' is already published in posts/ directory" + end + + [input_path, File.basename(input_path)] + else + [draft_path(input_path), input_path] + end +end + +def draft_path(filename) + File.join(DRAFTS_DIR, filename) +end + +def slugify(title) + title.downcase + .gsub(/[^a-z0-9\s-]/, '') + .gsub(/\s+/, '-') + .gsub(/-+/, '-') + .gsub(/^-|-$/, '') +end + +def next_available_draft(base_filename = 'untitled.md') + return base_filename unless File.exist?(draft_path(base_filename)) + + name_without_ext = File.basename(base_filename, '.md') + counter = 1 + loop do + numbered_filename = "#{name_without_ext}-#{counter}.md" + return numbered_filename unless File.exist?(draft_path(numbered_filename)) + + counter += 1 + end +end + +def render_draft_template(title) + now = Time.now + <<~FRONTMATTER + --- + Author: #{current_author} + Title: #{title} + Date: unpublished + Timestamp: #{now.strftime('%Y-%m-%dT%H:%M:%S%:z')} + Tags: + --- + + # #{title} + + TKTK + FRONTMATTER +end + +def current_author + Etc.getlogin || ENV['USER'] || `whoami`.strip +rescue StandardError + ENV['USER'] || `whoami`.strip +end + +def ordinal_date(time) + day = time.day + suffix = case day + when 1, 21, 31 + 'st' + when 2, 22 + 'nd' + when 3, 23 + 'rd' + else + 'th' + end + + time.strftime("#{day}#{suffix} %B, %Y") +end + +def command_available?(command) + system('which', command, out: File::NULL, err: File::NULL) +end + +def truthy?(value) + case value + when true + true + when false, nil + false + else + %w[1 true yes on].include?(value.to_s.downcase) + end end diff --git a/bin/bootstrap b/bin/bootstrap deleted file mode 100755 index 74ec8fa..0000000 --- a/bin/bootstrap +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -RUBY_VERSION="$(cat "$ROOT_DIR/.ruby-version")" - -if [[ "$(uname)" = "Linux" ]]; then - echo "*** installing Linux prerequisites" - sudo apt install -y \ - build-essential \ - git \ - inotify-tools \ - libffi-dev \ - libyaml-dev \ - pkg-config \ - zlib1g-dev -fi - -cd "$ROOT_DIR" - -if command -v rbenv >/dev/null 2>/dev/null; then - echo "*** using rbenv (ruby $RUBY_VERSION)" - rbenv install -s "$RUBY_VERSION" - rbenv exec bundle install -else - echo "*** rbenv not found, using system Ruby" - bundle install -fi - -echo "*** done" diff --git a/bin/new-draft b/bin/new-draft deleted file mode 100755 index 5e93aa7..0000000 --- a/bin/new-draft +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env ruby -w - -require 'fileutils' - -DRAFTS_DIR = File.expand_path("../public/drafts", __dir__).freeze - -def usage - puts "Usage: #{$0} [title]" - puts - puts "Examples:" - puts " #{$0} Top 5 Ways to Write Clickbait # using a title without quotes" - puts " #{$0} 'Something with punctuation?!' # fancy chars need quotes" - puts " #{$0} working-with-databases # using a slug" - puts " #{$0} # Creates untitled.md (or untitled-2.md, etc.)" - puts - puts "Creates a new draft in public/drafts/ directory with proper frontmatter." -end - -def draft_path(filename) - File.join(DRAFTS_DIR, filename) -end - -def main - if ARGV.include?('-h') || ARGV.include?('--help') - usage - exit 0 - end - - title, filename = - if ARGV.empty? - ['Untitled', next_available_draft] - else - given_title = ARGV.join(' ') - filename = "#{slugify(given_title)}.md" - path = draft_path(filename) - if File.exist?(path) - puts "Error: draft already exists at #{path}" - exit 1 - end - - [given_title, filename] - end - - FileUtils.mkdir_p(DRAFTS_DIR) - path = draft_path(filename) - content = render_template(title) - File.write(path, content) - - puts "Created new draft at #{path}" - puts '>>> Contents below <<<' - puts - puts content -end - -def slugify(title) - title.downcase - .gsub(/[^a-z0-9\s-]/, '') - .gsub(/\s+/, '-') - .gsub(/-+/, '-') - .gsub(/^-|-$/, '') -end - -def next_available_draft(base_filename = 'untitled.md') - return base_filename unless File.exist?(draft_path(base_filename)) - - name_without_ext = File.basename(base_filename, '.md') - counter = 1 - loop do - numbered_filename = "#{name_without_ext}-#{counter}.md" - return numbered_filename unless File.exist?(draft_path(numbered_filename)) - counter += 1 - end -end - -def render_template(title) - now = Time.now - iso_timestamp = now.strftime('%Y-%m-%dT%H:%M:%S%:z') - - <<~FRONTMATTER - --- - Author: #{`whoami`.strip} - Title: #{title} - Date: unpublished - Timestamp: #{iso_timestamp} - Tags: - --- - - # #{title} - - TKTK - FRONTMATTER -end - -main if $0 == __FILE__ diff --git a/bin/pressa b/bin/pressa deleted file mode 100755 index ec72405..0000000 --- a/bin/pressa +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env ruby - -require_relative '../lib/pressa' - -if ARGV.length < 2 - puts "Usage: pressa SOURCE TARGET [URL]" - puts "" - puts "Arguments:" - puts " SOURCE Directory containing posts/ and public/" - puts " TARGET Directory to write generated site" - puts " URL Optional site URL override" - exit 1 -end - -source_path = ARGV[0] -target_path = ARGV[1] -site_url = ARGV[2] - -begin - site = Pressa.create_site(source_path:, url_override: site_url) - generator = Pressa::SiteGenerator.new(site:) - generator.generate(source_path:, target_path:) - puts "Site generated successfully!" -rescue => e - puts "Error: #{e.message}" - puts e.backtrace - exit 1 -end diff --git a/bin/publish b/bin/publish deleted file mode 100755 index af294d3..0000000 --- a/bin/publish +++ /dev/null @@ -1,54 +0,0 @@ -#!/bin/bash - -# exit on errors -set -e - -PUBLISH_HOST="mudge" -PUBLISH_DIR="/var/www/samhuri.net/public" -ECHO=0 -RSYNC_OPTS="" - -BREAK_WHILE=0 -while [[ $# > 0 ]]; do - ARG="$1" - case "$ARG" in - - -b|--beta) - PUBLISH_DIR="/var/www/beta.samhuri.net/public" - shift - ;; - - -t|--test) - ECHO=1 - RSYNC_OPTS="$RSYNC_OPTS --dry-run" - shift - ;; - - -d|--delete) - RSYNC_OPTS="$RSYNC_OPTS --delete" - shift - ;; - - # we're at the paths, no more options - *) - BREAK_WHILE=1 - break - ;; - - esac - - [[ $BREAK_WHILE -eq 1 ]] && break -done - -declare -a CMD -if [[ $# -eq 0 ]]; then - CMD=(rsync -aKv -e "ssh -4" $RSYNC_OPTS www/ $PUBLISH_HOST:$PUBLISH_DIR) -else - CMD=(rsync -aKv -e "ssh -4" $RSYNC_OPTS $@ $PUBLISH_HOST:$PUBLISH_DIR) -fi - -if [[ $ECHO -eq 1 ]]; then - echo "${CMD[@]}" -fi - -"${CMD[@]}" diff --git a/bin/publish-draft b/bin/publish-draft deleted file mode 100755 index b754287..0000000 --- a/bin/publish-draft +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env ruby -w - -require 'fileutils' - -def usage - puts "Usage: #{$0} " - puts - puts "Examples:" - puts " #{$0} public/drafts/reverse-engineering-photo-urls.md" - puts - puts "Available drafts:" - drafts = Dir.glob('public/drafts/*.md').map { |f| File.basename(f) } - if drafts.empty? - puts " (no drafts found)" - else - drafts.each { |d| puts " #{d}" } - end -end - -if ARGV.empty? - usage - abort -end - -input_path = ARGV.first - -# Handle both full paths and just filenames -if input_path.include?('/') - draft_path = input_path - draft_file = File.basename(input_path) - if input_path.start_with?('posts/') - abort "Error: '#{input_path}' is already published in posts/ directory" - end -else - draft_file = input_path - draft_path = "public/drafts/#{draft_file}" -end - -abort "Error: File not found: #{draft_path}" unless File.exist?(draft_path) - -# Update display date timestamp to current time -def ordinal_date(time) - day = time.day - suffix = case day - when 1, 21, 31 then 'st' - when 2, 22 then 'nd' - when 3, 23 then 'rd' - else 'th' - end - time.strftime("#{day}#{suffix} %B, %Y") -end -now = Time.now -iso_timestamp = now.strftime('%Y-%m-%dT%H:%M:%S%:z') -human_date = ordinal_date(now) -content = File.read(draft_path) -content.sub!(/^Date:.*$/, "Date: #{human_date}") -content.sub!(/^Timestamp:.*$/, "Timestamp: #{iso_timestamp}") - -# Use current year/month for directory, pad with strftime -year_month = now.strftime('%Y-%m') -year, month = year_month.split('-') - -target_dir = "posts/#{year}/#{month}" -FileUtils.mkdir_p(target_dir) -target_path = "#{target_dir}/#{draft_file}" - -File.write(target_path, content) -FileUtils.rm_f(draft_path) - -puts "Published draft: #{draft_path} → #{target_path}" diff --git a/bin/watch b/bin/watch deleted file mode 100755 index 32e55a2..0000000 --- a/bin/watch +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -BLOG_TARGET=${BLOG_TARGET:-mudge} - -if ! command -v inotifywait >/dev/null 2>/dev/null; then - echo "inotifywait is required (install inotify-tools)." - exit 1 -fi - -while true; do - inotifywait -e modify,create,delete,move -r public -r posts -r lib - echo "changed at $(date)" - sleep 2 - rbenv exec bundle exec bake "$BLOG_TARGET" -done