mirror of
https://github.com/samsonjs/samhuri.net.git
synced 2026-03-25 09:05:47 +00:00
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:
parent
23e62f4a49
commit
007b1058b6
297 changed files with 5847 additions and 57263 deletions
65
.github/workflows/ci.yml
vendored
Normal file
65
.github/workflows/ci.yml
vendored
Normal 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
1
.gitignore
vendored
|
|
@ -1,3 +1,2 @@
|
|||
www
|
||||
Tests/*/actual
|
||||
bin/gensite
|
||||
|
|
|
|||
1
.ruby-version
Normal file
1
.ruby-version
Normal file
|
|
@ -0,0 +1 @@
|
|||
4.0.1
|
||||
75
AGENTS.md
Normal file
75
AGENTS.md
Normal 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
15
Gemfile
Normal 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
178
Gemfile.lock
Normal 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
|
||||
51
Makefile
51
Makefile
|
|
@ -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
283
Readme.md
|
|
@ -1,252 +1,111 @@
|
|||
# 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
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
- 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+
|
||||
## Requirements
|
||||
|
||||
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).
|
||||
|
||||
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:
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/samsonjs/samhuri.net.git
|
||||
cd samhuri.net
|
||||
make debug
|
||||
bin/bootstrap
|
||||
```
|
||||
|
||||
Start a local development server:
|
||||
Or manually:
|
||||
|
||||
```bash
|
||||
make serve # http://localhost:8000
|
||||
make watch # Auto-rebuild on file changes (Linux only)
|
||||
rbenv install -s "$(cat .ruby-version)"
|
||||
bundle install
|
||||
```
|
||||
|
||||
## Workflows
|
||||
|
||||
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
|
||||
## Build And Serve
|
||||
|
||||
```bash
|
||||
bin/new-draft # Create a new empty draft post with frontmatter
|
||||
bin/new-draft hello # You can pass in a title if you want using any number of args, quotes not needed
|
||||
|
||||
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
|
||||
bake debug # build for http://localhost:8000
|
||||
bake serve # serve www/ locally
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
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`:
|
||||
- Site title, description, author name
|
||||
- Base URL for your domain
|
||||
- RSS/JSON feed metadata
|
||||
|
||||
2. **Modify deployment** in `bin/publish`:
|
||||
- Update rsync destination to your server
|
||||
- Adjust staging/production URLs in Makefile
|
||||
|
||||
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]
|
||||
}
|
||||
```bash
|
||||
bake mudge
|
||||
bake beta
|
||||
bake release
|
||||
bake watch target=debug
|
||||
bake clean
|
||||
bake publish_beta
|
||||
bake publish
|
||||
```
|
||||
|
||||
There are `Renderer`s that plugins use to transform files, e.g. Markdown to HTML:
|
||||
## Draft Workflow
|
||||
|
||||
```swift
|
||||
protocol Renderer {
|
||||
func canRenderFile(named filename: String, withExtension ext: String?) -> Bool
|
||||
func render(site: Site, fileURL: URL, targetDir: URL) throws
|
||||
}
|
||||
```bash
|
||||
bake new_draft "Post title"
|
||||
bake drafts
|
||||
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
|
||||
protocol Plugin {
|
||||
func setUp(site: Site, sourceURL: URL) throws
|
||||
func render(site: Site, targetURL: URL) throws
|
||||
}
|
||||
- `Title`
|
||||
- `Author`
|
||||
- `Date`
|
||||
- `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
|
||||
public enum samhuri {}
|
||||
|
||||
public extension samhuri {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
```bash
|
||||
bake test
|
||||
bake lint
|
||||
bake lint_fix
|
||||
```
|
||||
|
||||
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
|
||||
[ProjectsPlugin]: https://github.com/samsonjs/samhuri.net/blob/main/samhuri.net/Sources/samhuri.net/Projects/ProjectsPlugin.swift
|
||||
|
||||
Here's what a plugin might look like for generating photo galleries:
|
||||
|
||||
```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).
|
||||
- `bake watch` is Linux-only and requires `inotifywait`.
|
||||
- Deployment uses `rsync` to host `mudge` (configured in `bake.rb`):
|
||||
- production: `/var/www/samhuri.net/public`
|
||||
- beta: `/var/www/beta.samhuri.net/public`
|
||||
|
|
|
|||
484
bake.rb
Normal file
484
bake.rb
Normal 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
|
||||
|
|
@ -1,50 +1,37 @@
|
|||
#!/bin/bash
|
||||
|
||||
# bail on errors and unset variables
|
||||
set -euo pipefail
|
||||
|
||||
SWIFT_VERSION=6.1
|
||||
SWIFT_DIR=swift-$SWIFT_VERSION-RELEASE-ubuntu24.04
|
||||
SWIFT_FILENAME=$SWIFT_DIR.tar.gz
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
RUBY_VERSION="$(cat "$ROOT_DIR/.ruby-version")"
|
||||
|
||||
if [[ $(uname) = "Linux" ]]; then
|
||||
if [[ "$(uname)" = "Linux" ]]; then
|
||||
echo "*** installing Linux prerequisites"
|
||||
sudo apt install -y \
|
||||
binutils \
|
||||
build-essential \
|
||||
git \
|
||||
gnupg2 \
|
||||
libc6-dev \
|
||||
libcurl4 \
|
||||
libedit2 \
|
||||
libgcc-s1 \
|
||||
libpython3.12 \
|
||||
libsqlite3-0 \
|
||||
libstdc++-14-dev \
|
||||
libxml2 \
|
||||
libz3-dev \
|
||||
inotify-tools \
|
||||
libffi-dev \
|
||||
libyaml-dev \
|
||||
pkg-config \
|
||||
tzdata \
|
||||
uuid-dev \
|
||||
zlib1g-dev
|
||||
fi
|
||||
|
||||
if which swift >/dev/null 2>/dev/null && swift --version | grep $SWIFT_VERSION >/dev/null 2>/dev/null; then
|
||||
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
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
echo "*** installing inotify-tools for watch script"
|
||||
sudo apt install -y inotify-tools
|
||||
if command -v rbenv >/dev/null 2>/dev/null; then
|
||||
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
|
||||
|
||||
echo "*** done"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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__
|
||||
54
bin/publish
54
bin/publish
|
|
@ -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[@]}"
|
||||
|
|
@ -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}"
|
||||
10
bin/watch
10
bin/watch
|
|
@ -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
5
gensite/.gitignore
vendored
|
|
@ -1,5 +0,0 @@
|
|||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
/.swiftpm
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"]),
|
||||
]
|
||||
)
|
||||
|
|
@ -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.
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
@testable import gensite
|
||||
import Testing
|
||||
|
||||
struct gensiteTests {
|
||||
@Test func example() {
|
||||
#expect(true)
|
||||
}
|
||||
}
|
||||
14
lib/pressa.rb
Normal file
14
lib/pressa.rb
Normal 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
217
lib/pressa/config/loader.rb
Normal 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
|
||||
224
lib/pressa/config/simple_toml.rb
Normal file
224
lib/pressa/config/simple_toml.rb
Normal 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
11
lib/pressa/plugin.rb
Normal 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
|
||||
76
lib/pressa/posts/json_feed.rb
Normal file
76
lib/pressa/posts/json_feed.rb
Normal 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
|
||||
50
lib/pressa/posts/metadata.rb
Normal file
50
lib/pressa/posts/metadata.rb
Normal 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
|
||||
95
lib/pressa/posts/models.rb
Normal file
95
lib/pressa/posts/models.rb
Normal 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
|
||||
38
lib/pressa/posts/plugin.rb
Normal file
38
lib/pressa/posts/plugin.rb
Normal 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
124
lib/pressa/posts/repo.rb
Normal 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
|
||||
53
lib/pressa/posts/rss_feed.rb
Normal file
53
lib/pressa/posts/rss_feed.rb
Normal 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
137
lib/pressa/posts/writer.rb
Normal 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
|
||||
22
lib/pressa/projects/models.rb
Normal file
22
lib/pressa/projects/models.rb
Normal 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
|
||||
86
lib/pressa/projects/plugin.rb
Normal file
86
lib/pressa/projects/plugin.rb
Normal 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
39
lib/pressa/site.rb
Normal 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
|
||||
123
lib/pressa/site_generator.rb
Normal file
123
lib/pressa/site_generator.rb
Normal 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
|
||||
20
lib/pressa/utils/file_writer.rb
Normal file
20
lib/pressa/utils/file_writer.rb
Normal 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
|
||||
148
lib/pressa/utils/markdown_renderer.rb
Normal file
148
lib/pressa/utils/markdown_renderer.rb
Normal 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
|
||||
24
lib/pressa/views/archive_view.rb
Normal file
24
lib/pressa/views/archive_view.rb
Normal 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
|
||||
33
lib/pressa/views/feed_post_view.rb
Normal file
33
lib/pressa/views/feed_post_view.rb
Normal 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
34
lib/pressa/views/icons.rb
Normal 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
208
lib/pressa/views/layout.rb
Normal 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
|
||||
26
lib/pressa/views/month_posts_view.rb
Normal file
26
lib/pressa/views/month_posts_view.rb
Normal 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
|
||||
46
lib/pressa/views/post_view.rb
Normal file
46
lib/pressa/views/post_view.rb
Normal 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
|
||||
63
lib/pressa/views/project_view.rb
Normal file
63
lib/pressa/views/project_view.rb
Normal 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
|
||||
34
lib/pressa/views/projects_view.rb
Normal file
34
lib/pressa/views/projects_view.rb
Normal 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
|
||||
21
lib/pressa/views/recent_posts_view.rb
Normal file
21
lib/pressa/views/recent_posts_view.rb
Normal 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
|
||||
66
lib/pressa/views/year_posts_view.rb
Normal file
66
lib/pressa/views/year_posts_view.rb
Normal 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
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
Title: First Post!
|
||||
Title: "First Post!"
|
||||
Author: Sami Samhuri
|
||||
Date: 8th February, 2006
|
||||
Date: "8th February, 2006"
|
||||
Timestamp: 2006-02-07T19:21:00-08:00
|
||||
Tags: life
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
Title: Girlfriend X
|
||||
Title: "Girlfriend X"
|
||||
Author: Sami Samhuri
|
||||
Date: 18th February, 2006
|
||||
Date: "18th February, 2006"
|
||||
Timestamp: 2006-02-18T11:50:00-08:00
|
||||
Tags: crazy, funny
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
Title: Intelligent Migration Snippets 0.1 for TextMate
|
||||
Title: "Intelligent Migration Snippets 0.1 for TextMate"
|
||||
Author: Sami Samhuri
|
||||
Date: 22nd February, 2006
|
||||
Date: "22nd February, 2006"
|
||||
Timestamp: 2006-02-22T03:28:00-08:00
|
||||
Tags: mac os x, textmate, rails, hacking, migrations, snippets
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
Title: Jump to view/controller in TextMate
|
||||
Title: "Jump to view/controller in TextMate"
|
||||
Author: Sami Samhuri
|
||||
Date: 18th February, 2006
|
||||
Date: "18th February, 2006"
|
||||
Timestamp: 2006-02-18T14:51:00-08:00
|
||||
Tags: hacking, rails, textmate, rails, textmate
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
---
|
||||
Title: Obligatory Post about Ruby on Rails
|
||||
Title: "Obligatory Post about Ruby on Rails"
|
||||
Author: Sami Samhuri
|
||||
Date: 20th February, 2006
|
||||
Date: "20th February, 2006"
|
||||
Timestamp: 2006-02-20T00:31:00-08:00
|
||||
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>
|
||||
|
|
@ -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>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>
|
||||
|
||||
|
||||
<h2>Rails through my eyes</h2>
|
||||
|
||||
|
||||
<p>Rails is like my Black & 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>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>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>
|
||||
|
||||
|
||||
<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>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>
|
||||
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
<ol>
|
||||
<li>Separating data, function, and design</li>
|
||||
<li>Readability (which is underrated) </li>
|
||||
|
|
@ -70,148 +53,120 @@ Styles: typocode.css
|
|||
<li>Testing is so easy it hurts</li>
|
||||
</ol>
|
||||
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
<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>
|
||||
</ul>
|
||||
|
||||
|
||||
<ul>
|
||||
<li>The view deals <em>only</em> with presentation. That's it, honestly. An interface to your app.</li>
|
||||
</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>
|
||||
</ul>
|
||||
|
||||
|
||||
<p>Of course this is not exclusive to Rails, but it's an integral part of it's design.</p>
|
||||
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
<div class="typocode"><pre><code class="typocode_ruby "><span class="keyword">class </span><span class="class">Article</span> <span class="punct"><</span> <span class="constant">Content</span>
|
||||
<span class="ident">has_many</span> <span class="symbol">:comments</span><span class="punct">,</span> <span class="symbol">:dependent</span> <span class="punct">=></span> <span class="constant">true</span><span class="punct">,</span> <span class="symbol">:order</span> <span class="punct">=></span> <span class="punct">"</span><span class="string">created_at ASC</span><span class="punct">"</span>
|
||||
<span class="ident">has_many</span> <span class="symbol">:trackbacks</span><span class="punct">,</span> <span class="symbol">:dependent</span> <span class="punct">=></span> <span class="constant">true</span><span class="punct">,</span> <span class="symbol">:order</span> <span class="punct">=></span> <span class="punct">"</span><span class="string">created_at ASC</span><span class="punct">"</span>
|
||||
<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">=></span> <span class="punct">'</span><span class="string">article_id</span><span class="punct">'</span>
|
||||
<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">=></span> <span class="punct">'</span><span class="string">article_id</span><span class="punct">'</span>
|
||||
<span class="ident">belongs_to</span> <span class="symbol">:user</span>
|
||||
<span class="punct">...</span></code></pre></div>
|
||||
```ruby
|
||||
class Article < Content
|
||||
has_many :comments, :dependent => true, :order => "created_at ASC"
|
||||
has_many :trackbacks, :dependent => true, :order => "created_at ASC"
|
||||
has_and_belongs_to_many :categories, :foreign_key => 'article_id'
|
||||
has_and_belongs_to_many :tags, :foreign_key => 'article_id'
|
||||
belongs_to :user
|
||||
...
|
||||
```
|
||||
|
||||
<p><code>dependent => 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>
|
||||
|
||||
```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"><</span> <span class="constant">Content</span>
|
||||
<span class="ident">belongs_to</span> <span class="symbol">:article</span>
|
||||
<span class="ident">belongs_to</span> <span class="symbol">:user</span>
|
||||
|
||||
<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>
|
||||
validates_presence_of :author, :body
|
||||
validates_against_spamdb :body, :url, :ip
|
||||
validates_age_of :article_id
|
||||
...
|
||||
```
|
||||
|
||||
<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>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>
|
||||
|
||||
|
||||
<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"><</span> <span class="constant">ActiveRecord</span><span class="punct">::</span><span class="constant">Migration</span>
|
||||
<span class="keyword">def </span><span class="method">self.up</span>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
def self.down
|
||||
remove_column "albums", "date_released"
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
<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>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>
|
||||
|
||||
|
||||
<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><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>
|
||||
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<span class="keyword">end</span></code></pre></div>
|
||||
```ruby
|
||||
article = Article.find :first
|
||||
for comment in article.comments do
|
||||
print comment unless comment.downcase == 'you suck!'
|
||||
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 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>
|
||||
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Date: 23rd February, 2006
|
||||
Date: "23rd February, 2006"
|
||||
Timestamp: 2006-02-23T17:18:00-08:00
|
||||
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.
|
||||
|
|
@ -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:
|
||||
|
||||
|
||||
<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>
|
||||
```ruby
|
||||
method(arg1, arg2_)
|
||||
```
|
||||
|
||||
Typing <strong>⌃D</strong> at this point results in this code:
|
||||
|
||||
|
||||
<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>
|
||||
<span class="ident">_</span>
|
||||
<span class="keyword">end</span></code></pre></div>
|
||||
```ruby
|
||||
def method(arg1, arg2)
|
||||
_
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
Title: Some TextMate snippets for Rails Migrations
|
||||
Title: "Some TextMate snippets for Rails Migrations"
|
||||
Author: Sami Samhuri
|
||||
Date: 18th February, 2006
|
||||
Date: "18th February, 2006"
|
||||
Timestamp: 2006-02-18T22:48:00-08:00
|
||||
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
|
||||
|
||||
create_table "${1:table}" do |t|
|
||||
```ruby
|
||||
create_table "${1:table}" do |t|
|
||||
$0
|
||||
end
|
||||
${2:drop_table "$1"}
|
||||
end
|
||||
${2:drop_table "$1"}
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
add_column "${1:table}", "${2:column}", :${3:string}
|
||||
${4:remove_column "$1", "$2"}
|
||||
```ruby
|
||||
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*.
|
||||
|
||||
mct: **M**igration **C**reate **T**able
|
||||
|
||||
create_table "${1:table}" do |t|
|
||||
```ruby
|
||||
create_table "${1:table}" do |t|
|
||||
$0
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
mdt: **M**igration **D**rop **T**able
|
||||
|
||||
drop_table "${1:table}"
|
||||
```ruby
|
||||
drop_table "${1:table}"
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
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...
|
||||
|
||||
|
|
@ -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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,46 +1,44 @@
|
|||
---
|
||||
Title: TextMate: Insert text into self.down
|
||||
Title: "TextMate: Insert text into self.down"
|
||||
Author: Sami Samhuri
|
||||
Date: 21st February, 2006
|
||||
Date: "21st February, 2006"
|
||||
Timestamp: 2006-02-21T14:55:00-08:00
|
||||
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>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>
|
||||
|
||||
```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>
|
||||
<span class="keyword">def </span><span class="method">indent</span><span class="punct">(</span><span class="ident">s</span><span class="punct">)</span>
|
||||
<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>
|
||||
up_line = 'rename_column "${1:table}", "${2:column}", "${3:new_name}"$0'
|
||||
down_line = "rename_column \"$$1\", \"$$3\", \"$$2\"\n"
|
||||
|
||||
<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>
|
||||
<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>
|
||||
# find the end of self.down and insert 2nd line
|
||||
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>
|
||||
<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>
|
||||
<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>
|
||||
# return the new text, escaping special chars
|
||||
print up_line + lines.reverse.to_s.gsub(/([$`\\])/, '\\\\\1').gsub(/\$\$/, '$')
|
||||
```
|
||||
|
||||
<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>
|
||||
<li><strong>Save:</strong> 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>
|
||||
</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>
|
||||
|
||||
|
||||
The macro I'm thinking of to invoke this is tab-triggered and will simply:
|
||||
<ul>
|
||||
<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>Run command "Put in self.down"</li>
|
||||
</ul>
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,32 +1,29 @@
|
|||
---
|
||||
Title: TextMate: Move selection to self.down
|
||||
Title: "TextMate: Move selection to self.down"
|
||||
Author: Sami Samhuri
|
||||
Date: 21st February, 2006
|
||||
Date: "21st February, 2006"
|
||||
Timestamp: 2006-02-21T00:26:00-08:00
|
||||
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><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>
|
||||
<span class="ident">drop_table</span> <span class="punct">"</span><span class="string">table</span><span class="punct">"</span></code></pre></div>
|
||||
end
|
||||
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>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>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>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
Title: TextMate Snippets for Rails Assertions
|
||||
Title: "TextMate Snippets for Rails Assertions"
|
||||
Author: Sami Samhuri
|
||||
Date: 20th February, 2006
|
||||
Date: "20th February, 2006"
|
||||
Timestamp: 2006-02-20T23:52:00-08:00
|
||||
Tags: textmate, rails, coding, rails, snippets, testing, textmate
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
Title: Touch Screen on Steroids
|
||||
Title: "Touch Screen on Steroids"
|
||||
Author: Sami Samhuri
|
||||
Date: 8th February, 2006
|
||||
Date: "8th February, 2006"
|
||||
Timestamp: 2006-02-08T06:06:00-08:00
|
||||
Tags: technology, touch
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
Title: Urban Extreme Gymnastics?
|
||||
Title: "Urban Extreme Gymnastics?"
|
||||
Author: Sami Samhuri
|
||||
Date: 15th February, 2006
|
||||
Date: "15th February, 2006"
|
||||
Timestamp: 2006-02-15T10:41:00-08:00
|
||||
Tags: amusement
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
Title: Generate self.down in your Rails migrations
|
||||
Title: "Generate self.down in your Rails migrations"
|
||||
Author: Sami Samhuri
|
||||
Date: 3rd March, 2006
|
||||
Date: "3rd March, 2006"
|
||||
Timestamp: 2006-03-03T21:38:00-08:00
|
||||
Tags: rails, textmate, migrations, rails, textmate
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
Title: I don't mind FairPlay either
|
||||
Title: "I don't mind FairPlay either"
|
||||
Author: Sami Samhuri
|
||||
Date: 3rd March, 2006
|
||||
Date: "3rd March, 2006"
|
||||
Timestamp: 2006-03-03T21:56:00-08:00
|
||||
Tags: apple, mac os x, life, drm, fairplay, ipod, itunes
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
Title: Spore
|
||||
Title: "Spore"
|
||||
Author: Sami Samhuri
|
||||
Date: 3rd March, 2006
|
||||
Date: "3rd March, 2006"
|
||||
Timestamp: 2006-03-03T21:43:00-08:00
|
||||
Tags: amusement, technology, cool, fun, games
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
Title: zsh terminal goodness on OS X
|
||||
Title: "zsh terminal goodness on OS X"
|
||||
Author: Sami Samhuri
|
||||
Date: 4th April, 2006
|
||||
Date: "4th April, 2006"
|
||||
Timestamp: 2006-04-04T14:57:00-07:00
|
||||
Tags: mac os x, apple, osx, terminal, zsh
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
Title: OS X and Fitt's law
|
||||
Title: "OS X and Fitt's law"
|
||||
Author: Sami Samhuri
|
||||
Date: 7th May, 2006
|
||||
Date: "7th May, 2006"
|
||||
Timestamp: 2006-05-07T20:43:00-07:00
|
||||
Tags: mac os x, apple, mac, os, usability, x
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
Title: WikipediaFS on Linux, in Python
|
||||
Title: "WikipediaFS on Linux, in Python"
|
||||
Author: Sami Samhuri
|
||||
Date: 7th May, 2006
|
||||
Date: "7th May, 2006"
|
||||
Timestamp: 2006-05-07T20:49:00-07:00
|
||||
Tags: hacking, python, linux, fuse, linux, mediawiki, python, wikipediafs
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
Title: Apple pays attention to detail
|
||||
Title: "Apple pays attention to detail"
|
||||
Author: Sami Samhuri
|
||||
Date: 11th June, 2006
|
||||
Date: "11th June, 2006"
|
||||
Timestamp: 2006-06-11T01:30:00-07:00
|
||||
Tags: technology, mac os x, apple
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Date: 5th June, 2006
|
||||
Date: "5th June, 2006"
|
||||
Timestamp: 2006-06-05T10:11:00-07:00
|
||||
Tags: life, munich, seekport, work
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
Title: Never buy a German keyboard!
|
||||
Title: "Never buy a German keyboard!"
|
||||
Author: Sami Samhuri
|
||||
Date: 9th June, 2006
|
||||
Date: "9th June, 2006"
|
||||
Timestamp: 2006-06-09T01:17:00-07:00
|
||||
Tags: apple, apple, german, keyboard
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
Title: There's nothing regular about regular expressions
|
||||
Title: "There's nothing regular about regular expressions"
|
||||
Author: Sami Samhuri
|
||||
Date: 10th June, 2006
|
||||
Date: "10th June, 2006"
|
||||
Timestamp: 2006-06-10T01:28:00-07:00
|
||||
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:
|
||||
|
||||
\(([^()]|\(([^()]|\(([^()]|\(([^()])*\))*\))*\))*\)
|
||||
Wow, that's ugly.
|
||||
```conf
|
||||
\(([^()]|\(([^()]|\(([^()]|\(([^()])*\))*\))*\))*\)
|
||||
Wow, that's ugly.
|
||||
```
|
||||
|
||||
(Don't worry, there's a much better solution on the next 2 pages after that quote.)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Date: 21st July, 2006
|
||||
Date: "21st July, 2006"
|
||||
Timestamp: 2006-07-21T07:56:00-07:00
|
||||
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:
|
||||
|
||||
<pre><code>
|
||||
```php
|
||||
class Foo {
|
||||
public static function static_fun()
|
||||
{
|
||||
|
|
@ -29,7 +29,7 @@ class Foo {
|
|||
}
|
||||
}
|
||||
|
||||
echo '<pre>';
|
||||
echo '<pre>';
|
||||
echo "From Foo:\n";
|
||||
echo Foo::static_fun();
|
||||
echo Foo::not_static();
|
||||
|
|
@ -37,14 +37,14 @@ echo "\n";
|
|||
|
||||
echo "From \$foo = new Foo():\n";
|
||||
$foo = new Foo();
|
||||
echo $foo->static_fun();
|
||||
echo $foo->not_static();
|
||||
echo '</pre>';
|
||||
</code></pre>
|
||||
echo $foo->static_fun();
|
||||
echo $foo->not_static();
|
||||
echo '</pre>';
|
||||
```
|
||||
|
||||
Produces:
|
||||
|
||||
<pre><code>
|
||||
```php
|
||||
From Foo:
|
||||
This is a class method!
|
||||
This is an instance method!
|
||||
|
|
@ -52,7 +52,7 @@ This is an instance method!
|
|||
From $foo = new Foo():
|
||||
This is a class 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
Title: Late static binding
|
||||
Title: "Late static binding"
|
||||
Author: Sami Samhuri
|
||||
Date: 19th July, 2006
|
||||
Date: "19th July, 2006"
|
||||
Timestamp: 2006-07-19T10:23:00-07:00
|
||||
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:
|
||||
|
||||
<pre>
|
||||
<code>
|
||||
```php
|
||||
class Foo
|
||||
{
|
||||
public static function my_method()
|
||||
|
|
@ -24,15 +23,13 @@ class Bar extends Foo
|
|||
{}
|
||||
|
||||
Bar::my_method();
|
||||
</code>
|
||||
</pre>
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
<pre>
|
||||
<code>
|
||||
```php
|
||||
class Bar extends Foo
|
||||
{
|
||||
public static function my_method()
|
||||
|
|
@ -40,8 +37,7 @@ class Bar extends Foo
|
|||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
Title: Ruby and Rails have spoiled me rotten
|
||||
Title: "Ruby and Rails have spoiled me rotten"
|
||||
Author: Sami Samhuri
|
||||
Date: 17th July, 2006
|
||||
Date: "17th July, 2006"
|
||||
Timestamp: 2006-07-17T05:40:00-07:00
|
||||
Tags: rails, ruby, php, coding, framework, php, rails, ruby, zend
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
Title: Ubuntu: Linux for Linux users please
|
||||
Title: "Ubuntu: Linux for Linux users please"
|
||||
Author: Sami Samhuri
|
||||
Date: 13th July, 2006
|
||||
Date: "13th July, 2006"
|
||||
Timestamp: 2006-07-13T08:34:00-07:00
|
||||
Tags: linux, linux, ubuntu
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
Title: Working with the Zend Framework
|
||||
Title: "Working with the Zend Framework"
|
||||
Author: Sami Samhuri
|
||||
Date: 6th July, 2006
|
||||
Date: "6th July, 2006"
|
||||
Timestamp: 2006-07-06T07:36:00-07:00
|
||||
Tags: coding, technology, php, framework, php, seekport, zend
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
Title: Where are my headphones?
|
||||
Title: "Where are my headphones?"
|
||||
Author: Sami Samhuri
|
||||
Date: 22nd August, 2006
|
||||
Date: "22nd August, 2006"
|
||||
Timestamp: 2006-08-22T07:31:00-07:00
|
||||
Tags: life, seekport
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Date: 16th September, 2006
|
||||
Date: "16th September, 2006"
|
||||
Timestamp: 2006-09-16T22:11:00-07:00
|
||||
Tags: amusement, buffalo
|
||||
Link: http://en.wikipedia.org/wiki/Buffalo_buffalo_buffalo_buffalo_buffalo_buffalo_buffalo_buffalo
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Date: 22nd September, 2006
|
||||
Date: "22nd September, 2006"
|
||||
Timestamp: 2006-09-22T16:59:00-07:00
|
||||
Tags: apple, apple, itunes
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Date: 17th December, 2006
|
||||
Date: "17th December, 2006"
|
||||
Timestamp: 2006-12-17T23:30:00-08:00
|
||||
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:
|
||||
|
||||
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.
|
||||
|
||||
|
|
@ -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:
|
||||
|
||||
mkdir C:\Windows\System32\Parallels
|
||||
copy C:\Windows\System32\wpa.* C:\Windows\System32\Parallels
|
||||
```bat
|
||||
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>
|
||||
|
||||
|
|
@ -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:
|
||||
|
||||
mkdir C:\Windows\System32\BootCamp
|
||||
copy C:\Windows\System32\wpa.* C:\Windows\System32\BootCamp
|
||||
```bat
|
||||
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>
|
||||
|
||||
|
|
@ -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>
|
||||
|
||||
@echo off
|
||||
```bat
|
||||
@echo off
|
||||
|
||||
ipconfig /all | find "Parallels" > network.tmp
|
||||
for /F "tokens=14" %%x in (network.tmp) do set parallels=%x
|
||||
del network.tmp
|
||||
ipconfig /all | find "Parallels" > network.tmp
|
||||
for /F "tokens=14" %%x in (network.tmp) do set parallels=%x
|
||||
del network.tmp
|
||||
|
||||
if defined parallels (
|
||||
if defined parallels (
|
||||
echo Parallels
|
||||
copy C:\Windows\System32\Parallels\wpa.* C:\Windows\System32
|
||||
) else (
|
||||
) else (
|
||||
echo Boot Camp
|
||||
copy C:\Windows\System32\BootCamp\wpa.* C:\Windows\System32
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
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 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
Title: Digg v4: Reply to replies (Greasemonkey script)
|
||||
Title: "Digg v4: Reply to replies (Greasemonkey script)"
|
||||
Author: Sami Samhuri
|
||||
Date: 8th March, 2007
|
||||
Date: "8th March, 2007"
|
||||
Timestamp: 2007-03-08T23:19:00-08:00
|
||||
Tags: coding, digg, firefox, userscript
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
Title: Diggscuss 0.9
|
||||
Title: "Diggscuss 0.9"
|
||||
Author: Sami Samhuri
|
||||
Date: 25th March, 2007
|
||||
Date: "25th March, 2007"
|
||||
Timestamp: 2007-03-25T08:03:00-07:00
|
||||
Tags: coding, digg, firefox, userscript
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
Title: Full-screen Cover Flow
|
||||
Title: "Full-screen Cover Flow"
|
||||
Author: Sami Samhuri
|
||||
Date: 6th March, 2007
|
||||
Date: "6th March, 2007"
|
||||
Timestamp: 2007-03-06T13:51:00-08:00
|
||||
Tags: apple, coverflow, itunes
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
Title: A triple-booting, schizophrenic MacBook
|
||||
Title: "A triple-booting, schizophrenic MacBook"
|
||||
Author: Sami Samhuri
|
||||
Date: 4th April, 2007
|
||||
Date: "4th April, 2007"
|
||||
Timestamp: 2007-04-04T23:30:00-07:00
|
||||
Tags: linux, mac os x, windows
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Date: 11th April, 2007
|
||||
Date: "11th April, 2007"
|
||||
Timestamp: 2007-04-11T03:24:00-07:00
|
||||
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:
|
||||
|
||||
<pre class="line-numbers">1
|
||||
2
|
||||
3
|
||||
4
|
||||
</pre>
|
||||
<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
|
||||
# extend ActiveRecord::Base with find_or_create and find_or_initialize.
|
||||
ActiveRecord::Base.class_eval do
|
||||
include ActiveRecordExtensions
|
||||
end
|
||||
```
|
||||
|
||||
```ruby
|
||||
module ActiveRecordExtensions
|
||||
def self.included(base)
|
||||
base.extend(ClassMethods)
|
||||
end
|
||||
|
||||
<pre class="line-numbers">1
|
||||
2
|
||||
3
|
||||
4
|
||||
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>
|
||||
module ClassMethods
|
||||
def find_or_initialize(params)
|
||||
find_or_do('initialize', params)
|
||||
end
|
||||
|
||||
<span class="r">module</span> <span class="cl">ClassMethods</span>
|
||||
<span class="r">def</span> <span class="fu">find_or_initialize</span>(params)
|
||||
find_or_do(<span class="s"><span class="dl">'</span><span class="k">initialize</span><span class="dl">'</span></span>, params)
|
||||
<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>
|
||||
def find_or_create(params)
|
||||
find_or_do('create', params)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
<span class="c"># Find a record that matches the attributes given in the +params+ hash, or do +action+</span>
|
||||
<span class="c"># to retrieve a new object with the given parameters and return that.</span>
|
||||
<span class="r">def</span> <span class="fu">find_or_do</span>(action, params)
|
||||
<span class="c"># if an id is given just find the record directly</span>
|
||||
<span class="pc">self</span>.find(params[<span class="sy">:id</span>])
|
||||
# Find a record that matches the attributes given in the +params+ hash, or do +action+
|
||||
# to retrieve a new object with the given parameters and return that.
|
||||
def find_or_do(action, params)
|
||||
# if an id is given just find the record directly
|
||||
self.find(params[:id])
|
||||
|
||||
<span class="r">rescue</span> <span class="co">ActiveRecord</span>::<span class="co">RecordNotFound</span> => e
|
||||
attrs = {} <span class="c"># hash of attributes passed in params</span>
|
||||
rescue ActiveRecord::RecordNotFound => e
|
||||
attrs = {} # hash of attributes passed in params
|
||||
|
||||
<span class="c"># search for valid attributes in params</span>
|
||||
<span class="pc">self</span>.column_names.map(&<span class="sy">:to_sym</span>).each <span class="r">do</span> |attrib|
|
||||
<span class="c"># skip unknown columns, and the id field</span>
|
||||
<span class="r">next</span> <span class="r">if</span> params[attrib].nil? || attrib == <span class="sy">:id</span>
|
||||
# search for valid attributes in params
|
||||
self.column_names.map(&:to_sym).each do |attrib|
|
||||
# skip unknown columns, and the id field
|
||||
next if params[attrib].nil? || attrib == :id
|
||||
|
||||
attrs[attrib] = params[attrib]
|
||||
<span class="r">end</span>
|
||||
end
|
||||
|
||||
<span class="c"># no valid params given, return nil</span>
|
||||
<span class="r">return</span> <span class="pc">nil</span> <span class="r">if</span> attrs.empty?
|
||||
# no valid params given, return nil
|
||||
return nil if attrs.empty?
|
||||
|
||||
<span class="c"># call the appropriate ActiveRecord finder method</span>
|
||||
<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)
|
||||
<span class="r">end</span>
|
||||
<span class="r">end</span>
|
||||
<span class="r">end</span></code></pre>
|
||||
# call the appropriate ActiveRecord finder method
|
||||
self.send("find_or_#{action}_by_#{attrs.keys.join('_and_')}", *attrs.values)
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
---
|
||||
Title: Funny how code can be beautiful
|
||||
Title: "Funny how code can be beautiful"
|
||||
Author: Sami Samhuri
|
||||
Date: 30th April, 2007
|
||||
Date: "30th April, 2007"
|
||||
Timestamp: 2007-04-30T07:07:00-07:00
|
||||
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>:
|
||||
|
||||
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 couldn’t help but think how <strong>beautiful</strong> it is. I don’t mean that it’s 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 you’ll 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://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>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
Title: Getting to know Vista
|
||||
Title: "Getting to know Vista"
|
||||
Author: Sami Samhuri
|
||||
Date: 16th April, 2007
|
||||
Date: "16th April, 2007"
|
||||
Timestamp: 2007-04-16T11:09:00-07:00
|
||||
Tags: windows
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Date: 26th April, 2007
|
||||
Date: "26th April, 2007"
|
||||
Timestamp: 2007-04-26T07:06:00-07:00
|
||||
Tags: linux, mysql
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
Title: A New Way to Look at Networking
|
||||
Title: "A New Way to Look at Networking"
|
||||
Author: Sami Samhuri
|
||||
Date: 5th May, 2007
|
||||
Date: "5th May, 2007"
|
||||
Timestamp: 2007-05-05T16:10:00-07:00
|
||||
Tags: technology, networking
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
Title: A Scheme parser in Haskell: Part 1
|
||||
Title: "A Scheme parser in Haskell: Part 1"
|
||||
Author: Sami Samhuri
|
||||
Date: 3rd May, 2007
|
||||
Date: "3rd May, 2007"
|
||||
Timestamp: 2007-05-03T00:47:50-07:00
|
||||
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>>>=</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
|
||||
</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.
|
||||
|
||||
* <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).
|
||||
|
||||
|
||||
Familiar <code>liftM</code> method:
|
||||
<pre><code>parseNumber -> liftM (Number . read) $ many1 digit
|
||||
</code></pre>
|
||||
```haskell
|
||||
parseNumber -> liftM (Number . read) $ many1 digit
|
||||
```
|
||||
|
||||
Using <code>do</code> notation:
|
||||
<pre><code>parseNumber -> do digits <- many1 digit
|
||||
```haskell
|
||||
parseNumber -> do digits <- many1 digit
|
||||
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><-</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>.
|
||||
|
||||
|
||||
Finally, using <code>>>=</code> (bind) notation:
|
||||
<pre><code>parseNumber -> many1 digit >>= \digits ->
|
||||
```haskell
|
||||
parseNumber -> many1 digit >>= \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>>>=</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&ct=res&cd=2&url=http%3A%2F%2Fblog.moertel.com%2Farticles%2F2005%2F03%2F25%2Fwriting-a-simple-ruby-evaluator-in-haskell&ei=Q1A6RtWPLZvYigGZsMjxAQ&usg=AFrqEzdrRepwsuNaQqe1gHYjHvqdCDKfoA&sig2=0qNTIOB9XxeZRqKR7J61Iw">in the wild</a>.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
Title: Cheating at Life in General
|
||||
Title: "Cheating at Life in General"
|
||||
Author: Sami Samhuri
|
||||
Date: 16th May, 2007
|
||||
Date: "16th May, 2007"
|
||||
Timestamp: 2007-05-16T02:46:00-07:00
|
||||
Tags: cheat, vim, emacs, textmate
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
Title: dtrace + Ruby = Goodness for Sun
|
||||
Title: "dtrace + Ruby = Goodness for Sun"
|
||||
Author: Sami Samhuri
|
||||
Date: 9th May, 2007
|
||||
Date: "9th May, 2007"
|
||||
Timestamp: 2007-05-09T08:45:00-07:00
|
||||
Tags: ruby, dtrace, sun
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,35 +1,38 @@
|
|||
---
|
||||
Title: Dumping Objects to the Browser in Rails
|
||||
Title: "Dumping Objects to the Browser in Rails"
|
||||
Author: Sami Samhuri
|
||||
Date: 15th May, 2007
|
||||
Date: "15th May, 2007"
|
||||
Timestamp: 2007-05-15T13:38:00-07:00
|
||||
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.
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
**/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>
|
||||
<span class="ident">s</span> <span class="punct">=</span> <span class="constant">StringIO</span><span class="punct">.</span><span class="ident">new</span>
|
||||
<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>
|
||||
<span class="ident">s</span><span class="punct">.</span><span class="ident">string</span>
|
||||
<span class="keyword">end</span></code></pre></div>
|
||||
```ruby
|
||||
def dump(thing)
|
||||
s = StringIO.new
|
||||
PP.pp(thing, s)
|
||||
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.
|
||||
|
||||
**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>
|
||||
<span class="keyword">def </span><span class="method">pp_s</span>
|
||||
<span class="ident">pps</span> <span class="punct">=</span> <span class="constant">StringIO</span><span class="punct">.</span><span class="ident">new</span>
|
||||
<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>
|
||||
<span class="ident">pps</span><span class="punct">.</span><span class="ident">string</span>
|
||||
<span class="keyword">end</span>
|
||||
<span class="keyword">end</span></code></pre></div>
|
||||
```ruby
|
||||
class Object
|
||||
def pp_s
|
||||
pps = StringIO.new
|
||||
PP.pp(self, pps)
|
||||
pps.string
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Date: 10th May, 2007
|
||||
Date: "10th May, 2007"
|
||||
Timestamp: 2007-05-10T16:14:00-07:00
|
||||
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>.
|
||||
|
|
@ -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.
|
||||
|
||||
<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>
|
||||
<span class="comment"># Turns a symbol into a proc.</span>
|
||||
<span class="comment">#</span>
|
||||
<span class="comment"># Example:</span>
|
||||
<span class="comment"># # The same as people.map { |p| p.birthdate }</span>
|
||||
<span class="comment"># people.map(&:birthdate)</span>
|
||||
<span class="comment">#</span>
|
||||
<span class="keyword">def </span><span class="method">to_proc</span>
|
||||
<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>
|
||||
<span class="keyword">end</span>
|
||||
<span class="keyword">end</span>
|
||||
</code></pre></div>
|
||||
**Symbol#to_proc**
|
||||
|
||||
```ruby
|
||||
class Symbol
|
||||
# Turns a symbol into a proc.
|
||||
#
|
||||
# Example:
|
||||
# # The same as people.map { |p| p.birthdate }
|
||||
# people.map(&:birthdate)
|
||||
#
|
||||
def to_proc
|
||||
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.
|
||||
|
||||
<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>
|
||||
<span class="comment"># Turns a string into a proc.</span>
|
||||
<span class="comment">#</span>
|
||||
<span class="comment"># Example:</span>
|
||||
<span class="comment"># # The same as people.map { |p| p.birthdate.year }</span>
|
||||
<span class="comment"># people.map(&'birthdate.year')</span>
|
||||
<span class="comment">#</span>
|
||||
<span class="keyword">def </span><span class="method">to_proc</span>
|
||||
<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>
|
||||
<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>
|
||||
<span class="keyword">end</span>
|
||||
<span class="keyword">end</span>
|
||||
<span class="keyword">end</span>
|
||||
<span class="keyword">end</span>
|
||||
</code></pre></div>
|
||||
**String#to_proc**
|
||||
|
||||
```ruby
|
||||
class String
|
||||
# Turns a string into a proc.
|
||||
#
|
||||
# Example:
|
||||
# # The same as people.map { |p| p.birthdate.year }
|
||||
# people.map(&'birthdate.year')
|
||||
#
|
||||
def to_proc
|
||||
Proc.new do |*args|
|
||||
split('.').inject(args.shift) do |thing, msg|
|
||||
thing = thing.send(msg.to_sym, *args)
|
||||
end
|
||||
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.
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<span class="comment"># in that list to the value.</span>
|
||||
<span class="comment">#</span>
|
||||
<span class="comment"># Example:</span>
|
||||
<span class="comment"># # The same as people.map { |p| [p.birthdate, p.email] }</span>
|
||||
<span class="comment"># people.map(&[:birthdate, :email])</span>
|
||||
<span class="comment">#</span>
|
||||
<span class="keyword">def </span><span class="method">to_proc</span>
|
||||
<span class="attribute">@procs</span> <span class="punct">||=</span> <span class="ident">map</span><span class="punct">(&</span><span class="symbol">:to_proc</span><span class="punct">)</span>
|
||||
<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>
|
||||
<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>
|
||||
<span class="keyword">end</span>
|
||||
<span class="keyword">end</span>
|
||||
<span class="keyword">end</span>
|
||||
<span class="keyword">end</span></code></pre></div>
|
||||
**Enumerable#to_proc**
|
||||
|
||||
```ruby
|
||||
module Enumerable
|
||||
# Effectively treats itself as a list of transformations, and returns a proc
|
||||
# which maps values to a list of the results of applying each transformation
|
||||
# in that list to the value.
|
||||
#
|
||||
# Example:
|
||||
# # The same as people.map { |p| [p.birthdate, p.email] }
|
||||
# people.map(&[:birthdate, :email])
|
||||
#
|
||||
def to_proc
|
||||
@procs ||= map(&:to_proc)
|
||||
Proc.new do |thing, *args|
|
||||
@procs.map do |proc|
|
||||
proc.call(thing, *args)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
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>
|
||||
<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>
|
||||
<span class="comment">#</span>
|
||||
<span class="comment"># You can pluck values simply, like so:</span>
|
||||
<span class="comment"># >> people.pluck(:last_name) #=> ['Samhuri', 'Jones', ...]</span>
|
||||
<span class="comment">#</span>
|
||||
<span class="comment"># But with Symbol#to_proc defined this is effectively the same as:</span>
|
||||
<span class="comment"># >> people.map(&:last_name) #=> ['Samhuri', 'Jones', ...]</span>
|
||||
<span class="comment">#</span>
|
||||
<span class="comment"># Where pluck's power becomes evident is when you want to do something like:</span>
|
||||
<span class="comment"># >> people.pluck(:name, :address, :phone)</span>
|
||||
<span class="comment"># #=> [['Johnny Canuck', '123 Maple Lane', '416-555-124'], ...]</span>
|
||||
<span class="comment">#</span>
|
||||
<span class="comment"># Instead of:</span>
|
||||
<span class="comment"># >> people.map { |p| [p.name, p.address, p.phone] }</span>
|
||||
<span class="comment">#</span>
|
||||
<span class="comment"># # map each person to: [person.country.code, person.id]</span>
|
||||
<span class="comment"># >> people.pluck('country.code', :id)</span>
|
||||
<span class="comment"># #=> [['US', 1], ['CA', 2], ...]</span>
|
||||
<span class="comment">#</span>
|
||||
<span class="keyword">def </span><span class="method">pluck</span><span class="punct">(*</span><span class="ident">args</span><span class="punct">)</span>
|
||||
<span class="comment"># Thanks to Symbol#to_proc, Enumerable#to_proc and String#to_proc this Just Works(tm)</span>
|
||||
<span class="ident">map</span><span class="punct">(&</span><span class="ident">args</span><span class="punct">)</span>
|
||||
<span class="keyword">end</span>
|
||||
<span class="keyword">end</span></code></pre></div>
|
||||
**Enumerable#pluck**
|
||||
|
||||
```ruby
|
||||
module Enumerable
|
||||
# Use this to pluck values from objects, especially useful for ActiveRecord models.
|
||||
# This is analogous to Prototype's Enumerable.pluck method but more powerful.
|
||||
#
|
||||
# You can pluck values simply, like so:
|
||||
# >> people.pluck(:last_name) #=> ['Samhuri', 'Jones', ...]
|
||||
#
|
||||
# But with Symbol#to_proc defined this is effectively the same as:
|
||||
# >> people.map(&:last_name) #=> ['Samhuri', 'Jones', ...]
|
||||
#
|
||||
# Where pluck's power becomes evident is when you want to do something like:
|
||||
# >> people.pluck(:name, :address, :phone)
|
||||
# #=> [['Johnny Canuck', '123 Maple Lane', '416-555-124'], ...]
|
||||
#
|
||||
# Instead of:
|
||||
# >> people.map { |p| [p.name, p.address, p.phone] }
|
||||
#
|
||||
# # map each person to: [person.country.code, person.id]
|
||||
# >> people.pluck('country.code', :id)
|
||||
# #=> [['US', 1], ['CA', 2], ...]
|
||||
#
|
||||
def pluck(*args)
|
||||
# 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.
|
||||
|
||||
<div class="typocode"><pre><code class="typocode_ruby "><span class="keyword">module </span><span class="module">Enumerable</span>
|
||||
<span class="comment"># A version of pluck which doesn't require any to_proc methods.</span>
|
||||
<span class="keyword">def </span><span class="method">pluck</span><span class="punct">(*</span><span class="ident">args</span><span class="punct">)</span>
|
||||
<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>
|
||||
<span class="comment"># always operate on lists of messages</span>
|
||||
<span class="keyword">if</span> <span class="constant">String</span> <span class="punct">===</span> <span class="ident">msgs</span>
|
||||
<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>
|
||||
<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>
|
||||
<span class="ident">msgs</span> <span class="punct">=</span> <span class="punct">[</span><span class="ident">msgs</span><span class="punct">]</span>
|
||||
<span class="keyword">end</span>
|
||||
<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>
|
||||
<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>
|
||||
<span class="keyword">end</span>
|
||||
<span class="keyword">end</span>
|
||||
```ruby
|
||||
module Enumerable
|
||||
# A version of pluck which doesn't require any to_proc methods.
|
||||
def pluck(*args)
|
||||
procs = args.map do |msgs|
|
||||
# always operate on lists of messages
|
||||
if String === msgs
|
||||
msgs = msgs.split('.').map {|a| a.to_sym} # allow 'country.code'
|
||||
elsif !(Enumerable === msgs)
|
||||
msgs = [msgs]
|
||||
end
|
||||
Proc.new do |orig|
|
||||
msgs.inject(orig) { |thing, msg| thing = thing.send(msg) }
|
||||
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>
|
||||
<span class="ident">map</span><span class="punct">(&</span><span class="ident">procs</span><span class="punct">.</span><span class="ident">first</span><span class="punct">)</span>
|
||||
<span class="keyword">else</span>
|
||||
<span class="ident">map</span> <span class="keyword">do</span> <span class="punct">|</span><span class="ident">thing</span><span class="punct">|</span>
|
||||
<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>
|
||||
<span class="keyword">end</span>
|
||||
<span class="keyword">end</span>
|
||||
<span class="keyword">end</span>
|
||||
<span class="keyword">end</span></code></pre></div>
|
||||
if procs.size == 1
|
||||
map(&procs.first)
|
||||
else
|
||||
map do |thing|
|
||||
procs.map { |proc| proc.call(thing) }
|
||||
end
|
||||
end
|
||||
end
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Date: 26th May, 2007
|
||||
Date: "26th May, 2007"
|
||||
Timestamp: 2007-05-26T03:24:00-07:00
|
||||
Tags: drm
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
Title: Gotta Love the Ferry Ride
|
||||
Title: "Gotta Love the Ferry Ride"
|
||||
Author: Sami Samhuri
|
||||
Date: 5th May, 2007
|
||||
Date: "5th May, 2007"
|
||||
Timestamp: 2007-05-05T04:25:00-07:00
|
||||
Tags: life, photo, bc, victoria
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Date: 9th May, 2007
|
||||
Date: "9th May, 2007"
|
||||
Timestamp: 2007-05-09T14:34:00-07:00
|
||||
Tags: crazy
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
Title: Inspirado
|
||||
Title: "Inspirado"
|
||||
Author: Sami Samhuri
|
||||
Date: 22nd May, 2007
|
||||
Date: "22nd May, 2007"
|
||||
Timestamp: 2007-05-22T13:23:00-07:00
|
||||
Tags: rails, inspirado
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
Title: iPhone Humour
|
||||
Title: "iPhone Humour"
|
||||
Author: Sami Samhuri
|
||||
Date: 18th May, 2007
|
||||
Date: "18th May, 2007"
|
||||
Timestamp: 2007-05-18T11:34:00-07:00
|
||||
Tags: apple, funny, iphone
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
Title: Rails Plugins (link dump)
|
||||
Title: "Rails Plugins (link dump)"
|
||||
Author: Sami Samhuri
|
||||
Date: 10th May, 2007
|
||||
Date: "10th May, 2007"
|
||||
Timestamp: 2007-05-09T17:22:00-07:00
|
||||
Tags: rails
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
Title: Typo and I are friends again
|
||||
Title: "Typo and I are friends again"
|
||||
Author: Sami Samhuri
|
||||
Date: 1st May, 2007
|
||||
Date: "1st May, 2007"
|
||||
Timestamp: 2007-05-01T21:51:37-07:00
|
||||
Tags: typo
|
||||
---
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue