WIP: pressa generator in Ruby and Phlex

This commit is contained in:
Sami Samhuri 2025-11-11 15:10:37 -08:00
parent 23e62f4a49
commit b0588fed20
No known key found for this signature in database
53 changed files with 2957 additions and 0 deletions

1
pressa/.ruby-version Normal file
View file

@ -0,0 +1 @@
3.4.1

18
pressa/Gemfile Normal file
View file

@ -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

197
pressa/Gemfile.lock Normal file
View file

@ -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

191
pressa/README.md Normal file
View file

@ -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.

View file

@ -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 `<span>` tags that have semantic class names.
Example output:
```html
<div class="language-ruby highlighter-rouge">
<span class="k">class</span> <span class="nc">Post</span>
<span class="k">end</span>
</div>
```
### 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 `<pre><code>` 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 <div> 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.

92
pressa/bake.rb Normal file
View file

@ -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

38
pressa/bin/convert-frontmatter Executable file
View file

@ -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

28
pressa/bin/pressa Executable file
View file

@ -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

151
pressa/bin/validate-output Executable file
View file

@ -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>.*?<\/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

11
pressa/lib/plugin.rb Normal file
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

123
pressa/lib/posts/repo.rb Normal file
View file

@ -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

View file

@ -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

128
pressa/lib/posts/writer.rb Normal file
View file

@ -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

59
pressa/lib/pressa.rb Normal file
View file

@ -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

View file

@ -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

View file

@ -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

38
pressa/lib/site.rb Normal file
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

103
pressa/lib/views/layout.rb Normal file
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

17
pressa/spec/examples.txt Normal file
View file

@ -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 |

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
---
<h2>Zelda</h2>
<p>
<a href="http://mattgemmell.com">Matt Gemmell</a> recently shared some
<a href="http://mattgemmell.com/2013/03/05/iphone-5-super-nintendo-wallpapers/">sweet Super Nintendo wallpapers for iPhone 5</a>.
</p>
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("<h2>Zelda</h2>")
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

View file

@ -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?

View file

@ -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;
}

View file

@ -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;
}

33
pressa/test-www/feed.json Normal file
View file

@ -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": "<p>This is a test post to verify that Pressa is working correctly. Were building a static site generator using:</p>\n\n<ul>\n <li>Ruby 3.4</li>\n <li>Phlex for HTML generation</li>\n <li>Kramdown with Rouge for Markdown and syntax highlighting</li>\n <li>dry-struct for immutable data models</li>\n</ul>\n\n<h2 id=\"code-example\">Code Example</h2>\n\n<p>Heres some Ruby code:</p>\n\n<div class=\"language-ruby highlighter-rouge\"><span class=\"k\">class</span> <span class=\"nc\">Post</span> <span class=\"o\">&lt;</span> <span class=\"no\">Dry</span><span class=\"o\">::</span><span class=\"no\">Struct</span>\n <span class=\"n\">attribute</span> <span class=\"ss\">:title</span><span class=\"p\">,</span> <span class=\"no\">Types</span><span class=\"o\">::</span><span class=\"no\">String</span>\n <span class=\"n\">attribute</span> <span class=\"ss\">:body</span><span class=\"p\">,</span> <span class=\"no\">Types</span><span class=\"o\">::</span><span class=\"no\">String</span>\n<span class=\"k\">end</span>\n\n<span class=\"n\">post</span> <span class=\"o\">=</span> <span class=\"no\">Post</span><span class=\"p\">.</span><span class=\"nf\">new</span><span class=\"p\">(</span><span class=\"ss\">title: </span><span class=\"s2\">\"Hello World\"</span><span class=\"p\">,</span> <span class=\"ss\">body: </span><span class=\"s2\">\"This is a test\"</span><span class=\"p\">)</span>\n<span class=\"nb\">puts</span> <span class=\"n\">post</span><span class=\"p\">.</span><span class=\"nf\">title</span>\n</div>\n\n<h2 id=\"features\">Features</h2>\n\n<p>The generator supports:</p>\n\n<ol>\n <li>Hierarchical post organization (year/month)</li>\n <li>Link posts (external URLs)</li>\n <li>JSON and RSS feeds</li>\n <li>Archive pages</li>\n <li>Projects section</li>\n</ol>\n\n<p>Pretty cool, right?</p>\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"
]
}
]
}

60
pressa/test-www/feed.xml Normal file
View file

