Migrate from Swift to Ruby (#33)

Replace the Swift site generator with a Ruby and Phlex implementation.
Loads site and projects from TOML, derive site metadata from posts.

Migrate from make to bake and add standardrb and code coverage tasks.

Update CI and docs to match the new workflow, and remove unused
assets/dependencies plus obsolete tooling.
This commit is contained in:
Sami Samhuri 2026-02-07 21:19:03 -08:00 committed by GitHub
parent 23e62f4a49
commit 007b1058b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
297 changed files with 5847 additions and 57263 deletions

65
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,65 @@
name: CI
on:
push:
branches:
- main
pull_request:
jobs:
coverage:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
bundler-cache: true
- name: Bootstrap
run: bin/bootstrap
- name: Coverage
run: bundle exec bake coverage
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
bundler-cache: true
- name: Bootstrap
run: bin/bootstrap
- name: Lint
run: bundle exec bake lint
debug:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
bundler-cache: true
- name: Bootstrap
run: bin/bootstrap
- name: Debug Build
run: bundle exec bake debug

1
.gitignore vendored
View file

@ -1,3 +1,2 @@
www www
Tests/*/actual Tests/*/actual
bin/gensite

1
.ruby-version Normal file
View file

@ -0,0 +1 @@
4.0.1

75
AGENTS.md Normal file
View file

@ -0,0 +1,75 @@
# Repository Guidelines
## Project Structure & Module Organization
This repository is a single Ruby static-site generator project (the legacy Swift generators were removed).
- Generator code: `lib/pressa/` (entrypoint: `lib/pressa.rb`)
- Build/deploy/draft tasks: `bake.rb`
- Tests: `test/`
- Site config: `site.toml`, `projects.toml`
- Published posts: `posts/YYYY/MM/*.md`
- Static and renderable public content: `public/`
- Draft posts: `public/drafts/`
- Generated output: `www/` (safe to delete/regenerate)
Keep new code under the existing `Pressa` module structure (for example `lib/pressa/posts`, `lib/pressa/projects`, `lib/pressa/views`, `lib/pressa/config`, `lib/pressa/utils`) and add matching tests under `test/`.
## Setup, Build, Test, and Development Commands
- Use `rbenv exec` for Ruby commands in this repository (for example `rbenv exec bundle exec ...`) to ensure the project Ruby version is used.
- `bin/bootstrap`: install prerequisites and gems (uses `rbenv` when available).
- `rbenv exec bundle exec bake debug`: build for `http://localhost:8000` into `www/`.
- `rbenv exec bundle exec bake serve`: serve `www/` via WEBrick on port 8000.
- `rbenv exec bundle exec bake watch target=debug`: Linux-only autorebuild loop (`inotifywait` required).
- `rbenv exec bundle exec bake mudge|beta|release`: build with environment-specific base URLs.
- `rbenv exec bundle exec bake publish_beta|publish`: build and rsync `www/` to remote host.
- `rbenv exec bundle exec bake clean`: remove `www/`.
- `rbenv exec bundle exec bake test`: run test suite.
- `rbenv exec bundle exec bake lint`: lint code.
- `rbenv exec bundle exec bake lint_fix`: auto-fix lint issues.
- `rbenv exec bundle exec bake coverage`: run tests and report `lib/` line coverage.
- `rbenv exec bundle exec bake coverage_regression baseline=merge-base`: compare coverage to a baseline and fail on regression (override `baseline` as needed).
## Draft Workflow
- `rbenv exec bundle exec bake new_draft "Post Title"` creates `public/drafts/<slug>.md`.
- `rbenv exec bundle exec bake drafts` lists available drafts.
- `rbenv exec bundle exec bake publish_draft public/drafts/<slug>.md` moves draft to `posts/YYYY/MM/` and updates `Date` and `Timestamp`.
## Content and Metadata Requirements
Posts must include YAML front matter. Required keys (enforced by `Pressa::Posts::PostMetadata`) are:
- `Title`
- `Author`
- `Date`
- `Timestamp`
Optional keys include `Tags`, `Link`, `Scripts`, and `Styles`.
## Coding Style & Naming Conventions
- Ruby (see `.ruby-version`).
- Follow idiomatic Ruby style and keep code `bake lint`-clean.
- Use 2-space indentation and descriptive `snake_case` names for methods/variables, `UpperCamelCase` for classes/modules.
- Prefer small, focused classes for plugins, views, renderers, and config loaders.
- Do not hand-edit generated files in `www/`.
## Testing Guidelines
- Use Minitest under `test/` (for example `test/posts`, `test/config`, `test/views`).
- Add regression tests for parser, rendering, feed, and generator behavior changes.
- Before submitting, run:
- `rbenv exec bundle exec bake test`
- `rbenv exec bundle exec bake coverage`
- `rbenv exec bundle exec bake lint`
- `rbenv exec bundle exec bake debug`
## Commit & Pull Request Guidelines
- Use concise, imperative commit subjects (history examples: `Fix internal permalink regression in archives`).
- Keep commits scoped to one concern (generator logic, content, or deployment changes).
- In PRs, include motivation, verification commands run, and deployment impact.
- Include screenshots when changing rendered layout/CSS output.
## Deployment & Security Notes
- Deployment is defined in `bake.rb` via rsync over SSH.
- Current publish host is `mudge` with:
- production: `/var/www/samhuri.net/public`
- beta: `/var/www/beta.samhuri.net/public`
- Validate `www/` before publishing to avoid shipping stale assets.
- Never commit credentials, SSH keys, or other secrets.

15
Gemfile Normal file
View file

@ -0,0 +1,15 @@
source "https://rubygems.org"
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 "guard", "~> 2.18"
gem "minitest", "~> 6.0"
gem "standard", "~> 1.43"
end

178
Gemfile.lock Normal file
View file

@ -0,0 +1,178 @@
GEM
remote: https://rubygems.org/
specs:
ast (2.4.3)
bake (0.24.1)
bigdecimal
samovar (~> 2.1)
bigdecimal (4.0.1)
builder (3.3.0)
coderay (1.1.3)
concurrent-ruby (1.3.6)
console (1.34.2)
fiber-annotation
fiber-local (~> 1.1)
json
dry-core (1.2.0)
concurrent-ruby (~> 1.0)
logger
zeitwerk (~> 2.6)
dry-inflector (1.3.1)
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.9.1)
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.3-aarch64-linux-gnu)
ffi (1.17.3-aarch64-linux-musl)
ffi (1.17.3-arm-linux-gnu)
ffi (1.17.3-arm-linux-musl)
ffi (1.17.3-arm64-darwin)
ffi (1.17.3-x86-linux-gnu)
ffi (1.17.3-x86-linux-musl)
ffi (1.17.3-x86_64-darwin)
ffi (1.17.3-x86_64-linux-gnu)
ffi (1.17.3-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.20.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)
pry (>= 0.13.0)
shellany (~> 0.0)
thor (>= 0.18.1)
ice_nine (0.11.2)
io-console (0.8.2)
json (2.18.1)
kramdown (2.5.2)
rexml (>= 3.4.4)
kramdown-parser-gfm (1.1.0)
kramdown (~> 2.0)
language_server-protocol (3.17.0.5)
lint_roller (1.1.0)
listen (3.10.0)
logger
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)
minitest (6.0.1)
prism (~> 1.5)
nenv (0.3.0)
notiffany (0.1.3)
nenv (~> 0.1)
shellany (~> 0.0)
parallel (1.27.0)
parser (3.3.10.1)
ast (~> 2.4.1)
racc
phlex (2.4.1)
refract (~> 1.0)
zeitwerk (~> 2.7)
prism (1.9.0)
pry (0.16.0)
coderay (~> 1.1)
method_source (~> 1.0)
reline (>= 0.6.0)
racc (1.8.1)
rainbow (3.1.1)
rb-fsevent (0.11.2)
rb-inotify (0.11.1)
ffi (~> 1.0)
refract (1.1.0)
prism
zeitwerk
regexp_parser (2.11.3)
reline (0.6.3)
io-console (~> 0.5)
rexml (3.4.4)
rouge (4.7.0)
rubocop (1.82.1)
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.48.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.49.0)
parser (>= 3.3.7.2)
prism (~> 1.7)
rubocop-performance (1.26.1)
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0)
ruby-progressbar (1.13.0)
samovar (2.4.1)
console (~> 1.0)
mapping (~> 1.0)
shellany (0.0.1)
standard (1.53.0)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.0)
rubocop (~> 1.82.0)
standard-custom (~> 1.0.0)
standard-performance (~> 1.8)
standard-custom (1.0.2)
lint_roller (~> 1.0)
rubocop (~> 1.50)
standard-performance (1.9.0)
lint_roller (~> 1.1)
rubocop-performance (~> 1.26.0)
thor (1.5.0)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.2.0)
zeitwerk (2.7.4)
PLATFORMS
aarch64-linux-gnu
aarch64-linux-musl
arm-linux-gnu
arm-linux-musl
arm64-darwin
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)
kramdown (~> 2.5)
kramdown-parser-gfm (~> 1.1)
minitest (~> 6.0)
phlex (~> 2.3)
rouge (~> 4.6)
standard (~> 1.43)
BUNDLED WITH
4.0.6

View file

