diff --git a/pressa/.ruby-version b/pressa/.ruby-version new file mode 100644 index 0000000..47b322c --- /dev/null +++ b/pressa/.ruby-version @@ -0,0 +1 @@ +3.4.1 diff --git a/pressa/Gemfile b/pressa/Gemfile new file mode 100644 index 0000000..8bcdf50 --- /dev/null +++ b/pressa/Gemfile @@ -0,0 +1,18 @@ +source 'https://rubygems.org' + +ruby '~> 3.4.0' + +gem 'phlex', '~> 2.3' +gem 'kramdown', '~> 2.5' +gem 'kramdown-parser-gfm', '~> 1.1' +gem 'rouge', '~> 4.6' +gem 'dry-struct', '~> 1.8' +gem 'builder', '~> 3.3' +gem 'bake', '~> 0.20' + +group :development, :test do + gem 'rspec', '~> 3.13' + gem 'guard', '~> 2.18' + gem 'guard-rspec', '~> 4.7' + gem 'standard', '~> 1.43' +end diff --git a/pressa/Gemfile.lock b/pressa/Gemfile.lock new file mode 100644 index 0000000..2846ec6 --- /dev/null +++ b/pressa/Gemfile.lock @@ -0,0 +1,197 @@ +GEM + remote: https://rubygems.org/ + specs: + ast (2.4.3) + bake (0.24.1) + bigdecimal + samovar (~> 2.1) + bigdecimal (3.3.1) + builder (3.3.0) + coderay (1.1.3) + concurrent-ruby (1.3.5) + console (1.34.2) + fiber-annotation + fiber-local (~> 1.1) + json + diff-lcs (1.6.2) + dry-core (1.1.0) + concurrent-ruby (~> 1.0) + logger + zeitwerk (~> 2.6) + dry-inflector (1.2.0) + dry-logic (1.6.0) + bigdecimal + concurrent-ruby (~> 1.0) + dry-core (~> 1.1) + zeitwerk (~> 2.6) + dry-struct (1.8.0) + dry-core (~> 1.1) + dry-types (~> 1.8, >= 1.8.2) + ice_nine (~> 0.11) + zeitwerk (~> 2.6) + dry-types (1.8.3) + bigdecimal (~> 3.0) + concurrent-ruby (~> 1.0) + dry-core (~> 1.0) + dry-inflector (~> 1.0) + dry-logic (~> 1.4) + zeitwerk (~> 2.6) + ffi (1.17.2) + ffi (1.17.2-aarch64-linux-gnu) + ffi (1.17.2-aarch64-linux-musl) + ffi (1.17.2-arm-linux-gnu) + ffi (1.17.2-arm-linux-musl) + ffi (1.17.2-arm64-darwin) + ffi (1.17.2-x86-linux-gnu) + ffi (1.17.2-x86-linux-musl) + ffi (1.17.2-x86_64-darwin) + ffi (1.17.2-x86_64-linux-gnu) + ffi (1.17.2-x86_64-linux-musl) + fiber-annotation (0.2.0) + fiber-local (1.1.0) + fiber-storage + fiber-storage (1.0.1) + formatador (1.2.3) + reline + guard (2.19.1) + formatador (>= 0.2.4) + listen (>= 2.7, < 4.0) + logger (~> 1.6) + lumberjack (>= 1.0.12, < 2.0) + nenv (~> 0.1) + notiffany (~> 0.0) + ostruct (~> 0.6) + pry (>= 0.13.0) + shellany (~> 0.0) + thor (>= 0.18.1) + guard-compat (1.2.1) + guard-rspec (4.7.3) + guard (~> 2.1) + guard-compat (~> 1.1) + rspec (>= 2.99.0, < 4.0) + ice_nine (0.11.2) + io-console (0.8.1) + json (2.16.0) + kramdown (2.5.1) + rexml (>= 3.3.9) + kramdown-parser-gfm (1.1.0) + kramdown (~> 2.0) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + listen (3.9.0) + rb-fsevent (~> 0.10, >= 0.10.3) + rb-inotify (~> 0.9, >= 0.9.10) + logger (1.7.0) + lumberjack (1.4.2) + mapping (1.1.3) + method_source (1.1.0) + nenv (0.3.0) + notiffany (0.1.3) + nenv (~> 0.1) + shellany (~> 0.0) + ostruct (0.6.3) + parallel (1.27.0) + parser (3.3.10.0) + ast (~> 2.4.1) + racc + phlex (2.3.1) + zeitwerk (~> 2.7) + prism (1.6.0) + pry (0.15.2) + coderay (~> 1.1) + method_source (~> 1.0) + racc (1.8.1) + rainbow (3.1.1) + rb-fsevent (0.11.2) + rb-inotify (0.11.1) + ffi (~> 1.0) + regexp_parser (2.11.3) + reline (0.6.3) + io-console (~> 0.5) + rexml (3.4.4) + rouge (4.6.1) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.7) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.6) + rubocop (1.80.2) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.46.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.48.0) + parser (>= 3.3.7.2) + prism (~> 1.4) + rubocop-performance (1.25.0) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.38.0, < 2.0) + ruby-progressbar (1.13.0) + samovar (2.4.1) + console (~> 1.0) + mapping (~> 1.0) + shellany (0.0.1) + standard (1.51.1) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.80.2) + standard-custom (~> 1.0.0) + standard-performance (~> 1.8) + standard-custom (1.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.50) + standard-performance (1.8.0) + lint_roller (~> 1.1) + rubocop-performance (~> 1.25.0) + thor (1.4.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.1.0) + zeitwerk (2.7.3) + +PLATFORMS + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin + ruby + x86-linux-gnu + x86-linux-musl + x86_64-darwin + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + bake (~> 0.20) + builder (~> 3.3) + dry-struct (~> 1.8) + guard (~> 2.18) + guard-rspec (~> 4.7) + kramdown (~> 2.5) + kramdown-parser-gfm (~> 1.1) + phlex (~> 2.3) + rouge (~> 4.6) + rspec (~> 3.13) + standard (~> 1.43) + +RUBY VERSION + ruby 3.4.1p0 + +BUNDLED WITH + 2.6.2 diff --git a/pressa/README.md b/pressa/README.md new file mode 100644 index 0000000..6363fdb --- /dev/null +++ b/pressa/README.md @@ -0,0 +1,191 @@ +# Pressa + +A Ruby-based static site generator using Phlex for HTML generation. Built to replace the Swift-based generator for samhuri.net. + +## Features + +- **Plugin-based architecture** - Extensible system with PostsPlugin and ProjectsPlugin +- **Hierarchical post organization** - Posts organized by year/month +- **Markdown processing** - Kramdown with Rouge for syntax highlighting +- **Multiple output formats** - Individual posts, homepage, archives, year/month indexes +- **RSS & JSON feeds** - Both feeds with 30 most recent posts +- **Link posts** - Support for posts linking to external URLs +- **Phlex templates** - Type-safe HTML generation +- **dry-struct models** - Immutable data structures + +## Requirements + +- Ruby 3.4.1+ +- Bundler + +## Installation + +```bash +bundle install +``` + +## Usage + +### Build Commands + +```bash +# Development build (localhost:8000) +bundle exec bake debug + +# Start local server +bundle exec bake serve + +# Build for staging +bundle exec bake beta +bundle exec bake publish_beta # build + deploy + +# Build for production +bundle exec bake release +bundle exec bake publish # build + deploy +``` + +### Running Tests + +```bash +# Run all specs +bundle exec bake test + +# Run specs with Guard (auto-run on file changes) +bundle exec bake guard +``` + +### Linting + +```bash +# Check code style +bundle exec bake lint + +# Auto-fix style issues +bundle exec bake lint_fix +``` + +### CLI + +```bash +# Build site +bin/pressa SOURCE TARGET [URL] + +# Example +bin/pressa . www https://samhuri.net +``` + +### Migration Tools + +```bash +# Convert front-matter from custom format to YAML +bin/convert-frontmatter posts/**/*.md + +# Validate output comparison between Swift and Ruby +bin/validate-output www-swift www-ruby +``` + +See [SYNTAX_HIGHLIGHTING.md](SYNTAX_HIGHLIGHTING.md) for details on Rouge syntax highlighting. + +## Project Structure + +``` +pressa/ +├── lib/ +│ ├── pressa.rb # Main entry point +│ ├── site_generator.rb # Orchestrator +│ ├── site.rb # Site model (dry-struct) +│ ├── plugin.rb # Plugin base class +│ ├── posts/ # PostsPlugin +│ │ ├── plugin.rb +│ │ ├── repo.rb # Read/parse posts +│ │ ├── writer.rb # Write HTML +│ │ ├── metadata.rb # YAML front-matter parsing +│ │ ├── models.rb # Post, PostsByYear, etc. +│ │ ├── json_feed.rb +│ │ └── rss_feed.rb +│ ├── projects/ # ProjectsPlugin +│ │ ├── plugin.rb +│ │ └── models.rb +│ ├── views/ # Phlex templates +│ │ ├── layout.rb # Base layout +│ │ ├── post_view.rb +│ │ ├── recent_posts_view.rb +│ │ ├── archive_view.rb +│ │ └── ... +│ └── utils/ +│ ├── file_writer.rb +│ └── markdown_renderer.rb +├── bin/ +│ └── pressa # CLI executable +├── spec/ # RSpec tests +└── bake.rb # Build tasks +``` + +## Content Structure + +### Posts + +Posts must be in `/posts/YYYY/MM/` with YAML front-matter: + +```yaml +--- +Title: Post Title +Author: Author Name +Date: 11th November, 2025 +Timestamp: 2025-11-11T14:00:00-08:00 +Tags: Ruby, Phlex # Optional +Link: https://example.net # Optional (for link posts) +Scripts: highlight.js # Optional +Styles: code.css # Optional +--- + +Post content in Markdown... +``` + +### Output Structure + +``` +www/ +├── index.html # Recent posts (10 most recent) +├── posts/ +│ ├── index.html # Archive page +│ ├── YYYY/ +│ │ ├── index.html # Year index +│ │ └── MM/ +│ │ ├── index.html # Month rollup +│ │ └── slug/ +│ │ └── index.html # Individual post +├── projects/ +│ ├── index.html +│ └── project-name/ +│ └── index.html +├── feed.json # JSON Feed 1.1 +├── feed.xml # RSS 2.0 +└── [static files from public/] +``` + +## Tech Stack + +- **Ruby**: 3.4.1 +- **Phlex**: 2.3 - HTML generation +- **Kramdown**: 2.5 - Markdown parsing +- **kramdown-parser-gfm**: 1.1 - GitHub Flavored Markdown +- **Rouge**: 4.6 - Syntax highlighting +- **dry-struct**: 1.8 - Immutable data models +- **Builder**: 3.3 - XML/RSS generation +- **Bake**: 0.20+ - Task runner +- **RSpec**: 3.13 - Testing +- **StandardRB**: 1.43 - Code linting + +## Differences from Swift Version + +1. **Language**: Ruby 3.4 vs Swift 6.1 +2. **HTML generation**: Phlex vs Plot +3. **Markdown**: Kramdown+Rouge vs Ink +4. **Models**: dry-struct vs Swift structs +5. **Build system**: Bake vs Make +6. **Front-matter**: YAML vs custom format + +## License + +Personal project - not currently licensed for general use. diff --git a/pressa/SYNTAX_HIGHLIGHTING.md b/pressa/SYNTAX_HIGHLIGHTING.md new file mode 100644 index 0000000..d505591 --- /dev/null +++ b/pressa/SYNTAX_HIGHLIGHTING.md @@ -0,0 +1,141 @@ +# Syntax Highlighting with Rouge + +Pressa uses Rouge for syntax highlighting through Kramdown. Your posts should already work correctly with Rouge if they use standard Markdown code fences. + +## Supported Formats + +### GitHub Flavored Markdown (Recommended) + +````markdown +```ruby +def hello + puts "Hello, World!" +end +``` +```` + +### Kramdown Syntax + +````markdown +~~~ ruby +def hello + puts "Hello, World!" +end +~~~ +```` + +### With Line Numbers (if needed) + +You can enable line numbers in the Kramdown configuration, but by default Pressa has them disabled for cleaner output. + +## Supported Languages + +Rouge supports 200+ languages. Common ones include: + +- `ruby` +- `javascript` / `js` +- `python` / `py` +- `swift` +- `bash` / `shell` +- `html` +- `css` +- `sql` +- `yaml` / `yml` +- `json` +- `markdown` / `md` + +Full list: https://github.com/rouge-ruby/rouge/wiki/List-of-supported-languages-and-lexers + +## CSS Styling + +Rouge generates syntax highlighting by wrapping code elements with `` tags that have semantic class names. + +Example output: +```html +
+ class Post + end +
+``` + +### CSS Classes + +Common classes used by Rouge: + +- `.k` - Keyword +- `.nc` - Class name +- `.nf` - Function name +- `.s`, `.s1`, `.s2` - Strings +- `.c`, `.c1` - Comments +- `.n` - Name/identifier +- `.o` - Operator +- `.p` - Punctuation + +### Generating CSS + +You can generate Rouge CSS themes with: + +```bash +# List available themes +bundle exec rougify help style + +# Generate CSS for a theme +bundle exec rougify style github > public/css/syntax.css +bundle exec rougify style monokai > public/css/syntax-dark.css +``` + +Popular themes: +- `github` - GitHub's light theme +- `monokai` - Dark theme +- `base16` - Base16 color scheme +- `thankful_eyes` - Easy on the eyes +- `tulip` - Colorful + +## Checking Your Posts + +Your existing posts should work fine if they use: +1. Standard Markdown code fences with language specifiers +2. HTML `
` blocks (will work but won't be highlighted)
+
+To check a specific post:
+
+```bash
+grep -A 5 '```' posts/2025/11/your-post.md
+```
+
+## Configuration in Pressa
+
+Syntax highlighting is configured in `lib/posts/repo.rb` and `lib/utils/markdown_renderer.rb`:
+
+```ruby
+Kramdown::Document.new(
+  markdown,
+  input: 'GFM',
+  syntax_highlighter: 'rouge',
+  syntax_highlighter_opts: {
+    line_numbers: false,  # Change to true if you want line numbers
+    wrap: false           # Change to true for 
wrapping + } +).to_html +``` + +## Testing + +You can test syntax highlighting with the provided test post: + +```bash +bundle exec bake debug +bundle exec bake serve +# Open http://localhost:8000 in browser +``` + +The test post at `test-site/posts/2025/11/test-post.md` includes a Ruby code example with syntax highlighting. + +## Migration Notes + +If you're migrating from Swift/Ink, both use similar Markdown parsers, so your code blocks should "just work." The main difference is: + +- **Ink**: Built-in syntax highlighting (uses its own system) +- **Rouge**: External gem, more themes, more languages, generates semantic HTML + +Rouge output is more flexible because it generates plain HTML with classes, allowing you to change themes by just swapping CSS files. diff --git a/pressa/bake.rb b/pressa/bake.rb new file mode 100644 index 0000000..ce4a3a0 --- /dev/null +++ b/pressa/bake.rb @@ -0,0 +1,92 @@ +# Build tasks for Pressa static site generator + +# Generate the site in debug mode (localhost:8000) +def debug + build('http://localhost:8000') +end + +# Generate the site for the mudge development server +def mudge + build('http://mudge:8000') +end + +# Generate the site for beta/staging +def beta + build('https://beta.samhuri.net') +end + +# Generate the site for production +def release + build('https://samhuri.net') +end + +# Start local development server +def serve + require 'webrick' + server = WEBrick::HTTPServer.new(Port: 8000, DocumentRoot: 'www') + trap('INT') { server.shutdown } + puts "Server running at http://localhost:8000" + server.start +end + +# Publish to beta/staging server +def publish_beta + beta + puts "Deploying to beta server..." + system('rsync -avz --delete www/ beta.samhuri.net:/var/www/beta/') +end + +# Publish to production server +def publish + release + puts "Deploying to production server..." + system('rsync -avz --delete www/ samhuri.net:/var/www/html/') +end + +# Clean generated files +def clean + require 'fileutils' + FileUtils.rm_rf('www') + puts "Cleaned www/ directory" +end + +# Run RSpec tests +def test + exec 'bundle exec rspec' +end + +# Run Guard for continuous testing +def guard + exec 'bundle exec guard' +end + +# List all available drafts +def drafts + Dir.glob('drafts/*.md').sort.each do |draft| + puts File.basename(draft) + end +end + +# Run StandardRB linter +def lint + exec 'bundle exec standardrb' +end + +# Auto-fix StandardRB issues +def lint_fix + exec 'bundle exec standardrb --fix' +end + +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(url_override: url) + generator = Pressa::SiteGenerator.new(site:) + generator.generate(source_path: '.', target_path: 'www') + puts "Site built successfully in www/" +end diff --git a/pressa/bin/convert-frontmatter b/pressa/bin/convert-frontmatter new file mode 100755 index 0000000..9b95e91 --- /dev/null +++ b/pressa/bin/convert-frontmatter @@ -0,0 +1,38 @@ +#!/usr/bin/env ruby + +require_relative '../lib/utils/frontmatter_converter' + +if ARGV.empty? + puts "Usage: convert-frontmatter FILE [FILE...]" + puts "" + puts "Converts front-matter from custom format to YAML in-place." + puts "" + puts "Examples:" + puts " convert-frontmatter posts/2025/11/*.md" + puts " convert-frontmatter posts/**/*.md" + exit 1 +end + +converted_count = 0 +error_count = 0 + +ARGV.each do |file_path| + unless File.exist?(file_path) + puts "ERROR: File not found: #{file_path}" + error_count += 1 + next + end + + begin + Pressa::Utils::FrontmatterConverter.convert_file(file_path) + puts "✓ #{file_path}" + converted_count += 1 + rescue => e + puts "ERROR: #{file_path}: #{e.message}" + error_count += 1 + end +end + +puts "" +puts "Converted: #{converted_count}" +puts "Errors: #{error_count}" if error_count > 0 diff --git a/pressa/bin/pressa b/pressa/bin/pressa new file mode 100755 index 0000000..e779045 --- /dev/null +++ b/pressa/bin/pressa @@ -0,0 +1,28 @@ +#!/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(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/pressa/bin/validate-output b/pressa/bin/validate-output new file mode 100755 index 0000000..fb8c913 --- /dev/null +++ b/pressa/bin/validate-output @@ -0,0 +1,151 @@ +#!/usr/bin/env ruby + +require 'fileutils' +require 'digest' + +class OutputValidator + def initialize(swift_dir:, ruby_dir:) + @swift_dir = swift_dir + @ruby_dir = ruby_dir + @differences = [] + @missing_in_ruby = [] + @missing_in_swift = [] + @identical_count = 0 + end + + def validate + puts "Comparing outputs:" + puts " Swift: #{@swift_dir}" + puts " Ruby: #{@ruby_dir}" + puts "" + + swift_files = find_html_files(@swift_dir) + ruby_files = find_html_files(@ruby_dir) + + puts "Found #{swift_files.length} Swift HTML files" + puts "Found #{ruby_files.length} Ruby HTML files" + puts "" + + compare_files(swift_files, ruby_files) + print_summary + end + + private + + def find_html_files(dir) + Dir.glob(File.join(dir, '**', '*.{html,xml,json}')) + .map { |f| f.sub("#{dir}/", '') } + .sort + end + + def compare_files(swift_files, ruby_files) + all_files = (swift_files + ruby_files).uniq.sort + + all_files.each do |relative_path| + swift_path = File.join(@swift_dir, relative_path) + ruby_path = File.join(@ruby_dir, relative_path) + + if !File.exist?(swift_path) + @missing_in_swift << relative_path + elsif !File.exist?(ruby_path) + @missing_in_ruby << relative_path + else + compare_file_contents(relative_path, swift_path, ruby_path) + end + end + end + + def compare_file_contents(relative_path, swift_path, ruby_path) + swift_content = normalize_html(File.read(swift_path)) + ruby_content = normalize_html(File.read(ruby_path)) + + if swift_content == ruby_content + @identical_count += 1 + puts "✓ #{relative_path}" + else + @differences << { + path: relative_path, + swift_hash: Digest::SHA256.hexdigest(swift_content), + ruby_hash: Digest::SHA256.hexdigest(ruby_content), + swift_size: swift_content.length, + ruby_size: ruby_content.length + } + puts "✗ #{relative_path} (differs)" + end + end + + def normalize_html(content) + content + .gsub(/\s+/, ' ') + .gsub(/>\s+<') + .gsub(/.*?<\/lastBuildDate>/, '') + .strip + end + + def print_summary + puts "" + puts "=" * 60 + puts "VALIDATION SUMMARY" + puts "=" * 60 + puts "" + puts "Identical files: #{@identical_count}" + puts "Different files: #{@differences.length}" + puts "Missing in Ruby: #{@missing_in_ruby.length}" + puts "Missing in Swift: #{@missing_in_swift.length}" + puts "" + + if @differences.any? + puts "Files with differences:" + @differences.each do |diff| + puts " #{diff[:path]}" + puts " Swift: #{diff[:swift_size]} bytes (#{diff[:swift_hash][0..7]}...)" + puts " Ruby: #{diff[:ruby_size]} bytes (#{diff[:ruby_hash][0..7]}...)" + end + puts "" + end + + if @missing_in_ruby.any? + puts "Missing in Ruby output:" + @missing_in_ruby.each { |path| puts " #{path}" } + puts "" + end + + if @missing_in_swift.any? + puts "Missing in Swift output:" + @missing_in_swift.each { |path| puts " #{path}" } + puts "" + end + + success = @differences.empty? && @missing_in_ruby.empty? && @missing_in_swift.empty? + puts success ? "✅ VALIDATION PASSED" : "❌ VALIDATION FAILED" + puts "" + + exit(success ? 0 : 1) + end +end + +if ARGV.length != 2 + puts "Usage: validate-output SWIFT_DIR RUBY_DIR" + puts "" + puts "Compares HTML/XML/JSON output from Swift and Ruby generators." + puts "" + puts "Example:" + puts " validate-output www-swift www-ruby" + exit 1 +end + +swift_dir = ARGV[0] +ruby_dir = ARGV[1] + +unless Dir.exist?(swift_dir) + puts "ERROR: Swift directory not found: #{swift_dir}" + exit 1 +end + +unless Dir.exist?(ruby_dir) + puts "ERROR: Ruby directory not found: #{ruby_dir}" + exit 1 +end + +validator = OutputValidator.new(swift_dir: swift_dir, ruby_dir: ruby_dir) +validator.validate diff --git a/pressa/lib/plugin.rb b/pressa/lib/plugin.rb new file mode 100644 index 0000000..4bd5d83 --- /dev/null +++ b/pressa/lib/plugin.rb @@ -0,0 +1,11 @@ +module Pressa + class Plugin + def setup(site:, source_path:) + raise NotImplementedError, "#{self.class}#setup must be implemented" + end + + def render(site:, target_path:) + raise NotImplementedError, "#{self.class}#render must be implemented" + end + end +end diff --git a/pressa/lib/posts/json_feed.rb b/pressa/lib/posts/json_feed.rb new file mode 100644 index 0000000..303b3d2 --- /dev/null +++ b/pressa/lib/posts/json_feed.rb @@ -0,0 +1,60 @@ +require 'json' +require_relative '../utils/file_writer' + +module Pressa + module Posts + class JSONFeedWriter + FEED_VERSION = "https://jsonfeed.org/version/1.1" + + def initialize(site:, posts_by_year:) + @site = site + @posts_by_year = posts_by_year + end + + def write_feed(target_path:, limit: 30) + recent = @posts_by_year.recent_posts(limit) + + feed = { + version: FEED_VERSION, + title: @site.title, + home_page_url: @site.url, + feed_url: @site.url_for('/feed.json'), + description: @site.description, + authors: [ + { + name: @site.author, + url: @site.url + } + ], + items: recent.map { |post| feed_item(post) } + } + + json = JSON.pretty_generate(feed) + file_path = File.join(target_path, 'feed.json') + Utils::FileWriter.write(path: file_path, content: json) + end + + private + + def feed_item(post) + item = { + id: @site.url_for(post.path), + url: post.link_post? ? post.link : @site.url_for(post.path), + title: post.link_post? ? "→ #{post.title}" : post.title, + content_html: post.body, + summary: post.excerpt, + date_published: post.date.iso8601, + authors: [ + { + name: post.author + } + ] + } + + item[:tags] = post.tags unless post.tags.empty? + + item + end + end + end +end diff --git a/pressa/lib/posts/metadata.rb b/pressa/lib/posts/metadata.rb new file mode 100644 index 0000000..373bd58 --- /dev/null +++ b/pressa/lib/posts/metadata.rb @@ -0,0 +1,62 @@ +require 'yaml' +require 'date' + +module Pressa + module Posts + class PostMetadata + REQUIRED_FIELDS = %w[Title Author Date Timestamp].freeze + + attr_reader :title, :author, :date, :formatted_date, :link, :tags, :scripts, :styles + + def initialize(yaml_hash) + @raw = yaml_hash + validate_required_fields! + parse_fields + end + + def self.parse(content) + if content =~ /\A---\s*\n(.*?)\n---\s*\n/m + yaml_content = $1 + yaml_hash = YAML.safe_load(yaml_content, permitted_classes: [Date, Time]) + new(yaml_hash) + else + raise "No YAML front-matter found in post" + end + end + + private + + def validate_required_fields! + missing = REQUIRED_FIELDS.reject { |field| @raw.key?(field) } + raise "Missing required fields: #{missing.join(', ')}" unless missing.empty? + end + + def parse_fields + @title = @raw['Title'] + @author = @raw['Author'] + timestamp = @raw['Timestamp'] + @date = timestamp.is_a?(String) ? DateTime.parse(timestamp) : timestamp.to_datetime + @formatted_date = @raw['Date'] + @link = @raw['Link'] + @tags = parse_comma_separated(@raw['Tags']) + @scripts = parse_scripts(@raw['Scripts']) + @styles = parse_styles(@raw['Styles']) + end + + def parse_comma_separated(value) + return [] if value.nil? || value.empty? + value.split(',').map(&:strip) + end + + def parse_scripts(value) + return [] if value.nil? + parse_comma_separated(value).map { |src| Script.new(src:, defer: true) } + end + + def parse_styles(value) + return [] if value.nil? + parse_comma_separated(value).map { |href| Stylesheet.new(href:) } + end + end + end +end diff --git a/pressa/lib/posts/models.rb b/pressa/lib/posts/models.rb new file mode 100644 index 0000000..aecf0cc --- /dev/null +++ b/pressa/lib/posts/models.rb @@ -0,0 +1,93 @@ +require 'dry-struct' +require_relative '../site' + +module Pressa + module Posts + class Post < Dry::Struct + attribute :slug, Types::String + attribute :title, Types::String + attribute :author, Types::String + attribute :date, Types::Params::DateTime + attribute :formatted_date, Types::String + attribute :link, Types::String.optional.default(nil) + attribute :tags, Types::Array.of(Types::String).default([].freeze) + attribute :scripts, Types::Array.of(Script).default([].freeze) + attribute :styles, Types::Array.of(Stylesheet).default([].freeze) + attribute :body, Types::String + attribute :excerpt, Types::String + attribute :path, Types::String + + def link_post? + !link.nil? + end + + def year + date.year + end + + def month + date.month + end + + def formatted_month + date.strftime('%B') + end + + def padded_month + format('%02d', month) + end + end + + class Month < Dry::Struct + attribute :name, Types::String + attribute :number, Types::Integer + attribute :padded, Types::String + + def self.from_date(date) + new( + name: date.strftime('%B'), + number: date.month, + padded: format('%02d', date.month) + ) + end + end + + class MonthPosts < Dry::Struct + attribute :month, Month + attribute :posts, Types::Array.of(Post) + + def sorted_posts + posts.sort_by(&:date).reverse + end + end + + class YearPosts < Dry::Struct + attribute :year, Types::Integer + attribute :by_month, Types::Hash.map(Types::Integer, MonthPosts) + + def sorted_months + by_month.keys.sort.reverse.map { |month_num| by_month[month_num] } + end + + def all_posts + by_month.values.flat_map(&:posts).sort_by(&:date).reverse + end + end + + class PostsByYear < Dry::Struct + attribute :by_year, Types::Hash.map(Types::Integer, YearPosts) + + def sorted_years + by_year.keys.sort.reverse + end + + def all_posts + by_year.values.flat_map(&:all_posts).sort_by(&:date).reverse + end + + def recent_posts(limit = 10) + all_posts.take(limit) + end + end + end +end diff --git a/pressa/lib/posts/plugin.rb b/pressa/lib/posts/plugin.rb new file mode 100644 index 0000000..e5ba904 --- /dev/null +++ b/pressa/lib/posts/plugin.rb @@ -0,0 +1,38 @@ +require_relative '../plugin' +require_relative 'repo' +require_relative 'writer' +require_relative 'json_feed' +require_relative 'rss_feed' + +module Pressa + module Posts + class Plugin < Pressa::Plugin + attr_reader :posts_by_year + + def setup(site:, source_path:) + posts_dir = File.join(source_path, 'posts') + return unless Dir.exist?(posts_dir) + + repo = PostRepo.new + @posts_by_year = repo.read_posts(posts_dir) + end + + def render(site:, target_path:) + return unless @posts_by_year + + writer = PostWriter.new(site:, posts_by_year: @posts_by_year) + writer.write_posts(target_path:) + writer.write_recent_posts(target_path:, limit: 10) + writer.write_archive(target_path:) + writer.write_year_indexes(target_path:) + writer.write_month_rollups(target_path:) + + json_feed = JSONFeedWriter.new(site:, posts_by_year: @posts_by_year) + json_feed.write_feed(target_path:, limit: 30) + + rss_feed = RSSFeedWriter.new(site:, posts_by_year: @posts_by_year) + rss_feed.write_feed(target_path:, limit: 30) + end + end + end +end diff --git a/pressa/lib/posts/repo.rb b/pressa/lib/posts/repo.rb new file mode 100644 index 0000000..a1cf4c3 --- /dev/null +++ b/pressa/lib/posts/repo.rb @@ -0,0 +1,123 @@ +require 'kramdown' +require_relative 'models' +require_relative 'metadata' + +module Pressa + module Posts + class PostRepo + EXCERPT_LENGTH = 300 + + def initialize(output_path: 'posts') + @output_path = output_path + @posts_by_year = {} + end + + def read_posts(posts_dir) + enumerate_markdown_files(posts_dir) do |file_path| + post = read_post(file_path) + add_post_to_hierarchy(post) + end + + PostsByYear.new(by_year: @posts_by_year) + end + + private + + def enumerate_markdown_files(dir, &block) + Dir.glob(File.join(dir, '**', '*.md')).each(&block) + end + + def read_post(file_path) + content = File.read(file_path) + metadata = PostMetadata.parse(content) + + body_markdown = content.sub(/\A---\s*\n.*?\n---\s*\n/m, '') + + html_body = render_markdown(body_markdown) + + slug = File.basename(file_path, '.md') + path = generate_path(slug, metadata.date) + excerpt = generate_excerpt(body_markdown) + + Post.new( + slug:, + title: metadata.title, + author: metadata.author, + date: metadata.date, + formatted_date: metadata.formatted_date, + link: metadata.link, + tags: metadata.tags, + scripts: metadata.scripts, + styles: metadata.styles, + body: html_body, + excerpt:, + path: + ) + end + + def render_markdown(markdown) + Kramdown::Document.new( + markdown, + input: 'GFM', + syntax_highlighter: 'rouge', + syntax_highlighter_opts: { + line_numbers: false, + wrap: false + } + ).to_html + end + + def generate_path(slug, date) + year = date.year + month = format('%02d', date.month) + "/#{@output_path}/#{year}/#{month}/#{slug}" + end + + def generate_excerpt(markdown) + text = markdown.dup + + text.gsub!(/!\[.*?\]\([^)]+\)/, '') + + text.gsub!(/\[(.*?)\]\([^)]+\)/, '\1') + + text.gsub!(/<[^>]+>/, '') + + text.gsub!(/\s+/, ' ') + text.strip! + + if text.length > EXCERPT_LENGTH + "#{text[0...EXCERPT_LENGTH]}..." + else + text + end + end + + def add_post_to_hierarchy(post) + year = post.year + month_num = post.month + + @posts_by_year[year] ||= create_year_posts(year) + year_posts = @posts_by_year[year] + + month_posts = year_posts.by_month[month_num] + if month_posts + updated_posts = month_posts.posts + [post] + year_posts.by_month[month_num] = MonthPosts.new( + month: month_posts.month, + posts: updated_posts + ) + else + month = Month.from_date(post.date) + year_posts.by_month[month_num] = MonthPosts.new( + month:, + posts: [post] + ) + end + end + + def create_year_posts(year) + YearPosts.new(year:, by_month: {}) + end + end + end +end diff --git a/pressa/lib/posts/rss_feed.rb b/pressa/lib/posts/rss_feed.rb new file mode 100644 index 0000000..c723903 --- /dev/null +++ b/pressa/lib/posts/rss_feed.rb @@ -0,0 +1,51 @@ +require 'builder' +require_relative '../utils/file_writer' + +module Pressa + module Posts + class RSSFeedWriter + def initialize(site:, posts_by_year:) + @site = site + @posts_by_year = posts_by_year + end + + def write_feed(target_path:, limit: 30) + recent = @posts_by_year.recent_posts(limit) + + xml = Builder::XmlMarkup.new(indent: 2) + xml.instruct! :xml, version: "1.0", encoding: "UTF-8" + + xml.rss version: "2.0", "xmlns:atom" => "http://www.w3.org/2005/Atom" do + xml.channel do + xml.title @site.title + xml.link @site.url + xml.description @site.description + xml.language "en-us" + xml.pubDate recent.first.date.rfc822 if recent.any? + xml.lastBuildDate Time.now.rfc822 + xml.tag! "atom:link", href: @site.url_for('/feed.xml'), rel: "self", type: "application/rss+xml" + + recent.each do |post| + xml.item do + title = post.link_post? ? "→ #{post.title}" : post.title + xml.title title + xml.link post.link_post? ? post.link : @site.url_for(post.path) + xml.guid @site.url_for(post.path), isPermaLink: "true" + xml.description { xml.cdata! post.body } + xml.pubDate post.date.rfc822 + xml.author "#{@site.email} (#{@site.author})" + + post.tags.each do |tag| + xml.category tag + end + end + end + end + end + + file_path = File.join(target_path, 'feed.xml') + Utils::FileWriter.write(path: file_path, content: xml.target!) + end + end + end +end diff --git a/pressa/lib/posts/writer.rb b/pressa/lib/posts/writer.rb new file mode 100644 index 0000000..800887c --- /dev/null +++ b/pressa/lib/posts/writer.rb @@ -0,0 +1,128 @@ +require_relative '../utils/file_writer' +require_relative '../views/layout' +require_relative '../views/post_view' +require_relative '../views/recent_posts_view' +require_relative '../views/archive_view' +require_relative '../views/year_posts_view' +require_relative '../views/month_posts_view' + +module Pressa + module Posts + class PostWriter + def initialize(site:, posts_by_year:) + @site = site + @posts_by_year = posts_by_year + end + + def write_posts(target_path:) + @posts_by_year.all_posts.each do |post| + write_post(post:, target_path:) + end + end + + def write_recent_posts(target_path:, limit: 10) + recent = @posts_by_year.recent_posts(limit) + content_view = Views::RecentPostsView.new(posts: recent, site: @site) + + html = render_layout( + page_title: @site.title, + canonical_url: @site.url, + content: content_view + ) + + file_path = File.join(target_path, 'index.html') + Utils::FileWriter.write(path: file_path, content: html) + end + + def write_archive(target_path:) + content_view = Views::ArchiveView.new(posts_by_year: @posts_by_year, site: @site) + + html = render_layout( + page_title: "Archive – #{@site.title}", + canonical_url: @site.url_for('/posts/'), + content: content_view + ) + + file_path = File.join(target_path, 'posts', 'index.html') + Utils::FileWriter.write(path: file_path, content: html) + end + + def write_year_indexes(target_path:) + @posts_by_year.sorted_years.each do |year| + year_posts = @posts_by_year.by_year[year] + write_year_index(year:, year_posts:, target_path:) + end + end + + def write_month_rollups(target_path:) + @posts_by_year.by_year.each do |year, year_posts| + year_posts.by_month.each do |_month_num, month_posts| + write_month_rollup(year:, month_posts:, target_path:) + end + end + end + + private + + def write_post(post:, target_path:) + content_view = Views::PostView.new(post:, site: @site) + + all_scripts = @site.scripts + post.scripts + all_styles = @site.styles + post.styles + + html = render_layout( + page_title: "#{post.title} – #{@site.title}", + canonical_url: @site.url_for(post.path), + content: content_view, + page_scripts: post.scripts, + page_styles: post.styles + ) + + file_path = File.join(target_path, post.path.sub(/^\//, ''), 'index.html') + Utils::FileWriter.write(path: file_path, content: html) + end + + def write_year_index(year:, year_posts:, target_path:) + content_view = Views::YearPostsView.new(year:, year_posts:, site: @site) + + html = render_layout( + page_title: "#{year} – #{@site.title}", + canonical_url: @site.url_for("/posts/#{year}/"), + content: content_view + ) + + file_path = File.join(target_path, 'posts', year.to_s, 'index.html') + Utils::FileWriter.write(path: file_path, content: html) + end + + def write_month_rollup(year:, month_posts:, target_path:) + month = month_posts.month + content_view = Views::MonthPostsView.new(year:, month_posts:, site: @site) + + title = "#{month.name} #{year}" + html = render_layout( + page_title: "#{title} – #{@site.title}", + canonical_url: @site.url_for("/posts/#{year}/#{month.padded}/"), + content: content_view + ) + + file_path = File.join(target_path, 'posts', year.to_s, month.padded, 'index.html') + Utils::FileWriter.write(path: file_path, content: html) + end + + def render_layout(page_title:, canonical_url:, content:, page_scripts: [], page_styles: []) + layout = Views::Layout.new( + site: @site, + page_title:, + canonical_url:, + page_scripts:, + page_styles: + ) + + layout.call do + content.call + end + end + end + end +end diff --git a/pressa/lib/pressa.rb b/pressa/lib/pressa.rb new file mode 100644 index 0000000..39d9a12 --- /dev/null +++ b/pressa/lib/pressa.rb @@ -0,0 +1,59 @@ +require_relative 'site' +require_relative 'site_generator' +require_relative 'plugin' +require_relative 'posts/plugin' +require_relative 'projects/plugin' +require_relative 'utils/markdown_renderer' + +module Pressa + def self.create_site(url_override: nil) + url = url_override || 'https://samhuri.net' + + projects = [ + Projects::Project.new( + name: 'bin', + title: 'bin', + description: 'my collection of scripts in ~/bin', + url: 'https://github.com/samsonjs/bin' + ), + Projects::Project.new( + name: 'config', + title: 'config', + description: 'important dot files', + url: 'https://github.com/samsonjs/config' + ), + Projects::Project.new( + name: 'unix-node', + title: 'unix-node', + description: 'Node.js CommonJS module that exports useful Unix commands', + url: 'https://github.com/samsonjs/unix-node' + ), + Projects::Project.new( + name: 'strftime', + title: 'strftime', + description: 'strftime for JavaScript', + url: 'https://github.com/samsonjs/strftime' + ) + ] + + Site.new( + author: 'Sami Samhuri', + email: 'sami@samhuri.net', + title: 'samhuri.net', + description: 'The personal blog of Sami Samhuri', + url:, + image_url: "#{url}/images", + scripts: [], + styles: [ + Stylesheet.new(href: 'css/style.css') + ], + plugins: [ + Posts::Plugin.new, + Projects::Plugin.new(projects:) + ], + renderers: [ + Utils::MarkdownRenderer.new + ] + ) + end +end diff --git a/pressa/lib/projects/models.rb b/pressa/lib/projects/models.rb new file mode 100644 index 0000000..75f7fb1 --- /dev/null +++ b/pressa/lib/projects/models.rb @@ -0,0 +1,22 @@ +require 'dry-struct' +require_relative '../site' + +module Pressa + module Projects + class Project < Dry::Struct + attribute :name, Types::String + attribute :title, Types::String + attribute :description, Types::String + attribute :url, Types::String + + def github_path + uri = URI.parse(url) + uri.path.sub(/^\//, '') + end + + def path + "/projects/#{name}" + end + end + end +end diff --git a/pressa/lib/projects/plugin.rb b/pressa/lib/projects/plugin.rb new file mode 100644 index 0000000..3f487bc --- /dev/null +++ b/pressa/lib/projects/plugin.rb @@ -0,0 +1,69 @@ +require_relative '../plugin' +require_relative '../utils/file_writer' +require_relative '../views/layout' +require_relative '../views/projects_view' +require_relative '../views/project_view' +require_relative 'models' + +module Pressa + module Projects + class Plugin < Pressa::Plugin + def initialize(projects: []) + @projects = projects + end + + def setup(site:, source_path:) + end + + def render(site:, target_path:) + write_projects_index(site:, target_path:) + + @projects.each do |project| + write_project_page(project:, site:, target_path:) + end + end + + private + + def write_projects_index(site:, target_path:) + content_view = Views::ProjectsView.new(projects: @projects, site:) + + html = render_layout( + site:, + page_title: "Projects – #{site.title}", + canonical_url: site.url_for('/projects/'), + content: content_view + ) + + file_path = File.join(target_path, 'projects', 'index.html') + Utils::FileWriter.write(path: file_path, content: html) + end + + def write_project_page(project:, site:, target_path:) + content_view = Views::ProjectView.new(project:, site:) + + html = render_layout( + site:, + page_title: "#{project.title} – #{site.title}", + canonical_url: site.url_for(project.path), + content: content_view + ) + + file_path = File.join(target_path, 'projects', project.name, 'index.html') + Utils::FileWriter.write(path: file_path, content: html) + end + + def render_layout(site:, page_title:, canonical_url:, content:) + layout = Views::Layout.new( + site:, + page_title:, + canonical_url: + ) + + layout.call do + content.call + end + end + end + end +end diff --git a/pressa/lib/site.rb b/pressa/lib/site.rb new file mode 100644 index 0000000..03a4726 --- /dev/null +++ b/pressa/lib/site.rb @@ -0,0 +1,38 @@ +require 'dry-struct' + +module Pressa + module Types + include Dry.Types() + end + + class Script < Dry::Struct + attribute :src, Types::String + attribute :defer, Types::Bool.default(true) + end + + class Stylesheet < Dry::Struct + attribute :href, Types::String + end + + class Site < Dry::Struct + attribute :author, Types::String + attribute :email, Types::String + attribute :title, Types::String + attribute :description, Types::String + attribute :url, Types::String + attribute :image_url, Types::String.optional.default(nil) + attribute :scripts, Types::Array.of(Script).default([].freeze) + attribute :styles, Types::Array.of(Stylesheet).default([].freeze) + attribute :plugins, Types::Array.default([].freeze) + attribute :renderers, Types::Array.default([].freeze) + + def url_for(path) + "#{url}#{path}" + end + + def image_url_for(path) + return nil unless image_url + "#{image_url}#{path}" + end + end +end diff --git a/pressa/lib/site_generator.rb b/pressa/lib/site_generator.rb new file mode 100644 index 0000000..309c2dc --- /dev/null +++ b/pressa/lib/site_generator.rb @@ -0,0 +1,63 @@ +require 'fileutils' +require_relative 'utils/file_writer' + +module Pressa + class SiteGenerator + attr_reader :site + + def initialize(site:) + @site = site + end + + def generate(source_path:, target_path:) + FileUtils.rm_rf(target_path) + FileUtils.mkdir_p(target_path) + + site.plugins.each { |plugin| plugin.setup(site:, source_path:) } + + site.plugins.each { |plugin| plugin.render(site:, target_path:) } + + copy_static_files(source_path, target_path) + process_public_directory(source_path, target_path) + end + + private + + def copy_static_files(source_path, target_path) + public_dir = File.join(source_path, 'public') + return unless Dir.exist?(public_dir) + + Dir.glob(File.join(public_dir, '**', '*'), File::FNM_DOTMATCH).each do |source_file| + next if File.directory?(source_file) + next if File.basename(source_file) == '.' || File.basename(source_file) == '..' + + relative_path = source_file.sub("#{public_dir}/", '') + target_file = File.join(target_path, relative_path) + + FileUtils.mkdir_p(File.dirname(target_file)) + FileUtils.cp(source_file, target_file) + end + end + + def process_public_directory(source_path, target_path) + public_dir = File.join(source_path, 'public') + return unless Dir.exist?(public_dir) + + site.renderers.each do |renderer| + Dir.glob(File.join(public_dir, '**', '*'), File::FNM_DOTMATCH).each do |source_file| + next if File.directory?(source_file) + + filename = File.basename(source_file) + ext = File.extname(source_file)[1..] + + if renderer.can_render_file?(filename:, extension: ext) + relative_path = File.dirname(source_file).sub("#{public_dir}/", '') + target_dir = File.join(target_path, relative_path) + + renderer.render(site:, file_path: source_file, target_dir:) + end + end + end + end + end +end diff --git a/pressa/lib/utils/file_writer.rb b/pressa/lib/utils/file_writer.rb new file mode 100644 index 0000000..1c2809b --- /dev/null +++ b/pressa/lib/utils/file_writer.rb @@ -0,0 +1,21 @@ +require 'fileutils' + +module Pressa + module Utils + class FileWriter + def self.write(path:, content:, permissions: 0o644) + FileUtils.mkdir_p(File.dirname(path)) + + File.write(path, content, mode: 'w') + File.chmod(permissions, path) + end + + def self.write_data(path:, data:, permissions: 0o644) + FileUtils.mkdir_p(File.dirname(path)) + + File.binwrite(path, data) + File.chmod(permissions, path) + end + end + end +end diff --git a/pressa/lib/utils/frontmatter_converter.rb b/pressa/lib/utils/frontmatter_converter.rb new file mode 100644 index 0000000..65cad2b --- /dev/null +++ b/pressa/lib/utils/frontmatter_converter.rb @@ -0,0 +1,71 @@ +module Pressa + module Utils + class FrontmatterConverter + FIELD_PATTERN = /^([A-Z][a-z]+):\s*(.+)$/ + + def self.convert_file(input_path, output_path = nil) + content = File.read(input_path) + converted = convert_content(content) + + if output_path + File.write(output_path, converted) + else + File.write(input_path, converted) + end + end + + def self.convert_content(content) + unless content.start_with?("---\n") + raise "File does not start with front-matter delimiter" + end + + parts = content.split(/^---\n/, 3) + if parts.length < 3 + raise "Could not find end of front-matter" + end + + frontmatter = parts[1] + body = parts[2] + + yaml_frontmatter = convert_frontmatter_to_yaml(frontmatter) + + "---\n#{yaml_frontmatter}---\n#{body}" + end + + def self.convert_frontmatter_to_yaml(frontmatter) + fields = {} + + frontmatter.each_line do |line| + line = line.strip + next if line.empty? + + if line =~ FIELD_PATTERN + field_name = $1 + field_value = $2.strip + + fields[field_name] = field_value + end + end + + yaml_lines = [] + fields.each do |name, value| + yaml_lines << format_yaml_field(name, value) + end + + yaml_lines.join("\n") + "\n" + end + + private_class_method def self.format_yaml_field(name, value) + needs_quoting = (value.include?(':') && !value.start_with?('http')) || + value.include?(',') || + name == 'Date' + + if needs_quoting + "#{name}: \"#{value}\"" + else + "#{name}: #{value}" + end + end + end + end +end diff --git a/pressa/lib/utils/markdown_renderer.rb b/pressa/lib/utils/markdown_renderer.rb new file mode 100644 index 0000000..3ac7295 --- /dev/null +++ b/pressa/lib/utils/markdown_renderer.rb @@ -0,0 +1,85 @@ +require 'kramdown' +require 'yaml' +require_relative 'file_writer' +require_relative '../views/layout' + +class String + include Phlex::SGML::SafeObject +end + +module Pressa + module Utils + class MarkdownRenderer + def can_render_file?(filename:, extension:) + extension == 'md' + end + + def render(site:, file_path:, target_dir:) + content = File.read(file_path) + metadata, body_markdown = parse_content(content) + + html_body = render_markdown(body_markdown) + + page_title = metadata['Title'] || File.basename(file_path, '.md').capitalize + show_extension = metadata['Show extension'] == 'true' + + html = render_layout( + site:, + page_title:, + canonical_url: site.url, + body: html_body + ) + + output_filename = if show_extension + File.basename(file_path, '.md') + '.html' + else + File.join(File.basename(file_path, '.md'), 'index.html') + end + + output_path = File.join(target_dir, output_filename) + FileWriter.write(path: output_path, content: html) + end + + private + + def parse_content(content) + if content =~ /\A---\s*\n(.*?)\n---\s*\n(.*)/m + yaml_content = $1 + markdown = $2 + metadata = YAML.safe_load(yaml_content) || {} + [metadata, markdown] + else + [{}, content] + end + end + + def render_markdown(markdown) + Kramdown::Document.new( + markdown, + input: 'GFM', + syntax_highlighter: 'rouge', + syntax_highlighter_opts: { + line_numbers: false, + wrap: false + } + ).to_html + end + + def render_layout(site:, page_title:, canonical_url:, body:) + layout = Views::Layout.new( + site:, + page_title: "#{page_title} – #{site.title}", + canonical_url: + ) + + layout.call do + article(class: "page") do + h1 { page_title } + raw(body) + div(class: "fin") { "◼" } + end + end + end + end + end +end diff --git a/pressa/lib/views/archive_view.rb b/pressa/lib/views/archive_view.rb new file mode 100644 index 0000000..c9cdf17 --- /dev/null +++ b/pressa/lib/views/archive_view.rb @@ -0,0 +1,62 @@ +require 'phlex' + +module Pressa + module Views + class ArchiveView < Phlex::HTML + def initialize(posts_by_year:, site:) + @posts_by_year = posts_by_year + @site = site + end + + def view_template + article(class: "archive") do + h1 { "Archive" } + + @posts_by_year.sorted_years.each do |year| + year_posts = @posts_by_year.by_year[year] + render_year(year, year_posts) + end + end + end + + private + + def render_year(year, year_posts) + section(class: "year") do + h2 do + a(href: @site.url_for("/posts/#{year}/")) { year.to_s } + end + + year_posts.sorted_months.each do |month_posts| + render_month(year, month_posts) + end + end + end + + def render_month(year, month_posts) + month = month_posts.month + + section(class: "month") do + h3 do + a(href: @site.url_for("/posts/#{year}/#{month.padded}/")) do + "#{month.name} #{year}" + end + end + + ul do + month_posts.sorted_posts.each do |post| + li do + if post.link_post? + a(href: post.link) { "→ #{post.title}" } + else + a(href: @site.url_for(post.path)) { post.title } + end + plain " – #{post.formatted_date}" + end + end + end + end + end + end + end +end diff --git a/pressa/lib/views/layout.rb b/pressa/lib/views/layout.rb new file mode 100644 index 0000000..725db4e --- /dev/null +++ b/pressa/lib/views/layout.rb @@ -0,0 +1,103 @@ +require 'phlex' + +module Pressa + module Views + class Layout < Phlex::HTML + attr_reader :site, :page_title, :canonical_url, :page_scripts, :page_styles + + def initialize(site:, page_title:, canonical_url:, page_scripts: [], page_styles: []) + @site = site + @page_title = page_title + @canonical_url = canonical_url + @page_scripts = page_scripts + @page_styles = page_styles + end + + def view_template(&block) + doctype + + html(lang: "en") do + head do + meta(charset: "utf-8") + meta(name: "viewport", content: "width=device-width, initial-scale=1") + title { page_title } + meta(name: "description", content: site.description) + meta(name: "author", content: site.author) + + link(rel: "canonical", href: canonical_url) + + meta(property: "og:title", content: page_title) + meta(property: "og:description", content: site.description) + meta(property: "og:url", content: canonical_url) + meta(property: "og:type", content: "website") + if site.image_url + meta(property: "og:image", content: site.image_url) + end + + meta(name: "twitter:card", content: "summary") + meta(name: "twitter:title", content: page_title) + meta(name: "twitter:description", content: site.description) + + link(rel: "alternate", type: "application/rss+xml", title: "RSS Feed", href: site.url_for('/feed.xml')) + link(rel: "alternate", type: "application/json", title: "JSON Feed", href: site.url_for('/feed.json')) + + all_styles.each do |style| + link(rel: "stylesheet", href: site.url_for("/#{style.href}")) + end + end + + body do + render_header + main(&block) + render_footer + render_scripts + end + end + end + + private + + def all_styles + site.styles + page_styles + end + + def all_scripts + site.scripts + page_scripts + end + + def render_header + header do + h1 { a(href: "/") { site.title } } + nav do + ul do + li { a(href: "/") { "Home" } } + li { a(href: "/posts/") { "Archive" } } + li { a(href: "/projects/") { "Projects" } } + li { a(href: "/about/") { "About" } } + end + end + end + end + + def render_footer + footer do + p { "© #{Time.now.year} #{site.author}" } + p do + plain "Email: " + a(href: "mailto:#{site.email}") { site.email } + end + end + end + + def render_scripts + all_scripts.each do |script| + if script.defer + script_(src: site.url_for("/#{script.src}"), defer: true) + else + script_(src: site.url_for("/#{script.src}")) + end + end + end + end + end +end diff --git a/pressa/lib/views/month_posts_view.rb b/pressa/lib/views/month_posts_view.rb new file mode 100644 index 0000000..621f7b5 --- /dev/null +++ b/pressa/lib/views/month_posts_view.rb @@ -0,0 +1,24 @@ +require 'phlex' +require_relative 'post_view' + +module Pressa + module Views + class MonthPostsView < Phlex::HTML + def initialize(year:, month_posts:, site:) + @year = year + @month_posts = month_posts + @site = site + end + + def view_template + article(class: "month-posts") do + h1 { "#{@month_posts.month.name} #{@year}" } + + @month_posts.sorted_posts.each do |post| + render PostView.new(post:, site: @site) + end + end + end + end + end +end diff --git a/pressa/lib/views/post_view.rb b/pressa/lib/views/post_view.rb new file mode 100644 index 0000000..45b36aa --- /dev/null +++ b/pressa/lib/views/post_view.rb @@ -0,0 +1,44 @@ +require 'phlex' + +class String + include Phlex::SGML::SafeObject +end + +module Pressa + module Views + class PostView < Phlex::HTML + def initialize(post:, site:) + @post = post + @site = site + end + + def view_template + article(class: "post") do + header do + if @post.link_post? + h1 do + a(href: @post.link) { "→ #{@post.title}" } + end + else + h1 { @post.title } + end + + div(class: "post-meta") do + time(datetime: @post.date.iso8601) { @post.formatted_date } + plain " · " + a(href: @site.url_for(@post.path), class: "permalink") { "Permalink" } + end + end + + div(class: "post-content") do + raw(@post.body) + end + + footer(class: "post-footer") do + div(class: "fin") { "◼" } + end + end + end + end + end +end diff --git a/pressa/lib/views/project_view.rb b/pressa/lib/views/project_view.rb new file mode 100644 index 0000000..2a45684 --- /dev/null +++ b/pressa/lib/views/project_view.rb @@ -0,0 +1,45 @@ +require 'phlex' + +module Pressa + module Views + class ProjectView < Phlex::HTML + def initialize(project:, site:) + @project = project + @site = site + end + + def view_template + article(class: "project", data_title: @project.github_path) do + header do + h1 { @project.title } + p(class: "description") { @project.description } + p do + a(href: @project.url) { "View on GitHub →" } + end + end + + section(class: "project-stats") do + h2 { "Statistics" } + div(id: "stats") do + p { "Loading..." } + end + end + + section(class: "project-contributors") do + h2 { "Contributors" } + div(id: "contributors") do + p { "Loading..." } + end + end + + section(class: "project-languages") do + h2 { "Languages" } + div(id: "languages") do + p { "Loading..." } + end + end + end + end + end + end +end diff --git a/pressa/lib/views/projects_view.rb b/pressa/lib/views/projects_view.rb new file mode 100644 index 0000000..19c216c --- /dev/null +++ b/pressa/lib/views/projects_view.rb @@ -0,0 +1,29 @@ +require 'phlex' + +module Pressa + module Views + class ProjectsView < Phlex::HTML + def initialize(projects:, site:) + @projects = projects + @site = site + end + + def view_template + article(class: "projects") do + h1 { "Projects" } + + p { "Open source projects I've created or contributed to." } + + ul(class: "projects-list") do + @projects.each do |project| + li do + a(href: @site.url_for(project.path)) { project.title } + plain " – #{project.description}" + end + end + end + end + end + end + end +end diff --git a/pressa/lib/views/recent_posts_view.rb b/pressa/lib/views/recent_posts_view.rb new file mode 100644 index 0000000..4e59886 --- /dev/null +++ b/pressa/lib/views/recent_posts_view.rb @@ -0,0 +1,21 @@ +require 'phlex' +require_relative 'post_view' + +module Pressa + module Views + class RecentPostsView < Phlex::HTML + def initialize(posts:, site:) + @posts = posts + @site = site + end + + def view_template + div(class: "recent-posts") do + @posts.each do |post| + render PostView.new(post:, site: @site) + end + end + end + end + end +end diff --git a/pressa/lib/views/year_posts_view.rb b/pressa/lib/views/year_posts_view.rb new file mode 100644 index 0000000..88a7c3a --- /dev/null +++ b/pressa/lib/views/year_posts_view.rb @@ -0,0 +1,50 @@ +require 'phlex' + +module Pressa + module Views + class YearPostsView < Phlex::HTML + def initialize(year:, year_posts:, site:) + @year = year + @year_posts = year_posts + @site = site + end + + def view_template + article(class: "year-posts") do + h1 { @year.to_s } + + @year_posts.sorted_months.each do |month_posts| + render_month(month_posts) + end + end + end + + private + + def render_month(month_posts) + month = month_posts.month + + section(class: "month") do + h2 do + a(href: @site.url_for("/posts/#{@year}/#{month.padded}/")) do + month.name + end + end + + ul do + month_posts.sorted_posts.each do |post| + li do + if post.link_post? + a(href: post.link) { "→ #{post.title}" } + else + a(href: @site.url_for(post.path)) { post.title } + end + plain " – #{post.formatted_date}" + end + end + end + end + end + end + end +end diff --git a/pressa/spec/examples.txt b/pressa/spec/examples.txt new file mode 100644 index 0000000..0e06a65 --- /dev/null +++ b/pressa/spec/examples.txt @@ -0,0 +1,17 @@ +example_id | status | run_time | +------------------------------------------------- | ------ | --------------- | +./spec/posts/metadata_spec.rb[1:1:1] | passed | 0.00009 seconds | +./spec/posts/metadata_spec.rb[1:1:2] | passed | 0.00032 seconds | +./spec/posts/metadata_spec.rb[1:1:3] | passed | 0.00228 seconds | +./spec/posts/repo_spec.rb[1:1:1] | passed | 0.00078 seconds | +./spec/posts/repo_spec.rb[1:1:2] | passed | 0.01536 seconds | +./spec/utils/frontmatter_converter_spec.rb[1:1:1] | passed | 0.00024 seconds | +./spec/utils/frontmatter_converter_spec.rb[1:1:2] | passed | 0.00003 seconds | +./spec/utils/frontmatter_converter_spec.rb[1:1:3] | passed | 0.00006 seconds | +./spec/utils/frontmatter_converter_spec.rb[1:1:4] | passed | 0.00004 seconds | +./spec/utils/frontmatter_converter_spec.rb[1:1:5] | passed | 0.00004 seconds | +./spec/utils/frontmatter_converter_spec.rb[1:1:6] | passed | 0.00003 seconds | +./spec/utils/frontmatter_converter_spec.rb[1:1:7] | passed | 0.00042 seconds | +./spec/utils/frontmatter_converter_spec.rb[1:1:8] | passed | 0.00013 seconds | +./spec/utils/frontmatter_converter_spec.rb[1:2:1] | passed | 0.00059 seconds | +./spec/utils/frontmatter_converter_spec.rb[1:2:2] | passed | 0.00004 seconds | diff --git a/pressa/spec/posts/metadata_spec.rb b/pressa/spec/posts/metadata_spec.rb new file mode 100644 index 0000000..85a18b8 --- /dev/null +++ b/pressa/spec/posts/metadata_spec.rb @@ -0,0 +1,69 @@ +require 'spec_helper' + +RSpec.describe Pressa::Posts::PostMetadata do + describe '.parse' do + it 'parses valid YAML front-matter' do + content = <<~MARKDOWN + --- + Title: Test Post + Author: Trent Reznor + Date: 5th November, 2025 + Timestamp: 2025-11-05T10:00:00-08:00 + Tags: Ruby, Testing + Scripts: highlight.js + Styles: code.css + Link: https://example.net/external + --- + + This is the post body. + MARKDOWN + + metadata = described_class.parse(content) + + expect(metadata.title).to eq('Test Post') + expect(metadata.author).to eq('Trent Reznor') + expect(metadata.formatted_date).to eq('5th November, 2025') + expect(metadata.date.year).to eq(2025) + expect(metadata.date.month).to eq(11) + expect(metadata.date.day).to eq(5) + expect(metadata.link).to eq('https://example.net/external') + expect(metadata.tags).to eq(['Ruby', 'Testing']) + expect(metadata.scripts.map(&:src)).to eq(['highlight.js']) + expect(metadata.styles.map(&:href)).to eq(['code.css']) + end + + it 'raises error when required fields are missing' do + content = <<~MARKDOWN + --- + Title: Incomplete Post + --- + + Body content + MARKDOWN + + expect { + described_class.parse(content) + }.to raise_error(/Missing required fields/) + end + + it 'handles posts without optional fields' do + content = <<~MARKDOWN + --- + Title: Simple Post + Author: Fat Mike + Date: 1st January, 2025 + Timestamp: 2025-01-01T12:00:00-08:00 + --- + + Simple content + MARKDOWN + + metadata = described_class.parse(content) + + expect(metadata.tags).to eq([]) + expect(metadata.scripts).to eq([]) + expect(metadata.styles).to eq([]) + expect(metadata.link).to be_nil + end + end +end diff --git a/pressa/spec/posts/repo_spec.rb b/pressa/spec/posts/repo_spec.rb new file mode 100644 index 0000000..f6d43b7 --- /dev/null +++ b/pressa/spec/posts/repo_spec.rb @@ -0,0 +1,73 @@ +require 'spec_helper' +require 'fileutils' +require 'tmpdir' + +RSpec.describe Pressa::Posts::PostRepo do + let(:repo) { described_class.new } + + describe '#read_posts' do + it 'reads and organizes posts by year and month' do + Dir.mktmpdir do |tmpdir| + posts_dir = File.join(tmpdir, 'posts', '2025', '11') + FileUtils.mkdir_p(posts_dir) + + post_content = <<~MARKDOWN + --- + Title: Shredding in November + Author: Shaun White + Date: 5th November, 2025 + Timestamp: 2025-11-05T10:00:00-08:00 + --- + + Had an epic day at Whistler. The powder was deep and the lines were short. + MARKDOWN + + File.write(File.join(posts_dir, 'shredding.md'), post_content) + + posts_by_year = repo.read_posts(File.join(tmpdir, 'posts')) + + expect(posts_by_year.all_posts.length).to eq(1) + + post = posts_by_year.all_posts.first + expect(post.title).to eq('Shredding in November') + expect(post.author).to eq('Shaun White') + expect(post.slug).to eq('shredding') + expect(post.year).to eq(2025) + expect(post.month).to eq(11) + expect(post.path).to eq('/posts/2025/11/shredding') + end + end + + it 'generates excerpts from post content' do + Dir.mktmpdir do |tmpdir| + posts_dir = File.join(tmpdir, 'posts', '2025', '11') + FileUtils.mkdir_p(posts_dir) + + post_content = <<~MARKDOWN + --- + Title: Test Post + Author: Greg Graffin + Date: 5th November, 2025 + Timestamp: 2025-11-05T10:00:00-08:00 + --- + + This is a test post with some content. It should generate an excerpt. + + ![Image](image.png) + + More content with a [link](https://example.net). + MARKDOWN + + File.write(File.join(posts_dir, 'test.md'), post_content) + + posts_by_year = repo.read_posts(File.join(tmpdir, 'posts')) + post = posts_by_year.all_posts.first + + expect(post.excerpt).to include('test post') + expect(post.excerpt).not_to include('![') + expect(post.excerpt).to include('link') + expect(post.excerpt).not_to include('[link]') + end + end + end +end diff --git a/pressa/spec/spec_helper.rb b/pressa/spec/spec_helper.rb new file mode 100644 index 0000000..87d14b2 --- /dev/null +++ b/pressa/spec/spec_helper.rb @@ -0,0 +1,25 @@ +require_relative '../lib/pressa' + +RSpec.configure do |config| + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.shared_context_metadata_behavior = :apply_to_host_groups + config.filter_run_when_matching :focus + config.example_status_persistence_file_path = "spec/examples.txt" + config.disable_monkey_patching! + config.warnings = true + + if config.files_to_run.one? + config.default_formatter = "doc" + end + + config.profile_examples = 10 + config.order = :random + Kernel.srand config.seed +end diff --git a/pressa/spec/utils/frontmatter_converter_spec.rb b/pressa/spec/utils/frontmatter_converter_spec.rb new file mode 100644 index 0000000..7f6ab42 --- /dev/null +++ b/pressa/spec/utils/frontmatter_converter_spec.rb @@ -0,0 +1,194 @@ +require 'spec_helper' +require_relative '../../lib/utils/frontmatter_converter' + +RSpec.describe Pressa::Utils::FrontmatterConverter do + describe '.convert_content' do + it 'converts simple front-matter to YAML' do + input = <<~MARKDOWN + --- + Title: Test Post + Author: Sami Samhuri + Date: 11th November, 2025 + Timestamp: 2025-11-11T14:00:00-08:00 + --- + + This is the post body. + MARKDOWN + + output = described_class.convert_content(input) + + expect(output).to start_with("---\n") + expect(output).to include("Title: Test Post") + expect(output).to include("Author: Sami Samhuri") + expect(output).to include("Date: \"11th November, 2025\"") + expect(output).to include("Timestamp: \"2025-11-11T14:00:00-08:00\"") + expect(output).to end_with("---\n\nThis is the post body.\n") + end + + it 'converts front-matter with tags' do + input = <<~MARKDOWN + --- + Title: Zelda Tones for iOS + Author: Sami Samhuri + Date: 6th March, 2013 + Timestamp: 2013-03-06T18:51:13-08:00 + Tags: zelda, nintendo, pacman, ringtones, tones, ios + --- + +