@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>samhuri.net</title>
<link>http://localhost:8000</link>
<description>The personal blog of Sami Samhuri</description>
<language>en-us</language>
<pubDate>Tue, 11 Nov 2025 14:00:00 -0800</pubDate>
<lastBuildDate>Tue, 11 Nov 2025 14:52:39 -0800</lastBuildDate>
<atom:link href="http://localhost:8000/feed.xml" rel="self" type="application/rss+xml"/>
<item>
<title>Testing Pressa with Ruby and Phlex</title>
<link>http://localhost:8000/posts/2025/11/test-post</link>
<guid isPermaLink="true">http://localhost:8000/posts/2025/11/test-post</guid>
<description>
<![CDATA[<p>This is a test post to verify that Pressa is working correctly. Were building a static site generator using:</p>
<ul>
<li>Ruby 3.4</li>
<li>Phlex for HTML generation</li>
<li>Kramdown with Rouge for Markdown and syntax highlighting</li>
<li>dry-struct for immutable data models</li>
</ul>
<h2 id="code-example">Code Example</h2>
<p>Heres some Ruby code:</p>
<div class="language-ruby highlighter-rouge"><span class="k">class</span> <span class="nc">Post</span> <span class="o">&lt;</span> <span class="no">Dry</span><span class="o">::</span><span class="no">Struct</span>
<span class="n">attribute</span> <span class="ss">:title</span><span class="p">,</span> <span class="no">Types</span><span class="o">::</span><span class="no">String</span>
<span class="n">attribute</span> <span class="ss">:body</span><span class="p">,</span> <span class="no">Types</span><span class="o">::</span><span class="no">String</span>
<span class="k">end</span>
<span class="n">post</span> <span class="o">=</span> <span class="no">Post</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="ss">title: </span><span class="s2">"Hello World"</span><span class="p">,</span> <span class="ss">body: </span><span class="s2">"This is a test"</span><span class="p">)</span>
<span class="nb">puts</span> <span class="n">post</span><span class="p">.</span><span class="nf">title</span>
</div>
<h2 id="features">Features</h2>
<p>The generator supports:</p>
<ol>
<li>Hierarchical post organization (year/month)</li>
<li>Link posts (external URLs)</li>
<li>JSON and RSS feeds</li>
<li>Archive pages</li>
<li>Projects section</li>
</ol>
<p>Pretty cool, right?</p>
]]>
</description>
<pubDate>Tue, 11 Nov 2025 14:00:00 -0800</pubDate>
<author>sami@samhuri.net (Sami Samhuri)</author>
<category>Ruby</category>
<category>Phlex</category>
<category>Static Sites</category>
</item>
</channel>
</rss>

View file