@ -1,51 +0,0 @@
all: debug
debug:
@echo
bin/build-gensite
bin/gensite . www http://localhost:8000
mudge:
@echo
bin/build-gensite
bin/gensite . www http://mudge:8000
beta: clean_blog
@echo
bin/build-gensite
bin/gensite . www https://beta.samhuri.net
release: clean_blog
@echo
bin/build-gensite
bin/gensite . www
publish: release
@echo
bin/publish --delete www/
publish_beta: beta
@echo
bin/publish --beta --delete www/
clean: clean_blog
clean_blog:
@echo
rm -rf www/* www/.htaccess
clean_swift:
@echo
rm -rf gensite/.build
rm -rf $(HOME)/Library/Developer/Xcode/DerivedData/gensite-*
rm -rf samhuri.net/.build
rm -rf $(HOME)/Library/Developer/Xcode/DerivedData/samhuri-*
serve:
@echo
cd www && python3 -m http.server --bind localhost
watch:
bin/watch
.PHONY: debug beta release publish publish_beta clean clean_blog clean_swift serve watch

283
Readme.md
View file

@ -1,252 +1,111 @@
# samhuri.net # samhuri.net
The source code for [samhuri.net](https://samhuri.net). Source code for [samhuri.net](https://samhuri.net), powered by a Ruby static site generator.
## Overview ## Overview
This is a custom static site generator written in Swift and geared towards blogging, though it's built to be flexible enough to be any kind of static site. As is tradition it gets a lot more attention than my actual writing for the blog. This repository contains the Ruby static-site generator and site content for samhuri.net.
If what you want is an artisanal, hand-crafted, static site generator for your personal blog then this might be a decent starting point. If you want a static site generator for other purposes then this has the bones you need to do that too, by ripping out the bundled plugins for posts and projects and writing your own. If what you want is an artisanal, hand-crafted, static site generator for your personal blog then this might be a decent starting point. If you want a static site generator for other purposes then this has the bones you need to do that too, by ripping out the bundled plugins for posts and projects and writing your own.
Some features: - Generator core: `lib/pressa/` (entrypoint: `lib/pressa.rb`)
- Build tasks and utility workflows: `bake.rb`
- Tests: `test/`
- Config: `site.toml` and `projects.toml`
- Content: `posts/` and `public/`
- Output: `www/`
- Plugin-based architecture, including plugins for rendering posts and projects ## Requirements
- Uses Markdown for posts, rendered using [Ink][] and [Plot][] by [@johnsundell][]
- Supports the notion of a link post
- Generates RSS and JSON feeds
- Runs on Linux and macOS, requires Swift 6.0+
If you don't use the posts or projects plugins then what this does at its core is transform and copy files from `public/` to `www/`, and the only transforms that it performs is Markdown to HTML. Everything else is layered on top of this foundation. - Ruby (see `.ruby-version`)
- Bundler
- `rbenv` recommended
Posts are [organized by year/month directories](https://github.com/samsonjs/samhuri.net/tree/main/posts), there's [an archive page that lists all posts at /posts](https://samhuri.net/posts), plus individual pages for [each year at /posts/2011](https://samhuri.net/posts/2011) and [each month at /posts/2011/12](https://samhuri.net/posts/2011/12). You can throw [any Markdown file](https://github.com/samsonjs/samhuri.net/blob/main/public/about.md) in `public/` and it gets [rendered as HTML using your site's layout](https://samhuri.net/about). ## Setup
The main project is in the [samhuri.net directory][], and there's a second project for the command line tool called [gensite][] that uses the samhuri.net package. The entry points to everything live in the Makefile and the bin/ directory so those are good starting points for exploration. This project isn't intended to be a reusable library but rather something that you can fork and make your own without doing a ton of work beyond renaming some things and plugging in your personal info.
[samhuri.net directory]: https://github.com/samsonjs/samhuri.net/tree/main/samhuri.net
[gensite]: https://github.com/samsonjs/samhuri.net/tree/main/gensite
[Ink]: https://github.com/johnsundell/ink
[Plot]: https://github.com/johnsundell/plot
[@johnsundell]: https://github.com/johnsundell
### Post format
Posts are formatted with Markdown, and require this front-matter (build will fail without these fields):
```
---
Title: What's Golden
Author: Chali 2na
Date: 5th June, 2025
Timestamp: 2025-06-05T09:41:42-07:00
Tags: Ruby, C, structs, interop
Link: https://example.net/chali-2na/whats-golden # For link posts
---
```
## Getting started
Clone this repo and build my blog:
```bash ```bash
git clone https://github.com/samsonjs/samhuri.net.git bin/bootstrap
cd samhuri.net
make debug
``` ```
Start a local development server: Or manually:
```bash ```bash
make serve # http://localhost:8000 rbenv install -s "$(cat .ruby-version)"
make watch # Auto-rebuild on file changes (Linux only) bundle install
``` ```
## Workflows ## Build And Serve
Work on drafts in `public/drafts/` and publish/edit posts in `posts/YYYY/MM/`. The build process renders source files from these directories:
- posts: Markdown files organized in subdirectories by year and month that are rendered into `www/posts/YYYY/MM/`
- public: static files that are copied directly to the output directory `www/`, rendering Markdown along the way
- public/drafts: by extension this is automatically handled, nothing special for drafts they're just regular pages
```bash ```bash
bin/new-draft # Create a new empty draft post with frontmatter bake debug # build for http://localhost:8000
bin/new-draft hello # You can pass in a title if you want using any number of args, quotes not needed bake serve # serve www/ locally
bin/publish-draft public/drafts/hello.md # Publish a draft (updates date and timestamp to current time)
make debug # Build for local development, browse at http://localhost:8000 after running make serve
make serve # Start local server at http://localhost:8000
make beta # Build for staging at https://beta.samhuri.net
make publish_beta # Deploy to staging server
make release # Build for production at https://samhuri.net
make publish # Deploy to production server
``` ```
## Configuration
Site metadata and project data are configured with TOML files at the repository root:
- `site.toml`: site identity, default scripts/styles, and a `plugins` list (for example `["posts", "projects"]`), plus `projects_plugin` assets when that plugin is enabled.
- `projects.toml`: project listing entries using `[[projects]]`.
`Pressa.create_site` loads both files from the provided `source_path` and supports URL overrides for `debug`, `beta`, and `release` builds.
## Customizing for your site ## Customizing for your site
If this seems like a reasonable workflow then you could see what it takes to make it your own. If this workflow seems like a good fit, here is the minimum to make it your own:
### Essential changes - Update `site.toml` with your site identity (`author`, `email`, `title`, `description`, `url`) and any global `scripts` / `styles`.
- Set `plugins` in `site.toml` to explicitly enable features (`"posts"`, `"projects"`). Safe default if omitted is no plugins.
- Define your projects in `projects.toml` using `[[projects]]` entries with `name`, `title`, `description`, and `url`.
- Configure project-page-only assets in `site.toml` under `[projects_plugin]` (`scripts` and `styles`) when using the `"projects"` plugin.
- Add custom plugins by implementing `Pressa::Plugin` in `lib/pressa/` and registering them in `lib/pressa/config/loader.rb`.
- Adjust rendering and layout in `lib/pressa/views/` and the static content in `public/` as needed.
0. Probably **rename everything** unless you want to impersonate me 🥸 Other targets:
1. **Update site configuration** in `samhuri.net/Sources/samhuri.net/samhuri.net.swift`: ```bash
- Site title, description, author name bake mudge
- Base URL for your domain bake beta
- RSS/JSON feed metadata bake release
bake watch target=debug
2. **Modify deployment** in `bin/publish`: bake clean
- Update rsync destination to your server bake publish_beta
- Adjust staging/production URLs in Makefile bake publish
3. **Customize styling** in `public/css/style.css`
4. **Replace static assets** in `public/`:
- Favicon, apple-touch-icon
- About page, CV, any personal content or pages you want go in here
## How it works
There's a `Site` that contains everything needed to render the site:
```swift
struct Site {
let author: String
let email: String
let title: String
let description: String
let imageURL: URL?
let url: URL
let scripts: [Script]
let styles: [Stylesheet]
let renderers: [Renderer]
let plugins: [Plugin]
}
``` ```
There are `Renderer`s that plugins use to transform files, e.g. Markdown to HTML: ## Draft Workflow
```swift ```bash
protocol Renderer { bake new_draft "Post title"
func canRenderFile(named filename: String, withExtension ext: String?) -> Bool bake drafts
func render(site: Site, fileURL: URL, targetDir: URL) throws bake publish_draft public/drafts/post-title.md
}
``` ```
And this is the `Plugin` protocol: Published posts in `posts/YYYY/MM/*.md` require YAML front matter keys:
```swift - `Title`
protocol Plugin { - `Author`
func setUp(site: Site, sourceURL: URL) throws - `Date`
func render(site: Site, targetURL: URL) throws - `Timestamp`
}
## Tests And Lint
```bash
bake test
standardrb
``` ```
Your site plus its renderers and plugins defines everything that it can do. Or via bake:
```swift ```bash
public enum samhuri {} bake test
bake lint
public extension samhuri { bake lint_fix
struct net {
let siteURLOverride: URL?
public init(siteURLOverride: URL? = nil) {
self.siteURLOverride = siteURLOverride
}
public func generate(sourceURL: URL, targetURL: URL) throws {
let renderer = PageRenderer()
let site = makeSite(renderer: renderer)
let generator = try SiteGenerator(sourceURL: sourceURL, site: site)
try generator.generate(targetURL: targetURL)
}
func makeSite(renderer: PageRenderer) -> Site {
let projectsPlugin = ProjectsPlugin.Builder(renderer: renderer)
.path("projects")
.assets(TemplateAssets(scripts: [
"https://ajax.googleapis.com/ajax/libs/prototype/1.6.1.0/prototype.js",
"gitter.js",
"store.js",
"projects.js",
], styles: []))
.add("bin", description: "my collection of scripts in ~/bin")
.add("config", description: "important dot files (zsh, emacs, vim, screen)")
.add("compiler", description: "a compiler targeting x86 in Ruby")
.add("lake", description: "a simple implementation of Scheme in C")
.add("strftime", description: "strftime for JavaScript")
.add("format", description: "printf for JavaScript")
.add("gitter", description: "a GitHub client for Node (v3 API)")
.add("mojo.el", description: "turn emacs into a sweet mojo editor")
.add("ThePusher", description: "Github post-receive hook router")
.add("NorthWatcher", description: "cron for filesystem changes")
.add("repl-edit", description: "edit Node repl commands with your text editor")
.add("cheat.el", description: "cheat from emacs")
.add("batteries", description: "a general purpose node library")
.add("samhuri.net", description: "this site")
.build()
let postsPlugin = PostsPlugin.Builder(renderer: renderer)
.path("posts")
.jsonFeed(
iconPath: "images/apple-touch-icon-300.png",
faviconPath: "images/apple-touch-icon-80.png"
)
.rssFeed()
.build()
return Site.Builder(
title: "samhuri.net",
description: "Sami Samhuri's blog about programming, mainly about iOS and Ruby and Rails these days.",
author: "Sami Samhuri",
imagePath: "images/me.jpg",
email: "sami@samhuri.net",
url: siteURLOverride ?? URL(string: "https://samhuri.net")!
)
.styles("normalize.css", "style.css", "fontawesome.min.css", "brands.min.css", "solid.min.css")
.renderMarkdown(pageRenderer: renderer)
.plugin(projectsPlugin)
.plugin(postsPlugin)
.build()
}
}
}
``` ```
You can swap out the [posts plugin][PostsPlugin] for something that handles recipes, or photos, or documentation, or whatever. Each plugin defines how to find content files, process them, and where to put the output. So while this is currently set up as a blog generator the underlying architecture doesn't dictate that at all. ## Notes
[PostsPlugin]: https://github.com/samsonjs/samhuri.net/blob/main/samhuri.net/Sources/samhuri.net/Posts/PostsPlugin.swift - `bake watch` is Linux-only and requires `inotifywait`.
[ProjectsPlugin]: https://github.com/samsonjs/samhuri.net/blob/main/samhuri.net/Sources/samhuri.net/Projects/ProjectsPlugin.swift - Deployment uses `rsync` to host `mudge` (configured in `bake.rb`):
- production: `/var/www/samhuri.net/public`
Here's what a plugin might look like for generating photo galleries: - beta: `/var/www/beta.samhuri.net/public`
```swift
final class PhotoPlugin: Plugin {
private var galleries: [Gallery] = []
func setUp(site: Site, sourceURL: URL) throws {
let photosURL = sourceURL.appendingPathComponent("photos")
let galleryDirs = try FileManager.default.contentsOfDirectory(at: photosURL, ...)
for galleryDir in galleryDirs {
let imageFiles = try FileManager.default.contentsOfDirectory(at: galleryDir, ...)
.filter { $0.pathExtension.lowercased() == "jpg" }
galleries.append(Gallery(name: galleryDir.lastPathComponent, images: imageFiles))
}
}
func render(site: Site, targetURL: URL) throws {
let galleriesURL = targetURL.appendingPathComponent("galleries")
for gallery in galleries {
let galleryDirectory = galleriesURL.appendingPathComponent(gallery.name)
// Generate HTML in the targetURL directory using Ink and Plot, or whatever else you want
}
}
}
```
## License
Released under the terms of the [MIT license](https://sjs.mit-license.org).

484
bake.rb Normal file
View file

@ -0,0 +1,484 @@
# Build tasks for samhuri.net static site generator
require "etc"
require "fileutils"
require "open3"
require "tmpdir"
LIB_PATH = File.expand_path("lib", __dir__).freeze
$LOAD_PATH.unshift(LIB_PATH) unless $LOAD_PATH.include?(LIB_PATH)
DRAFTS_DIR = "public/drafts".freeze
PUBLISH_HOST = "mudge".freeze
PRODUCTION_PUBLISH_DIR = "/var/www/samhuri.net/public".freeze
BETA_PUBLISH_DIR = "/var/www/beta.samhuri.net/public".freeze
WATCHABLE_DIRECTORIES = %w[public posts lib].freeze
LINT_TARGETS = %w[bake.rb Gemfile lib test].freeze
BUILD_TARGETS = %w[debug mudge beta release].freeze
# 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
# Create a new draft in public/drafts/.
# @parameter title_parts [Array] Optional title words; defaults to Untitled.
def new_draft(*title_parts)
title, filename =
if title_parts.empty?
["Untitled", next_available_draft]
else
given_title = title_parts.join(" ")
slug = slugify(given_title)
abort "Error: title cannot be converted to a filename." if slug.empty?
filename = "#{slug}.md"
path = draft_path(filename)
abort "Error: draft already exists at #{path}" if File.exist?(path)
[given_title, filename]
end
FileUtils.mkdir_p(DRAFTS_DIR)
path = draft_path(filename)
content = render_draft_template(title)
File.write(path, content)
puts "Created new draft at #{path}"
puts ">>> Contents below <<<"
puts
puts content
end
# Publish a draft by moving it to posts/YYYY/MM and updating dates.
# @parameter input_path [String] Draft path or filename in public/drafts.
def publish_draft(input_path = nil)
if input_path.nil? || input_path.strip.empty?
puts "Usage: bake publish_draft <draft-path-or-filename>"
puts
puts "Available drafts:"
drafts = Dir.glob("#{DRAFTS_DIR}/*.md").map { |path| File.basename(path) }
if drafts.empty?
puts " (no drafts found)"
else
drafts.each { |draft| puts " #{draft}" }
end
abort
end
draft_path_value, draft_file = resolve_draft_input(input_path)
abort "Error: File not found: #{draft_path_value}" unless File.exist?(draft_path_value)
now = Time.now
content = File.read(draft_path_value)
content.sub!(/^Date:.*$/, "Date: #{ordinal_date(now)}")
content.sub!(/^Timestamp:.*$/, "Timestamp: #{now.strftime("%Y-%m-%dT%H:%M:%S%:z")}")
target_dir = "posts/#{now.strftime("%Y/%m")}"
FileUtils.mkdir_p(target_dir)
target_path = "#{target_dir}/#{draft_file}"
File.write(target_path, content)
FileUtils.rm_f(draft_path_value)
puts "Published draft: #{draft_path_value} -> #{target_path}"
end
# Watch content directories and rebuild on every change.
# @parameter target [String] One of debug, mudge, beta, or release.
def watch(target: "debug")
unless command_available?("inotifywait")
abort "inotifywait is required (install inotify-tools)."
end
loop do
abort "Error: watch failed." unless system("inotifywait", "-e", "modify,create,delete,move", *watch_paths)
puts "changed at #{Time.now}"
sleep 2
run_build_target(target)
end
end
# Publish to beta/staging server
def publish_beta
beta
run_rsync(local_paths: ["www/"], publish_dir: BETA_PUBLISH_DIR, dry_run: false, delete: true)
end
# Publish to production server
def publish
release
run_rsync(local_paths: ["www/"], publish_dir: PRODUCTION_PUBLISH_DIR, dry_run: false, delete: true)
end
# Clean generated files
def clean
FileUtils.rm_rf("www")
puts "Cleaned www/ directory"
end
# Default task: run coverage and lint.
def default
coverage
lint
end
# Run Minitest tests
def test
run_test_suite(test_file_list)
end
# Run Guard for continuous testing
def guard
exec "bundle exec guard"
end
# List all available drafts
def drafts
Dir.glob("#{DRAFTS_DIR}/*.md").sort.each do |draft|
puts File.basename(draft)
end
end
# Run StandardRB linter
def lint
run_standardrb
end
# Auto-fix StandardRB issues
def lint_fix
run_standardrb("--fix")
end
# Measure line coverage for files under lib/.
# @parameter lowest [Integer] Number of lowest-covered files to print (default: 10, use 0 to hide).
def coverage(lowest: 10)
lowest_count = Integer(lowest)
abort "Error: lowest must be >= 0." if lowest_count.negative?
run_coverage(test_files: test_file_list, lowest_count:)
end
# Compare line coverage for files under lib/ against a baseline and fail on regression.
# @parameter baseline [String] Baseline ref, or "merge-base" (default) to compare against merge-base with remote default branch.
# @parameter lowest [Integer] Number of lowest-covered files to print for the current checkout (default: 10, use 0 to hide).
def coverage_regression(baseline: "merge-base", lowest: 10)
lowest_count = Integer(lowest)
abort "Error: lowest must be >= 0." if lowest_count.negative?
baseline_ref = resolve_coverage_baseline_ref(baseline)
baseline_commit = capture_command("git", "rev-parse", "--short", baseline_ref).strip
puts "Running coverage for current checkout..."
current_output = capture_coverage_output(test_files: test_file_list, lowest_count:, chdir: Dir.pwd)
print current_output
current_percent = parse_coverage_percent(current_output)
puts "Running coverage for baseline #{baseline_ref} (#{baseline_commit})..."
baseline_percent = with_temporary_worktree(ref: baseline_ref) do |worktree_path|
baseline_tests = test_file_list(chdir: worktree_path)
baseline_output = capture_coverage_output(test_files: baseline_tests, lowest_count: 0, chdir: worktree_path)
parse_coverage_percent(baseline_output)
end
delta = current_percent - baseline_percent
puts format("Baseline coverage (%s %s): %.2f%%", baseline_ref, baseline_commit, baseline_percent)
puts format("Coverage delta: %+0.2f%%", delta)
return unless delta.negative?
abort format("Error: coverage regressed by %.2f%% against %s (%s).", -delta, baseline_ref, baseline_commit)
end
private
def run_test_suite(test_files)
run_command("ruby", "-Ilib", "-Itest", "-e", "ARGV.each { |file| require File.expand_path(file) }", *test_files)
end
def run_coverage(test_files:, lowest_count:)
output = capture_coverage_output(test_files:, lowest_count:, chdir: Dir.pwd)
print output
end
def test_file_list(chdir: Dir.pwd)
test_files = Dir.chdir(chdir) { Dir.glob("test/**/*_test.rb").sort }
abort "Error: no tests found in test/**/*_test.rb under #{chdir}" if test_files.empty?
test_files
end
def coverage_script(lowest_count:)
<<~RUBY
require "coverage"
root = Dir.pwd
lib_root = File.join(root, "lib") + "/"
Coverage.start(lines: true)
at_exit do
result = Coverage.result
rows = result.keys
.select { |file| file.start_with?(lib_root) && file.end_with?(".rb") }
.sort
.map do |file|
lines = result[file][:lines] || []
total = 0
covered = 0
lines.each do |line_count|
next if line_count.nil?
total += 1
covered += 1 if line_count.positive?
end
percent = total.zero? ? 100.0 : (covered.to_f / total * 100)
[file, covered, total, percent]
end
covered_lines = rows.sum { |row| row[1] }
total_lines = rows.sum { |row| row[2] }
overall_percent = total_lines.zero? ? 100.0 : (covered_lines.to_f / total_lines * 100)
puts format("Coverage (lib): %.2f%% (%d / %d lines)", overall_percent, covered_lines, total_lines)
unless #{lowest_count}.zero? || rows.empty?
puts "Lowest covered files:"
rows.sort_by { |row| row[3] }.first(#{lowest_count}).each do |file, covered, total, percent|
relative_path = file.delete_prefix(root + "/")
puts format(" %6.2f%% %d/%d %s", percent, covered, total, relative_path)
end
end
end
ARGV.each { |file| require File.expand_path(file) }
RUBY
end
def capture_coverage_output(test_files:, lowest_count:, chdir:)
capture_command("ruby", "-Ilib", "-Itest", "-e", coverage_script(lowest_count:), *test_files, chdir:)
end
def parse_coverage_percent(output)
match = output.match(/Coverage \(lib\):\s+([0-9]+\.[0-9]+)%/)
abort "Error: unable to parse coverage output." unless match
Float(match[1])
end
def resolve_coverage_baseline_ref(baseline)
baseline_name = baseline.to_s.strip
abort "Error: baseline cannot be empty." if baseline_name.empty?
return coverage_merge_base_ref if baseline_name == "merge-base"
baseline_name
end
def coverage_merge_base_ref
remote = preferred_remote
remote_head_ref = remote_default_branch_ref(remote)
merge_base = capture_command("git", "merge-base", "HEAD", remote_head_ref).strip
abort "Error: could not resolve merge-base with #{remote_head_ref}." if merge_base.empty?
merge_base
end
def preferred_remote
upstream = capture_command_optional("git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}").strip
upstream_remote = upstream.split("/").first unless upstream.empty?
return upstream_remote if upstream_remote && !upstream_remote.empty?
remotes = capture_command("git", "remote").lines.map(&:strip).reject(&:empty?)
abort "Error: no git remotes configured; pass baseline=<ref>." if remotes.empty?
remotes.include?("origin") ? "origin" : remotes.first
end
def remote_default_branch_ref(remote)
symbolic = capture_command_optional("git", "symbolic-ref", "--quiet", "refs/remotes/#{remote}/HEAD").strip
if symbolic.empty?
fallback = "#{remote}/main"
capture_command("git", "rev-parse", "--verify", fallback)
return fallback
end
symbolic.sub("refs/remotes/", "")
end
def with_temporary_worktree(ref:)
temp_root = Dir.mktmpdir("coverage-baseline-")
worktree_path = File.join(temp_root, "worktree")
run_command("git", "worktree", "add", "--detach", worktree_path, ref)
begin
yield worktree_path
ensure
system("git", "worktree", "remove", "--force", worktree_path)
FileUtils.rm_rf(temp_root)
end
end
def capture_command(*command, chdir: Dir.pwd)
stdout, stderr, status = Dir.chdir(chdir) { Open3.capture3(*command) }
output = +""
output << stdout unless stdout.empty?
output << stderr unless stderr.empty?
abort "Error: command failed: #{command.join(" ")}\n#{output}" unless status.success?
output
end
def capture_command_optional(*command, chdir: Dir.pwd)
stdout, stderr, status = Dir.chdir(chdir) { Open3.capture3(*command) }
return stdout if status.success?
return "" if stderr.include?("no upstream configured") || stderr.include?("is not a symbolic ref")
""
end
# Build the site with specified URL
# @parameter url [String] The site URL to use
def build(url)
require "pressa"
puts "Building site for #{url}..."
site = Pressa.create_site(source_path: ".", url_override: url)
generator = Pressa::SiteGenerator.new(site:)
generator.generate(source_path: ".", target_path: "www")
puts "Site built successfully in www/"
end
def run_build_target(target)
target_name = target.to_s
unless BUILD_TARGETS.include?(target_name)
abort "Error: invalid target '#{target_name}'. Use one of: #{BUILD_TARGETS.join(", ")}"
end
public_send(target_name)
end
def watch_paths
WATCHABLE_DIRECTORIES.flat_map { |path| ["-r", path] }
end
def standardrb_command(*extra_args)
["bundle", "exec", "standardrb", *extra_args, *LINT_TARGETS]
end
def run_standardrb(*extra_args)
run_command(*standardrb_command(*extra_args))
end
def run_command(*command)
abort "Error: command failed: #{command.join(" ")}" unless system(*command)
end
def run_rsync(local_paths:, publish_dir:, dry_run:, delete:)
command = ["rsync", "-aKv", "-e", "ssh -4"]
command << "--dry-run" if dry_run
command << "--delete" if delete
command.concat(local_paths)
command << "#{PUBLISH_HOST}:#{publish_dir}"
abort "Error: rsync failed." unless system(*command)
end
def resolve_draft_input(input_path)
if input_path.include?("/")
if input_path.start_with?("posts/")
abort "Error: '#{input_path}' is already published in posts/ directory"
end
[input_path, File.basename(input_path)]
else
[draft_path(input_path), input_path]
end
end
def draft_path(filename)
File.join(DRAFTS_DIR, filename)
end
def slugify(title)
title.downcase
.gsub(/[^a-z0-9\s-]/, "")
.gsub(/\s+/, "-").squeeze("-")
.gsub(/^-|-$/, "")
end
def next_available_draft(base_filename = "untitled.md")
return base_filename unless File.exist?(draft_path(base_filename))
name_without_ext = File.basename(base_filename, ".md")
counter = 1
loop do
numbered_filename = "#{name_without_ext}-#{counter}.md"
return numbered_filename unless File.exist?(draft_path(numbered_filename))
counter += 1
end
end
def render_draft_template(title)
now = Time.now
<<~FRONTMATTER
---
Author: #{current_author}
Title: #{title}
Date: unpublished
Timestamp: #{now.strftime("%Y-%m-%dT%H:%M:%S%:z")}
Tags:
---
# #{title}
TKTK
FRONTMATTER
end
def current_author
Etc.getlogin || ENV["USER"] || `whoami`.strip
rescue
ENV["USER"] || `whoami`.strip
end
def ordinal_date(time)
day = time.day
suffix = case day
when 1, 21, 31
"st"
when 2, 22
"nd"
when 3, 23
"rd"
else
"th"
end
time.strftime("#{day}#{suffix} %B, %Y")
end
def command_available?(command)
system("which", command, out: File::NULL, err: File::NULL)
end

View file

@ -1,50 +1,37 @@
#!/bin/bash #!/bin/bash
# bail on errors and unset variables
set -euo pipefail set -euo pipefail
SWIFT_VERSION=6.1 ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
SWIFT_DIR=swift-$SWIFT_VERSION-RELEASE-ubuntu24.04 RUBY_VERSION="$(cat "$ROOT_DIR/.ruby-version")"
SWIFT_FILENAME=$SWIFT_DIR.tar.gz
if [[ $(uname) = "Linux" ]]; then if [[ "$(uname)" = "Linux" ]]; then
echo "*** installing Linux prerequisites"
sudo apt install -y \ sudo apt install -y \
binutils \ build-essential \
git \ git \
gnupg2 \ inotify-tools \
libc6-dev \ libffi-dev \
libcurl4 \ libyaml-dev \
libedit2 \
libgcc-s1 \
libpython3.12 \
libsqlite3-0 \
libstdc++-14-dev \
libxml2 \
libz3-dev \
pkg-config \ pkg-config \
tzdata \
uuid-dev \
zlib1g-dev zlib1g-dev
fi
if which swift >/dev/null 2>/dev/null && swift --version | grep $SWIFT_VERSION >/dev/null 2>/dev/null; then cd "$ROOT_DIR"
echo "*** swift $SWIFT_VERSION is installed"
else
echo "*** installing swift"
if [[ -e $SWIFT_FILENAME ]]; then
echo "*** $SWIFT_FILENAME exists, skipping download"
else
wget https://download.swift.org/swift-$SWIFT_VERSION-release/ubuntu2404/swift-$SWIFT_VERSION-RELEASE/$SWIFT_FILENAME
fi
if [[ -e $SWIFT_DIR ]]; then
echo "*** $SWIFT_DIR exists, skipping extraction"
else
tar xzf $SWIFT_FILENAME
fi
echo "*** add $PWD/$SWIFT_DIR/usr/bin to PATH in your shell's rc file"
fi
echo "*** installing inotify-tools for watch script" if command -v rbenv >/dev/null 2>/dev/null; then
sudo apt install -y inotify-tools echo "*** using rbenv (ruby $RUBY_VERSION)"
rbenv install -s "$RUBY_VERSION"
if ! rbenv exec gem list -i bundler >/dev/null 2>/dev/null; then
rbenv exec gem install bundler
fi
rbenv exec bundle install
else
echo "*** rbenv not found, using system Ruby"
if ! gem list -i bundler >/dev/null 2>/dev/null; then
gem install bundler
fi
bundle install
fi fi
echo "*** done" echo "*** done"

View file

@ -1,14 +0,0 @@
#!/bin/bash
set -euo pipefail
if [[ $(uname) = "Linux" ]]; then
build_platform_dir="$(arch)-unknown-linux-gnu"
else
build_platform_dir="$(arch)-apple-macosx"
fi
pushd "gensite" >/dev/null
swift build
cp .build/$build_platform_dir/debug/gensite ../bin/gensite
popd >/dev/null

View file

@ -1,94 +0,0 @@
#!/usr/bin/env ruby -w
require 'fileutils'
DRAFTS_DIR = File.expand_path("../public/drafts", __dir__).freeze
def usage
puts "Usage: #{$0} [title]"
puts
puts "Examples:"
puts " #{$0} Top 5 Ways to Write Clickbait # using a title without quotes"
puts " #{$0} 'Something with punctuation?!' # fancy chars need quotes"
puts " #{$0} working-with-databases # using a slug"
puts " #{$0} # Creates untitled.md (or untitled-2.md, etc.)"
puts
puts "Creates a new draft in public/drafts/ directory with proper frontmatter."
end
def draft_path(filename)
File.join(DRAFTS_DIR, filename)
end
def main
if ARGV.include?('-h') || ARGV.include?('--help')
usage
exit 0
end
title, filename =
if ARGV.empty?
['Untitled', next_available_draft]
else
given_title = ARGV.join(' ')
filename = "#{slugify(given_title)}.md"
path = draft_path(filename)
if File.exist?(path)
puts "Error: draft already exists at #{path}"
exit 1
end
[given_title, filename]
end
FileUtils.mkdir_p(DRAFTS_DIR)
path = draft_path(filename)
content = render_template(title)
File.write(path, content)
puts "Created new draft at #{path}"
puts '>>> Contents below <<<'
puts
puts content
end
def slugify(title)
title.downcase
.gsub(/[^a-z0-9\s-]/, '')
.gsub(/\s+/, '-')
.gsub(/-+/, '-')
.gsub(/^-|-$/, '')
end
def next_available_draft(base_filename = 'untitled.md')
return base_filename unless File.exist?(draft_path(base_filename))
name_without_ext = File.basename(base_filename, '.md')
counter = 1
loop do
numbered_filename = "#{name_without_ext}-#{counter}.md"
return numbered_filename unless File.exist?(draft_path(numbered_filename))
counter += 1
end
end
def render_template(title)
now = Time.now
iso_timestamp = now.strftime('%Y-%m-%dT%H:%M:%S%:z')
<<~FRONTMATTER
---
Author: #{`whoami`.strip}
Title: #{title}
Date: unpublished
Timestamp: #{iso_timestamp}
Tags:
---
# #{title}
TKTK
FRONTMATTER
end
main if $0 == __FILE__

View file

@ -1,54 +0,0 @@
#!/bin/bash
# exit on errors
set -e
PUBLISH_HOST="mudge"
PUBLISH_DIR="/var/www/samhuri.net/public"
ECHO=0
RSYNC_OPTS=""
BREAK_WHILE=0
while [[ $# > 0 ]]; do
ARG="$1"
case "$ARG" in
-b|--beta)
PUBLISH_DIR="/var/www/beta.samhuri.net/public"
shift
;;
-t|--test)
ECHO=1
RSYNC_OPTS="$RSYNC_OPTS --dry-run"
shift
;;
-d|--delete)
RSYNC_OPTS="$RSYNC_OPTS --delete"
shift
;;
# we're at the paths, no more options
*)
BREAK_WHILE=1
break
;;
esac
[[ $BREAK_WHILE -eq 1 ]] && break
done
declare -a CMD
if [[ $# -eq 0 ]]; then
CMD=(rsync -aKv -e "ssh -4" $RSYNC_OPTS www/ $PUBLISH_HOST:$PUBLISH_DIR)
else
CMD=(rsync -aKv -e "ssh -4" $RSYNC_OPTS $@ $PUBLISH_HOST:$PUBLISH_DIR)
fi
if [[ $ECHO -eq 1 ]]; then
echo "${CMD[@]}"
fi
"${CMD[@]}"

View file

@ -1,70 +0,0 @@
#!/usr/bin/env ruby -w
require 'fileutils'
def usage
puts "Usage: #{$0} <draft-path-or-filename>"
puts
puts "Examples:"
puts " #{$0} public/drafts/reverse-engineering-photo-urls.md"
puts
puts "Available drafts:"
drafts = Dir.glob('public/drafts/*.md').map { |f| File.basename(f) }
if drafts.empty?
puts " (no drafts found)"
else
drafts.each { |d| puts " #{d}" }
end
end
if ARGV.empty?
usage
abort
end
input_path = ARGV.first
# Handle both full paths and just filenames
if input_path.include?('/')
draft_path = input_path
draft_file = File.basename(input_path)
if input_path.start_with?('posts/')
abort "Error: '#{input_path}' is already published in posts/ directory"
end
else
draft_file = input_path
draft_path = "public/drafts/#{draft_file}"
end
abort "Error: File not found: #{draft_path}" unless File.exist?(draft_path)
# Update display date timestamp to current time
def ordinal_date(time)
day = time.day
suffix = case day
when 1, 21, 31 then 'st'
when 2, 22 then 'nd'
when 3, 23 then 'rd'
else 'th'
end
time.strftime("#{day}#{suffix} %B, %Y")
end
now = Time.now
iso_timestamp = now.strftime('%Y-%m-%dT%H:%M:%S%:z')
human_date = ordinal_date(now)
content = File.read(draft_path)
content.sub!(/^Date:.*$/, "Date: #{human_date}")
content.sub!(/^Timestamp:.*$/, "Timestamp: #{iso_timestamp}")
# Use current year/month for directory, pad with strftime
year_month = now.strftime('%Y-%m')
year, month = year_month.split('-')
target_dir = "posts/#{year}/#{month}"
FileUtils.mkdir_p(target_dir)
target_path = "#{target_dir}/#{draft_file}"
File.write(target_path, content)
FileUtils.rm_f(draft_path)
puts "Published draft: #{draft_path} → #{target_path}"

View file

@ -1,10 +0,0 @@
#!/bin/bash
BLOG_TARGET=${BLOG_TARGET:-mudge}
while true; do
inotifywait -e modify,create,delete,move -r drafts -r posts
echo "changed at $(date)"
sleep 5
make "$TARGET"
done

5
gensite/.gitignore vendored
View file

@ -1,5 +0,0 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
/.swiftpm

View file

@ -1,25 +0,0 @@
{
"object": {
"pins": [
{
"package": "Ink",
"repositoryURL": "https://github.com/johnsundell/ink.git",
"state": {
"branch": null,
"revision": "bcc9f219900a62c4210e6db726035d7f03ae757b",
"version": "0.6.0"
}
},
{
"package": "Plot",
"repositoryURL": "https://github.com/johnsundell/plot.git",
"state": {
"branch": null,
"revision": "271926b4413fe868739d99f5eadcf2bd6cd62fb8",
"version": "0.14.0"
}
}
]
},
"version": 1
}

View file

@ -1,21 +0,0 @@
// swift-tools-version:6.1
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "gensite",
platforms: [
.macOS(.v14),
.iOS(.v17),
],
dependencies: [
.package(path: "../samhuri.net"),
],
targets: [
.executableTarget( name: "gensite", dependencies: [
"samhuri.net",
]),
.testTarget(name: "gensiteTests", dependencies: ["gensite"]),
]
)

View file

@ -1,5 +0,0 @@
# gensite
A binary to build [samhuri.net](https://samhuri.net) using SiteGenerator.
See https://github.com/samsonjs/samhuri.net for details.

View file

@ -1,50 +0,0 @@
//
// main.swift
// gensite
//
// Created by Sami Samhuri on 2019-12-01.
//
import Foundation
import samhuri_net
guard CommandLine.arguments.count >= 3 else {
let name = CommandLine.arguments[0]
FileHandle.standardError.write("Usage: \(name) <site dir> <target dir>\n".data(using: .utf8)!)
exit(1)
}
let sourcePath = CommandLine.arguments[1]
var isDir: ObjCBool = false
let sourceExists = FileManager.default.fileExists(atPath: sourcePath, isDirectory: &isDir)
guard sourceExists, isDir.boolValue else {
FileHandle.standardError.write("error: Site path \(sourcePath) does not exist or is not a directory\n".data(using: .utf8)!)
exit(2)
}
let targetPath = CommandLine.arguments[2]
let siteURLOverride: URL?
if CommandLine.argc > 3, CommandLine.arguments[3].isEmpty == false {
let urlString = CommandLine.arguments[3]
guard let url = URL(string: urlString) else {
FileHandle.standardError.write("error: invalid site URL \(urlString)\n".data(using: .utf8)!)
exit(4)
}
siteURLOverride = url
}
else {
siteURLOverride = nil
}
do {
let sourceURL = URL(fileURLWithPath: sourcePath)
let targetURL = URL(fileURLWithPath: targetPath)
let site = samhuri.net(siteURLOverride: siteURLOverride)
try site.generate(sourceURL: sourceURL, targetURL: targetURL)
exit(0)
}
catch {
FileHandle.standardError.write("error: \(error)\n".data(using: .utf8)!)
exit(-1)
}

View file

@ -1,8 +0,0 @@
@testable import gensite
import Testing
struct gensiteTests {
@Test func example() {
#expect(true)
}
}

14
lib/pressa.rb Normal file
View file

@ -0,0 +1,14 @@
require "pressa/site"
require "pressa/site_generator"
require "pressa/plugin"
require "pressa/posts/plugin"
require "pressa/projects/plugin"
require "pressa/utils/markdown_renderer"
require "pressa/config/loader"
module Pressa
def self.create_site(source_path: ".", url_override: nil)
loader = Config::Loader.new(source_path:)
loader.build_site(url_override:)
end
end

217
lib/pressa/config/loader.rb Normal file
View file

@ -0,0 +1,217 @@
require "pressa/site"
require "pressa/posts/plugin"
require "pressa/projects/plugin"
require "pressa/utils/markdown_renderer"
require "pressa/config/simple_toml"
module Pressa
module Config
class ValidationError < StandardError; end
class Loader
REQUIRED_SITE_KEYS = %w[author email title description url].freeze
REQUIRED_PROJECT_KEYS = %w[name title description url].freeze
def initialize(source_path:)
@source_path = source_path
end
def build_site(url_override: nil)
site_config = load_toml("site.toml")
validate_required!(site_config, REQUIRED_SITE_KEYS, context: "site.toml")
site_url = url_override || site_config["url"]
plugins = build_plugins(site_config)
Site.new(
author: site_config["author"],
email: site_config["email"],
title: site_config["title"],
description: site_config["description"],
url: site_url,
image_url: normalize_image_url(site_config["image_url"], site_url),
scripts: build_scripts(site_config["scripts"], context: "site.toml scripts"),
styles: build_styles(site_config["styles"], context: "site.toml styles"),
plugins:,
renderers: [
Utils::MarkdownRenderer.new
]
)
end
private
def load_toml(filename)
path = File.join(@source_path, filename)
SimpleToml.load_file(path)
rescue ParseError => e
raise ValidationError, e.message
end
def build_projects(projects_config)
projects = projects_config["projects"]
raise ValidationError, "Missing required top-level array 'projects' in projects.toml" unless projects
raise ValidationError, "Expected 'projects' to be an array in projects.toml" unless projects.is_a?(Array)
projects.map.with_index do |project, index|
unless project.is_a?(Hash)
raise ValidationError, "Project entry #{index + 1} must be a table in projects.toml"
end
validate_required!(project, REQUIRED_PROJECT_KEYS, context: "projects.toml project ##{index + 1}")
Projects::Project.new(
name: project["name"],
title: project["title"],
description: project["description"],
url: project["url"]
)
end
end
def validate_required!(hash, keys, context:)
missing = keys.reject do |key|
hash[key].is_a?(String) && !hash[key].strip.empty?
end
return if missing.empty?
raise ValidationError, "Missing required #{context} keys: #{missing.join(", ")}"
end
def build_plugins(site_config)
plugin_names = parse_plugin_names(site_config["plugins"])
plugin_names.map.with_index do |plugin_name, index|
case plugin_name
when "posts"
Posts::Plugin.new
when "projects"
build_projects_plugin(site_config)
else
raise ValidationError, "Unknown plugin '#{plugin_name}' at site.toml plugins[#{index}]"
end
end
end
def parse_plugin_names(value)
return [] if value.nil?
raise ValidationError, "Expected site.toml plugins to be an array" unless value.is_a?(Array)
seen = {}
value.map.with_index do |plugin_name, index|
unless plugin_name.is_a?(String) && !plugin_name.strip.empty?
raise ValidationError, "Expected site.toml plugins[#{index}] to be a non-empty String"
end
normalized_plugin_name = plugin_name.strip
if seen[normalized_plugin_name]
raise ValidationError, "Duplicate plugin '#{normalized_plugin_name}' in site.toml plugins"
end
seen[normalized_plugin_name] = true
normalized_plugin_name
end
end
def build_projects_plugin(site_config)
projects_plugin = hash_or_empty(site_config["projects_plugin"], "site.toml projects_plugin")
projects_config = load_toml("projects.toml")
projects = build_projects(projects_config)
Projects::Plugin.new(
projects:,
scripts: build_scripts(projects_plugin["scripts"], context: "site.toml projects_plugin.scripts"),
styles: build_styles(projects_plugin["styles"], context: "site.toml projects_plugin.styles")
)
end
def hash_or_empty(value, context)
return {} if value.nil?
return value if value.is_a?(Hash)
raise ValidationError, "Expected #{context} to be a table"
end
def build_scripts(value, context:)
entries = array_or_empty(value, context)
entries.map.with_index do |item, index|
case item
when String
validate_asset_path!(
item,
context: "#{context}[#{index}]"
)
Script.new(src: item, defer: true)
when Hash
src = item["src"]
raise ValidationError, "Expected #{context}[#{index}].src to be a String" unless src.is_a?(String) && !src.empty?
validate_asset_path!(
src,
context: "#{context}[#{index}].src"
)
defer = item.key?("defer") ? item["defer"] : true
unless [true, false].include?(defer)
raise ValidationError, "Expected #{context}[#{index}].defer to be a Boolean"
end
Script.new(src:, defer:)
else
raise ValidationError, "Expected #{context}[#{index}] to be a String or table"
end
end
end
def build_styles(value, context:)
entries = array_or_empty(value, context)
entries.map.with_index do |item, index|
case item
when String
validate_asset_path!(
item,
context: "#{context}[#{index}]"
)
Stylesheet.new(href: item)
when Hash
href = item["href"]
raise ValidationError, "Expected #{context}[#{index}].href to be a String" unless href.is_a?(String) && !href.empty?
validate_asset_path!(
href,
context: "#{context}[#{index}].href"
)
Stylesheet.new(href:)
else
raise ValidationError, "Expected #{context}[#{index}] to be a String or table"
end
end
end
def array_or_empty(value, context)
return [] if value.nil?
return value if value.is_a?(Array)
raise ValidationError, "Expected #{context} to be an array"
end
def normalize_image_url(value, site_url)
return nil if value.nil?
return value if value.start_with?("http://", "https://")
normalized = value.start_with?("/") ? value : "/#{value}"
"#{site_url}#{normalized}"
end
def validate_asset_path!(value, context:)
return if value.start_with?("/", "http://", "https://")
raise ValidationError, "Expected #{context} to start with / or use http(s) scheme"
end
end
end
end

View file

@ -0,0 +1,224 @@
require "json"
module Pressa
module Config
class ParseError < StandardError; end
class SimpleToml
def self.load_file(path)
new.parse(File.read(path))
rescue Errno::ENOENT
raise ParseError, "Config file not found: #{path}"
end
def parse(content)
root = {}
current_table = root
lines = content.each_line.to_a
line_index = 0
while line_index < lines.length
line = lines[line_index]
line_number = line_index + 1
source = strip_comments(line).strip
if source.empty?
line_index += 1
next
end
if source =~ /\A\[\[(.+)\]\]\z/
current_table = start_array_table(root, Regexp.last_match(1), line_number)
line_index += 1
next
end
if source =~ /\A\[(.+)\]\z/
current_table = start_table(root, Regexp.last_match(1), line_number)
line_index += 1
next
end
key, raw_value = parse_assignment(source, line_number)
while needs_continuation?(raw_value)
line_index += 1
raise ParseError, "Unterminated value for key '#{key}' at line #{line_number}" if line_index >= lines.length
continuation = strip_comments(lines[line_index]).strip
next if continuation.empty?
raw_value = "#{raw_value} #{continuation}"
end
if current_table.key?(key)
raise ParseError, "Duplicate key '#{key}' at line #{line_number}"
end
current_table[key] = parse_value(raw_value, line_number)
line_index += 1
end
root
end
private
def start_array_table(root, raw_path, line_number)
keys = parse_path(raw_path, line_number)
parent = ensure_path(root, keys[0..-2], line_number)
table_name = keys.last
parent[table_name] ||= []
array = parent[table_name]
unless array.is_a?(Array)
raise ParseError, "Expected array for '[[#{raw_path}]]' at line #{line_number}"
end
table = {}
array << table
table
end
def start_table(root, raw_path, line_number)
keys = parse_path(raw_path, line_number)
ensure_path(root, keys, line_number)
end
def ensure_path(root, keys, line_number)
cursor = root
keys.each do |key|
cursor[key] ||= {}
unless cursor[key].is_a?(Hash)
raise ParseError, "Expected table path '#{keys.join(".")}' at line #{line_number}"
end
cursor = cursor[key]
end
cursor
end
def parse_path(raw_path, line_number)
keys = raw_path.split(".").map(&:strip)
if keys.empty? || keys.any? { |part| part.empty? || part !~ /\A[A-Za-z0-9_]+\z/ }
raise ParseError, "Invalid table path '#{raw_path}' at line #{line_number}"
end
keys
end
def parse_assignment(source, line_number)
separator = index_of_unquoted(source, "=")
raise ParseError, "Invalid assignment at line #{line_number}" unless separator
key = source[0...separator].strip
value = source[(separator + 1)..].strip
if key.empty? || key !~ /\A[A-Za-z0-9_]+\z/
raise ParseError, "Invalid key '#{key}' at line #{line_number}"
end
raise ParseError, "Missing value for key '#{key}' at line #{line_number}" if value.empty?
[key, value]
end
def parse_value(raw_value, line_number)
JSON.parse(raw_value)
rescue JSON::ParserError
raise ParseError, "Unsupported TOML value '#{raw_value}' at line #{line_number}"
end
def needs_continuation?(source)
in_string = false
escaped = false
depth = 0
source.each_char do |char|
if in_string
if escaped
escaped = false
elsif char == "\\"
escaped = true
elsif char == '"'
in_string = false
end
next
end
case char
when '"'
in_string = true
when "[", "{"
depth += 1
when "]", "}"
depth -= 1
end
end
in_string || depth.positive?
end
def strip_comments(line)
output = +""
in_string = false
escaped = false
line.each_char do |char|
if in_string
output << char
if escaped
escaped = false
elsif char == "\\"
escaped = true
elsif char == '"'
in_string = false
end
next
end
case char
when '"'
in_string = true
output << char
when "#"
break
else
output << char
end
end
output
end
def index_of_unquoted(source, target)
in_string = false
escaped = false
source.each_char.with_index do |char, index|
if in_string
if escaped
escaped = false
elsif char == "\\"
escaped = true
elsif char == '"'
in_string = false
end
next
end
if char == '"'
in_string = true
next
end
return index if char == target
end
nil
end
end
end
end

11
lib/pressa/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,76 @@
require "json"
require "pressa/utils/file_writer"
require "pressa/views/feed_post_view"
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 = build_feed(recent)
json = JSON.pretty_generate(feed)
file_path = File.join(target_path, "feed.json")
Utils::FileWriter.write(path: file_path, content: json)
end
private
def build_feed(posts)
author = {
name: @site.author,
url: @site.url,
avatar: @site.image_url
}
items = posts.map { |post| feed_item(post) }
{
icon: icon_url,
favicon: favicon_url,
items: items,
home_page_url: @site.url,
author:,
version: FEED_VERSION,
authors: [author],
feed_url: @site.url_for("/feed.json"),
language: "en-CA",
title: @site.title
}
end
def icon_url
@site.url_for("/images/apple-touch-icon-300.png")
end
def favicon_url
@site.url_for("/images/apple-touch-icon-80.png")
end
def feed_item(post)
content_html = Views::FeedPostView.new(post:, site: @site).call
permalink = @site.url_for(post.path)
item = {}
item[:url] = permalink
item[:external_url] = post.link if post.link_post?
item[:tags] = post.tags unless post.tags.empty?
item[:content_html] = content_html
item[:title] = post.link_post? ? "#{post.title}" : post.title
item[:author] = {name: post.author}
item[:date_published] = post.date.iso8601
item[:id] = permalink
item
end
end
end
end

View file

@ -0,0 +1,50 @@
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
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_tags(@raw["Tags"])
end
def parse_tags(value)
return [] if value.nil?
value.is_a?(Array) ? value : value.split(",").map(&:strip)
end
end
end
end

View file

@ -0,0 +1,95 @@
require "dry-struct"
require "pressa/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 :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
def earliest_year
by_year.keys.min
end
end
end
end

View file

@ -0,0 +1,38 @@
require "pressa/plugin"
require "pressa/posts/repo"
require "pressa/posts/writer"
require "pressa/posts/json_feed"
require "pressa/posts/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

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

@ -0,0 +1,124 @@
require "kramdown"
require "pressa/posts/models"
require "pressa/posts/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,
body: html_body,
excerpt:,
path:
)
end
def render_markdown(markdown)
Kramdown::Document.new(
markdown,
input: "GFM",
hard_wrap: false,
syntax_highlighter: "rouge",
syntax_highlighter_opts: {
line_numbers: false,
wrap: true
}
).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!(/!\[[^\]]*\]\[[^\]]+\]/, "")
text.gsub!(/\[([^\]]+)\]\([^)]+\)/, '\1')
text.gsub!(/\[([^\]]+)\]\[[^\]]+\]/, '\1')
text.gsub!(/(?m)^\[[^\]]+\]:\s*\S.*$/, "")
text.gsub!(/<[^>]+>/, "")
text.gsub!(/\s+/, " ")
text.strip!
return "..." if text.empty?
"#{text[0...EXCERPT_LENGTH]}..."
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,53 @@
require "builder"
require "pressa/utils/file_writer"
require "pressa/views/feed_post_view"
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",
"xmlns:content" => "http://purl.org/rss/1.0/modules/content/" do
xml.channel do
xml.title @site.title
xml.link @site.url
xml.description @site.description
xml.pubDate recent.first.date.rfc822 if recent.any?
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
permalink = @site.url_for(post.path)
xml.title title
xml.link permalink
xml.guid permalink, isPermaLink: "true"
xml.pubDate post.date.rfc822
xml.author post.author
xml.tag!("content:encoded") { xml.cdata!(render_feed_post(post)) }
end
end
end
end
file_path = File.join(target_path, "feed.xml")
Utils::FileWriter.write(path: file_path, content: xml.target!)
end
def render_feed_post(post)
Views::FeedPostView.new(post:, site: @site).call
end
end
end
end

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