Zelda

+ +

+ Matt Gemmell recently shared some + sweet Super Nintendo wallpapers for iPhone 5. +

+ MARKDOWN + + output = described_class.convert_content(input) + + expect(output).to include("Title: Zelda Tones for iOS") + expect(output).to include("Tags: \"zelda, nintendo, pacman, ringtones, tones, ios\"") + expect(output).to include("

Zelda

") + end + + it 'converts front-matter with Link field' do + input = <<~MARKDOWN + --- + Title: Buffalo buffalo Buffalo buffalo buffalo buffalo Buffalo buffalo + Author: Sami Samhuri + Date: 16th September, 2006 + Timestamp: 2006-09-16T22:11:00-07:00 + Tags: amusement, buffalo + Link: http://en.wikipedia.org/wiki/Buffalo_buffalo_buffalo_buffalo_buffalo_buffalo_buffalo_buffalo + --- + + Wouldn't the sentence 'I want to put a hyphen between the words Fish and And... + MARKDOWN + + output = described_class.convert_content(input) + + expect(output).to include("Link: http://en.wikipedia.org/wiki/Buffalo_buffalo_buffalo_buffalo_buffalo_buffalo_buffalo_buffalo") + expect(output).to include("Tags: \"amusement, buffalo\"") + end + + it 'converts front-matter with Scripts and Styles' do + input = <<~MARKDOWN + --- + Title: Code Example Post + Author: Sami Samhuri + Date: 1st January, 2025 + Timestamp: 2025-01-01T12:00:00-08:00 + Scripts: highlight.js, custom.js + Styles: code.css, theme.css + --- + + Some code here. + MARKDOWN + + output = described_class.convert_content(input) + + expect(output).to include("Scripts: \"highlight.js, custom.js\"") + expect(output).to include("Styles: \"code.css, theme.css\"") + end + + it 'handles Date fields with colons correctly' do + input = <<~MARKDOWN + --- + Title: Test + Author: Sami Samhuri + Date: 1st January, 2025 + Timestamp: 2025-01-01T12:00:00-08:00 + --- + + Body + MARKDOWN + + output = described_class.convert_content(input) + + expect(output).to include("Date: \"1st January, 2025\"") + expect(output).to include("Timestamp: \"2025-01-01T12:00:00-08:00\"") + end + + it 'raises error if no front-matter delimiter' do + input = "Just some content without front-matter" + + expect { + described_class.convert_content(input) + }.to raise_error("File does not start with front-matter delimiter") + end + + it 'raises error if front-matter is not closed' do + input = <<~MARKDOWN + --- + Title: Unclosed + Author: Test + + Body without closing delimiter + MARKDOWN + + expect { + described_class.convert_content(input) + }.to raise_error("Could not find end of front-matter") + end + + it 'preserves empty lines in body' do + input = <<~MARKDOWN + --- + Title: Test + Author: Sami Samhuri + Date: 1st January, 2025 + Timestamp: 2025-01-01T12:00:00-08:00 + --- + + First paragraph. + + Second paragraph after empty line. + MARKDOWN + + output = described_class.convert_content(input) + + expect(output).to include("\nFirst paragraph.\n\nSecond paragraph after empty line.\n") + end + end + + describe '.convert_frontmatter_to_yaml' do + it 'converts all standard fields' do + input = <<~FRONTMATTER + Title: Test Post + Author: Sami Samhuri + Date: 11th November, 2025 + Timestamp: 2025-11-11T14:00:00-08:00 + Tags: Ruby, Testing + Link: https://example.net + Scripts: app.js + Styles: style.css + FRONTMATTER + + yaml = described_class.convert_frontmatter_to_yaml(input) + + expect(yaml).to include("Title: Test Post") + expect(yaml).to include("Author: Sami Samhuri") + expect(yaml).to include("Date: \"11th November, 2025\"") + expect(yaml).to include("Timestamp: \"2025-11-11T14:00:00-08:00\"") + expect(yaml).to include("Tags: \"Ruby, Testing\"") + expect(yaml).to include("Link: https://example.net") + expect(yaml).to include("Scripts: app.js") + expect(yaml).to include("Styles: style.css") + end + + it 'handles empty lines gracefully' do + input = <<~FRONTMATTER + Title: Test + + Author: Sami Samhuri + Date: 1st January, 2025 + + Timestamp: 2025-01-01T12:00:00-08:00 + FRONTMATTER + + yaml = described_class.convert_frontmatter_to_yaml(input) + + expect(yaml).to include("Title: Test") + expect(yaml).to include("Author: Sami Samhuri") + end + end +end diff --git a/pressa/test-site/posts/2025/11/test-post.md b/pressa/test-site/posts/2025/11/test-post.md new file mode 100644 index 0000000..6030136 --- /dev/null +++ b/pressa/test-site/posts/2025/11/test-post.md @@ -0,0 +1,40 @@ +--- +Title: Testing Pressa with Ruby and Phlex +Author: Trent Reznor +Date: 11th November, 2025 +Timestamp: 2025-11-11T14:00:00-08:00 +Tags: Ruby, Phlex, Static Sites +--- + +This is a test post to verify that Pressa is working correctly. We're building a static site generator using: + +- Ruby 3.4 +- Phlex for HTML generation +- Kramdown with Rouge for Markdown and syntax highlighting +- dry-struct for immutable data models + +## Code Example + +Here's some Ruby code: + +```ruby +class Post < Dry::Struct + attribute :title, Types::String + attribute :body, Types::String +end + +post = Post.new(title: "Hello World", body: "This is a test") +puts post.title +``` + +## Features + +The generator supports: + +1. Hierarchical post organization (year/month) +2. Link posts (external URLs) +3. JSON and RSS feeds +4. Archive pages +5. Projects section + +Pretty cool, right? diff --git a/pressa/test-site/public/css/style.css b/pressa/test-site/public/css/style.css new file mode 100644 index 0000000..6831b38 --- /dev/null +++ b/pressa/test-site/public/css/style.css @@ -0,0 +1,51 @@ +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + line-height: 1.6; + max-width: 800px; + margin: 40px auto; + padding: 0 20px; + color: #333; +} + +header { + border-bottom: 2px solid #eee; + margin-bottom: 40px; + padding-bottom: 20px; +} + +header h1 { + margin: 0; +} + +header nav ul { + list-style: none; + padding: 0; + display: flex; + gap: 20px; +} + +.post { + margin-bottom: 60px; +} + +.post-meta { + color: #666; + font-size: 0.9em; +} + +.fin { + text-align: center; + color: #999; + margin: 40px 0; +} + +pre { + background: #f5f5f5; + padding: 15px; + border-radius: 4px; + overflow-x: auto; +} + +code { + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; +} diff --git a/pressa/test-www/css/style.css b/pressa/test-www/css/style.css new file mode 100644 index 0000000..6831b38 --- /dev/null +++ b/pressa/test-www/css/style.css @@ -0,0 +1,51 @@ +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + line-height: 1.6; + max-width: 800px; + margin: 40px auto; + padding: 0 20px; + color: #333; +} + +header { + border-bottom: 2px solid #eee; + margin-bottom: 40px; + padding-bottom: 20px; +} + +header h1 { + margin: 0; +} + +header nav ul { + list-style: none; + padding: 0; + display: flex; + gap: 20px; +} + +.post { + margin-bottom: 60px; +} + +.post-meta { + color: #666; + font-size: 0.9em; +} + +.fin { + text-align: center; + color: #999; + margin: 40px 0; +} + +pre { + background: #f5f5f5; + padding: 15px; + border-radius: 4px; + overflow-x: auto; +} + +code { + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; +} diff --git a/pressa/test-www/feed.json b/pressa/test-www/feed.json new file mode 100644 index 0000000..312dbb1 --- /dev/null +++ b/pressa/test-www/feed.json @@ -0,0 +1,33 @@ +{ + "version": "https://jsonfeed.org/version/1.1", + "title": "samhuri.net", + "home_page_url": "http://localhost:8000", + "feed_url": "http://localhost:8000/feed.json", + "description": "The personal blog of Sami Samhuri", + "authors": [ + { + "name": "Sami Samhuri", + "url": "http://localhost:8000" + } + ], + "items": [ + { + "id": "http://localhost:8000/posts/2025/11/test-post", + "url": "http://localhost:8000/posts/2025/11/test-post", + "title": "Testing Pressa with Ruby and Phlex", + "content_html": "

This is a test post to verify that Pressa is working correctly. We’re building a static site generator using:

\n\n
    \n
  • Ruby 3.4
  • \n
  • Phlex for HTML generation
  • \n
  • Kramdown with Rouge for Markdown and syntax highlighting
  • \n
  • dry-struct for immutable data models
  • \n
\n\n

Code Example

\n\n

Here’s some Ruby code:

\n\n
class Post < Dry::Struct\n attribute :title, Types::String\n attribute :body, Types::String\nend\n\npost = Post.new(title: \"Hello World\", body: \"This is a test\")\nputs post.title\n
\n\n

Features

\n\n

The generator supports:

\n\n
    \n
  1. Hierarchical post organization (year/month)
  2. \n
  3. Link posts (external URLs)
  4. \n
  5. JSON and RSS feeds
  6. \n
  7. Archive pages
  8. \n
  9. Projects section
  10. \n
\n\n

Pretty cool, right?

\n", + "summary": "This is a test post to verify that Pressa is working correctly. We're building a static site generator using: - Ruby 3.4 - Phlex for HTML generation - Kramdown with Rouge for Markdown and syntax highlighting - dry-struct for immutable data models ## Code Example Here's some Ruby code: ```ruby class ...", + "date_published": "2025-11-11T14:00:00-08:00", + "authors": [ + { + "name": "Trent Reznor" + } + ], + "tags": [ + "Ruby", + "Phlex", + "Static Sites" + ] + } + ] +} \ No newline at end of file diff --git a/pressa/test-www/feed.xml b/pressa/test-www/feed.xml new file mode 100644 index 0000000..700677d --- /dev/null +++ b/pressa/test-www/feed.xml @@ -0,0 +1,60 @@ + + + + samhuri.net + http://localhost:8000 + The personal blog of Sami Samhuri + en-us + Tue, 11 Nov 2025 14:00:00 -0800 + Tue, 11 Nov 2025 14:52:39 -0800 + + + Testing Pressa with Ruby and Phlex + http://localhost:8000/posts/2025/11/test-post + http://localhost:8000/posts/2025/11/test-post + + This is a test post to verify that Pressa is working correctly. We’re building a static site generator using:

+ +
    +
  • Ruby 3.4
  • +
  • Phlex for HTML generation
  • +
  • Kramdown with Rouge for Markdown and syntax highlighting
  • +
  • dry-struct for immutable data models
  • +
+ +

Code Example

+ +

Here’s some Ruby code:

+ +
class Post < Dry::Struct + attribute :title, Types::String + attribute :body, Types::String +end + +post = Post.new(title: "Hello World", body: "This is a test") +puts post.title +
+ +

Features

+ +

The generator supports:

+ +
    +
  1. Hierarchical post organization (year/month)
  2. +
  3. Link posts (external URLs)
  4. +
  5. JSON and RSS feeds
  6. +
  7. Archive pages
  8. +
  9. Projects section
  10. +
+ +

Pretty cool, right?

+]]> +
+ Tue, 11 Nov 2025 14:00:00 -0800 + sami@samhuri.net (Sami Samhuri) + Ruby + Phlex + Static Sites +
+
+
diff --git a/pressa/test-www/index.html b/pressa/test-www/index.html new file mode 100644 index 0000000..742fbc6 --- /dev/null +++ b/pressa/test-www/index.html @@ -0,0 +1,36 @@ +samhuri.net

samhuri.net

Testing Pressa with Ruby and Phlex

This is a test post to verify that Pressa is working correctly. We’re building a static site generator using:

+ +
    +
  • Ruby 3.4
  • +
  • Phlex for HTML generation
  • +
  • Kramdown with Rouge for Markdown and syntax highlighting
  • +
  • dry-struct for immutable data models
  • +
+ +

Code Example

+ +

Here’s some Ruby code:

+ +
class Post < Dry::Struct + attribute :title, Types::String + attribute :body, Types::String +end + +post = Post.new(title: "Hello World", body: "This is a test") +puts post.title +
+ +

Features

+ +

The generator supports:

+ +
    +
  1. Hierarchical post organization (year/month)
  2. +
  3. Link posts (external URLs)
  4. +
  5. JSON and RSS feeds
  6. +
  7. Archive pages
  8. +
  9. Projects section
  10. +
+ +

Pretty cool, right?

+
\ No newline at end of file diff --git a/pressa/test-www/posts/2025/11/index.html b/pressa/test-www/posts/2025/11/index.html new file mode 100644 index 0000000..fb42447 --- /dev/null +++ b/pressa/test-www/posts/2025/11/index.html @@ -0,0 +1,36 @@ +November 2025 – samhuri.net

samhuri.net

November 2025

Testing Pressa with Ruby and Phlex

This is a test post to verify that Pressa is working correctly. We’re building a static site generator using:

+ +
    +
  • Ruby 3.4
  • +
  • Phlex for HTML generation
  • +
  • Kramdown with Rouge for Markdown and syntax highlighting
  • +
  • dry-struct for immutable data models
  • +
+ +

Code Example

+ +

Here’s some Ruby code:

+ +
class Post < Dry::Struct + attribute :title, Types::String + attribute :body, Types::String +end + +post = Post.new(title: "Hello World", body: "This is a test") +puts post.title +
+ +

Features

+ +

The generator supports:

+ +
    +
  1. Hierarchical post organization (year/month)
  2. +
  3. Link posts (external URLs)
  4. +
  5. JSON and RSS feeds
  6. +
  7. Archive pages
  8. +
  9. Projects section
  10. +
+ +

Pretty cool, right?

+
\ No newline at end of file diff --git a/pressa/test-www/posts/2025/11/test-post/index.html b/pressa/test-www/posts/2025/11/test-post/index.html new file mode 100644 index 0000000..fd252ce --- /dev/null +++ b/pressa/test-www/posts/2025/11/test-post/index.html @@ -0,0 +1,36 @@ +Testing Pressa with Ruby and Phlex – samhuri.net

samhuri.net

Testing Pressa with Ruby and Phlex

This is a test post to verify that Pressa is working correctly. We’re building a static site generator using:

+ +
    +
  • Ruby 3.4
  • +
  • Phlex for HTML generation
  • +
  • Kramdown with Rouge for Markdown and syntax highlighting
  • +
  • dry-struct for immutable data models
  • +
+ +

Code Example

+ +

Here’s some Ruby code:

+ +
class Post < Dry::Struct + attribute :title, Types::String + attribute :body, Types::String +end + +post = Post.new(title: "Hello World", body: "This is a test") +puts post.title +
+ +

Features

+ +

The generator supports:

+ +
    +
  1. Hierarchical post organization (year/month)
  2. +
  3. Link posts (external URLs)
  4. +
  5. JSON and RSS feeds
  6. +
  7. Archive pages
  8. +
  9. Projects section
  10. +
+ +

Pretty cool, right?

+
\ No newline at end of file diff --git a/pressa/test-www/posts/2025/index.html b/pressa/test-www/posts/2025/index.html new file mode 100644 index 0000000..c204ff0 --- /dev/null +++ b/pressa/test-www/posts/2025/index.html @@ -0,0 +1 @@ +2025 – samhuri.net

samhuri.net

\ No newline at end of file diff --git a/pressa/test-www/posts/index.html b/pressa/test-www/posts/index.html new file mode 100644 index 0000000..4dbeca9 --- /dev/null +++ b/pressa/test-www/posts/index.html @@ -0,0 +1 @@ +Archive – samhuri.net

samhuri.net

\ No newline at end of file diff --git a/pressa/test-www/projects/bin/index.html b/pressa/test-www/projects/bin/index.html new file mode 100644 index 0000000..fca7053 --- /dev/null +++ b/pressa/test-www/projects/bin/index.html @@ -0,0 +1 @@ +bin – samhuri.net

samhuri.net

bin

my collection of scripts in ~/bin

View on GitHub →

Statistics

Loading...

Contributors

Loading...

Languages

Loading...

\ No newline at end of file diff --git a/pressa/test-www/projects/config/index.html b/pressa/test-www/projects/config/index.html new file mode 100644 index 0000000..0301952 --- /dev/null +++ b/pressa/test-www/projects/config/index.html @@ -0,0 +1 @@ +config – samhuri.net

samhuri.net

config

important dot files

View on GitHub →

Statistics

Loading...

Contributors

Loading...

Languages

Loading...

\ No newline at end of file diff --git a/pressa/test-www/projects/index.html b/pressa/test-www/projects/index.html new file mode 100644 index 0000000..9d3af58 --- /dev/null +++ b/pressa/test-www/projects/index.html @@ -0,0 +1 @@ +Projects – samhuri.net

samhuri.net

Projects

Open source projects I've created or contributed to.

  • bin – my collection of scripts in ~/bin
  • config – important dot files
  • unix-node – Node.js CommonJS module that exports useful Unix commands
  • strftime – strftime for JavaScript
\ No newline at end of file diff --git a/pressa/test-www/projects/strftime/index.html b/pressa/test-www/projects/strftime/index.html new file mode 100644 index 0000000..41cd0fd --- /dev/null +++ b/pressa/test-www/projects/strftime/index.html @@ -0,0 +1 @@ +strftime – samhuri.net

samhuri.net

strftime

strftime for JavaScript

View on GitHub →

Statistics

Loading...

Contributors

Loading...

Languages

Loading...

\ No newline at end of file diff --git a/pressa/test-www/projects/unix-node/index.html b/pressa/test-www/projects/unix-node/index.html new file mode 100644 index 0000000..b179573 --- /dev/null +++ b/pressa/test-www/projects/unix-node/index.html @@ -0,0 +1 @@ +unix-node – samhuri.net

samhuri.net

unix-node

Node.js CommonJS module that exports useful Unix commands

View on GitHub →

Statistics

Loading...

Contributors

Loading...

Languages

Loading...

\ No newline at end of file