@ -0,0 +1,36 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>samhuri.net</title><meta name="description" content="The personal blog of Sami Samhuri"><meta name="author" content="Sami Samhuri"><link rel="canonical" href="http://localhost:8000"><meta property="og:title" content="samhuri.net"><meta property="og:description" content="The personal blog of Sami Samhuri"><meta property="og:url" content="http://localhost:8000"><meta property="og:type" content="website"><meta property="og:image" content="http://localhost:8000/images"><meta name="twitter:card" content="summary"><meta name="twitter:title" content="samhuri.net"><meta name="twitter:description" content="The personal blog of Sami Samhuri"><link rel="alternate" type="application/rss+xml" title="RSS Feed" href="http://localhost:8000/feed.xml"><link rel="alternate" type="application/json" title="JSON Feed" href="http://localhost:8000/feed.json"><link rel="stylesheet" href="http://localhost:8000/css/style.css"></head><body><header><h1><a href="/">samhuri.net</a></h1><nav><ul><li><a href="/">Home</a></li><li><a href="/posts/">Archive</a></li><li><a href="/projects/">Projects</a></li><li><a href="/about/">About</a></li></ul></nav></header><main><div class="recent-posts"><article class="post"><header><h1>Testing Pressa with Ruby and Phlex</h1><div class="post-meta"><time datetime="2025-11-11T14:00:00-08:00">11th November, 2025</time> · <a href="http://localhost:8000/posts/2025/11/test-post" class="permalink">Permalink</a></div></header><div class="post-content"><p>This is a test post to verify that Pressa is working correctly. Were building a static site generator using:</p>
<ul>
<li>Ruby 3.4</li>
<li>Phlex for HTML generation</li>
<li>Kramdown with Rouge for Markdown and syntax highlighting</li>
<li>dry-struct for immutable data models</li>
</ul>
<h2 id="code-example">Code Example</h2>
<p>Heres some Ruby code:</p>
<div class="language-ruby highlighter-rouge"><span class="k">class</span> <span class="nc">Post</span> <span class="o">&lt;</span> <span class="no">Dry</span><span class="o">::</span><span class="no">Struct</span>
<span class="n">attribute</span> <span class="ss">:title</span><span class="p">,</span> <span class="no">Types</span><span class="o">::</span><span class="no">String</span>
<span class="n">attribute</span> <span class="ss">:body</span><span class="p">,</span> <span class="no">Types</span><span class="o">::</span><span class="no">String</span>
<span class="k">end</span>
<span class="n">post</span> <span class="o">=</span> <span class="no">Post</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="ss">title: </span><span class="s2">"Hello World"</span><span class="p">,</span> <span class="ss">body: </span><span class="s2">"This is a test"</span><span class="p">)</span>
<span class="nb">puts</span> <span class="n">post</span><span class="p">.</span><span class="nf">title</span>
</div>
<h2 id="features">Features</h2>
<p>The generator supports:</p>
<ol>
<li>Hierarchical post organization (year/month)</li>
<li>Link posts (external URLs)</li>
<li>JSON and RSS feeds</li>
<li>Archive pages</li>
<li>Projects section</li>
</ol>
<p>Pretty cool, right?</p>
</div><footer class="post-footer"><div class="fin"></div></footer></article></div></main><footer><p>© 2025 Sami Samhuri</p><p>Email: <a href="mailto:sami@samhuri.net">sami@samhuri.net</a></p></footer></body></html>

View file

@ -0,0 +1,36 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>November 2025 samhuri.net</title><meta name="description" content="The personal blog of Sami Samhuri"><meta name="author" content="Sami Samhuri"><link rel="canonical" href="http://localhost:8000/posts/2025/11/"><meta property="og:title" content="November 2025 samhuri.net"><meta property="og:description" content="The personal blog of Sami Samhuri"><meta property="og:url" content="http://localhost:8000/posts/2025/11/"><meta property="og:type" content="website"><meta property="og:image" content="http://localhost:8000/images"><meta name="twitter:card" content="summary"><meta name="twitter:title" content="November 2025 samhuri.net"><meta name="twitter:description" content="The personal blog of Sami Samhuri"><link rel="alternate" type="application/rss+xml" title="RSS Feed" href="http://localhost:8000/feed.xml"><link rel="alternate" type="application/json" title="JSON Feed" href="http://localhost:8000/feed.json"><link rel="stylesheet" href="http://localhost:8000/css/style.css"></head><body><header><h1><a href="/">samhuri.net</a></h1><nav><ul><li><a href="/">Home</a></li><li><a href="/posts/">Archive</a></li><li><a href="/projects/">Projects</a></li><li><a href="/about/">About</a></li></ul></nav></header><main><article class="month-posts"><h1>November 2025</h1><article class="post"><header><h1>Testing Pressa with Ruby and Phlex</h1><div class="post-meta"><time datetime="2025-11-11T14:00:00-08:00">11th November, 2025</time> · <a href="http://localhost:8000/posts/2025/11/test-post" class="permalink">Permalink</a></div></header><div class="post-content"><p>This is a test post to verify that Pressa is working correctly. Were building a static site generator using:</p>
<ul>
<li>Ruby 3.4</li>
<li>Phlex for HTML generation</li>
<li>Kramdown with Rouge for Markdown and syntax highlighting</li>
<li>dry-struct for immutable data models</li>
</ul>
<h2 id="code-example">Code Example</h2>
<p>Heres some Ruby code:</p>
<div class="language-ruby highlighter-rouge"><span class="k">class</span> <span class="nc">Post</span> <span class="o">&lt;</span> <span class="no">Dry</span><span class="o">::</span><span class="no">Struct</span>
<span class="n">attribute</span> <span class="ss">:title</span><span class="p">,</span> <span class="no">Types</span><span class="o">::</span><span class="no">String</span>
<span class="n">attribute</span> <span class="ss">:body</span><span class="p">,</span> <span class="no">Types</span><span class="o">::</span><span class="no">String</span>
<span class="k">end</span>
<span class="n">post</span> <span class="o">=</span> <span class="no">Post</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="ss">title: </span><span class="s2">"Hello World"</span><span class="p">,</span> <span class="ss">body: </span><span class="s2">"This is a test"</span><span class="p">)</span>
<span class="nb">puts</span> <span class="n">post</span><span class="p">.</span><span class="nf">title</span>
</div>
<h2 id="features">Features</h2>
<p>The generator supports:</p>
<ol>
<li>Hierarchical post organization (year/month)</li>
<li>Link posts (external URLs)</li>
<li>JSON and RSS feeds</li>
<li>Archive pages</li>
<li>Projects section</li>
</ol>
<p>Pretty cool, right?</p>
</div><footer class="post-footer"><div class="fin"></div></footer></article></article></main><footer><p>© 2025 Sami Samhuri</p><p>Email: <a href="mailto:sami@samhuri.net">sami@samhuri.net</a></p></footer></body></html>