@ -0,0 +1,137 @@
require "pressa/utils/file_writer"
require "pressa/views/layout"
require "pressa/views/post_view"
require "pressa/views/recent_posts_view"
require "pressa/views/archive_view"
require "pressa/views/year_posts_view"
require "pressa/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_subtitle: nil,
canonical_url: @site.url,
content: content_view,
page_description: "Recent posts",
page_type: "article"
)
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_subtitle: "Archive",
canonical_url: @site.url_for("/posts/"),
content: content_view,
page_description: "Archive of all posts"
)
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, article_class: "container")
html = render_layout(
page_subtitle: post.title,
canonical_url: @site.url_for(post.path),
content: content_view,
page_description: post.excerpt,
page_type: "article"
)
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_subtitle: year.to_s,
canonical_url: @site.url_for("/posts/#{year}/"),
content: content_view,
page_description: "Archive of all posts from #{year}",
page_type: "article"
)
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_subtitle: title,
canonical_url: @site.url_for("/posts/#{year}/#{month.padded}/"),
content: content_view,
page_description: "Archive of all posts from #{title}",
page_type: "article"
)
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_subtitle:,
canonical_url:,
content:,
page_description: nil,
page_type: "website"
)
layout = Views::Layout.new(
site: @site,
page_subtitle:,
canonical_url:,
page_description:,
page_type:,
content:
)
layout.call
end
end
end
end

View file

@ -0,0 +1,22 @@
require "dry-struct"
require "pressa/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,86 @@
require "pressa/plugin"
require "pressa/utils/file_writer"
require "pressa/views/layout"
require "pressa/views/projects_view"
require "pressa/views/project_view"
require "pressa/projects/models"
module Pressa
module Projects
class Plugin < Pressa::Plugin
attr_reader :scripts, :styles
def initialize(projects: [], scripts: [], styles: [])
@projects = projects
@scripts = scripts
@styles = styles
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_subtitle: "Projects",
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_subtitle: project.title,
canonical_url: site.url_for(project.path),
content: content_view,
page_scripts: @scripts,
page_styles: @styles,
page_description: project.description
)
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_subtitle:,
canonical_url:,
content:,
page_scripts: [],
page_styles: [],
page_description: nil
)
layout = Views::Layout.new(
site:,
page_subtitle:,
canonical_url:,
page_scripts:,
page_styles:,
page_description:,
content:
)
layout.call
end
end
end
end

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

@ -0,0 +1,39 @@
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 :copyright_start_year, Types::Integer.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,123 @@
require "fileutils"
require "pressa/utils/file_writer"
module Pressa
class SiteGenerator
attr_reader :site
def initialize(site:)
@site = site
end
def generate(source_path:, target_path:)
validate_paths!(source_path:, target_path:)
FileUtils.rm_rf(target_path)
FileUtils.mkdir_p(target_path)
setup_site = site
setup_site.plugins.each { |plugin| plugin.setup(site: setup_site, source_path:) }
@site = site_with_copyright_start_year(setup_site)
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 validate_paths!(source_path:, target_path:)
source_abs = absolute_path(source_path)
target_abs = absolute_path(target_path)
return unless contains_path?(container: target_abs, path: source_abs)
raise ArgumentError, "target_path must not be the same as or contain source_path"
end
def absolute_path(path)
File.exist?(path) ? File.realpath(path) : File.expand_path(path)
end
def contains_path?(container:, path:)
path == container || path.start_with?("#{container}#{File::SEPARATOR}")
end
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 skip_file?(source_file)
filename = File.basename(source_file)
ext = File.extname(source_file)[1..]
if can_render?(filename, ext)
next
end
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 can_render?(filename, ext)
site.renderers.any? { |renderer| renderer.can_render_file?(filename:, extension: ext) }
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)
next if skip_file?(source_file)
filename = File.basename(source_file)
ext = File.extname(source_file)[1..]
if renderer.can_render_file?(filename:, extension: ext)
dir_name = File.dirname(source_file)
relative_path = if dir_name == public_dir
""
else
dir_name.sub("#{public_dir}/", "")
end
target_dir = File.join(target_path, relative_path)
renderer.render(site:, file_path: source_file, target_dir:)
end
end
end
end
def skip_file?(source_file)
basename = File.basename(source_file)
basename.start_with?(".")
end
def site_with_copyright_start_year(base_site)
start_year = find_copyright_start_year(base_site)
Site.new(**base_site.to_h.merge(copyright_start_year: start_year))
end
def find_copyright_start_year(base_site)
years = base_site.plugins.filter_map do |plugin|
next unless plugin.respond_to?(:posts_by_year)
posts_by_year = plugin.posts_by_year
next unless posts_by_year.respond_to?(:earliest_year)
posts_by_year.earliest_year
end
years.min || Time.now.year
end
end
end

View file

@ -0,0 +1,20 @@
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,148 @@
require "kramdown"
require "yaml"
require "pressa/utils/file_writer"
require "pressa/site"
require "pressa/views/layout"
require "pressa/views/icons"
module Pressa
module Utils
class MarkdownRenderer
EXCERPT_LENGTH = 300
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 = presence(metadata["Title"]) || File.basename(file_path, ".md").capitalize
page_type = presence(metadata["Page type"]) || "website"
page_description = presence(metadata["Description"]) || generate_excerpt(body_markdown)
show_extension = ["true", "yes", true].include?(metadata["Show extension"])
slug = File.basename(file_path, ".md")
relative_dir = File.dirname(file_path).sub(/^.*?\/public\/?/, "")
relative_dir = "" if relative_dir == "."
canonical_path = if show_extension
"/#{relative_dir}/#{slug}.html".squeeze("/")
else
"/#{relative_dir}/#{slug}/".squeeze("/")
end
html = render_layout(
site:,
page_subtitle: page_title,
canonical_url: site.url_for(canonical_path),
body: html_body,
page_description:,
page_type:
)
output_filename = if show_extension
"#{slug}.html"
else
File.join(slug, "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",
hard_wrap: false,
syntax_highlighter: "rouge",
syntax_highlighter_opts: {
line_numbers: false,
wrap: true
}
).to_html
end
def render_layout(site:, page_subtitle:, canonical_url:, body:, page_description:, page_type:)
layout = Views::Layout.new(
site:,
page_subtitle:,
canonical_url:,
page_description:,
page_type:,
content: PageView.new(page_title: page_subtitle, body:)
)
layout.call
end
class PageView < Phlex::HTML
def initialize(page_title:, body:)
@page_title = page_title
@body = body
end
def view_template
article(class: "container") do
h1 { @page_title }
raw(safe(@body))
end
div(class: "row clearfix") do
p(class: "fin") do
raw(safe(Views::Icons.code))
end
end
end
end
def generate_excerpt(markdown)
text = markdown.dup
# Drop inline and reference-style images before links are simplified.
text.gsub!(/!\[[^\]]*\]\([^)]+\)/, "")
text.gsub!(/!\[[^\]]*\]\[[^\]]+\]/, "")
# Replace inline and reference links with just their text.
text.gsub!(/\[([^\]]+)\]\([^)]+\)/, '\1')
text.gsub!(/\[([^\]]+)\]\[[^\]]+\]/, '\1')
# Remove link reference definitions such as: [foo]: http://example.com
text.gsub!(/(?m)^\[[^\]]+\]:\s*\S.*$/, "")
text.gsub!(/<[^>]+>/, "")
text.gsub!(/\s+/, " ")
text.strip!
return nil if text.empty?
"#{text[0...EXCERPT_LENGTH]}..."
end
def presence(value)
return value unless value.respond_to?(:strip)
stripped = value.strip
stripped.empty? ? nil : stripped
end
end
end
end

View file

@ -0,0 +1,24 @@
require "phlex"
require "pressa/views/year_posts_view"
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
div(class: "container") do
h1 { "Archive" }
end
@posts_by_year.sorted_years.each do |year|
year_posts = @posts_by_year.by_year[year]
render Views::YearPostsView.new(year:, year_posts:, site: @site)
end
end
end
end
end

View file