View file

@ -0,0 +1,36 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Testing Pressa with Ruby and Phlex samhuri.net</title><meta name="description" content="The personal blog of Sami Samhuri"><meta name="author" content="Sami Samhuri"><link rel="canonical" href="http://localhost:8000/posts/2025/11/test-post"><meta property="og:title" content="Testing Pressa with Ruby and Phlex samhuri.net"><meta property="og:description" content="The personal blog of Sami Samhuri"><meta property="og:url" content="http://localhost:8000/posts/2025/11/test-post"><meta property="og:type" content="website"><meta property="og:image" content="http://localhost:8000/images"><meta name="twitter:card" content="summary"><meta name="twitter:title" content="Testing Pressa with Ruby and Phlex samhuri.net"><meta name="twitter:description" content="The personal blog of Sami Samhuri"><link rel="alternate" type="application/rss+xml" title="RSS Feed" href="http://localhost:8000/feed.xml"><link rel="alternate" type="application/json" title="JSON Feed" href="http://localhost:8000/feed.json"><link rel="stylesheet" href="http://localhost:8000/css/style.css"></head><body><header><h1><a href="/">samhuri.net</a></h1><nav><ul><li><a href="/">Home</a></li><li><a href="/posts/">Archive</a></li><li><a href="/projects/">Projects</a></li><li><a href="/about/">About</a></li></ul></nav></header><main><article class="post"><header><h1>Testing Pressa with Ruby and Phlex</h1><div class="post-meta"><time datetime="2025-11-11T14:00:00-08:00">11th November, 2025</time> · <a href="http://localhost:8000/posts/2025/11/test-post" class="permalink">Permalink</a></div></header><div class="post-content"><p>This is a test post to verify that Pressa is working correctly. Were building a static site generator using:</p>
<ul>
<li>Ruby 3.4</li>
<li>Phlex for HTML generation</li>
<li>Kramdown with Rouge for Markdown and syntax highlighting</li>
<li>dry-struct for immutable data models</li>
</ul>
<h2 id="code-example">Code Example</h2>
<p>Heres some Ruby code:</p>
<div class="language-ruby highlighter-rouge"><span class="k">class</span> <span class="nc">Post</span> <span class="o">&lt;</span> <span class="no">Dry</span><span class="o">::</span><span class="no">Struct</span>
<span class="n">attribute</span> <span class="ss">:title</span><span class="p">,</span> <span class="no">Types</span><span class="o">::</span><span class="no">String</span>
<span class="n">attribute</span> <span class="ss">:body</span><span class="p">,</span> <span class="no">Types</span><span class="o">::</span><span class="no">String</span>
<span class="k">end</span>
<span class="n">post</span> <span class="o">=</span> <span class="no">Post</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="ss">title: </span><span class="s2">"Hello World"</span><span class="p">,</span> <span class="ss">body: </span><span class="s2">"This is a test"</span><span class="p">)</span>
<span class="nb">puts</span> <span class="n">post</span><span class="p">.</span><span class="nf">title</span>
</div>
<h2 id="features">Features</h2>
<p>The generator supports:</p>
<ol>
<li>Hierarchical post organization (year/month)</li>
<li>Link posts (external URLs)</li>
<li>JSON and RSS feeds</li>
<li>Archive pages</li>
<li>Projects section</li>
</ol>
<p>Pretty cool, right?</p>
</div><footer class="post-footer"><div class="fin"></div></footer></article></main><footer><p>© 2025 Sami Samhuri</p><p>Email: <a href="mailto:sami@samhuri.net">sami@samhuri.net</a></p></footer></body></html>

View file

@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>2025 samhuri.net</title><meta name="description" content="The personal blog of Sami Samhuri"><meta name="author" content="Sami Samhuri"><link rel="canonical" href="http://localhost:8000/posts/2025/"><meta property="og:title" content="2025 samhuri.net"><meta property="og:description" content="The personal blog of Sami Samhuri"><meta property="og:url" content="http://localhost:8000/posts/2025/"><meta property="og:type" content="website"><meta property="og:image" content="http://localhost:8000/images"><meta name="twitter:card" content="summary"><meta name="twitter:title" content="2025 samhuri.net"><meta name="twitter:description" content="The personal blog of Sami Samhuri"><link rel="alternate" type="application/rss+xml" title="RSS Feed" href="http://localhost:8000/feed.xml"><link rel="alternate" type="application/json" title="JSON Feed" href="http://localhost:8000/feed.json"><link rel="stylesheet" href="http://localhost:8000/css/style.css"></head><body><header><h1><a href="/">samhuri.net</a></h1><nav><ul><li><a href="/">Home</a></li><li><a href="/posts/">Archive</a></li><li><a href="/projects/">Projects</a></li><li><a href="/about/">About</a></li></ul></nav></header><main><article class="year-posts"><h1>2025</h1><section class="month"><h2><a href="http://localhost:8000/posts/2025/11/">November</a></h2><ul><li><a href="http://localhost:8000/posts/2025/11/test-post">Testing Pressa with Ruby and Phlex</a> 11th November, 2025</li></ul></section></article></main><footer><p>© 2025 Sami Samhuri</p><p>Email: <a href="mailto:sami@samhuri.net">sami@samhuri.net</a></p></footer></body></html>

View file

@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Archive samhuri.net</title><meta name="description" content="The personal blog of Sami Samhuri"><meta name="author" content="Sami Samhuri"><link rel="canonical" href="http://localhost:8000/posts/"><meta property="og:title" content="Archive samhuri.net"><meta property="og:description" content="The personal blog of Sami Samhuri"><meta property="og:url" content="http://localhost:8000/posts/"><meta property="og:type" content="website"><meta property="og:image" content="http://localhost:8000/images"><meta name="twitter:card" content="summary"><meta name="twitter:title" content="Archive samhuri.net"><meta name="twitter:description" content="The personal blog of Sami Samhuri"><link rel="alternate" type="application/rss+xml" title="RSS Feed" href="http://localhost:8000/feed.xml"><link rel="alternate" type="application/json" title="JSON Feed" href="http://localhost:8000/feed.json"><link rel="stylesheet" href="http://localhost:8000/css/style.css"></head><body><header><h1><a href="/">samhuri.net</a></h1><nav><ul><li><a href="/">Home</a></li><li><a href="/posts/">Archive</a></li><li><a href="/projects/">Projects</a></li><li><a href="/about/">About</a></li></ul></nav></header><main><article class="archive"><h1>Archive</h1><section class="year"><h2><a href="http://localhost:8000/posts/2025/">2025</a></h2><section class="month"><h3><a href="http://localhost:8000/posts/2025/11/">November 2025</a></h3><ul><li><a href="http://localhost:8000/posts/2025/11/test-post">Testing Pressa with Ruby and Phlex</a> 11th November, 2025</li></ul></section></section></article></main><footer><p>© 2025 Sami Samhuri</p><p>Email: <a href="mailto:sami@samhuri.net">sami@samhuri.net</a></p></footer></body></html>

View file