@ -0,0 +1,33 @@
require "phlex"
module Pressa
module Views
class FeedPostView < Phlex::HTML
def initialize(post:, site:)
@post = post
@site = site
end
def view_template
div do
p(class: "time") { @post.formatted_date }
raw(safe(normalized_body))
p do
a(class: "permalink", href: @site.url_for(@post.path)) { "" }
end
end
end
private
def normalized_body
@post.body.gsub(/(href|src)=(['"])(\/(?!\/)[^'"]*)\2/) do
attr = Regexp.last_match(1)
quote = Regexp.last_match(2)
path = Regexp.last_match(3)
%(#{attr}=#{quote}#{@site.url_for(path)}#{quote})
end
end
end
end
end

34
lib/pressa/views/icons.rb Normal file
View file

@ -0,0 +1,34 @@
module Pressa
module Views
module Icons
module_function
def mastodon
svg(class_name: "icon icon-mastodon", view_box: "0 0 448 512", path: IconPath::MASTODON)
end
def github
svg(class_name: "icon icon-github", view_box: "0 0 496 512", path: IconPath::GITHUB)
end
def rss
svg(class_name: "icon icon-rss", view_box: "0 0 448 512", path: IconPath::RSS)
end
def code
svg(class_name: "icon icon-code", view_box: "0 0 640 512", path: IconPath::CODE)
end
private_class_method def svg(class_name:, view_box:, path:)
"<svg class=\"#{class_name}\" viewBox=\"#{view_box}\" aria-hidden=\"true\" focusable=\"false\"><path transform=\"translate(0,448) scale(1,-1)\" d=\"#{path}\"/></svg>"
end
module IconPath
MASTODON = "M433 268.89c0 0 0.799805 -71.6992 -9 -121.5c-6.23047 -31.5996 -55.1104 -66.1992 -111.23 -72.8994c-20.0996 -2.40039 -93.1191 -14.2002 -178.75 6.7002c0 -0.116211 -0.00390625 -0.119141 -0.00390625 -0.235352c0 -4.63281 0.307617 -9.19434 0.904297 -13.665 c6.62988 -49.5996 49.2197 -52.5996 89.6299 -54c40.8105 -1.2998 77.1201 10.0996 77.1201 10.0996l1.7002 -36.8994s-28.5098 -15.2998 -79.3203 -18.1006c-28.0098 -1.59961 -62.8193 0.700195 -103.33 11.4004c-112.229 29.7002 -105.63 173.4 -105.63 289.1 c0 97.2002 63.7197 125.7 63.7197 125.7c61.9209 28.4004 227.96 28.7002 290.48 0c0 0 63.71 -28.5 63.71 -125.7zM357.88 143.69c0 122 5.29004 147.71 -18.4199 175.01c-25.71 28.7002 -79.7197 31 -103.83 -6.10059l-11.5996 -19.5l-11.6006 19.5 c-24.0098 36.9004 -77.9297 35 -103.83 6.10059c-23.6094 -27.1006 -18.4092 -52.9004 -18.4092 -175h46.7295v114.2c0 49.6992 64 51.5996 64 -6.90039v-62.5098h46.3301v62.5c0 58.5 64 56.5996 64 6.89941v-114.199h46.6299z"
GITHUB = "M165.9 50.5996c0 -2 -2.30078 -3.59961 -5.2002 -3.59961c-3.2998 -0.299805 -5.60059 1.2998 -5.60059 3.59961c0 2 2.30078 3.60059 5.2002 3.60059c3 0.299805 5.60059 -1.2998 5.60059 -3.60059zM134.8 55.0996c0.700195 2 3.60059 3 6.2002 2.30078 c3 -0.900391 4.90039 -3.2002 4.2998 -5.2002c-0.599609 -2 -3.59961 -3 -6.2002 -2c-3 0.599609 -5 2.89941 -4.2998 4.89941zM179 56.7998c2.90039 0.299805 5.59961 -1 5.90039 -2.89941c0.299805 -2 -1.7002 -3.90039 -4.60059 -4.60059 c-3 -0.700195 -5.59961 0.600586 -5.89941 2.60059c-0.300781 2.2998 1.69922 4.19922 4.59961 4.89941zM244.8 440c138.7 0 251.2 -105.3 251.2 -244c0 -110.9 -67.7998 -205.8 -167.8 -239c-12.7002 -2.2998 -17.2998 5.59961 -17.2998 12.0996 c0 8.2002 0.299805 49.9004 0.299805 83.6006c0 23.5 -7.7998 38.5 -17 46.3994c55.8994 6.30078 114.8 14 114.8 110.5c0 27.4004 -9.7998 41.2002 -25.7998 58.9004c2.59961 6.5 11.0996 33.2002 -2.60059 67.9004c-20.8994 6.59961 -69 -27 -69 -27 c-20 5.59961 -41.5 8.5 -62.7998 8.5s-42.7998 -2.90039 -62.7998 -8.5c0 0 -48.0996 33.5 -69 27c-13.7002 -34.6006 -5.2002 -61.4004 -2.59961 -67.9004c-16 -17.5996 -23.6006 -31.4004 -23.6006 -58.9004c0 -96.1992 56.4004 -104.3 112.3 -110.5 c-7.19922 -6.59961 -13.6992 -17.6992 -16 -33.6992c-14.2998 -6.60059 -51 -17.7002 -72.8994 20.8994c-13.7002 23.7998 -38.6006 25.7998 -38.6006 25.7998c-24.5 0.300781 -1.59961 -15.3994 -1.59961 -15.3994c16.4004 -7.5 27.7998 -36.6006 27.7998 -36.6006 c14.7002 -44.7998 84.7002 -29.7998 84.7002 -29.7998c0 -21 0.299805 -55.2002 0.299805 -61.3994c0 -6.5 -4.5 -14.4004 -17.2998 -12.1006c-99.7002 33.4004 -169.5 128.3 -169.5 239.2c0 138.7 106.1 244 244.8 244zM97.2002 95.0996 c1.2998 1.30078 3.59961 0.600586 5.2002 -1c1.69922 -1.89941 2 -4.19922 0.699219 -5.19922c-1.2998 -1.30078 -3.59961 -0.600586 -5.19922 1c-1.7002 1.89941 -2 4.19922 -0.700195 5.19922zM86.4004 103.2c0.699219 1 2.2998 1.2998 4.2998 0.700195 c2 -1 3 -2.60059 2.2998 -3.90039c-0.700195 -1.40039 -2.7002 -1.7002 -4.2998 -0.700195c-2 1 -3 2.60059 -2.2998 3.90039zM118.8 67.5996c1.2998 1.60059 4.2998 1.30078 6.5 -1c2 -1.89941 2.60059 -4.89941 1.2998 -6.19922 c-1.2998 -1.60059 -4.19922 -1.30078 -6.5 1c-2.2998 1.89941 -2.89941 4.89941 -1.2998 6.19922zM107.4 82.2998c1.59961 1.2998 4.19922 0.299805 5.59961 -2c1.59961 -2.2998 1.59961 -4.89941 0 -6.2002c-1.2998 -1 -4 0 -5.59961 2.30078 c-1.60059 2.2998 -1.60059 4.89941 0 5.89941z"
RSS = "M128.081 32.041c0 -35.3691 -28.6719 -64.041 -64.041 -64.041s-64.04 28.6719 -64.04 64.041s28.6719 64.041 64.041 64.041s64.04 -28.6729 64.04 -64.041zM303.741 -15.209c0.494141 -9.13477 -6.84668 -16.791 -15.9951 -16.79h-48.0693 c-8.41406 0 -15.4707 6.49023 -16.0176 14.8867c-7.29883 112.07 -96.9404 201.488 -208.772 208.772c-8.39648 0.545898 -14.8867 7.60254 -14.8867 16.0176v48.0693c0 9.14746 7.65625 16.4883 16.791 15.9941c154.765 -8.36328 278.596 -132.351 286.95 -286.95z M447.99 -15.4971c0.324219 -9.03027 -6.97168 -16.5029 -16.0049 -16.5039h-48.0684c-8.62598 0 -15.6455 6.83496 -15.999 15.4531c-7.83789 191.148 -161.286 344.626 -352.465 352.465c-8.61816 0.354492 -15.4531 7.37402 -15.4531 15.999v48.0684 c0 9.03418 7.47266 16.3301 16.5029 16.0059c234.962 -8.43555 423.093 -197.667 431.487 -431.487z"
CODE = "M278.9 -63.5l-61 17.7002c-6.40039 1.7998 -10 8.5 -8.2002 14.8994l136.5 470.2c1.7998 6.40039 8.5 10 14.8994 8.2002l61 -17.7002c6.40039 -1.7998 10 -8.5 8.2002 -14.8994l-136.5 -470.2c-1.89941 -6.40039 -8.5 -10.1006 -14.8994 -8.2002zM164.9 48.7002 c-4.5 -4.90039 -12.1006 -5.10059 -17 -0.5l-144.101 135.1c-5.09961 4.7002 -5.09961 12.7998 0 17.5l144.101 135c4.89941 4.60059 12.5 4.2998 17 -0.5l43.5 -46.3994c4.69922 -4.90039 4.2998 -12.7002 -0.800781 -17.2002l-90.5996 -79.7002l90.5996 -79.7002 c5.10059 -4.5 5.40039 -12.2998 0.800781 -17.2002zM492.1 48.0996c-4.89941 -4.5 -12.5 -4.2998 -17 0.600586l-43.5 46.3994c-4.69922 4.90039 -4.2998 12.7002 0.800781 17.2002l90.5996 79.7002l-90.5996 79.7998c-5.10059 4.5 -5.40039 12.2998 -0.800781 17.2002 l43.5 46.4004c4.60059 4.7998 12.2002 5 17 0.5l144.101 -135.2c5.09961 -4.7002 5.09961 -12.7998 0 -17.5z"
end
end
end
end

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

@ -0,0 +1,208 @@
require "phlex"
require "pressa/views/icons"
module Pressa
module Views
class Layout < Phlex::HTML
attr_reader :site,
:page_subtitle,
:page_description,
:page_type,
:canonical_url,
:page_scripts,
:page_styles,
:content
def initialize(
site:,
canonical_url:, page_subtitle: nil,
page_description: nil,
page_type: "website",
page_scripts: [],
page_styles: [],
content: nil
)
@site = site
@page_subtitle = page_subtitle
@page_description = page_description
@page_type = page_type
@canonical_url = canonical_url
@page_scripts = page_scripts
@page_styles = page_styles
@content = content
end
def view_template
doctype
html(lang: "en") do
comment { "meow" }
head do
meta(charset: "UTF-8")
title { full_title }
meta(name: "twitter:title", content: full_title)
meta(property: "og:title", content: full_title)
meta(name: "description", content: description)
meta(name: "twitter:description", content: description)
meta(property: "og:description", content: description)
meta(property: "og:site_name", content: site.title)
link(rel: "canonical", href: canonical_url)
meta(name: "twitter:url", content: canonical_url)
meta(property: "og:url", content: canonical_url)
meta(property: "og:image", content: og_image_url) if og_image_url
meta(property: "og:type", content: page_type)
meta(property: "article:author", content: site.author)
meta(name: "twitter:card", content: "summary")
link(
rel: "alternate",
href: site.url_for("/feed.xml"),
type: "application/rss+xml",
title: site.title
)
link(
rel: "alternate",
href: site.url_for("/feed.json"),
type: "application/json",
title: site.title
)
meta(name: "fediverse:creator", content: "@sjs@techhub.social")
link(rel: "author", type: "text/plain", href: site.url_for("/humans.txt"))
link(rel: "icon", type: "image/png", href: site.url_for("/images/favicon-32x32.png"))
link(rel: "shortcut icon", href: site.url_for("/images/favicon.icon"))
link(rel: "apple-touch-icon", href: site.url_for("/images/apple-touch-icon.png"))
link(rel: "mask-icon", color: "#aa0000", href: site.url_for("/images/safari-pinned-tab.svg"))
link(rel: "manifest", href: site.url_for("/images/manifest.json"))
meta(name: "msapplication-config", content: site.url_for("/images/browserconfig.xml"))
meta(name: "theme-color", content: "#121212")
meta(name: "viewport", content: "width=device-width, initial-scale=1.0, viewport-fit=cover")
link(rel: "dns-prefetch", href: "https://gist.github.com")
all_styles.each do |style|
link(rel: "stylesheet", type: "text/css", href: style_href(style.href))
end
end
body do
render_header
render(content) if content
render_footer
render_scripts
end
end
end
private
def description
page_description || site.description
end
def full_title
return site.title unless page_subtitle
"#{site.title}: #{page_subtitle}"
end
def og_image_url
site.image_url
end
def all_styles
site.styles + page_styles
end
def all_scripts
site.scripts + page_scripts
end
def render_header
header(class: "primary") do
div(class: "title") do
h1 do
a(href: site.url) { site.title }
end
br
h4 do
plain "By "
a(href: site.url_for("/about")) { site.author }
end
end
nav(class: "remote") do
ul do
li(class: "mastodon") do
a(rel: "me", "aria-label": "Mastodon", href: "https://techhub.social/@sjs") do
raw(safe(Icons.mastodon))
end
end
li(class: "github") do
a("aria-label": "GitHub", href: "https://github.com/samsonjs") do
raw(safe(Icons.github))
end
end
li(class: "rss") do
a("aria-label": "RSS", href: site.url_for("/feed.xml")) do
raw(safe(Icons.rss))
end
end
end
end
nav(class: "local") do
ul do
li { a(href: site.url_for("/about")) { "About" } }
li { a(href: site.url_for("/posts")) { "Archive" } }
li { a(href: site.url_for("/projects")) { "Projects" } }
end
end
div(class: "clearfix")
end
end
def render_footer
footer do
plain "© #{footer_years} "
a(href: site.url_for("/about")) { site.author }
end
end
def render_scripts
all_scripts.each do |scr|
attrs = {src: script_src(scr.src)}
attrs[:defer] = true if scr.defer
script(**attrs)
end
end
def script_src(src)
return src if src.start_with?("http://", "https://")
absolute_asset(src)
end
def style_href(href)
return href if href.start_with?("http://", "https://")
absolute_asset(href)
end
def absolute_asset(path)
normalized = path.start_with?("/") ? path : "/#{path}"
site.url_for(normalized)
end
def footer_years
current_year = Time.now.year
start_year = site.copyright_start_year || current_year
return current_year.to_s if start_year >= current_year
"#{start_year} - #{current_year}"
end
end
end
end

View file

@ -0,0 +1,26 @@
require "phlex"
require "pressa/views/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
div(class: "container") do
h1 { "#{@month_posts.month.name} #{@year}" }
end
@month_posts.sorted_posts.each do |post|
div(class: "container") do
render PostView.new(post:, site: @site)
end
end
end
end
end
end

View file

@ -0,0 +1,46 @@
require "phlex"
require "pressa/views/icons"
module Pressa
module Views
class PostView < Phlex::HTML
def initialize(post:, site:, article_class: nil)
@post = post
@site = site
@article_class = article_class
end
def view_template
article(**article_attributes) do
header do
h2 do
if @post.link_post?
a(href: @post.link) { "#{@post.title}" }
else
a(href: @post.path) { @post.title }
end
end
time { @post.formatted_date }
a(href: @post.path, class: "permalink") { "" }
end
raw(safe(@post.body))
end
div(class: "row clearfix") do
p(class: "fin") do
raw(safe(Icons.code))
end
end
end
private
def article_attributes
return {} unless @article_class
{class: @article_class}
end
end
end
end

View file

@ -0,0 +1,63 @@
require "phlex"
require "pressa/views/icons"
module Pressa
module Views
class ProjectView < Phlex::HTML
def initialize(project:, site:)
@project = project
@site = site
end
def view_template
article(class: "container project") do
h1(id: "project", data: {title: @project.title}) { @project.title }
h4 { @project.description }
div(class: "project-stats") do
p do
a(href: @project.url) { "GitHub" }
plain ""
a(id: "nstar", href: stargazers_url)
plain ""
a(id: "nfork", href: network_url)
end
p do
plain "Last updated on "
span(id: "updated")
end
end
div(class: "project-info row clearfix") do
div(class: "column half") do
h3 { "Contributors" }
div(id: "contributors")
end
div(class: "column half") do
h3 { "Languages" }
div(id: "langs")
end
end
end
div(class: "row clearfix") do
p(class: "fin") do
raw(safe(Icons.code))
end
end
end
private
def stargazers_url
"#{@project.url}/stargazers"
end
def network_url
"#{@project.url}/network/members"
end
end
end
end

View file

@ -0,0 +1,34 @@
require "phlex"
require "pressa/views/icons"
module Pressa
module Views
class ProjectsView < Phlex::HTML
def initialize(projects:, site:)
@projects = projects
@site = site
end
def view_template
article(class: "container") do
h1 { "Projects" }
@projects.each do |project|
div(class: "project-listing") do
h4 do
a(href: @site.url_for(project.path)) { project.title }
end
p(class: "description") { project.description }
end
end
end
div(class: "row clearfix") do
p(class: "fin") do
raw(safe(Icons.code))
end
end
end
end
end
end

View file

@ -0,0 +1,21 @@
require "phlex"
require "pressa/views/post_view"
module Pressa
module Views
class RecentPostsView < Phlex::HTML
def initialize(posts:, site:)
@posts = posts
@site = site
end
def view_template
div(class: "container") do
@posts.each do |post|
render PostView.new(post:, site: @site)
end
end
end
end
end
end

View file

@ -0,0 +1,66 @@
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
div(class: "container") do
h2(class: "year") do
a(href: year_path) { @year.to_s }
end
@year_posts.sorted_months.each do |month_posts|
render_month(month_posts)
end
end
end
private
def year_path
@site.url_for("/posts/#{@year}/")
end
def render_month(month_posts)
month = month_posts.month
h3(class: "month") do
a(href: @site.url_for("/posts/#{@year}/#{month.padded}/")) do
month.name
end
end
ul(class: "archive") do
month_posts.sorted_posts.each do |post|
render_post_item(post)
end
end
end
def render_post_item(post)
if post.link_post?
li do
a(href: post.link) { "#{post.title}" }
time { short_date(post.date) }
a(class: "permalink", href: post.path) { "" }
end
else
li do
a(href: post.path) { post.title }
time { short_date(post.date) }
end
end
end
def short_date(date)
date.strftime("%-d %b")
end
end
end
end

View file

@ -1,7 +1,7 @@
--- ---
Title: First Post! Title: "First Post!"
Author: Sami Samhuri Author: Sami Samhuri
Date: 8th February, 2006 Date: "8th February, 2006"
Timestamp: 2006-02-07T19:21:00-08:00 Timestamp: 2006-02-07T19:21:00-08:00
Tags: life Tags: life
--- ---

View file

@ -1,7 +1,7 @@
--- ---
Title: Girlfriend X Title: "Girlfriend X"
Author: Sami Samhuri Author: Sami Samhuri
Date: 18th February, 2006 Date: "18th February, 2006"
Timestamp: 2006-02-18T11:50:00-08:00 Timestamp: 2006-02-18T11:50:00-08:00
Tags: crazy, funny Tags: crazy, funny
--- ---

View file

@ -1,7 +1,7 @@
--- ---
Title: Intelligent Migration Snippets 0.1 for TextMate Title: "Intelligent Migration Snippets 0.1 for TextMate"
Author: Sami Samhuri Author: Sami Samhuri
Date: 22nd February, 2006 Date: "22nd February, 2006"
Timestamp: 2006-02-22T03:28:00-08:00 Timestamp: 2006-02-22T03:28:00-08:00
Tags: mac os x, textmate, rails, hacking, migrations, snippets Tags: mac os x, textmate, rails, hacking, migrations, snippets
--- ---

View file

@ -1,7 +1,7 @@
--- ---
Title: Jump to view/controller in TextMate Title: "Jump to view/controller in TextMate"
Author: Sami Samhuri Author: Sami Samhuri
Date: 18th February, 2006 Date: "18th February, 2006"
Timestamp: 2006-02-18T14:51:00-08:00 Timestamp: 2006-02-18T14:51:00-08:00
Tags: hacking, rails, textmate, rails, textmate Tags: hacking, rails, textmate, rails, textmate
--- ---

View file

@ -1,10 +1,9 @@
--- ---
Title: Obligatory Post about Ruby on Rails Title: "Obligatory Post about Ruby on Rails"
Author: Sami Samhuri Author: Sami Samhuri
Date: 20th February, 2006 Date: "20th February, 2006"
Timestamp: 2006-02-20T00:31:00-08:00 Timestamp: 2006-02-20T00:31:00-08:00
Tags: rails, coding, hacking, migration, rails, testing Tags: rails, coding, hacking, migration, rails, testing
Styles: typocode.css
--- ---
<p><em>I'm a Rails newbie and eager to learn. I welcome any suggestions or criticism you have. You can direct them to <a href="mailto:sjs@uvic.ca">my inbox</a> or leave me a comment below.</em></p> <p><em>I'm a Rails newbie and eager to learn. I welcome any suggestions or criticism you have. You can direct them to <a href="mailto:sjs@uvic.ca">my inbox</a> or leave me a comment below.</em></p>
@ -17,52 +16,36 @@ Styles: typocode.css
<p>It's unlikely that he was surprised at my lengthy response, but I was. I have been known to write him long messages on topics that interest me. However, I've only been learning Rails for two weeks or so. Could I possibly have so much to say about it already? Apparently I do.</p><h2>Ruby on Rails background</h2> <p>It's unlikely that he was surprised at my lengthy response, but I was. I have been known to write him long messages on topics that interest me. However, I've only been learning Rails for two weeks or so. Could I possibly have so much to say about it already? Apparently I do.</p><h2>Ruby on Rails background</h2>
<p>I assume a pretty basic knowledge of what Rails is, so if you're not familiar with it now's a good time to read something on the official <a href="http://www.rubyonrails.com/">Rails website</a> and watch the infamous <a href="http://www.rubyonrails.com/screencasts">15-minute screencast</a>, where Rails creator, <a href="http://www.loudthinking.com/">David Heinemeier Hansson</a>, creates a simple blog application.</p> <p>I assume a pretty basic knowledge of what Rails is, so if you're not familiar with it now's a good time to read something on the official <a href="http://www.rubyonrails.com/">Rails website</a> and watch the infamous <a href="http://www.rubyonrails.com/screencasts">15-minute screencast</a>, where Rails creator, <a href="http://www.loudthinking.com/">David Heinemeier Hansson</a>, creates a simple blog application.</p>
<p>The screencasts are what sparked my curiosity, but they hardly scratch the surface of Rails. After that I spent hours reading whatever I could find about Rails before deciding to take the time to learn it well. As a result, a lot of what you read here will sound familiar if you've read other blogs and articles about Rails. This post wasn't planned so there's no list of references yet. I hope to add some links though so please contact me if any ideas or paraphrasing here is from your site, or if you know who I should give credit to.</p> <p>The screencasts are what sparked my curiosity, but they hardly scratch the surface of Rails. After that I spent hours reading whatever I could find about Rails before deciding to take the time to learn it well. As a result, a lot of what you read here will sound familiar if you've read other blogs and articles about Rails. This post wasn't planned so there's no list of references yet. I hope to add some links though so please contact me if any ideas or paraphrasing here is from your site, or if you know who I should give credit to.</p>
<h2>Rails through my eyes</h2> <h2>Rails through my eyes</h2>
<p>Rails is like my Black &amp; Decker toolkit. I have a hammer, power screwdriver, tape measure, needle-nose pliers, wire cutters, a level, etc. This is exactly what I need—no more, no less. It helps me get things done quickly and easily that would otherwise be painful and somewhat difficult. I can pick up the tools and use them without much training. Therefore I am instantly productive with them.</p> <p>Rails is like my Black &amp; Decker toolkit. I have a hammer, power screwdriver, tape measure, needle-nose pliers, wire cutters, a level, etc. This is exactly what I need—no more, no less. It helps me get things done quickly and easily that would otherwise be painful and somewhat difficult. I can pick up the tools and use them without much training. Therefore I am instantly productive with them.</p>
<p>The kit is suitable for many people who need these things at home, such as myself. Companies build skyscrapers and huge malls and apartments, and they clearly need more powerful tools than I. There are others that just need to drive in a nail to hang a picture, in which case the kit I have is overkill. They're better off just buying and using a single hammer. I happen to fall in the big grey middle <a href="http://web.archive.org/web/20070316171839/http://poignantguide.net/ruby/chapter-3.html#section2">chunk</a>, not the other two.</p> <p>The kit is suitable for many people who need these things at home, such as myself. Companies build skyscrapers and huge malls and apartments, and they clearly need more powerful tools than I. There are others that just need to drive in a nail to hang a picture, in which case the kit I have is overkill. They're better off just buying and using a single hammer. I happen to fall in the big grey middle <a href="http://web.archive.org/web/20070316171839/http://poignantguide.net/ruby/chapter-3.html#section2">chunk</a>, not the other two.</p>
<p>I'm a university student. I code because it's satisfying and fun to create software. I do plan on coding for a living when I graduate. I don't work with ancient databases, or create monster sites like Amazon, Google, or Ebay. The last time I started coding a website from scratch I was using <a href="http://www.php.net/">PHP</a>, that was around the turn of the millennium. [It was a fan site for a <a href="http://www.nofx.org/">favourite band</a> of mine.]</p> <p>I'm a university student. I code because it's satisfying and fun to create software. I do plan on coding for a living when I graduate. I don't work with ancient databases, or create monster sites like Amazon, Google, or Ebay. The last time I started coding a website from scratch I was using <a href="http://www.php.net/">PHP</a>, that was around the turn of the millennium. [It was a fan site for a <a href="http://www.nofx.org/">favourite band</a> of mine.]</p>
<p>After a year or so I realized I didn't have the time to do it properly (ie. securely and cleanly) if I wanted it to be done relatively soon. A slightly customized <a href="http://www.mediawiki.org/wiki/MediaWiki">MediaWiki</a> promptly took it's place. It did all that I needed quite well, just in a less specific way.</p> <p>After a year or so I realized I didn't have the time to do it properly (ie. securely and cleanly) if I wanted it to be done relatively soon. A slightly customized <a href="http://www.mediawiki.org/wiki/MediaWiki">MediaWiki</a> promptly took it's place. It did all that I needed quite well, just in a less specific way.</p>
<p>The wiki is serving my site extremely well, but there's still that itch to create my <strong>own</strong> site. I feel if Rails was around back then I may have been able to complete the project in a timely manner. I was also frustrated with PHP. Part of that is likely due to a lack of experience and of formal programming education at that time, but it was still not fun for me. It wasn't until I started learning Rails that I thought "<em>hey, I could create that site pretty quickly using this!</em>"</p> <p>The wiki is serving my site extremely well, but there's still that itch to create my <strong>own</strong> site. I feel if Rails was around back then I may have been able to complete the project in a timely manner. I was also frustrated with PHP. Part of that is likely due to a lack of experience and of formal programming education at that time, but it was still not fun for me. It wasn't until I started learning Rails that I thought "<em>hey, I could create that site pretty quickly using this!</em>"</p>
<p>Rails fits my needs like a glove, and this is where it shines. Many professionals are making money creating sites in Rails, so I'm not trying to say it's for amateurs only or something equally silly.</p> <p>Rails fits my needs like a glove, and this is where it shines. Many professionals are making money creating sites in Rails, so I'm not trying to say it's for amateurs only or something equally silly.</p>
<h2>Web Frameworks and iPods?</h2> <h2>Web Frameworks and iPods?</h2>
<p>Some might say I have merely been swept up in hype and am following the herd. You may be right, and that's okay. I'm going to tell you a story. There was a guy who didn't get one of the oh-so-shiny iPods for a long time, though they looked neat. His discman plays mp3 CDs, and that was good enough for him. The latest iPod, which plays video, was sufficiently cool enough for him to forget that <strong>everyone</strong> at his school has an iPod and he would be trendy just like them now.</p> <p>Some might say I have merely been swept up in hype and am following the herd. You may be right, and that's okay. I'm going to tell you a story. There was a guy who didn't get one of the oh-so-shiny iPods for a long time, though they looked neat. His discman plays mp3 CDs, and that was good enough for him. The latest iPod, which plays video, was sufficiently cool enough for him to forget that <strong>everyone</strong> at his school has an iPod and he would be trendy just like them now.</p>
<p>Shocker ending: he is I, and I am him. Now I know why everyone has one of those shiny devices. iPods and web frameworks have little in common except that many believe both the iPod and Rails are all hype and flash. I've realized that something creating this kind of buzz may actually just be a good product. I feel that this is the only other thing the iPod and Rails have in common: they are both <strong>damn good</strong>. Enough about the iPod, everyone hates hearing about it. My goal is to write about the other thing everyone is tired of hearing about.</p> <p>Shocker ending: he is I, and I am him. Now I know why everyone has one of those shiny devices. iPods and web frameworks have little in common except that many believe both the iPod and Rails are all hype and flash. I've realized that something creating this kind of buzz may actually just be a good product. I feel that this is the only other thing the iPod and Rails have in common: they are both <strong>damn good</strong>. Enough about the iPod, everyone hates hearing about it. My goal is to write about the other thing everyone is tired of hearing about.</p>
<h2>Why is Rails special?</h2> <h2>Why is Rails special?</h2>
<p><strong>Rails is not magic.</strong> There are no exclusive JavaScript libraries or HTML tags. We all have to produce pages that render in the same web browsers. My dad was correct, there <em>is</em> nothing special about my website either. It's more or less a stock Typo website.</p> <p><strong>Rails is not magic.</strong> There are no exclusive JavaScript libraries or HTML tags. We all have to produce pages that render in the same web browsers. My dad was correct, there <em>is</em> nothing special about my website either. It's more or less a stock Typo website.</p>
<p>So what makes developing with Rails different? For me there are four big things that set Rails apart from the alternatives:</p> <p>So what makes developing with Rails different? For me there are four big things that set Rails apart from the alternatives:</p>
<ol> <ol>
<li>Separating data, function, and design</li> <li>Separating data, function, and design</li>
<li>Readability (which is underrated) </li> <li>Readability (which is underrated) </li>
@ -70,148 +53,120 @@ Styles: typocode.css
<li>Testing is so easy it hurts</li> <li>Testing is so easy it hurts</li>
</ol> </ol>
<h3>MVC 101 <em>(or, Separating data, function, and design)</em></h3> <h3>MVC 101 <em>(or, Separating data, function, and design)</em></h3>
<p>Now I'm sure you've heard about separating content from design. Rails takes that one step further from just using CSS to style your website. It uses what's known as the MVC paradigm: <strong>Model-View-Controller</strong>. This is a tried and tested development method. I'd used MVC before in Cocoa programming on Mac OS X, so I was already sold on this point.</p> <p>Now I'm sure you've heard about separating content from design. Rails takes that one step further from just using CSS to style your website. It uses what's known as the MVC paradigm: <strong>Model-View-Controller</strong>. This is a tried and tested development method. I'd used MVC before in Cocoa programming on Mac OS X, so I was already sold on this point.</p>
<ul> <ul>
<li>The model deals with your data. If you're creating an online store you have a product model, a shopping cart model, a customer model, etc. The model takes care of storing this data in the database (persistence), and presenting it to you as an object you can manipulate at runtime.</li> <li>The model deals with your data. If you're creating an online store you have a product model, a shopping cart model, a customer model, etc. The model takes care of storing this data in the database (persistence), and presenting it to you as an object you can manipulate at runtime.</li>
</ul> </ul>
<ul> <ul>
<li>The view deals <em>only</em> with presentation. That's it, honestly. An interface to your app.</li> <li>The view deals <em>only</em> with presentation. That's it, honestly. An interface to your app.</li>
</ul> </ul>
<ul> <ul>
<li>The controller binds the model to the view, so that when the user clicks on the <strong>Add to cart</strong> link the controller is wired to call the <code>add_product</code> method of the cart model and tell it which product to add. Then the controller takes the appropriate action such as redirecting the user to the shopping cart view.</li> <li>The controller binds the model to the view, so that when the user clicks on the <strong>Add to cart</strong> link the controller is wired to call the <code>add_product</code> method of the cart model and tell it which product to add. Then the controller takes the appropriate action such as redirecting the user to the shopping cart view.</li>
</ul> </ul>
<p>Of course this is not exclusive to Rails, but it's an integral part of it's design.</p> <p>Of course this is not exclusive to Rails, but it's an integral part of it's design.</p>
<h3>Readability</h3> <h3>Readability</h3>
<p>Rails, and <a href="http://www.ruby-lang.org/">Ruby</a>, both read amazingly like spoken English. This code is more or less straight out of Typo. You define relationships between objects like this:</p> <p>Rails, and <a href="http://www.ruby-lang.org/">Ruby</a>, both read amazingly like spoken English. This code is more or less straight out of Typo. You define relationships between objects like this:</p>
```ruby
<div class="typocode"><pre><code class="typocode_ruby "><span class="keyword">class </span><span class="class">Article</span> <span class="punct">&lt;</span> <span class="constant">Content</span> class Article < Content
<span class="ident">has_many</span> <span class="symbol">:comments</span><span class="punct">,</span> <span class="symbol">:dependent</span> <span class="punct">=&gt;</span> <span class="constant">true</span><span class="punct">,</span> <span class="symbol">:order</span> <span class="punct">=&gt;</span> <span class="punct">"</span><span class="string">created_at ASC</span><span class="punct">"</span> has_many :comments, :dependent => true, :order => "created_at ASC"
<span class="ident">has_many</span> <span class="symbol">:trackbacks</span><span class="punct">,</span> <span class="symbol">:dependent</span> <span class="punct">=&gt;</span> <span class="constant">true</span><span class="punct">,</span> <span class="symbol">:order</span> <span class="punct">=&gt;</span> <span class="punct">"</span><span class="string">created_at ASC</span><span class="punct">"</span> has_many :trackbacks, :dependent => true, :order => "created_at ASC"
<span class="ident">has_and_belongs_to_many</span> <span class="symbol">:categories</span><span class="punct">,</span> <span class="symbol">:foreign_key</span> <span class="punct">=&gt;</span> <span class="punct">'</span><span class="string">article_id</span><span class="punct">'</span> has_and_belongs_to_many :categories, :foreign_key => 'article_id'
<span class="ident">has_and_belongs_to_many</span> <span class="symbol">:tags</span><span class="punct">,</span> <span class="symbol">:foreign_key</span> <span class="punct">=&gt;</span> <span class="punct">'</span><span class="string">article_id</span><span class="punct">'</span> has_and_belongs_to_many :tags, :foreign_key => 'article_id'
<span class="ident">belongs_to</span> <span class="symbol">:user</span> belongs_to :user
<span class="punct">...</span></code></pre></div> ...
```
<p><code>dependent =&gt; true</code> means <em>if an article is deleted, it's comments go with it</em>. Don't worry if you don't understand it all, this is just for you to see some actual Rails code.</p> <p><code>dependent =&gt; true</code> means <em>if an article is deleted, it's comments go with it</em>. Don't worry if you don't understand it all, this is just for you to see some actual Rails code.</p>
<p>In the Comment model you have:</p> <p>In the Comment model you have:</p>
```ruby
class Comment < Content
belongs_to :article
belongs_to :user
<div class="typocode"><pre><code class="typocode_ruby "><span class="keyword">class </span><span class="class">Comment</span> <span class="punct">&lt;</span> <span class="constant">Content</span> validates_presence_of :author, :body
<span class="ident">belongs_to</span> <span class="symbol">:article</span> validates_against_spamdb :body, :url, :ip
<span class="ident">belongs_to</span> <span class="symbol">:user</span> validates_age_of :article_id
...
<span class="ident">validates_presence_of</span> <span class="symbol">:author</span><span class="punct">,</span> <span class="symbol">:body</span> ```
<span class="ident">validates_against_spamdb</span> <span class="symbol">:body</span><span class="punct">,</span> <span class="symbol">:url</span><span class="punct">,</span> <span class="symbol">:ip</span>
<span class="ident">validates_age_of</span> <span class="symbol">:article_id</span>
<span class="punct">...</span></code></pre></div>
<p>(I snuck in some validations as well)</p> <p>(I snuck in some validations as well)</p>
<p>But look how it reads! Read it out loud. I'd bet that my mom would more or less follow this, and she's anything but a programmer. That's not to say programming should be easy for grandma, <strong>but code should be easily understood by humans</strong>. Let the computer understand things that are natural for me to type, since we're making it understand a common language anyways.</p> <p>But look how it reads! Read it out loud. I'd bet that my mom would more or less follow this, and she's anything but a programmer. That's not to say programming should be easy for grandma, <strong>but code should be easily understood by humans</strong>. Let the computer understand things that are natural for me to type, since we're making it understand a common language anyways.</p>
<p>Ruby and Ruby on Rails allow and encourage you to write beautiful code. That is so much more important than you may realize, because it leads to many other virtues. Readability is obvious, and hence maintainability. You must read code to understand and modify it. Oh, and happy programmers will be more productive than frustrated programmers.</p> <p>Ruby and Ruby on Rails allow and encourage you to write beautiful code. That is so much more important than you may realize, because it leads to many other virtues. Readability is obvious, and hence maintainability. You must read code to understand and modify it. Oh, and happy programmers will be more productive than frustrated programmers.</p>
<h3 id="migrations">Database Migrations</h3> <h3 id="migrations">Database Migrations</h3>
<p>Here's one more life-saver: migrations. Migrations are a way to version your database schema from within Rails. So you have a table, call it <code>albums</code>, and you want to add the date the album was released. You could modify the database directly, but that's not fun. Even if you only have one server, all your configuration will be in one central place, the app. And Rails doesn't care if you have PostgreSQL, MySQL, or SQLite behind it. You can develop and test on SQLite and deploy on MySQL and the migrations will just work in both environments.</p> <p>Here's one more life-saver: migrations. Migrations are a way to version your database schema from within Rails. So you have a table, call it <code>albums</code>, and you want to add the date the album was released. You could modify the database directly, but that's not fun. Even if you only have one server, all your configuration will be in one central place, the app. And Rails doesn't care if you have PostgreSQL, MySQL, or SQLite behind it. You can develop and test on SQLite and deploy on MySQL and the migrations will just work in both environments.</p>
```ruby
class AddDateReleased < ActiveRecord::Migration
def self.up
add_column "albums", "date_released", :datetime
Albums.update_all "date_released = now()"
end
<div class="typocode"><pre><code class="typocode_ruby "><span class="keyword">class </span><span class="class">AddDateReleased</span> <span class="punct">&lt;</span> <span class="constant">ActiveRecord</span><span class="punct">::</span><span class="constant">Migration</span> def self.down
<span class="keyword">def </span><span class="method">self.up</span> remove_column "albums", "date_released"
<span class="ident">add_column</span> <span class="punct">"</span><span class="string">albums</span><span class="punct">",</span> <span class="punct">"</span><span class="string">date_released</span><span class="punct">",</span> <span class="symbol">:datetime</span> end
<span class="constant">Albums</span><span class="punct">.</span><span class="ident">update_all</span> <span class="punct">"</span><span class="string">date_released = now()</span><span class="punct">"</span> end
<span class="keyword">end</span> ```
<span class="keyword">def </span><span class="method">self.down</span>
<span class="ident">remove_column</span> <span class="punct">"</span><span class="string">albums</span><span class="punct">",</span> <span class="punct">"</span><span class="string">date_released</span><span class="punct">"</span>
<span class="keyword">end</span>
<span class="keyword">end</span></code></pre></div>
<p>Then you run the migration (<code>rake migrate</code> does that) and boom, your up to date. If you're wondering, the <code>self.down</code> method indeed implies that you can take this the other direction as well. Think <code>rake migrate VERSION=X</code>.</p> <p>Then you run the migration (<code>rake migrate</code> does that) and boom, your up to date. If you're wondering, the <code>self.down</code> method indeed implies that you can take this the other direction as well. Think <code>rake migrate VERSION=X</code>.</p>
<p><em>Along with the other screencasts is one on <a href="http://www.rubyonrails.org/screencasts">migrations</a> featuring none other than David Hansson. You should take a look, it's the third video.</em></p> <p><em>Along with the other screencasts is one on <a href="http://www.rubyonrails.org/screencasts">migrations</a> featuring none other than David Hansson. You should take a look, it's the third video.</em></p>
<h3>Testing so easy it hurts</h3> <h3>Testing so easy it hurts</h3>
<p>To start a rails project you type <code>rails project_name</code> and it creates a directory structure with a fresh project in it. This includes a directory appropriately called <em>test</em> which houses unit tests for the project. When you generate models and controllers it creates test stubs for you in that directory. Basically, it makes it so easy to test that you're a fool not to do it. As someone wrote on their site: <em>It means never having to say "<strong>I introduced a new bug while fixing another.</strong>"</em></p> <p>To start a rails project you type <code>rails project_name</code> and it creates a directory structure with a fresh project in it. This includes a directory appropriately called <em>test</em> which houses unit tests for the project. When you generate models and controllers it creates test stubs for you in that directory. Basically, it makes it so easy to test that you're a fool not to do it. As someone wrote on their site: <em>It means never having to say "<strong>I introduced a new bug while fixing another.</strong>"</em></p>
<p>Rails builds on the unit testing that comes with Ruby. On a larger scale, that means that Rails is unlikely to flop on you because it is regularly tested using the same method. Ruby is unlikely to flop for the same reason. That makes me look good as a programmer. If you code for a living then it's of even more value to you.</p> <p>Rails builds on the unit testing that comes with Ruby. On a larger scale, that means that Rails is unlikely to flop on you because it is regularly tested using the same method. Ruby is unlikely to flop for the same reason. That makes me look good as a programmer. If you code for a living then it's of even more value to you.</p>
<p><em>I don't know why it hurts. Maybe it hurts developers working with other frameworks or languages to see us have it so nice and easy.</em></p> <p><em>I don't know why it hurts. Maybe it hurts developers working with other frameworks or languages to see us have it so nice and easy.</em></p>
<h2>Wrapping up</h2> <h2>Wrapping up</h2>
<p>Rails means I have fun doing web development instead of being frustrated (CSS hacks aside). David Hansson may be right when he said you have to have been soured by Java or PHP to fully appreciate Rails, but that doesn't mean you won't enjoy it if you <em>do</em> like Java or PHP.</p> <p>Rails means I have fun doing web development instead of being frustrated (CSS hacks aside). David Hansson may be right when he said you have to have been soured by Java or PHP to fully appreciate Rails, but that doesn't mean you won't enjoy it if you <em>do</em> like Java or PHP.</p>
<p><a href="http://www.relevancellc.com/blogs/wp-trackback.php?p=31">Justin Gehtland</a> rewrote a Java app using Rails and the number of lines of code of the Rails version was very close to that of the XML configuration for the Java version. Java has strengths, libraries available <strong>now</strong> seems to be a big one, but it's too big for my needs. If you're like me then maybe you'll enjoy Rails as much as I do.</p> <p><a href="http://www.relevancellc.com/blogs/wp-trackback.php?p=31">Justin Gehtland</a> rewrote a Java app using Rails and the number of lines of code of the Rails version was very close to that of the XML configuration for the Java version. Java has strengths, libraries available <strong>now</strong> seems to be a big one, but it's too big for my needs. If you're like me then maybe you'll enjoy Rails as much as I do.</p>
<h2>You're not done, you lied to me!</h2> <h2>You're not done, you lied to me!</h2>
<p>Sort of... there are a few things that it seems standard to include when someone writes about how Rails saved their life and gave them hope again. For completeness sake, I feel compelled to mention some principles common amongst those who develop Rails, and those who develop on Rails. It's entirely likely that there's nothing new for you here unless you're new to Rails or to programming, in which case I encourage you to read on.</p> <p>Sort of... there are a few things that it seems standard to include when someone writes about how Rails saved their life and gave them hope again. For completeness sake, I feel compelled to mention some principles common amongst those who develop Rails, and those who develop on Rails. It's entirely likely that there's nothing new for you here unless you're new to Rails or to programming, in which case I encourage you to read on.</p>
<h3>DRY</h3> <h3>DRY</h3>
<p>Rails follows the DRY principle religiously. That is, <strong>Don't Repeat Yourself</strong>. Like MVC, I was already sold on this. I had previously encountered it in <a href="http://www.pragmaticprogrammer.com/ppbook/index.shtml">The Pragmatic Programmer</a>. Apart from telling <em>some_model</em> it <code>belongs_to :other_model</code> and <em>other_model</em> that it <code>has_many :some_models</code> nothing has jumped out at me which violates this principle. However, I feel that reading a model's code and seeing it's relationships to other models right there is a Good Thing™.</p> <p>Rails follows the DRY principle religiously. That is, <strong>Don't Repeat Yourself</strong>. Like MVC, I was already sold on this. I had previously encountered it in <a href="http://www.pragmaticprogrammer.com/ppbook/index.shtml">The Pragmatic Programmer</a>. Apart from telling <em>some_model</em> it <code>belongs_to :other_model</code> and <em>other_model</em> that it <code>has_many :some_models</code> nothing has jumped out at me which violates this principle. However, I feel that reading a model's code and seeing it's relationships to other models right there is a Good Thing™.</p>
<h3>Convention over configuration <em>(or, Perceived intelligence)</em></h3> <h3>Convention over configuration <em>(or, Perceived intelligence)</em></h3>
<p>Rails' developers also have the mantra "<em>convention over configuration</em>", which you can see from the video there. (you did watch it, didn't you? ;) Basically that just means Rails has sane defaults, but is still flexible if you don't like the defaults. You don't have to write even one line of SQL with Rails, but if you need greater control then you <em>can</em> write your own SQL. A standard cliché: <em>it makes the simple things easy and the hard possible</em>.</p> <p>Rails' developers also have the mantra "<em>convention over configuration</em>", which you can see from the video there. (you did watch it, didn't you? ;) Basically that just means Rails has sane defaults, but is still flexible if you don't like the defaults. You don't have to write even one line of SQL with Rails, but if you need greater control then you <em>can</em> write your own SQL. A standard cliché: <em>it makes the simple things easy and the hard possible</em>.</p>
<p>Rails seems to have a level of intelligence which contributes to the wow-factor. After <a href="#migrations">these relationships</a> are defined I can now filter certain negative comments like so:</p> <p>Rails seems to have a level of intelligence which contributes to the wow-factor. After <a href="#migrations">these relationships</a> are defined I can now filter certain negative comments like so:</p>
```ruby
<div class="typocode"><pre><code class="typocode_ruby "><span class="ident">article</span> <span class="punct">=</span> <span class="constant">Article</span><span class="punct">.</span><span class="ident">find</span> <span class="symbol">:first</span> article = Article.find :first
<span class="keyword">for</span> <span class="ident">comment</span> <span class="keyword">in</span> <span class="ident">article</span><span class="punct">.</span><span class="ident">comments</span> <span class="keyword">do</span> for comment in article.comments do
<span class="ident">print</span> <span class="ident">comment</span> <span class="keyword">unless</span> <span class="ident">comment</span><span class="punct">.</span><span class="ident">downcase</span> <span class="punct">==</span> <span class="punct">'</span><span class="string">you suck!</span><span class="punct">'</span> print comment unless comment.downcase == 'you suck!'
<span class="keyword">end</span></code></pre></div> end
```
<p>Rails knows to look for the field <strong>article_id</strong> in the <strong>comments</strong> table of the database. This is just a convention. You can call it something else but then you have to tell Rails what you like to call it.</p> <p>Rails knows to look for the field <strong>article_id</strong> in the <strong>comments</strong> table of the database. This is just a convention. You can call it something else but then you have to tell Rails what you like to call it.</p>
<p>Rails understands pluralization, which is a detail but it makes everything feel more natural. If you have a <strong>Person</strong> model then it will know to look for the table named <strong>people</strong>.</p> <p>Rails understands pluralization, which is a detail but it makes everything feel more natural. If you have a <strong>Person</strong> model then it will know to look for the table named <strong>people</strong>.</p>
<h3>Code as you learn</h3> <h3>Code as you learn</h3>
<p>I love how I've only been coding in Rails for a week or two and I can do so much already. It's natural, concise and takes care of the inane details. I love how I <em>know</em> that I don't even have to explain that migration example. It's plainly clear what it does to the database. It doesn't take long to get the basics down and once you do it goes <strong>fast</strong>.</p> <p>I love how I've only been coding in Rails for a week or two and I can do so much already. It's natural, concise and takes care of the inane details. I love how I <em>know</em> that I don't even have to explain that migration example. It's plainly clear what it does to the database. It doesn't take long to get the basics down and once you do it goes <strong>fast</strong>.</p>

View file

@ -1,10 +1,9 @@
--- ---
Title: SJ's Rails Bundle 0.2 for TextMate Title: "SJ's Rails Bundle 0.2 for TextMate"
Author: Sami Samhuri Author: Sami Samhuri
Date: 23rd February, 2006 Date: "23rd February, 2006"
Timestamp: 2006-02-23T17:18:00-08:00 Timestamp: 2006-02-23T17:18:00-08:00
Tags: textmate, rails, coding, bundle, macros, rails, snippets, textmate Tags: textmate, rails, coding, bundle, macros, rails, snippets, textmate
Styles: typocode.css
--- ---
Everything that you've seen posted on my blog is now available in one bundle. Snippets for Rails database migrations and assertions are all included in this bundle. Everything that you've seen posted on my blog is now available in one bundle. Snippets for Rails database migrations and assertions are all included in this bundle.
@ -13,15 +12,17 @@ There are 2 macros for class-end and def-end blocks, bound to <strong>⌃C</stro
I use an underscore to denote the position of the cursor in the following example: I use an underscore to denote the position of the cursor in the following example:
```ruby
<div class="typocode"><pre><code class="typocode_ruby "><span class="ident">method</span><span class="punct">(</span><span class="ident">arg1</span><span class="punct">,</span> <span class="ident">arg2_</span><span class="punct">)</span></code></pre></div> method(arg1, arg2_)
```
Typing <strong>⌃D</strong> at this point results in this code: Typing <strong>⌃D</strong> at this point results in this code:
```ruby
<div class="typocode"><pre><code class="typocode_ruby "><span class="keyword">def </span><span class="method">method</span><span class="punct">(</span><span class="ident">arg1</span><span class="punct">,</span> <span class="ident">arg2</span><span class="punct">)</span> def method(arg1, arg2)
<span class="ident">_</span> _
<span class="keyword">end</span></code></pre></div> end
```
There is a list of the snippets in Features.rtf, which is included in the disk image. Of course you can also browse them in the Snippets Editor built into TextMate. There is a list of the snippets in Features.rtf, which is included in the disk image. Of course you can also browse them in the Snippets Editor built into TextMate.

View file

@ -1,7 +1,7 @@
--- ---
Title: Some TextMate snippets for Rails Migrations Title: "Some TextMate snippets for Rails Migrations"
Author: Sami Samhuri Author: Sami Samhuri
Date: 18th February, 2006 Date: "18th February, 2006"
Timestamp: 2006-02-18T22:48:00-08:00 Timestamp: 2006-02-18T22:48:00-08:00
Tags: textmate, rails, hacking, rails, snippets, textmate Tags: textmate, rails, hacking, rails, snippets, textmate
--- ---
@ -16,39 +16,53 @@ Scope should be *source.ruby.rails* and the triggers I use are above the snippet
mcdt: **M**igration **C**reate and **D**rop **T**able mcdt: **M**igration **C**reate and **D**rop **T**able
create_table "${1:table}" do |t| ```ruby
create_table "${1:table}" do |t|
$0 $0
end end
${2:drop_table "$1"} ${2:drop_table "$1"}
```
mcc: **M**igration **C**reate **C**olumn mcc: **M**igration **C**reate **C**olumn
t.column "${1:title}", :${2:string} ```ruby
t.column "${1:title}", :${2:string}
```
marc: **M**igration **A**dd and **R**emove **C**olumn marc: **M**igration **A**dd and **R**emove **C**olumn
add_column "${1:table}", "${2:column}", :${3:string} ```ruby
${4:remove_column "$1", "$2"} add_column "${1:table}", "${2:column}", :${3:string}
${4:remove_column "$1", "$2"}
```
I realize this might not be for everyone, so here are my original 4 snippets that do the work of *marc* and *mcdt*. I realize this might not be for everyone, so here are my original 4 snippets that do the work of *marc* and *mcdt*.
mct: **M**igration **C**reate **T**able mct: **M**igration **C**reate **T**able
create_table "${1:table}" do |t| ```ruby
create_table "${1:table}" do |t|
$0 $0
end end
```
mdt: **M**igration **D**rop **T**able mdt: **M**igration **D**rop **T**able
drop_table "${1:table}" ```ruby
drop_table "${1:table}"
```
mac: **M**igration **A**dd **C**olumn mac: **M**igration **A**dd **C**olumn
add_column "${1:table}", "${2:column}", :${3:string} ```ruby
add_column "${1:table}", "${2:column}", :${3:string}
```
mrc: **M**igration **R**remove **C**olumn mrc: **M**igration **R**remove **C**olumn
remove_column "${1:table}", "${2:column}" ```ruby
remove_column "${1:table}", "${2:column}"
```
I'll be adding more snippets and macros. There should be a central place where the rails bundle can be improved and extended. Maybe there is... I'll be adding more snippets and macros. There should be a central place where the rails bundle can be improved and extended. Maybe there is...
@ -91,4 +105,3 @@ I'll be adding more snippets and macros. There should be a central place where t
<p>P.S. I tried several ways to get the combo-snippets to put the pieces inside the right functions but failed. We'll see tomorrow if Allan (creator of TextMate) has any ideas.</p> <p>P.S. I tried several ways to get the combo-snippets to put the pieces inside the right functions but failed. We'll see tomorrow if Allan (creator of TextMate) has any ideas.</p>
</div> </div>
</div> </div>

View file

@ -1,46 +1,44 @@
--- ---
Title: TextMate: Insert text into self.down Title: "TextMate: Insert text into self.down"
Author: Sami Samhuri Author: Sami Samhuri
Date: 21st February, 2006 Date: "21st February, 2006"
Timestamp: 2006-02-21T14:55:00-08:00 Timestamp: 2006-02-21T14:55:00-08:00
Tags: textmate, rails, hacking, commands, macro, rails, snippets, textmate Tags: textmate, rails, hacking, commands, macro, rails, snippets, textmate
Styles: typocode.css
--- ---
<p><em><strong>UPDATE:</strong> I got everything working and it's all packaged up <a href="/posts/2006/02/intelligent-migration-snippets-0_1-for-textmate">here</a>. There's an installation script this time as well.</em></p> <p><em><strong>UPDATE:</strong> I got everything working and it's all packaged up <a href="/posts/2006/02/intelligent-migration-snippets-0_1-for-textmate">here</a>. There's an installation script this time as well.</em></p>
<p>Thanks to <a href="http://thread.gmane.org/gmane.editors.textmate.general/8520">a helpful thread</a> on the TextMate mailing list I have the beginning of a solution to insert text at 2 (or more) locations in a file.</p> <p>Thanks to <a href="http://thread.gmane.org/gmane.editors.textmate.general/8520">a helpful thread</a> on the TextMate mailing list I have the beginning of a solution to insert text at 2 (or more) locations in a file.</p>
<p>I implemented this for a new snippet I was working on for migrations, <code>rename_column</code>. Since the command is the same in self.up and self.down simply doing a reverse search for <code>rename_column</code> in my <a href="/posts/2006/02/textmate-move-selection-to-self-down">hackish macro</a> didn't return the cursor the desired location.</p><p>That's enough introduction, here's the program to do the insertion:</p> <p>I implemented this for a new snippet I was working on for migrations, <code>rename_column</code>. Since the command is the same in self.up and self.down simply doing a reverse search for <code>rename_column</code> in my <a href="/posts/2006/02/textmate-move-selection-to-self-down">hackish macro</a> didn't return the cursor the desired location.</p><p>That's enough introduction, here's the program to do the insertion:</p>
```ruby
#!/usr/bin/env ruby
def indent(s)
s =~ /^(\s*)/
' ' * $1.length
end
<div class="typocode"><pre><code class="typocode_ruby "><span class="comment">#!/usr/bin/env ruby</span> up_line = 'rename_column "${1:table}", "${2:column}", "${3:new_name}"$0'
<span class="keyword">def </span><span class="method">indent</span><span class="punct">(</span><span class="ident">s</span><span class="punct">)</span> down_line = "rename_column \"$$1\", \"$$3\", \"$$2\"\n"
<span class="ident">s</span> <span class="punct">=~</span> <span class="punct">/</span><span class="regex">^(<span class="escape">\s</span>*)</span><span class="punct">/</span>
<span class="punct">'</span><span class="string"> </span><span class="punct">'</span> <span class="punct">*</span> <span class="global">$1</span><span class="punct">.</span><span class="ident">length</span>
<span class="keyword">end</span>
<span class="ident">up_line</span> <span class="punct">=</span> <span class="punct">'</span><span class="string">rename_column "${1:table}", "${2:column}", "${3:new_name}"$0</span><span class="punct">'</span> # find the end of self.down and insert 2nd line
<span class="ident">down_line</span> <span class="punct">=</span> <span class="punct">"</span><span class="string">rename_column <span class="escape">\"</span>$$1<span class="escape">\"</span>, <span class="escape">\"</span>$$3<span class="escape">\"</span>, <span class="escape">\"</span>$$2<span class="escape">\"\n</span></span><span class="punct">"</span> lines = STDIN.read.to_a.reverse
ends_seen = 0
lines.each_with_index do |line, i|
ends_seen += 1 if line =~ /^\s*end\b/
if ends_seen == 2
lines[i..i] = [lines[i], indent(lines[i]) * 2 + down_line]
break
end
end
<span class="comment"># find the end of self.down and insert 2nd line</span> # return the new text, escaping special chars
<span class="ident">lines</span> <span class="punct">=</span> <span class="constant">STDIN</span><span class="punct">.</span><span class="ident">read</span><span class="punct">.</span><span class="ident">to_a</span><span class="punct">.</span><span class="ident">reverse</span> print up_line + lines.reverse.to_s.gsub(/([$`\\])/, '\\\\\1').gsub(/\$\$/, '$')
<span class="ident">ends_seen</span> <span class="punct">=</span> <span class="number">0</span> ```
<span class="ident">lines</span><span class="punct">.</span><span class="ident">each_with_index</span> <span class="keyword">do</span> <span class="punct">|</span><span class="ident">line</span><span class="punct">,</span> <span class="ident">i</span><span class="punct">|</span>
<span class="ident">ends_seen</span> <span class="punct">+=</span> <span class="number">1</span> <span class="keyword">if</span> <span class="ident">line</span> <span class="punct">=~</span> <span class="punct">/</span><span class="regex">^<span class="escape">\s</span>*end<span class="escape">\b</span></span><span class="punct">/</span>
<span class="keyword">if</span> <span class="ident">ends_seen</span> <span class="punct">==</span> <span class="number">2</span>
<span class="ident">lines</span><span class="punct">[</span><span class="ident">i</span><span class="punct">..</span><span class="ident">i</span><span class="punct">]</span> <span class="punct">=</span> <span class="punct">[</span><span class="ident">lines</span><span class="punct">[</span><span class="ident">i</span><span class="punct">],</span> <span class="ident">indent</span><span class="punct">(</span><span class="ident">lines</span><span class="punct">[</span><span class="ident">i</span><span class="punct">])</span> <span class="punct">*</span> <span class="number">2</span> <span class="punct">+</span> <span class="ident">down_line</span><span class="punct">]</span>
<span class="keyword">break</span>
<span class="keyword">end</span>
<span class="keyword">end</span>
<span class="comment"># return the new text, escaping special chars</span>
<span class="ident">print</span> <span class="ident">up_line</span> <span class="punct">+</span> <span class="ident">lines</span><span class="punct">.</span><span class="ident">reverse</span><span class="punct">.</span><span class="ident">to_s</span><span class="punct">.</span><span class="ident">gsub</span><span class="punct">('</span><span class="string">[$`<span class="escape">\\</span>]</span><span class="punct">',</span> <span class="punct">'</span><span class="string"><span class="escape">\\\\</span>\1</span><span class="punct">').</span><span class="ident">gsub</span><span class="punct">('</span><span class="string"><span class="escape">\\</span>$<span class="escape">\\</span>$</span><span class="punct">',</span> <span class="punct">'</span><span class="string">$</span><span class="punct">')</span></code></pre></div>
<p>Save this as a command in your Rails, or <a href="http://blog.inquirylabs.com/">syncPeople on Rails</a>, bundle. The command options should be as follows:</p> <p>Save this as a command in your Rails, or <a href="http://blog.inquirylabs.com/">syncPeople on Rails</a>, bundle. The command options should be as follows:</p>
<ul> <ul>
<li><strong>Save:</strong> Nothing</li> <li><strong>Save:</strong> Nothing</li>
<li><strong>Input:</strong> Selected Text or Nothing</li> <li><strong>Input:</strong> Selected Text or Nothing</li>
@ -49,10 +47,8 @@ Styles: typocode.css
<li><strong>Scope Selector:</strong> source.ruby.rails</li> <li><strong>Scope Selector:</strong> source.ruby.rails</li>
</ul> </ul>
<p>The first modification it needs is to get the lines to insert as command line arguments so we can use it for other snippets. Secondly, regardless of the <strong>Re-indent pasted text</strong> setting the text returned is indented incorrectly.</p> <p>The first modification it needs is to get the lines to insert as command line arguments so we can use it for other snippets. Secondly, regardless of the <strong>Re-indent pasted text</strong> setting the text returned is indented incorrectly.</p>
The macro I'm thinking of to invoke this is tab-triggered and will simply: The macro I'm thinking of to invoke this is tab-triggered and will simply:
<ul> <ul>
<li>Select word (<code><strong>⌃W</strong></code>)</li> <li>Select word (<code><strong>⌃W</strong></code>)</li>
@ -60,5 +56,3 @@ The macro I'm thinking of to invoke this is tab-triggered and will simply:
<li>Select to end of file (<code><strong>⇧⌘↓</strong></code>)</li> <li>Select to end of file (<code><strong>⇧⌘↓</strong></code>)</li>
<li>Run command "Put in self.down"</li> <li>Run command "Put in self.down"</li>
</ul> </ul>