@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>bin samhuri.net</title><meta name="description" content="The personal blog of Sami Samhuri"><meta name="author" content="Sami Samhuri"><link rel="canonical" href="http://localhost:8000/projects/bin"><meta property="og:title" content="bin samhuri.net"><meta property="og:description" content="The personal blog of Sami Samhuri"><meta property="og:url" content="http://localhost:8000/projects/bin"><meta property="og:type" content="website"><meta property="og:image" content="http://localhost:8000/images"><meta name="twitter:card" content="summary"><meta name="twitter:title" content="bin samhuri.net"><meta name="twitter:description" content="The personal blog of Sami Samhuri"><link rel="alternate" type="application/rss+xml" title="RSS Feed" href="http://localhost:8000/feed.xml"><link rel="alternate" type="application/json" title="JSON Feed" href="http://localhost:8000/feed.json"><link rel="stylesheet" href="http://localhost:8000/css/style.css"></head><body><header><h1><a href="/">samhuri.net</a></h1><nav><ul><li><a href="/">Home</a></li><li><a href="/posts/">Archive</a></li><li><a href="/projects/">Projects</a></li><li><a href="/about/">About</a></li></ul></nav></header><main><article class="project" data-title="samsonjs/bin"><header><h1>bin</h1><p class="description">my collection of scripts in ~/bin</p><p><a href="https://github.com/samsonjs/bin">View on GitHub →</a></p></header><section class="project-stats"><h2>Statistics</h2><div id="stats"><p>Loading...</p></div></section><section class="project-contributors"><h2>Contributors</h2><div id="contributors"><p>Loading...</p></div></section><section class="project-languages"><h2>Languages</h2><div id="languages"><p>Loading...</p></div></section></article></main><footer><p>© 2025 Sami Samhuri</p><p>Email: <a href="mailto:sami@samhuri.net">sami@samhuri.net</a></p></footer></body></html>

View file

@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>config samhuri.net</title><meta name="description" content="The personal blog of Sami Samhuri"><meta name="author" content="Sami Samhuri"><link rel="canonical" href="http://localhost:8000/projects/config"><meta property="og:title" content="config samhuri.net"><meta property="og:description" content="The personal blog of Sami Samhuri"><meta property="og:url" content="http://localhost:8000/projects/config"><meta property="og:type" content="website"><meta property="og:image" content="http://localhost:8000/images"><meta name="twitter:card" content="summary"><meta name="twitter:title" content="config samhuri.net"><meta name="twitter:description" content="The personal blog of Sami Samhuri"><link rel="alternate" type="application/rss+xml" title="RSS Feed" href="http://localhost:8000/feed.xml"><link rel="alternate" type="application/json" title="JSON Feed" href="http://localhost:8000/feed.json"><link rel="stylesheet" href="http://localhost:8000/css/style.css"></head><body><header><h1><a href="/">samhuri.net</a></h1><nav><ul><li><a href="/">Home</a></li><li><a href="/posts/">Archive</a></li><li><a href="/projects/">Projects</a></li><li><a href="/about/">About</a></li></ul></nav></header><main><article class="project" data-title="samsonjs/config"><header><h1>config</h1><p class="description">important dot files</p><p><a href="https://github.com/samsonjs/config">View on GitHub →</a></p></header><section class="project-stats"><h2>Statistics</h2><div id="stats"><p>Loading...</p></div></section><section class="project-contributors"><h2>Contributors</h2><div id="contributors"><p>Loading...</p></div></section><section class="project-languages"><h2>Languages</h2><div id="languages"><p>Loading...</p></div></section></article></main><footer><p>© 2025 Sami Samhuri</p><p>Email: <a href="mailto:sami@samhuri.net">sami@samhuri.net</a></p></footer></body></html>

View file

@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Projects samhuri.net</title><meta name="description" content="The personal blog of Sami Samhuri"><meta name="author" content="Sami Samhuri"><link rel="canonical" href="http://localhost:8000/projects/"><meta property="og:title" content="Projects samhuri.net"><meta property="og:description" content="The personal blog of Sami Samhuri"><meta property="og:url" content="http://localhost:8000/projects/"><meta property="og:type" content="website"><meta property="og:image" content="http://localhost:8000/images"><meta name="twitter:card" content="summary"><meta name="twitter:title" content="Projects samhuri.net"><meta name="twitter:description" content="The personal blog of Sami Samhuri"><link rel="alternate" type="application/rss+xml" title="RSS Feed" href="http://localhost:8000/feed.xml"><link rel="alternate" type="application/json" title="JSON Feed" href="http://localhost:8000/feed.json"><link rel="stylesheet" href="http://localhost:8000/css/style.css"></head><body><header><h1><a href="/">samhuri.net</a></h1><nav><ul><li><a href="/">Home</a></li><li><a href="/posts/">Archive</a></li><li><a href="/projects/">Projects</a></li><li><a href="/about/">About</a></li></ul></nav></header><main><article class="projects"><h1>Projects</h1><p>Open source projects I've created or contributed to.</p><ul class="projects-list"><li><a href="http://localhost:8000/projects/bin">bin</a> my collection of scripts in ~/bin</li><li><a href="http://localhost:8000/projects/config">config</a> important dot files</li><li><a href="http://localhost:8000/projects/unix-node">unix-node</a> Node.js CommonJS module that exports useful Unix commands</li><li><a href="http://localhost:8000/projects/strftime">strftime</a> strftime for JavaScript</li></ul></article></main><footer><p>© 2025 Sami Samhuri</p><p>Email: <a href="mailto:sami@samhuri.net">sami@samhuri.net</a></p></footer></body></html>

View file

@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>strftime samhuri.net</title><meta name="description" content="The personal blog of Sami Samhuri"><meta name="author" content="Sami Samhuri"><link rel="canonical" href="http://localhost:8000/projects/strftime"><meta property="og:title" content="strftime samhuri.net"><meta property="og:description" content="The personal blog of Sami Samhuri"><meta property="og:url" content="http://localhost:8000/projects/strftime"><meta property="og:type" content="website"><meta property="og:image" content="http://localhost:8000/images"><meta name="twitter:card" content="summary"><meta name="twitter:title" content="strftime samhuri.net"><meta name="twitter:description" content="The personal blog of Sami Samhuri"><link rel="alternate" type="application/rss+xml" title="RSS Feed" href="http://localhost:8000/feed.xml"><link rel="alternate" type="application/json" title="JSON Feed" href="http://localhost:8000/feed.json"><link rel="stylesheet" href="http://localhost:8000/css/style.css"></head><body><header><h1><a href="/">samhuri.net</a></h1><nav><ul><li><a href="/">Home</a></li><li><a href="/posts/">Archive</a></li><li><a href="/projects/">Projects</a></li><li><a href="/about/">About</a></li></ul></nav></header><main><article class="project" data-title="samsonjs/strftime"><header><h1>strftime</h1><p class="description">strftime for JavaScript</p><p><a href="https://github.com/samsonjs/strftime">View on GitHub →</a></p></header><section class="project-stats"><h2>Statistics</h2><div id="stats"><p>Loading...</p></div></section><section class="project-contributors"><h2>Contributors</h2><div id="contributors"><p>Loading...</p></div></section><section class="project-languages"><h2>Languages</h2><div id="languages"><p>Loading...</p></div></section></article></main><footer><p>© 2025 Sami Samhuri</p><p>Email: <a href="mailto:sami@samhuri.net">sami@samhuri.net</a></p></footer></body></html>

View file

@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>unix-node samhuri.net</title><meta name="description" content="The personal blog of Sami Samhuri"><meta name="author" content="Sami Samhuri"><link rel="canonical" href="http://localhost:8000/projects/unix-node"><meta property="og:title" content="unix-node samhuri.net"><meta property="og:description" content="The personal blog of Sami Samhuri"><meta property="og:url" content="http://localhost:8000/projects/unix-node"><meta property="og:type" content="website"><meta property="og:image" content="http://localhost:8000/images"><meta name="twitter:card" content="summary"><meta name="twitter:title" content="unix-node samhuri.net"><meta name="twitter:description" content="The personal blog of Sami Samhuri"><link rel="alternate" type="application/rss+xml" title="RSS Feed" href="http://localhost:8000/feed.xml"><link rel="alternate" type="application/json" title="JSON Feed" href="http://localhost:8000/feed.json"><link rel="stylesheet" href="http://localhost:8000/css/style.css"></head><body><header><h1><a href="/">samhuri.net</a></h1><nav><ul><li><a href="/">Home</a></li><li><a href="/posts/">Archive</a></li><li><a href="/projects/">Projects</a></li><li><a href="/about/">About</a></li></ul></nav></header><main><article class="project" data-title="samsonjs/unix-node"><header><h1>unix-node</h1><p class="description">Node.js CommonJS module that exports useful Unix commands</p><p><a href="https://github.com/samsonjs/unix-node">View on GitHub →</a></p></header><section class="project-stats"><h2>Statistics</h2><div id="stats"><p>Loading...</p></div></section><section class="project-contributors"><h2>Contributors</h2><div id="contributors"><p>Loading...</p></div></section><section class="project-languages"><h2>Languages</h2><div id="languages"><p>Loading...</p></div></section></article></main><footer><p>© 2025 Sami Samhuri</p><p>Email: <a href="mailto:sami@samhuri.net">sami@samhuri.net</a></p></footer></body></html>