View file

@ -1,32 +1,29 @@
--- ---
Title: TextMate: Move selection to self.down Title: "TextMate: Move selection to self.down"
Author: Sami Samhuri Author: Sami Samhuri
Date: 21st February, 2006 Date: "21st February, 2006"
Timestamp: 2006-02-21T00:26:00-08:00 Timestamp: 2006-02-21T00:26:00-08:00
Tags: textmate, rails, hacking, hack, macro, rails, textmate Tags: textmate, rails, hacking, hack, macro, rails, textmate
Styles: typocode.css
--- ---
<p><strong>UPDATE:</strong> <em>This is obsolete, see <a href="/posts/2006/02/textmate-insert-text-into-self-down">this post</a> for a better solution.</em></p> <p><strong>UPDATE:</strong> <em>This is obsolete, see <a href="/posts/2006/02/textmate-insert-text-into-self-down">this post</a> for a better solution.</em></p>
<p><a href="/posts/2006/02/some-textmate-snippets-for-rails-migrations.html#comment-3">Duane's comment</a> prompted me to think about how to get the <code>drop_table</code> and <code>remove_column</code> lines inserted in the right place. I don't think TextMate's snippets are built to do this sort of text manipulation. It would be nicer, but a quick hack will suffice for now.</p><p>Use <acronym title="Migration Create and Drop Table">MCDT</acronym> to insert:</p> <p><a href="/posts/2006/02/some-textmate-snippets-for-rails-migrations.html#comment-3">Duane's comment</a> prompted me to think about how to get the <code>drop_table</code> and <code>remove_column</code> lines inserted in the right place. I don't think TextMate's snippets are built to do this sort of text manipulation. It would be nicer, but a quick hack will suffice for now.</p><p>Use <acronym title="Migration Create and Drop Table">MCDT</acronym> to insert:</p>
<div class="typocode"><pre><code class="typocode_ruby "><span class="ident">create_table</span> <span class="punct">"</span><span class="string">table</span><span class="punct">"</span> <span class="keyword">do</span> <span class="punct">|</span><span class="ident">t</span><span class="punct">|</span> ```ruby
create_table "table" do |t|
<span class="keyword">end</span> end
<span class="ident">drop_table</span> <span class="punct">"</span><span class="string">table</span><span class="punct">"</span></code></pre></div> drop_table "table"
```
<p>Then press tab once more after typing the table name to select the code <code>drop_table "table"</code>. I created a macro that cuts the selected text, finds <code>def self.down</code> and pastes the line there. Then it searches for the previous occurence of <code>create_table</code> and moves the cursor to the next line, ready for you to add some columns.</p> <p>Then press tab once more after typing the table name to select the code <code>drop_table "table"</code>. I created a macro that cuts the selected text, finds <code>def self.down</code> and pastes the line there. Then it searches for the previous occurence of <code>create_table</code> and moves the cursor to the next line, ready for you to add some columns.</p>
<p>I have this bound to <strong>⌃⌥⌘M</strong> because it wasn't in use. If your Control key is to the left the A key it's quite comfortable to hit this combo. Copy the following file into <strong>~/Library/Application Support/TextMate/Bundles/Rails.tmbundle/Macros</strong>.</p> <p>I have this bound to <strong>⌃⌥⌘M</strong> because it wasn't in use. If your Control key is to the left the A key it's quite comfortable to hit this combo. Copy the following file into <strong>~/Library/Application Support/TextMate/Bundles/Rails.tmbundle/Macros</strong>.</p>
<p style="text-align: center;"><a href="http://sami.samhuri.net/files/move-to-self.down.plist">Move selection to self.down</a></p> <p style="text-align: center;"><a href="http://sami.samhuri.net/files/move-to-self.down.plist">Move selection to self.down</a></p>
<p>This works for the <acronym title="Migration Add and Remove Column">MARC</acronym> snippet as well. I didn't tell you the whole truth, the macro actually finds the previous occurence of <code>(create_table|add_column)</code>.</p> <p>This works for the <acronym title="Migration Add and Remove Column">MARC</acronym> snippet as well. I didn't tell you the whole truth, the macro actually finds the previous occurence of <code>(create_table|add_column)</code>.</p>
<p>The caveat here is that if there is a <code>create_table</code> or <code>add_column</code> between <code>self.down</code> and the table you just added, it will jump back to the wrong spot. It's still faster than doing it all manually, but should be improved. If you use these exclusively, the order they occur in <code>self.down</code> will be opposite of that in <code>self.up</code>. That means either leaving things backwards or doing the re-ordering manually. =/</p> <p>The caveat here is that if there is a <code>create_table</code> or <code>add_column</code> between <code>self.down</code> and the table you just added, it will jump back to the wrong spot. It's still faster than doing it all manually, but should be improved. If you use these exclusively, the order they occur in <code>self.down</code> will be opposite of that in <code>self.up</code>. That means either leaving things backwards or doing the re-ordering manually. =/</p>

View file

@ -1,7 +1,7 @@
--- ---
Title: TextMate Snippets for Rails Assertions Title: "TextMate Snippets for Rails Assertions"
Author: Sami Samhuri Author: Sami Samhuri
Date: 20th February, 2006 Date: "20th February, 2006"
Timestamp: 2006-02-20T23:52:00-08:00 Timestamp: 2006-02-20T23:52:00-08:00
Tags: textmate, rails, coding, rails, snippets, testing, textmate Tags: textmate, rails, coding, rails, snippets, testing, textmate
--- ---

View file

@ -1,7 +1,7 @@
--- ---
Title: Touch Screen on Steroids Title: "Touch Screen on Steroids"
Author: Sami Samhuri Author: Sami Samhuri
Date: 8th February, 2006 Date: "8th February, 2006"
Timestamp: 2006-02-08T06:06:00-08:00 Timestamp: 2006-02-08T06:06:00-08:00
Tags: technology, touch Tags: technology, touch
--- ---

View file

@ -1,7 +1,7 @@
--- ---
Title: Urban Extreme Gymnastics? Title: "Urban Extreme Gymnastics?"
Author: Sami Samhuri Author: Sami Samhuri
Date: 15th February, 2006 Date: "15th February, 2006"
Timestamp: 2006-02-15T10:41:00-08:00 Timestamp: 2006-02-15T10:41:00-08:00
Tags: amusement Tags: amusement
--- ---

View file

@ -1,7 +1,7 @@
--- ---
Title: Generate self.down in your Rails migrations Title: "Generate self.down in your Rails migrations"
Author: Sami Samhuri Author: Sami Samhuri
Date: 3rd March, 2006 Date: "3rd March, 2006"
Timestamp: 2006-03-03T21:38:00-08:00 Timestamp: 2006-03-03T21:38:00-08:00
Tags: rails, textmate, migrations, rails, textmate Tags: rails, textmate, migrations, rails, textmate
--- ---

View file

@ -1,7 +1,7 @@
--- ---
Title: I don't mind FairPlay either Title: "I don't mind FairPlay either"
Author: Sami Samhuri Author: Sami Samhuri
Date: 3rd March, 2006 Date: "3rd March, 2006"
Timestamp: 2006-03-03T21:56:00-08:00 Timestamp: 2006-03-03T21:56:00-08:00
Tags: apple, mac os x, life, drm, fairplay, ipod, itunes Tags: apple, mac os x, life, drm, fairplay, ipod, itunes
--- ---

View file

@ -1,7 +1,7 @@
--- ---
Title: Spore Title: "Spore"
Author: Sami Samhuri Author: Sami Samhuri
Date: 3rd March, 2006 Date: "3rd March, 2006"
Timestamp: 2006-03-03T21:43:00-08:00 Timestamp: 2006-03-03T21:43:00-08:00
Tags: amusement, technology, cool, fun, games Tags: amusement, technology, cool, fun, games
--- ---

View file

@ -1,7 +1,7 @@
--- ---
Title: zsh terminal goodness on OS X Title: "zsh terminal goodness on OS X"
Author: Sami Samhuri Author: Sami Samhuri
Date: 4th April, 2006 Date: "4th April, 2006"
Timestamp: 2006-04-04T14:57:00-07:00 Timestamp: 2006-04-04T14:57:00-07:00
Tags: mac os x, apple, osx, terminal, zsh Tags: mac os x, apple, osx, terminal, zsh
--- ---

View file

@ -1,7 +1,7 @@
--- ---
Title: OS X and Fitt's law Title: "OS X and Fitt's law"
Author: Sami Samhuri Author: Sami Samhuri
Date: 7th May, 2006 Date: "7th May, 2006"
Timestamp: 2006-05-07T20:43:00-07:00 Timestamp: 2006-05-07T20:43:00-07:00
Tags: mac os x, apple, mac, os, usability, x Tags: mac os x, apple, mac, os, usability, x
--- ---

View file

@ -1,7 +1,7 @@
--- ---
Title: WikipediaFS on Linux, in Python Title: "WikipediaFS on Linux, in Python"
Author: Sami Samhuri Author: Sami Samhuri
Date: 7th May, 2006 Date: "7th May, 2006"
Timestamp: 2006-05-07T20:49:00-07:00 Timestamp: 2006-05-07T20:49:00-07:00
Tags: hacking, python, linux, fuse, linux, mediawiki, python, wikipediafs Tags: hacking, python, linux, fuse, linux, mediawiki, python, wikipediafs
--- ---

View file

@ -1,7 +1,7 @@
--- ---
Title: Apple pays attention to detail Title: "Apple pays attention to detail"
Author: Sami Samhuri Author: Sami Samhuri
Date: 11th June, 2006 Date: "11th June, 2006"
Timestamp: 2006-06-11T01:30:00-07:00 Timestamp: 2006-06-11T01:30:00-07:00
Tags: technology, mac os x, apple Tags: technology, mac os x, apple
--- ---

View file

@ -1,7 +1,7 @@
--- ---
Title: Ich bin Ausländer und spreche nicht gut Deutsch Title: "Ich bin Ausländer und spreche nicht gut Deutsch"
Author: Sami Samhuri Author: Sami Samhuri
Date: 5th June, 2006 Date: "5th June, 2006"
Timestamp: 2006-06-05T10:11:00-07:00 Timestamp: 2006-06-05T10:11:00-07:00
Tags: life, munich, seekport, work Tags: life, munich, seekport, work
--- ---

View file

@ -1,7 +1,7 @@
--- ---
Title: Never buy a German keyboard! Title: "Never buy a German keyboard!"
Author: Sami Samhuri Author: Sami Samhuri
Date: 9th June, 2006 Date: "9th June, 2006"
Timestamp: 2006-06-09T01:17:00-07:00 Timestamp: 2006-06-09T01:17:00-07:00
Tags: apple, apple, german, keyboard Tags: apple, apple, german, keyboard
--- ---

View file

@ -1,7 +1,7 @@
--- ---
Title: There's nothing regular about regular expressions Title: "There's nothing regular about regular expressions"
Author: Sami Samhuri Author: Sami Samhuri
Date: 10th June, 2006 Date: "10th June, 2006"
Timestamp: 2006-06-10T01:28:00-07:00 Timestamp: 2006-06-10T01:28:00-07:00
Tags: technology, book, regex Tags: technology, book, regex
--- ---
@ -16,8 +16,9 @@ It requires more thinking than the last 2 computer books I read, *Programming Ru
QOTD, p. 329, about matching nested pairs of parens: QOTD, p. 329, about matching nested pairs of parens:
\(([^()]|\(([^()]|\(([^()]|\(([^()])*\))*\))*\))*\) ```conf
Wow, that's ugly. \(([^()]|\(([^()]|\(([^()]|\(([^()])*\))*\))*\))*\)
Wow, that's ugly.
```
(Don't worry, there's a much better solution on the next 2 pages after that quote.) (Don't worry, there's a much better solution on the next 2 pages after that quote.)

View file

@ -1,7 +1,7 @@
--- ---
Title: Class method? Instance method? It doesn't matter to PHP Title: "Class method? Instance method? It doesn't matter to PHP"
Author: Sami Samhuri Author: Sami Samhuri
Date: 21st July, 2006 Date: "21st July, 2006"
Timestamp: 2006-07-21T07:56:00-07:00 Timestamp: 2006-07-21T07:56:00-07:00
Tags: php, coding Tags: php, coding
--- ---
@ -16,7 +16,7 @@ I would fully expect the PHP parser to give me an error like "No class method [f
This code: This code:
<pre><code> ```php
class Foo { class Foo {
public static function static_fun() public static function static_fun()
{ {
@ -29,7 +29,7 @@ class Foo {
} }
} }
echo '&lt;pre&gt;'; echo '<pre>';
echo "From Foo:\n"; echo "From Foo:\n";
echo Foo::static_fun(); echo Foo::static_fun();
echo Foo::not_static(); echo Foo::not_static();
@ -37,14 +37,14 @@ echo "\n";
echo "From \$foo = new Foo():\n"; echo "From \$foo = new Foo():\n";
$foo = new Foo(); $foo = new Foo();
echo $foo-&gt;static_fun(); echo $foo->static_fun();
echo $foo-&gt;not_static(); echo $foo->not_static();
echo '&lt;/pre&gt;'; echo '</pre>';
</code></pre> ```
Produces: Produces:
<pre><code> ```php
From Foo: From Foo:
This is a class method! This is a class method!
This is an instance method! This is an instance method!
@ -52,7 +52,7 @@ This is an instance method!
From $foo = new Foo(): From $foo = new Foo():
This is a class method! This is a class method!
This is an instance method! This is an instance method!
</code></pre> ```
What the fuck?! <a href="http://www.php.net/manual/en/language.oop5.static.php">http://www.php.net/manual/en/language.oop5.static.php</a> is lying to everyone. What the fuck?! <a href="http://www.php.net/manual/en/language.oop5.static.php">http://www.php.net/manual/en/language.oop5.static.php</a> is lying to everyone.

View file

@ -1,7 +1,7 @@
--- ---
Title: Late static binding Title: "Late static binding"
Author: Sami Samhuri Author: Sami Samhuri
Date: 19th July, 2006 Date: "19th July, 2006"
Timestamp: 2006-07-19T10:23:00-07:00 Timestamp: 2006-07-19T10:23:00-07:00
Tags: php, coding, coding, php Tags: php, coding, coding, php
--- ---
@ -10,8 +10,7 @@ Tags: php, coding, coding, php
As colder on ##php (freenode) told me today, class methods in PHP don't have what they call late static binding. What's that? It means that this code: As colder on ##php (freenode) told me today, class methods in PHP don't have what they call late static binding. What's that? It means that this code:
<pre> ```php
<code>
class Foo class Foo
{ {
public static function my_method() public static function my_method()
@ -24,15 +23,13 @@ class Bar extends Foo
{} {}
Bar::my_method(); Bar::my_method();
</code> ```
</pre>
outputs "I'm a Foo!", instead of "I'm a Bar!". That's not fun. outputs "I'm a Foo!", instead of "I'm a Bar!". That's not fun.
Using <code>__CLASS__</code> in place of <code>get_class()</code> makes zero difference. You end up with proxy methods in each subclass of Foo that pass in the real name of the calling class, which sucks. Using <code>__CLASS__</code> in place of <code>get_class()</code> makes zero difference. You end up with proxy methods in each subclass of Foo that pass in the real name of the calling class, which sucks.
<pre> ```php
<code>
class Bar extends Foo class Bar extends Foo
{ {
public static function my_method() public static function my_method()
@ -40,8 +37,7 @@ class Bar extends Foo
return parent::my_method( get_class() ); return parent::my_method( get_class() );
} }
} }
</code> ```
</pre>
I was told that they had a discussion about this on the internal PHP list, so at least they're thinking about this stuff. Too bad PHP5 doesn't have it. I guess I should just be glad I won't be maintaining this code. I was told that they had a discussion about this on the internal PHP list, so at least they're thinking about this stuff. Too bad PHP5 doesn't have it. I guess I should just be glad I won't be maintaining this code.

View file

@ -1,7 +1,7 @@
--- ---
Title: Ruby and Rails have spoiled me rotten Title: "Ruby and Rails have spoiled me rotten"
Author: Sami Samhuri Author: Sami Samhuri
Date: 17th July, 2006 Date: "17th July, 2006"
Timestamp: 2006-07-17T05:40:00-07:00 Timestamp: 2006-07-17T05:40:00-07:00
Tags: rails, ruby, php, coding, framework, php, rails, ruby, zend Tags: rails, ruby, php, coding, framework, php, rails, ruby, zend
--- ---

View file

@ -1,7 +1,7 @@
--- ---
Title: Ubuntu: Linux for Linux users please Title: "Ubuntu: Linux for Linux users please"
Author: Sami Samhuri Author: Sami Samhuri
Date: 13th July, 2006 Date: "13th July, 2006"
Timestamp: 2006-07-13T08:34:00-07:00 Timestamp: 2006-07-13T08:34:00-07:00
Tags: linux, linux, ubuntu Tags: linux, linux, ubuntu
--- ---

View file

@ -1,7 +1,7 @@
--- ---
Title: Working with the Zend Framework Title: "Working with the Zend Framework"
Author: Sami Samhuri Author: Sami Samhuri
Date: 6th July, 2006 Date: "6th July, 2006"
Timestamp: 2006-07-06T07:36:00-07:00 Timestamp: 2006-07-06T07:36:00-07:00
Tags: coding, technology, php, framework, php, seekport, zend Tags: coding, technology, php, framework, php, seekport, zend
--- ---

View file

@ -1,7 +1,7 @@
--- ---
Title: Where are my headphones? Title: "Where are my headphones?"
Author: Sami Samhuri Author: Sami Samhuri
Date: 22nd August, 2006 Date: "22nd August, 2006"
Timestamp: 2006-08-22T07:31:00-07:00 Timestamp: 2006-08-22T07:31:00-07:00
Tags: life, seekport Tags: life, seekport
--- ---

View file

@ -1,7 +1,7 @@
--- ---
Title: Buffalo buffalo Buffalo buffalo buffalo buffalo Buffalo buffalo Title: "Buffalo buffalo Buffalo buffalo buffalo buffalo Buffalo buffalo"
Author: Sami Samhuri Author: Sami Samhuri
Date: 16th September, 2006 Date: "16th September, 2006"
Timestamp: 2006-09-16T22:11:00-07:00 Timestamp: 2006-09-16T22:11:00-07:00
Tags: amusement, buffalo Tags: amusement, buffalo
Link: http://en.wikipedia.org/wiki/Buffalo_buffalo_buffalo_buffalo_buffalo_buffalo_buffalo_buffalo Link: http://en.wikipedia.org/wiki/Buffalo_buffalo_buffalo_buffalo_buffalo_buffalo_buffalo_buffalo

View file

@ -1,7 +1,7 @@
--- ---
Title: Some features you might have missed in iTunes 7 Title: "Some features you might have missed in iTunes 7"
Author: Sami Samhuri Author: Sami Samhuri
Date: 22nd September, 2006 Date: "22nd September, 2006"
Timestamp: 2006-09-22T16:59:00-07:00 Timestamp: 2006-09-22T16:59:00-07:00
Tags: apple, apple, itunes Tags: apple, apple, itunes
--- ---

View file

@ -1,7 +1,7 @@
--- ---
Title: Coping with Windows XP activiation on a Mac Title: "Coping with Windows XP activiation on a Mac"
Author: Sami Samhuri Author: Sami Samhuri
Date: 17th December, 2006 Date: "17th December, 2006"
Timestamp: 2006-12-17T23:30:00-08:00 Timestamp: 2006-12-17T23:30:00-08:00
Tags: parallels, windows, apple, mac os x, bootcamp Tags: parallels, windows, apple, mac os x, bootcamp
--- ---
@ -28,7 +28,9 @@ If anyone actually knows how to write batch files I'd like to hear any suggestio
You will probably just want to test my method of testing for Parallels and Boot Camp first. The easiest way is to just open a command window and run this command: You will probably just want to test my method of testing for Parallels and Boot Camp first. The easiest way is to just open a command window and run this command:
ipconfig /all | find "Parallels" ```bat
ipconfig /all | find "Parallels"
```
If you see a line of output like **"Description . . . . : Parallels Network Adapter"** and you are in Parallels then the test works. If you see no output and you are in Boot Camp then the test works. If you see a line of output like **"Description . . . . : Parallels Network Adapter"** and you are in Parallels then the test works. If you see no output and you are in Boot Camp then the test works.
@ -46,8 +48,10 @@ If you're lazy then you can download <a href="http://sami.samhuri.net/files/para
Run Windows in Parallels, activate it, then open a command window and run: Run Windows in Parallels, activate it, then open a command window and run:
mkdir C:\Windows\System32\Parallels ```bat
copy C:\Windows\System32\wpa.* C:\Windows\System32\Parallels mkdir C:\Windows\System32\Parallels
copy C:\Windows\System32\wpa.* C:\Windows\System32\Parallels
```
Download <a href="http://sami.samhuri.net/files/parallels/backup-parallels-wpa.bat">backup-parallels-wpa.bat</a> Download <a href="http://sami.samhuri.net/files/parallels/backup-parallels-wpa.bat">backup-parallels-wpa.bat</a>
@ -57,8 +61,10 @@ Download <a href="http://sami.samhuri.net/files/parallels/backup-parallels-wpa.b
Run Windows using Boot Camp, activate it, then run: Run Windows using Boot Camp, activate it, then run:
mkdir C:\Windows\System32\BootCamp ```bat
copy C:\Windows\System32\wpa.* C:\Windows\System32\BootCamp mkdir C:\Windows\System32\BootCamp
copy C:\Windows\System32\wpa.* C:\Windows\System32\BootCamp
```
Download <a href="http://sami.samhuri.net/files/parallels/backup-bootcamp-wpa.bat">backup-bootcamp-wpa.bat</a> Download <a href="http://sami.samhuri.net/files/parallels/backup-bootcamp-wpa.bat">backup-bootcamp-wpa.bat</a>
@ -72,19 +78,21 @@ If you have XP Pro then you can get it to run using the Group Policy editor. Sav
<p>If you have XP Home then the best you can do is run this script from your Startup folder (Start -> All Programs -> Startup), but that is not really going to work because eventually Windows will not even let you log in until you activate it. What a P.O.S.</p> <p>If you have XP Home then the best you can do is run this script from your Startup folder (Start -> All Programs -> Startup), but that is not really going to work because eventually Windows will not even let you log in until you activate it. What a P.O.S.</p>
@echo off ```bat
@echo off
ipconfig /all | find "Parallels" > network.tmp ipconfig /all | find "Parallels" > network.tmp
for /F "tokens=14" %%x in (network.tmp) do set parallels=%x for /F "tokens=14" %%x in (network.tmp) do set parallels=%x
del network.tmp del network.tmp
if defined parallels ( if defined parallels (
echo Parallels echo Parallels
copy C:\Windows\System32\Parallels\wpa.* C:\Windows\System32 copy C:\Windows\System32\Parallels\wpa.* C:\Windows\System32
) else ( ) else (
echo Boot Camp echo Boot Camp
copy C:\Windows\System32\BootCamp\wpa.* C:\Windows\System32 copy C:\Windows\System32\BootCamp\wpa.* C:\Windows\System32
) )
```
Download <a href="http://sami.samhuri.net/files/parallels/activate.bat">activate.bat</a> Download <a href="http://sami.samhuri.net/files/parallels/activate.bat">activate.bat</a>
@ -105,4 +113,3 @@ This method worked for me and hopefully it will work for you as well. I'm intere
I finally bought Windows XP this week and I'm starting to regret it because of all the hoops they make you jump through to use it. I only use it to fix sites in IE because it can't render a web page properly and I didn't want to buy it just for that. I thought that it would be good to finally get a legit copy since I was using a pirated version and was sick of working around validation bullshit for updates. Now I have to work around MS's activation bullshit and it's just as bad! Screw Microsoft for putting their customers through this sort of thing. Things like this and the annoying balloons near the system tray just fuel my contempt for Windows and reinforce my love of Linux and Mac OS X. I finally bought Windows XP this week and I'm starting to regret it because of all the hoops they make you jump through to use it. I only use it to fix sites in IE because it can't render a web page properly and I didn't want to buy it just for that. I thought that it would be good to finally get a legit copy since I was using a pirated version and was sick of working around validation bullshit for updates. Now I have to work around MS's activation bullshit and it's just as bad! Screw Microsoft for putting their customers through this sort of thing. Things like this and the annoying balloons near the system tray just fuel my contempt for Windows and reinforce my love of Linux and Mac OS X.
I don't make money off any of my sites, which is why I didn't want to have to buy stupid Windows. I hate MS so much for making shitty IE the standard browser. I don't make money off any of my sites, which is why I didn't want to have to buy stupid Windows. I hate MS so much for making shitty IE the standard browser.

View file

@ -1,7 +1,7 @@
--- ---
Title: Digg v4: Reply to replies (Greasemonkey script) Title: "Digg v4: Reply to replies (Greasemonkey script)"
Author: Sami Samhuri Author: Sami Samhuri
Date: 8th March, 2007 Date: "8th March, 2007"
Timestamp: 2007-03-08T23:19:00-08:00 Timestamp: 2007-03-08T23:19:00-08:00
Tags: coding, digg, firefox, userscript Tags: coding, digg, firefox, userscript
--- ---

View file

@ -1,7 +1,7 @@
--- ---
Title: Diggscuss 0.9 Title: "Diggscuss 0.9"
Author: Sami Samhuri Author: Sami Samhuri
Date: 25th March, 2007 Date: "25th March, 2007"
Timestamp: 2007-03-25T08:03:00-07:00 Timestamp: 2007-03-25T08:03:00-07:00
Tags: coding, digg, firefox, userscript Tags: coding, digg, firefox, userscript
--- ---

View file

@ -1,7 +1,7 @@
--- ---
Title: Full-screen Cover Flow Title: "Full-screen Cover Flow"
Author: Sami Samhuri Author: Sami Samhuri
Date: 6th March, 2007 Date: "6th March, 2007"
Timestamp: 2007-03-06T13:51:00-08:00 Timestamp: 2007-03-06T13:51:00-08:00
Tags: apple, coverflow, itunes Tags: apple, coverflow, itunes
--- ---

View file

@ -1,7 +1,7 @@
--- ---
Title: A triple-booting, schizophrenic MacBook Title: "A triple-booting, schizophrenic MacBook"
Author: Sami Samhuri Author: Sami Samhuri
Date: 4th April, 2007 Date: "4th April, 2007"
Timestamp: 2007-04-04T23:30:00-07:00 Timestamp: 2007-04-04T23:30:00-07:00
Tags: linux, mac os x, windows Tags: linux, mac os x, windows
--- ---

View file

@ -1,7 +1,7 @@
--- ---
Title: ActiveRecord::Base.find_or_create and find_or_initialize Title: "ActiveRecord::Base.find_or_create and find_or_initialize"
Author: Sami Samhuri Author: Sami Samhuri
Date: 11th April, 2007 Date: "11th April, 2007"
Timestamp: 2007-04-11T03:24:00-07:00 Timestamp: 2007-04-11T03:24:00-07:00
Tags: activerecord, coding, rails, ruby Tags: activerecord, coding, rails, ruby
--- ---
@ -12,98 +12,54 @@ They work exactly as you'd expect them to work with possibly one gotcha. If you
Enough chat, here's the self-explanatory code: Enough chat, here's the self-explanatory code:
<pre class="line-numbers">1 ```ruby
2 # extend ActiveRecord::Base with find_or_create and find_or_initialize.
3 ActiveRecord::Base.class_eval do
4 include ActiveRecordExtensions
</pre> end
<pre><code><span class="c"># extend ActiveRecord::Base with find_or_create and find_or_initialize.</span> ```
<span class="co">ActiveRecord</span>::<span class="co">Base</span>.class_eval <span class="r">do</span>
include <span class="co">ActiveRecordExtensions</span>
<span class="r">end</span></code></pre>
```ruby
module ActiveRecordExtensions
def self.included(base)
base.extend(ClassMethods)
end
<pre class="line-numbers">1 module ClassMethods
2 def find_or_initialize(params)
3 find_or_do('initialize', params)
4 end
5
6
7
8
9
<strong>10</strong>
11
12
13
14
15
16
17
18
19
<strong>20</strong>
21
22
23
24
25
26
27
28
29
<strong>30</strong>
31
32
33
34
35
36
37
38
39
<strong>40</strong>
41
</pre>
<pre><code><span class="r">module</span> <span class="cl">ActiveRecordExtensions</span>
<span class="r">def</span> <span class="pc">self</span>.included(base)
base.extend(<span class="co">ClassMethods</span>)
<span class="r">end</span>
<span class="r">module</span> <span class="cl">ClassMethods</span> def find_or_create(params)
<span class="r">def</span> <span class="fu">find_or_initialize</span>(params) find_or_do('create', params)
find_or_do(<span class="s"><span class="dl">'</span><span class="k">initialize</span><span class="dl">'</span></span>, params) end
<span class="r">end</span>
<span class="r">def</span> <span class="fu">find_or_create</span>(params)
find_or_do(<span class="s"><span class="dl">'</span><span class="k">create</span><span class="dl">'</span></span>, params)
<span class="r">end</span>
private private
<span class="c"># Find a record that matches the attributes given in the +params+ hash, or do +action+</span> # Find a record that matches the attributes given in the +params+ hash, or do +action+
<span class="c"># to retrieve a new object with the given parameters and return that.</span> # to retrieve a new object with the given parameters and return that.
<span class="r">def</span> <span class="fu">find_or_do</span>(action, params) def find_or_do(action, params)
<span class="c"># if an id is given just find the record directly</span> # if an id is given just find the record directly
<span class="pc">self</span>.find(params[<span class="sy">:id</span>]) self.find(params[:id])
<span class="r">rescue</span> <span class="co">ActiveRecord</span>::<span class="co">RecordNotFound</span> =&gt; e rescue ActiveRecord::RecordNotFound => e
attrs = {} <span class="c"># hash of attributes passed in params</span> attrs = {} # hash of attributes passed in params
<span class="c"># search for valid attributes in params</span> # search for valid attributes in params
<span class="pc">self</span>.column_names.map(&amp;<span class="sy">:to_sym</span>).each <span class="r">do</span> |attrib| self.column_names.map(&:to_sym).each do |attrib|
<span class="c"># skip unknown columns, and the id field</span> # skip unknown columns, and the id field
<span class="r">next</span> <span class="r">if</span> params[attrib].nil? || attrib == <span class="sy">:id</span> next if params[attrib].nil? || attrib == :id
attrs[attrib] = params[attrib] attrs[attrib] = params[attrib]
<span class="r">end</span> end
<span class="c"># no valid params given, return nil</span> # no valid params given, return nil
<span class="r">return</span> <span class="pc">nil</span> <span class="r">if</span> attrs.empty? return nil if attrs.empty?
<span class="c"># call the appropriate ActiveRecord finder method</span> # call the appropriate ActiveRecord finder method
<span class="pc">self</span>.send(<span class="s"><span class="dl">"</span><span class="k">find_or_</span><span class="il"><span class="dl">#{</span>action<span class="dl">}</span></span><span class="k">_by_</span><span class="il"><span class="dl">#{</span>attrs.keys.join(<span class="s"><span class="dl">'</span><span class="k">_and_</span><span class="dl">'</span></span>)<span class="dl">}</span></span><span class="dl">"</span></span>, *attrs.values) self.send("find_or_#{action}_by_#{attrs.keys.join('_and_')}", *attrs.values)
<span class="r">end</span> end
<span class="r">end</span> end
<span class="r">end</span></code></pre> end
```

View file

@ -1,14 +1,16 @@
--- ---
Title: Funny how code can be beautiful Title: "Funny how code can be beautiful"
Author: Sami Samhuri Author: Sami Samhuri
Date: 30th April, 2007 Date: "30th April, 2007"
Timestamp: 2007-04-30T07:07:00-07:00 Timestamp: 2007-04-30T07:07:00-07:00
Tags: haskell Tags: haskell
--- ---
While reading a <a href="http://www.haskell.org/tutorial/index.html">Haskell tutorial</a> I came across the following code for defining the <a href="http://en.wikipedia.org/wiki/Fibonacci_number">Fibonacci numbers</a>: While reading a <a href="http://www.haskell.org/tutorial/index.html">Haskell tutorial</a> I came across the following code for defining the <a href="http://en.wikipedia.org/wiki/Fibonacci_number">Fibonacci numbers</a>:
fib = 1 : 1 : [ a + b | (a, b) <- zip fib (tail fib) ] ```haskell
fib = 1 : 1 : [ a + b | (a, b) <- zip fib (tail fib) ]
```
After reading it a few times and understanding how it works I couldnt help but think how <strong>beautiful</strong> it is. I dont mean that its aesthetically pleasing to me; the beautiful part is the meaning and simplicity. Lazy evaluation is sweet. After reading it a few times and understanding how it works I couldnt help but think how <strong>beautiful</strong> it is. I dont mean that its aesthetically pleasing to me; the beautiful part is the meaning and simplicity. Lazy evaluation is sweet.
@ -24,4 +26,3 @@ Going deeper down the functional rabbit-hole youll find things like <a href="
* <a href="http://web.cecs.pdx.edu/~antoy/Courses/TPFLP/lectures/MONADS/Noel/research/monads.html">What the hell are Monads?</a> * <a href="http://web.cecs.pdx.edu/~antoy/Courses/TPFLP/lectures/MONADS/Noel/research/monads.html">What the hell are Monads?</a>
* <a href="http://en.wikibooks.org/wiki/Programming:Haskell_monads">Monads on WikiBooks</a> * <a href="http://en.wikibooks.org/wiki/Programming:Haskell_monads">Monads on WikiBooks</a>
* <a href="http://www.engr.mun.ca/~theo/Misc/haskell_and_monads.htm">Monads for the Working Haskell Programmer</a> * <a href="http://www.engr.mun.ca/~theo/Misc/haskell_and_monads.htm">Monads for the Working Haskell Programmer</a>

View file

@ -1,7 +1,7 @@
--- ---
Title: Getting to know Vista Title: "Getting to know Vista"
Author: Sami Samhuri Author: Sami Samhuri
Date: 16th April, 2007 Date: "16th April, 2007"
Timestamp: 2007-04-16T11:09:00-07:00 Timestamp: 2007-04-16T11:09:00-07:00
Tags: windows Tags: windows
--- ---

View file

@ -1,7 +1,7 @@
--- ---
Title: Quickly inserting millions of rows with MySQL/InnoDB Title: "Quickly inserting millions of rows with MySQL/InnoDB"
Author: Sami Samhuri Author: Sami Samhuri
Date: 26th April, 2007 Date: "26th April, 2007"
Timestamp: 2007-04-26T07:06:00-07:00 Timestamp: 2007-04-26T07:06:00-07:00
Tags: linux, mysql Tags: linux, mysql
--- ---

View file

@ -1,7 +1,7 @@
--- ---
Title: A New Way to Look at Networking Title: "A New Way to Look at Networking"
Author: Sami Samhuri Author: Sami Samhuri
Date: 5th May, 2007 Date: "5th May, 2007"
Timestamp: 2007-05-05T16:10:00-07:00 Timestamp: 2007-05-05T16:10:00-07:00
Tags: technology, networking Tags: technology, networking
--- ---

View file

@ -1,7 +1,7 @@
--- ---
Title: A Scheme parser in Haskell: Part 1 Title: "A Scheme parser in Haskell: Part 1"
Author: Sami Samhuri Author: Sami Samhuri
Date: 3rd May, 2007 Date: "3rd May, 2007"
Timestamp: 2007-05-03T00:47:50-07:00 Timestamp: 2007-05-03T00:47:50-07:00
Tags: coding, haskell Tags: coding, haskell
--- ---
@ -18,9 +18,10 @@ I'm going to explain one of the exercises because converting between the various
Last night I rewrote <code>parseNumber</code> using <code>do</code> and <code>&gt;&gt;=</code> (bind) notations (ex. 3.3.1). Here's <code>parseNumber</code> using the <code>liftM</code> method given in the tutorial: Last night I rewrote <code>parseNumber</code> using <code>do</code> and <code>&gt;&gt;=</code> (bind) notations (ex. 3.3.1). Here's <code>parseNumber</code> using the <code>liftM</code> method given in the tutorial:
<pre><code>parseNumber :: Parser LispVal ```haskell
parseNumber :: Parser LispVal
parseNumber :: liftM (Number . read) $ many1 digit parseNumber :: liftM (Number . read) $ many1 digit
</code></pre> ```
Okay that's pretty simple right? Let's break it down, first looking at the right-hand side of the <code>$</code> operator, then the left. Okay that's pretty simple right? Let's break it down, first looking at the right-hand side of the <code>$</code> operator, then the left.
* <code>many1 digit</code> reads as many decimal digits as it can. * <code>many1 digit</code> reads as many decimal digits as it can.
@ -41,24 +42,25 @@ The <code>$</code> acts similar to a pipe in <code>$FAVOURITE_SHELL</code>, and
So how does a Haskell newbie go about re-writing that using other notations which haven't even been explained in the tutorial? Clearly one must search the web and read as much as they can until they understand enough to figure it out (which is one thing I like about the tutorial). If you're lazy like me, here are 3 equivalent pieces of code for you to chew on. <code>parseNumber</code>'s type is <code>Parser LispVal</code> (Parser is a monad). So how does a Haskell newbie go about re-writing that using other notations which haven't even been explained in the tutorial? Clearly one must search the web and read as much as they can until they understand enough to figure it out (which is one thing I like about the tutorial). If you're lazy like me, here are 3 equivalent pieces of code for you to chew on. <code>parseNumber</code>'s type is <code>Parser LispVal</code> (Parser is a monad).
Familiar <code>liftM</code> method: Familiar <code>liftM</code> method:
<pre><code>parseNumber -&gt; liftM (Number . read) $ many1 digit ```haskell
</code></pre> parseNumber -> liftM (Number . read) $ many1 digit
```
Using <code>do</code> notation: Using <code>do</code> notation:
<pre><code>parseNumber -&gt; do digits &lt;- many1 digit ```haskell
parseNumber -> do digits <- many1 digit
return $ (Number . read) digits return $ (Number . read) digits
</code></pre> ```
If you're thinking "Hey a <code>return</code>, I know that one!" then the devious masterminds behind Haskell are certainly laughing evilly right now. <code>return</code> simply wraps up it's argument in a monad of some sort. In this case it's the <code>Parser</code> monad. The <code>return</code> part may seem strange at first. Since <code>many1 digit</code> yields a monad why do we need to wrap anything? The answer is that using <code>&lt;-</code> causes <code>digits</code> to contain a <code>String</code>, stripped out of the monad which resulted from <code>many1 digit</code>. Hence we no longer use <code>liftM</code> to make <code>(Number . read)</code> monads, and instead need to use <code>return</code> to properly wrap it back up in a monad. If you're thinking "Hey a <code>return</code>, I know that one!" then the devious masterminds behind Haskell are certainly laughing evilly right now. <code>return</code> simply wraps up it's argument in a monad of some sort. In this case it's the <code>Parser</code> monad. The <code>return</code> part may seem strange at first. Since <code>many1 digit</code> yields a monad why do we need to wrap anything? The answer is that using <code>&lt;-</code> causes <code>digits</code> to contain a <code>String</code>, stripped out of the monad which resulted from <code>many1 digit</code>. Hence we no longer use <code>liftM</code> to make <code>(Number . read)</code> monads, and instead need to use <code>return</code> to properly wrap it back up in a monad.
In other words <code>liftM</code> eliminates the need to explicitly re-monadize the contents as is necessary using <code>do</code>. In other words <code>liftM</code> eliminates the need to explicitly re-monadize the contents as is necessary using <code>do</code>.
Finally, using <code>&gt;&gt;=</code> (bind) notation: Finally, using <code>&gt;&gt;=</code> (bind) notation:
<pre><code>parseNumber -&gt; many1 digit &gt;&gt;= \digits -&gt; ```haskell
parseNumber -> many1 digit >>= \digits ->
return $ (Number . read) digits return $ (Number . read) digits
</code></pre> ```
At this point I don't think this warrants much of an explanation. The syntactic sugar provided by <code>do</code> should be pretty obvious. Just in case it's not, <code>&gt;&gt;=</code> passes the contents of its left argument (a monad) to the <em>function</em> on its right. Once again <code>return</code> is needed to wrap up the result and send it on its way. At this point I don't think this warrants much of an explanation. The syntactic sugar provided by <code>do</code> should be pretty obvious. Just in case it's not, <code>&gt;&gt;=</code> passes the contents of its left argument (a monad) to the <em>function</em> on its right. Once again <code>return</code> is needed to wrap up the result and send it on its way.
When I first read about Haskell I was overwhelmed by not knowing anything, and not being able to apply my previous knowledge of programming to <em>anything</em> in Haskell. One piece of syntax at a time I am slowly able to understand more of the Haskell found <a href="http://www.google.com/url?sa=t&amp;ct=res&amp;cd=2&amp;url=http%3A%2F%2Fblog.moertel.com%2Farticles%2F2005%2F03%2F25%2Fwriting-a-simple-ruby-evaluator-in-haskell&amp;ei=Q1A6RtWPLZvYigGZsMjxAQ&amp;usg=AFrqEzdrRepwsuNaQqe1gHYjHvqdCDKfoA&amp;sig2=0qNTIOB9XxeZRqKR7J61Iw">in the wild</a>. When I first read about Haskell I was overwhelmed by not knowing anything, and not being able to apply my previous knowledge of programming to <em>anything</em> in Haskell. One piece of syntax at a time I am slowly able to understand more of the Haskell found <a href="http://www.google.com/url?sa=t&amp;ct=res&amp;cd=2&amp;url=http%3A%2F%2Fblog.moertel.com%2Farticles%2F2005%2F03%2F25%2Fwriting-a-simple-ruby-evaluator-in-haskell&amp;ei=Q1A6RtWPLZvYigGZsMjxAQ&amp;usg=AFrqEzdrRepwsuNaQqe1gHYjHvqdCDKfoA&amp;sig2=0qNTIOB9XxeZRqKR7J61Iw">in the wild</a>.

View file

@ -1,7 +1,7 @@
--- ---
Title: Cheating at Life in General Title: "Cheating at Life in General"
Author: Sami Samhuri Author: Sami Samhuri
Date: 16th May, 2007 Date: "16th May, 2007"
Timestamp: 2007-05-16T02:46:00-07:00 Timestamp: 2007-05-16T02:46:00-07:00
Tags: cheat, vim, emacs, textmate Tags: cheat, vim, emacs, textmate
--- ---

View file

@ -1,7 +1,7 @@
--- ---
Title: dtrace + Ruby = Goodness for Sun Title: "dtrace + Ruby = Goodness for Sun"
Author: Sami Samhuri Author: Sami Samhuri
Date: 9th May, 2007 Date: "9th May, 2007"
Timestamp: 2007-05-09T08:45:00-07:00 Timestamp: 2007-05-09T08:45:00-07:00
Tags: ruby, dtrace, sun Tags: ruby, dtrace, sun
--- ---

View file

@ -1,35 +1,38 @@
--- ---
Title: Dumping Objects to the Browser in Rails Title: "Dumping Objects to the Browser in Rails"
Author: Sami Samhuri Author: Sami Samhuri
Date: 15th May, 2007 Date: "15th May, 2007"
Timestamp: 2007-05-15T13:38:00-07:00 Timestamp: 2007-05-15T13:38:00-07:00
Tags: rails Tags: rails
Styles: typocode.css
--- ---
Here's an easy way to solve a problem that may have nagged you as it did me. Simply using <code>foo.inspect</code> to dump out some object to the browser dumps one long string which is barely useful except for short strings and the like. The ideal output is already available using the <a href="http://www.ruby-doc.org/stdlib/libdoc/prettyprint/rdoc/index.html"><code>PrettyPrint</code></a> module so we just need to use it. Here's an easy way to solve a problem that may have nagged you as it did me. Simply using <code>foo.inspect</code> to dump out some object to the browser dumps one long string which is barely useful except for short strings and the like. The ideal output is already available using the <a href="http://www.ruby-doc.org/stdlib/libdoc/prettyprint/rdoc/index.html"><code>PrettyPrint</code></a> module so we just need to use it.
Unfortunately typing <code>&lt;pre&gt;&lt;%= PP.pp(@something, '') %&gt;&lt;/pre&gt;</code> to quickly debug some possibly large object (or collection) can get old fast so we need a shortcut.
Unfortunately typing <code><pre><%= PP.pp(@something, '') %></pre></code> to quickly debug some possibly large object (or collection) can get old fast so we need a shortcut.
Taking the definition of <a href="http://extensions.rubyforge.org/rdoc/classes/Object.html#M000020"><code>Object#pp_s</code></a> from the <a href="http://extensions.rubyforge.org/rdoc/">extensions project</a> it's trivial to create a helper method to just dump out an object in a reasonable manner. Taking the definition of <a href="http://extensions.rubyforge.org/rdoc/classes/Object.html#M000020"><code>Object#pp_s</code></a> from the <a href="http://extensions.rubyforge.org/rdoc/">extensions project</a> it's trivial to create a helper method to just dump out an object in a reasonable manner.
**/app/helpers/application_helper.rb**
<div class="typocode"><div class="codetitle">/app/helpers/application_helper.rb</div><pre><code class="typocode_ruby "><span class="keyword">def </span><span class="method">dump</span><span class="punct">(</span><span class="ident">thing</span><span class="punct">)</span> ```ruby
<span class="ident">s</span> <span class="punct">=</span> <span class="constant">StringIO</span><span class="punct">.</span><span class="ident">new</span> def dump(thing)
<span class="constant">PP</span><span class="punct">.</span><span class="ident">pp</span><span class="punct">(</span><span class="ident">thing</span><span class="punct">,</span> <span class="ident">s</span><span class="punct">)</span> s = StringIO.new
<span class="ident">s</span><span class="punct">.</span><span class="ident">string</span> PP.pp(thing, s)
<span class="keyword">end</span></code></pre></div> s.string
end
```
Alternatively you could do as the extensions folks do and actually define <code>Object#pp_s</code> so you can use it in your logs or anywhere else you may want to inspect an object. If you do this you probably want to change the <code>dump</code> helper method accordingly in case you decide to change <code>pp_s</code> in the future. Alternatively you could do as the extensions folks do and actually define <code>Object#pp_s</code> so you can use it in your logs or anywhere else you may want to inspect an object. If you do this you probably want to change the <code>dump</code> helper method accordingly in case you decide to change <code>pp_s</code> in the future.
**lib/local_support/core_ext/object.rb**
<div class="typocode"><div class="codetitle">lib/local_support/core_ext/object.rb</div><pre><code class="typocode_ruby "><span class="keyword">class </span><span class="class">Object</span> ```ruby
<span class="keyword">def </span><span class="method">pp_s</span> class Object
<span class="ident">pps</span> <span class="punct">=</span> <span class="constant">StringIO</span><span class="punct">.</span><span class="ident">new</span> def pp_s
<span class="constant">PP</span><span class="punct">.</span><span class="ident">pp</span><span class="punct">(</span><span class="constant">self</span><span class="punct">,</span> <span class="ident">pps</span><span class="punct">)</span> pps = StringIO.new
<span class="ident">pps</span><span class="punct">.</span><span class="ident">string</span> PP.pp(self, pps)
<span class="keyword">end</span> pps.string
<span class="keyword">end</span></code></pre></div> end
end
```

View file

@ -1,10 +1,9 @@
--- ---
Title: Enumurable#pluck and String#to_proc for Ruby Title: "Enumurable#pluck and String#to_proc for Ruby"
Author: Sami Samhuri Author: Sami Samhuri
Date: 10th May, 2007 Date: "10th May, 2007"
Timestamp: 2007-05-10T16:14:00-07:00 Timestamp: 2007-05-10T16:14:00-07:00
Tags: ruby, extensions Tags: ruby, extensions
Styles: typocode.css
--- ---
I wanted a method analogous to Prototype's <a href="http://prototypejs.org/api/enumerable/pluck">pluck</a> and <a href="http://prototypejs.org/api/enumerable/invoke">invoke</a> in Rails for building lists for <a href="http://api.rubyonrails.org/classes/ActionView/Helpers/FormOptionsHelper.html#M000510">options_for_select</a>. Yes, I know about <a href="http://api.rubyonrails.org/classes/ActionView/Helpers/FormOptionsHelper.html#M000511">options_from_collection_for_select</a>. I wanted a method analogous to Prototype's <a href="http://prototypejs.org/api/enumerable/pluck">pluck</a> and <a href="http://prototypejs.org/api/enumerable/invoke">invoke</a> in Rails for building lists for <a href="http://api.rubyonrails.org/classes/ActionView/Helpers/FormOptionsHelper.html#M000510">options_for_select</a>. Yes, I know about <a href="http://api.rubyonrails.org/classes/ActionView/Helpers/FormOptionsHelper.html#M000511">options_from_collection_for_select</a>.
@ -13,114 +12,130 @@ I wanted something more general that I can use anywhere - not just in Rails - so
First you need <a href="http://pragdave.pragprog.com/pragdave/2005/11/symbolto_proc.html"><code>Symbol#to_proc</code></a>, which shouldn't need an introduction. If you're using Rails you have this already. First you need <a href="http://pragdave.pragprog.com/pragdave/2005/11/symbolto_proc.html"><code>Symbol#to_proc</code></a>, which shouldn't need an introduction. If you're using Rails you have this already.
<div class="typocode"><div class="codetitle">Symbol#to_proc</div><pre><code class="typocode_ruby "><span class="keyword">class </span><span class="class">Symbol</span> **Symbol#to_proc**
<span class="comment"># Turns a symbol into a proc.</span>
<span class="comment">#</span> ```ruby
<span class="comment"># Example:</span> class Symbol
<span class="comment"># # The same as people.map { |p| p.birthdate }</span> # Turns a symbol into a proc.
<span class="comment"># people.map(&amp;:birthdate)</span> #
<span class="comment">#</span> # Example:
<span class="keyword">def </span><span class="method">to_proc</span> # # The same as people.map { |p| p.birthdate }
<span class="constant">Proc</span><span class="punct">.</span><span class="ident">new</span> <span class="punct">{|</span><span class="ident">thing</span><span class="punct">,</span> <span class="punct">*</span><span class="ident">args</span><span class="punct">|</span> <span class="ident">thing</span><span class="punct">.</span><span class="ident">send</span><span class="punct">(</span><span class="constant">self</span><span class="punct">,</span> <span class="punct">*</span><span class="ident">args</span><span class="punct">)}</span> # people.map(&:birthdate)
<span class="keyword">end</span> #
<span class="keyword">end</span> def to_proc
</code></pre></div> Proc.new {|thing, *args| thing.send(self, *args)}
end
end
```
Next we define <code>String#to_proc</code>, which is nearly identical to the <code>Array#to_proc</code> method I previously wrote about. Next we define <code>String#to_proc</code>, which is nearly identical to the <code>Array#to_proc</code> method I previously wrote about.
<div class="typocode"><div class="codetitle">String#to_proc</div><pre><code class="typocode_ruby "><span class="keyword">class </span><span class="class">String</span> **String#to_proc**
<span class="comment"># Turns a string into a proc.</span>
<span class="comment">#</span> ```ruby
<span class="comment"># Example:</span> class String
<span class="comment"># # The same as people.map { |p| p.birthdate.year }</span> # Turns a string into a proc.
<span class="comment"># people.map(&amp;'birthdate.year')</span> #
<span class="comment">#</span> # Example:
<span class="keyword">def </span><span class="method">to_proc</span> # # The same as people.map { |p| p.birthdate.year }
<span class="constant">Proc</span><span class="punct">.</span><span class="ident">new</span> <span class="keyword">do</span> <span class="punct">|*</span><span class="ident">args</span><span class="punct">|</span> # people.map(&'birthdate.year')
<span class="ident">split</span><span class="punct">('</span><span class="string">.</span><span class="punct">').</span><span class="ident">inject</span><span class="punct">(</span><span class="ident">args</span><span class="punct">.</span><span class="ident">shift</span><span class="punct">)</span> <span class="keyword">do</span> <span class="punct">|</span><span class="ident">thing</span><span class="punct">,</span> <span class="ident">msg</span><span class="punct">|</span> #
<span class="ident">thing</span> <span class="punct">=</span> <span class="ident">thing</span><span class="punct">.</span><span class="ident">send</span><span class="punct">(</span><span class="ident">msg</span><span class="punct">.</span><span class="ident">to_sym</span><span class="punct">,</span> <span class="punct">*</span><span class="ident">args</span><span class="punct">)</span> def to_proc
<span class="keyword">end</span> Proc.new do |*args|
<span class="keyword">end</span> split('.').inject(args.shift) do |thing, msg|
<span class="keyword">end</span> thing = thing.send(msg.to_sym, *args)
<span class="keyword">end</span> end
</code></pre></div> end
end
end
```
Finally there's <code>Enumerable#to_proc</code> which returns a proc that passes its parameter through each of its members and collects their results. It's easier to explain by example. Finally there's <code>Enumerable#to_proc</code> which returns a proc that passes its parameter through each of its members and collects their results. It's easier to explain by example.
<div class="typocode"><div class="codetitle">Enumerable#to_proc</div><pre><code class="typocode_ruby "><span class="keyword">module </span><span class="module">Enumerable</span> **Enumerable#to_proc**
<span class="comment"># Effectively treats itself as a list of transformations, and returns a proc</span>
<span class="comment"># which maps values to a list of the results of applying each transformation</span> ```ruby
<span class="comment"># in that list to the value.</span> module Enumerable
<span class="comment">#</span> # Effectively treats itself as a list of transformations, and returns a proc
<span class="comment"># Example:</span> # which maps values to a list of the results of applying each transformation
<span class="comment"># # The same as people.map { |p| [p.birthdate, p.email] }</span> # in that list to the value.
<span class="comment"># people.map(&amp;[:birthdate, :email])</span> #
<span class="comment">#</span> # Example:
<span class="keyword">def </span><span class="method">to_proc</span> # # The same as people.map { |p| [p.birthdate, p.email] }
<span class="attribute">@procs</span> <span class="punct">||=</span> <span class="ident">map</span><span class="punct">(&amp;</span><span class="symbol">:to_proc</span><span class="punct">)</span> # people.map(&[:birthdate, :email])
<span class="constant">Proc</span><span class="punct">.</span><span class="ident">new</span> <span class="keyword">do</span> <span class="punct">|</span><span class="ident">thing</span><span class="punct">,</span> <span class="punct">*</span><span class="ident">args</span><span class="punct">|</span> #
<span class="attribute">@procs</span><span class="punct">.</span><span class="ident">map</span> <span class="keyword">do</span> <span class="punct">|</span><span class="ident">proc</span><span class="punct">|</span> def to_proc
<span class="ident">proc</span><span class="punct">.</span><span class="ident">call</span><span class="punct">(</span><span class="ident">thing</span><span class="punct">,</span> <span class="punct">*</span><span class="ident">args</span><span class="punct">)</span> @procs ||= map(&:to_proc)
<span class="keyword">end</span> Proc.new do |thing, *args|
<span class="keyword">end</span> @procs.map do |proc|
<span class="keyword">end</span> proc.call(thing, *args)
<span class="keyword">end</span></code></pre></div> end
end
end
end
```
Here's the cool part, <code>Enumerable#pluck</code> for Ruby in all its glory. Here's the cool part, <code>Enumerable#pluck</code> for Ruby in all its glory.
<div class="typocode"><div class="codetitle">Enumerable#pluck</div><pre><code class="typocode_ruby "><span class="keyword">module </span><span class="module">Enumerable</span> **Enumerable#pluck**
<span class="comment"># Use this to pluck values from objects, especially useful for ActiveRecord models.</span>
<span class="comment"># This is analogous to Prototype's Enumerable.pluck method but more powerful.</span> ```ruby
<span class="comment">#</span> module Enumerable
<span class="comment"># You can pluck values simply, like so:</span> # Use this to pluck values from objects, especially useful for ActiveRecord models.
<span class="comment"># &gt;&gt; people.pluck(:last_name) #=&gt; ['Samhuri', 'Jones', ...]</span> # This is analogous to Prototype's Enumerable.pluck method but more powerful.
<span class="comment">#</span> #
<span class="comment"># But with Symbol#to_proc defined this is effectively the same as:</span> # You can pluck values simply, like so:
<span class="comment"># &gt;&gt; people.map(&amp;:last_name) #=&gt; ['Samhuri', 'Jones', ...]</span> # >> people.pluck(:last_name) #=> ['Samhuri', 'Jones', ...]
<span class="comment">#</span> #
<span class="comment"># Where pluck's power becomes evident is when you want to do something like:</span> # But with Symbol#to_proc defined this is effectively the same as:
<span class="comment"># &gt;&gt; people.pluck(:name, :address, :phone)</span> # >> people.map(&:last_name) #=> ['Samhuri', 'Jones', ...]
<span class="comment"># #=&gt; [['Johnny Canuck', '123 Maple Lane', '416-555-124'], ...]</span> #
<span class="comment">#</span> # Where pluck's power becomes evident is when you want to do something like:
<span class="comment"># Instead of:</span> # >> people.pluck(:name, :address, :phone)
<span class="comment"># &gt;&gt; people.map { |p| [p.name, p.address, p.phone] }</span> # #=> [['Johnny Canuck', '123 Maple Lane', '416-555-124'], ...]
<span class="comment">#</span> #
<span class="comment"># # map each person to: [person.country.code, person.id]</span> # Instead of:
<span class="comment"># &gt;&gt; people.pluck('country.code', :id)</span> # >> people.map { |p| [p.name, p.address, p.phone] }
<span class="comment"># #=&gt; [['US', 1], ['CA', 2], ...]</span> #
<span class="comment">#</span> # # map each person to: [person.country.code, person.id]
<span class="keyword">def </span><span class="method">pluck</span><span class="punct">(*</span><span class="ident">args</span><span class="punct">)</span> # >> people.pluck('country.code', :id)
<span class="comment"># Thanks to Symbol#to_proc, Enumerable#to_proc and String#to_proc this Just Works(tm)</span> # #=> [['US', 1], ['CA', 2], ...]
<span class="ident">map</span><span class="punct">(&amp;</span><span class="ident">args</span><span class="punct">)</span> #
<span class="keyword">end</span> def pluck(*args)
<span class="keyword">end</span></code></pre></div> # Thanks to Symbol#to_proc, Enumerable#to_proc and String#to_proc this Just Works(tm)
map(&args)
end
end
```
I wrote another version without using the various <code>#to_proc</code> methods so as to work with a standard Ruby while only patching 1 module. I wrote another version without using the various <code>#to_proc</code> methods so as to work with a standard Ruby while only patching 1 module.
<div class="typocode"><pre><code class="typocode_ruby "><span class="keyword">module </span><span class="module">Enumerable</span> ```ruby
<span class="comment"># A version of pluck which doesn't require any to_proc methods.</span> module Enumerable
<span class="keyword">def </span><span class="method">pluck</span><span class="punct">(*</span><span class="ident">args</span><span class="punct">)</span> # A version of pluck which doesn't require any to_proc methods.
<span class="ident">procs</span> <span class="punct">=</span> <span class="ident">args</span><span class="punct">.</span><span class="ident">map</span> <span class="keyword">do</span> <span class="punct">|</span><span class="ident">msgs</span><span class="punct">|</span> def pluck(*args)
<span class="comment"># always operate on lists of messages</span> procs = args.map do |msgs|
<span class="keyword">if</span> <span class="constant">String</span> <span class="punct">===</span> <span class="ident">msgs</span> # always operate on lists of messages
<span class="ident">msgs</span> <span class="punct">=</span> <span class="ident">msgs</span><span class="punct">.</span><span class="ident">split</span><span class="punct">('</span><span class="string">.</span><span class="punct">').</span><span class="ident">map</span> <span class="punct">{|</span><span class="ident">a</span><span class="punct">|</span> <span class="ident">a</span><span class="punct">.</span><span class="ident">to_sym</span><span class="punct">}</span> <span class="comment"># allow 'country.code'</span> if String === msgs
<span class="keyword">elsif</span> <span class="punct">!(</span><span class="constant">Enumerable</span> <span class="punct">===</span> <span class="ident">msgs</span><span class="punct">)</span> msgs = msgs.split('.').map {|a| a.to_sym} # allow 'country.code'
<span class="ident">msgs</span> <span class="punct">=</span> <span class="punct">[</span><span class="ident">msgs</span><span class="punct">]</span> elsif !(Enumerable === msgs)
<span class="keyword">end</span> msgs = [msgs]
<span class="constant">Proc</span><span class="punct">.</span><span class="ident">new</span> <span class="keyword">do</span> <span class="punct">|</span><span class="ident">orig</span><span class="punct">|</span> end
<span class="ident">msgs</span><span class="punct">.</span><span class="ident">inject</span><span class="punct">(</span><span class="ident">orig</span><span class="punct">)</span> <span class="punct">{</span> <span class="punct">|</span><span class="ident">thing</span><span class="punct">,</span> <span class="ident">msg</span><span class="punct">|</span> <span class="ident">thing</span> <span class="punct">=</span> <span class="ident">thing</span><span class="punct">.</span><span class="ident">send</span><span class="punct">(</span><span class="ident">msg</span><span class="punct">)</span> <span class="punct">}</span> Proc.new do |orig|
<span class="keyword">end</span> msgs.inject(orig) { |thing, msg| thing = thing.send(msg) }
<span class="keyword">end</span> end
end
<span class="keyword">if</span> <span class="ident">procs</span><span class="punct">.</span><span class="ident">size</span> <span class="punct">==</span> <span class="number">1</span> if procs.size == 1
<span class="ident">map</span><span class="punct">(&amp;</span><span class="ident">procs</span><span class="punct">.</span><span class="ident">first</span><span class="punct">)</span> map(&procs.first)
<span class="keyword">else</span> else
<span class="ident">map</span> <span class="keyword">do</span> <span class="punct">|</span><span class="ident">thing</span><span class="punct">|</span> map do |thing|
<span class="ident">procs</span><span class="punct">.</span><span class="ident">map</span> <span class="punct">{</span> <span class="punct">|</span><span class="ident">proc</span><span class="punct">|</span> <span class="ident">proc</span><span class="punct">.</span><span class="ident">call</span><span class="punct">(</span><span class="ident">thing</span><span class="punct">)</span> <span class="punct">}</span> procs.map { |proc| proc.call(thing) }
<span class="keyword">end</span> end
<span class="keyword">end</span> end
<span class="keyword">end</span> end
<span class="keyword">end</span></code></pre></div> end
```
It's just icing on the cake considering Ruby's convenient block syntax, but there it is. Do with it what you will. You can change or extend any of these to support drilling down into hashes quite easily too. It's just icing on the cake considering Ruby's convenient block syntax, but there it is. Do with it what you will. You can change or extend any of these to support drilling down into hashes quite easily too.

View file

@ -1,7 +1,7 @@
--- ---
Title: Finnish court rules CSS ineffective at protecting DVDs Title: "Finnish court rules CSS ineffective at protecting DVDs"
Author: Sami Samhuri Author: Sami Samhuri
Date: 26th May, 2007 Date: "26th May, 2007"
Timestamp: 2007-05-26T03:24:00-07:00 Timestamp: 2007-05-26T03:24:00-07:00
Tags: drm Tags: drm
--- ---

View file

@ -1,7 +1,7 @@
--- ---
Title: Gotta Love the Ferry Ride Title: "Gotta Love the Ferry Ride"
Author: Sami Samhuri Author: Sami Samhuri
Date: 5th May, 2007 Date: "5th May, 2007"
Timestamp: 2007-05-05T04:25:00-07:00 Timestamp: 2007-05-05T04:25:00-07:00
Tags: life, photo, bc, victoria Tags: life, photo, bc, victoria
--- ---

View file

@ -1,7 +1,7 @@
--- ---
Title: I Can't Wait to See What Trey Parker & Matt Stone Do With This Title: "I Can't Wait to See What Trey Parker & Matt Stone Do With This"
Author: Sami Samhuri Author: Sami Samhuri
Date: 9th May, 2007 Date: "9th May, 2007"
Timestamp: 2007-05-09T14:34:00-07:00 Timestamp: 2007-05-09T14:34:00-07:00
Tags: crazy Tags: crazy
--- ---

View file

@ -1,7 +1,7 @@
--- ---
Title: Inspirado Title: "Inspirado"
Author: Sami Samhuri Author: Sami Samhuri
Date: 22nd May, 2007 Date: "22nd May, 2007"
Timestamp: 2007-05-22T13:23:00-07:00 Timestamp: 2007-05-22T13:23:00-07:00
Tags: rails, inspirado Tags: rails, inspirado
--- ---

View file

@ -1,7 +1,7 @@
--- ---
Title: iPhone Humour Title: "iPhone Humour"
Author: Sami Samhuri Author: Sami Samhuri
Date: 18th May, 2007 Date: "18th May, 2007"
Timestamp: 2007-05-18T11:34:00-07:00 Timestamp: 2007-05-18T11:34:00-07:00
Tags: apple, funny, iphone Tags: apple, funny, iphone
--- ---

View file

@ -1,7 +1,7 @@
--- ---
Title: Rails Plugins (link dump) Title: "Rails Plugins (link dump)"
Author: Sami Samhuri Author: Sami Samhuri
Date: 10th May, 2007 Date: "10th May, 2007"
Timestamp: 2007-05-09T17:22:00-07:00 Timestamp: 2007-05-09T17:22:00-07:00
Tags: rails Tags: rails
--- ---

View file

@ -1,7 +1,7 @@
--- ---
Title: Typo and I are friends again Title: "Typo and I are friends again"
Author: Sami Samhuri Author: Sami Samhuri
Date: 1st May, 2007 Date: "1st May, 2007"
Timestamp: 2007-05-01T21:51:37-07:00 Timestamp: 2007-05-01T21:51:37-07:00
Tags: typo Tags: typo
--- ---

Some files were not shown because too many files have changed in this diff Show more