Migrate repository to integrated Ruby site generator

This commit is contained in:
Sami Samhuri 2026-02-07 16:08:26 -08:00
parent cb42616c91
commit d67d1488b9
No known key found for this signature in database
122 changed files with 101 additions and 3851 deletions

1
.gitignore vendored
View file

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

View file

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

268
Readme.md
View file

@ -1,252 +1,90 @@
# 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 is now a single integrated Ruby project. The legacy Swift generators (`gensite/` and `samhuri.net/`) have been removed.
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.
- Generator core: `lib/`
- Build tasks: `bake.rb`
- CLI and utilities: `bin/`
- Tests: `spec/`
- Content: `posts/` and `public/`
- Output: `www/`
Some features:
## Requirements
- 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+
- Ruby `3.4.1` (see `.ruby-version`)
- Bundler
- `rbenv` recommended
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.
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)"
rbenv exec 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
rbenv exec bundle exec bake debug # build for http://localhost:8000
rbenv exec bundle exec bake serve # serve www/ locally
```
## Customizing for your site
Other targets:
If this seems like a reasonable workflow then you could see what it takes to make it your own.
### Essential changes
0. Probably **rename everything** unless you want to impersonate me 🥸
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
rbenv exec bundle exec bake mudge
rbenv exec bundle exec bake beta
rbenv exec bundle exec bake release
rbenv exec bundle exec bake publish_beta
rbenv exec bundle exec 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
bin/new-draft "Post title"
bin/publish-draft public/drafts/post-title.md
```
And this is the `Plugin` protocol:
## Post Utilities
```swift
protocol Plugin {
func setUp(site: Site, sourceURL: URL) throws
func render(site: Site, targetURL: URL) throws
}
```bash
bin/convert-frontmatter posts/2025/11/some-post.md
```
Your site plus its renderers and plugins defines everything that it can do.
## Tests And Lint
```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
rbenv exec bundle exec rspec
rbenv exec bundle exec standardrb
```
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.
Or via bake:
[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
}
}
}
```bash
rbenv exec bundle exec bake test
rbenv exec bundle exec bake lint
```
## License
## Site Generation CLI
Released under the terms of the [MIT license](https://sjs.mit-license.org).
```bash
bin/pressa SOURCE TARGET [URL]
# example
bin/pressa . www https://samhuri.net
```
## Notes
- `bin/watch` is Linux-only and requires `inotifywait`.
- Deployment uses `rsync` to the configured `mudge` host paths in `bake.rb` and `bin/publish`.

View file

@ -1,4 +1,4 @@
# Build tasks for Pressa static site generator
# Build tasks for samhuri.net static site generator
# Generate the site in debug mode (localhost:8000)
def debug
@ -62,7 +62,7 @@ end
# List all available drafts
def drafts
Dir.glob('drafts/*.md').sort.each do |draft|
Dir.glob('public/drafts/*.md').sort.each do |draft|
puts File.basename(draft)
end
end
@ -87,6 +87,6 @@ def build(url)
puts "Building site for #{url}..."
site = Pressa.create_site(url_override: url)
generator = Pressa::SiteGenerator.new(site:)
generator.generate(source_path: '..', target_path: 'www')
generator.generate(source_path: '.', target_path: 'www')
puts "Site built successfully in www/"
end

View file

@ -1,50 +1,31 @@
#!/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 \
git \
gnupg2 \
libc6-dev \
libcurl4 \
libedit2 \
libgcc-s1 \
libpython3.12 \
libsqlite3-0 \
libstdc++-14-dev \
libxml2 \
libz3-dev \
pkg-config \
tzdata \
uuid-dev \
zlib1g-dev
build-essential \
git \
inotify-tools \
libffi-dev \
libyaml-dev \
pkg-config \
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"
rbenv exec bundle install
else
echo "*** rbenv not found, using system Ruby"
bundle install
fi
echo "*** done"

View file

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

View file

@ -1,10 +1,17 @@
#!/bin/bash
set -euo pipefail
BLOG_TARGET=${BLOG_TARGET:-mudge}
if ! command -v inotifywait >/dev/null 2>/dev/null; then
echo "inotifywait is required (install inotify-tools)."
exit 1
fi
while true; do
inotifywait -e modify,create,delete,move -r drafts -r posts
inotifywait -e modify,create,delete,move -r public -r posts -r lib
echo "changed at $(date)"
sleep 5
make "$TARGET"
sleep 2
rbenv exec bundle exec bake "$BLOG_TARGET"
done

5
gensite/.gitignore vendored
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,191 +0,0 @@
# Pressa
A Ruby-based static site generator using Phlex for HTML generation. Built to replace the Swift-based generator for samhuri.net.
## Features
- **Plugin-based architecture** - Extensible system with PostsPlugin and ProjectsPlugin
- **Hierarchical post organization** - Posts organized by year/month
- **Markdown processing** - Kramdown with Rouge for syntax highlighting
- **Multiple output formats** - Individual posts, homepage, archives, year/month indexes
- **RSS & JSON feeds** - Both feeds with 30 most recent posts
- **Link posts** - Support for posts linking to external URLs
- **Phlex templates** - Type-safe HTML generation
- **dry-struct models** - Immutable data structures
## Requirements
- Ruby 3.4.1+
- Bundler
## Installation
```bash
bundle install
```
## Usage
### Build Commands
```bash
# Development build (localhost:8000)
bundle exec bake debug
# Start local server
bundle exec bake serve
# Build for staging
bundle exec bake beta
bundle exec bake publish_beta # build + deploy
# Build for production
bundle exec bake release
bundle exec bake publish # build + deploy
```
### Running Tests
```bash
# Run all specs
bundle exec bake test
# Run specs with Guard (auto-run on file changes)
bundle exec bake guard
```
### Linting
```bash
# Check code style
bundle exec bake lint
# Auto-fix style issues
bundle exec bake lint_fix
```
### CLI
```bash
# Build site
bin/pressa SOURCE TARGET [URL]
# Example
bin/pressa . www https://samhuri.net
```
### Migration Tools
```bash
# Convert front-matter from custom format to YAML
bin/convert-frontmatter posts/**/*.md
# Validate output comparison between Swift and Ruby
bin/validate-output www-swift www-ruby
```
See [SYNTAX_HIGHLIGHTING.md](SYNTAX_HIGHLIGHTING.md) for details on Rouge syntax highlighting.
## Project Structure
```
pressa/
├── lib/
│ ├── pressa.rb # Main entry point
│ ├── site_generator.rb # Orchestrator
│ ├── site.rb # Site model (dry-struct)
│ ├── plugin.rb # Plugin base class
│ ├── posts/ # PostsPlugin
│ │ ├── plugin.rb
│ │ ├── repo.rb # Read/parse posts
│ │ ├── writer.rb # Write HTML
│ │ ├── metadata.rb # YAML front-matter parsing
│ │ ├── models.rb # Post, PostsByYear, etc.
│ │ ├── json_feed.rb
│ │ └── rss_feed.rb
│ ├── projects/ # ProjectsPlugin
│ │ ├── plugin.rb
│ │ └── models.rb
│ ├── views/ # Phlex templates
│ │ ├── layout.rb # Base layout
│ │ ├── post_view.rb
│ │ ├── recent_posts_view.rb
│ │ ├── archive_view.rb
│ │ └── ...
│ └── utils/
│ ├── file_writer.rb
│ └── markdown_renderer.rb
├── bin/
│ └── pressa # CLI executable
├── spec/ # RSpec tests
└── bake.rb # Build tasks
```
## Content Structure
### Posts
Posts must be in `/posts/YYYY/MM/` with YAML front-matter:
```yaml
---
Title: Post Title
Author: Author Name
Date: 11th November, 2025
Timestamp: 2025-11-11T14:00:00-08:00
Tags: Ruby, Phlex # Optional
Link: https://example.net # Optional (for link posts)
Scripts: highlight.js # Optional
Styles: code.css # Optional
---
Post content in Markdown...
```
### Output Structure
```
www/
├── index.html # Recent posts (10 most recent)
├── posts/
│ ├── index.html # Archive page
│ ├── YYYY/
│ │ ├── index.html # Year index
│ │ └── MM/
│ │ ├── index.html # Month rollup
│ │ └── slug/
│ │ └── index.html # Individual post
├── projects/
│ ├── index.html
│ └── project-name/
│ └── index.html
├── feed.json # JSON Feed 1.1
├── feed.xml # RSS 2.0
└── [static files from public/]
```
## Tech Stack
- **Ruby**: 3.4.1
- **Phlex**: 2.3 - HTML generation
- **Kramdown**: 2.5 - Markdown parsing
- **kramdown-parser-gfm**: 1.1 - GitHub Flavored Markdown
- **Rouge**: 4.6 - Syntax highlighting
- **dry-struct**: 1.8 - Immutable data models
- **Builder**: 3.3 - XML/RSS generation
- **Bake**: 0.20+ - Task runner
- **RSpec**: 3.13 - Testing
- **StandardRB**: 1.43 - Code linting
## Differences from Swift Version
1. **Language**: Ruby 3.4 vs Swift 6.1
2. **HTML generation**: Phlex vs Plot
3. **Markdown**: Kramdown+Rouge vs Ink
4. **Models**: dry-struct vs Swift structs
5. **Build system**: Bake vs Make
6. **Front-matter**: YAML vs custom format
## License
Personal project - not currently licensed for general use.

View file

@ -1,141 +0,0 @@
# Syntax Highlighting with Rouge
Pressa uses Rouge for syntax highlighting through Kramdown. Your posts should already work correctly with Rouge if they use standard Markdown code fences.
## Supported Formats
### GitHub Flavored Markdown (Recommended)
````markdown
```ruby
def hello
puts "Hello, World!"
end
```
````
### Kramdown Syntax
````markdown
~~~ ruby
def hello
puts "Hello, World!"
end
~~~
````
### With Line Numbers (if needed)
You can enable line numbers in the Kramdown configuration, but by default Pressa has them disabled for cleaner output.
## Supported Languages
Rouge supports 200+ languages. Common ones include:
- `ruby`
- `javascript` / `js`
- `python` / `py`
- `swift`
- `bash` / `shell`
- `html`
- `css`
- `sql`
- `yaml` / `yml`
- `json`
- `markdown` / `md`
Full list: https://github.com/rouge-ruby/rouge/wiki/List-of-supported-languages-and-lexers
## CSS Styling
Rouge generates syntax highlighting by wrapping code elements with `<span>` tags that have semantic class names.
Example output:
```html
<div class="language-ruby highlighter-rouge">
<span class="k">class</span> <span class="nc">Post</span>
<span class="k">end</span>
</div>
```
### CSS Classes
Common classes used by Rouge:
- `.k` - Keyword
- `.nc` - Class name
- `.nf` - Function name
- `.s`, `.s1`, `.s2` - Strings
- `.c`, `.c1` - Comments
- `.n` - Name/identifier
- `.o` - Operator
- `.p` - Punctuation
### Generating CSS
You can generate Rouge CSS themes with:
```bash
# List available themes
bundle exec rougify help style
# Generate CSS for a theme
bundle exec rougify style github > public/css/syntax.css
bundle exec rougify style monokai > public/css/syntax-dark.css
```
Popular themes:
- `github` - GitHub's light theme
- `monokai` - Dark theme
- `base16` - Base16 color scheme
- `thankful_eyes` - Easy on the eyes
- `tulip` - Colorful
## Checking Your Posts
Your existing posts should work fine if they use:
1. Standard Markdown code fences with language specifiers
2. HTML `<pre><code>` blocks (will work but won't be highlighted)
To check a specific post:
```bash
grep -A 5 '```' posts/2025/11/your-post.md
```
## Configuration in Pressa
Syntax highlighting is configured in `lib/posts/repo.rb` and `lib/utils/markdown_renderer.rb`:
```ruby
Kramdown::Document.new(
markdown,
input: 'GFM',
syntax_highlighter: 'rouge',
syntax_highlighter_opts: {
line_numbers: false, # Change to true if you want line numbers
wrap: true # Leave true so Rouge emits <pre><code> blocks
}
).to_html
```
## Testing
You can test syntax highlighting with the provided test post:
```bash
bundle exec bake debug
bundle exec bake serve
# Open http://localhost:8000 in browser
```
The test post at `test-site/posts/2025/11/test-post.md` includes a Ruby code example with syntax highlighting.
## Migration Notes
If you're migrating from Swift/Ink, both use similar Markdown parsers, so your code blocks should "just work." The main difference is:
- **Ink**: Built-in syntax highlighting (uses its own system)
- **Rouge**: External gem, more themes, more languages, generates semantic HTML
Rouge output is more flexible because it generates plain HTML with classes, allowing you to change themes by just swapping CSS files.

View file

@ -1,66 +0,0 @@
#!/usr/bin/env ruby
require_relative '../lib/utils/frontmatter_converter'
source_dir = ARGV[0] || '../posts'
dry_run = ARGV.include?('--dry-run')
unless Dir.exist?(source_dir)
puts "ERROR: Directory not found: #{source_dir}"
exit 1
end
posts = Dir.glob(File.join(source_dir, '**', '*.md')).sort
puts "Found #{posts.length} posts to convert"
puts "Mode: #{dry_run ? 'DRY RUN (no changes will be made)' : 'CONVERTING FILES'}"
puts ""
converted = 0
errors = []
posts.each do |post_path|
relative_path = post_path.sub("#{source_dir}/", '')
begin
if dry_run
content = File.read(post_path)
Pressa::Utils::FrontmatterConverter.convert_content(content)
puts "✓ Would convert: #{relative_path}"
else
Pressa::Utils::FrontmatterConverter.convert_file(post_path)
puts "✓ Converted: #{relative_path}"
end
converted += 1
rescue => e
errors << {path: relative_path, error: e.message}
puts "✗ Error: #{relative_path}"
puts " #{e.message}"
end
end
puts ""
puts "=" * 60
puts "CONVERSION SUMMARY"
puts "=" * 60
puts ""
puts "Total posts: #{posts.length}"
puts "Converted: #{converted}"
puts "Errors: #{errors.length}"
puts ""
if errors.any?
puts "Posts with errors:"
errors.each do |err|
puts " #{err[:path]}"
puts " #{err[:error]}"
end
puts ""
end
if dry_run
puts "This was a dry run. Run without --dry-run to actually convert files."
puts ""
end
exit(errors.empty? ? 0 : 1)

View file

@ -1,285 +0,0 @@
#!/usr/bin/env ruby
require 'optparse'
require 'fileutils'
require 'digest'
require 'json'
require 'nokogiri'
begin
require 'htmlbeautifier'
rescue LoadError
# Optional dependency used only for nicer diffs.
end
class OutputValidator
def initialize(swift_dir:, ruby_dir:, verbose:, ignore_patterns:, show_details:, dump_dir:)
@swift_dir = swift_dir
@ruby_dir = ruby_dir
@differences = []
@missing_in_ruby = []
@missing_in_swift = []
@identical_count = 0
@verbose = verbose
@ignore_patterns = ignore_patterns
@show_details = show_details
@dump_dir = dump_dir
prepare_dump_dirs if @dump_dir
end
def validate
puts "Comparing outputs:"
puts " Swift: #{@swift_dir}"
puts " Ruby: #{@ruby_dir}"
puts ""
swift_files = find_html_files(@swift_dir)
ruby_files = find_html_files(@ruby_dir)
puts "Found #{swift_files.length} Swift output files"
puts "Found #{ruby_files.length} Ruby output files"
puts ""
compare_files(swift_files, ruby_files)
print_summary
end
private
def find_html_files(dir)
Dir.glob(File.join(dir, '**', '*.{html,xml,json,css,js,png,jpg,jpeg,gif,svg,ico,woff,woff2,ttf,eot}'))
.map { |f| f.sub("#{dir}/", '') }
.sort
end
def compare_files(swift_files, ruby_files)
all_files = (swift_files + ruby_files).uniq.sort
all_files.each do |relative_path|
next if ignored?(relative_path)
swift_path = File.join(@swift_dir, relative_path)
ruby_path = File.join(@ruby_dir, relative_path)
if !File.exist?(swift_path)
@missing_in_swift << relative_path
elsif !File.exist?(ruby_path)
@missing_in_ruby << relative_path
else
compare_file_contents(relative_path, swift_path, ruby_path)
end
end
end
def compare_file_contents(relative_path, swift_path, ruby_path)
if binary_file?(relative_path)
swift_content = File.binread(swift_path)
ruby_content = File.binread(ruby_path)
else
swift_content = normalize_content(relative_path, File.read(swift_path))
ruby_content = normalize_content(relative_path, File.read(ruby_path))
dump_normalized(relative_path, swift_content, ruby_content) if @dump_dir
end
if swift_content == ruby_content
@identical_count += 1
puts "✓ #{relative_path}" if @verbose
else
@differences << {
path: relative_path,
swift_hash: Digest::SHA256.hexdigest(swift_content),
ruby_hash: Digest::SHA256.hexdigest(ruby_content),
swift_size: swift_content.length,
ruby_size: ruby_content.length
}
puts "✗ #{relative_path} (differs)"
end
end
def prepare_dump_dirs
FileUtils.rm_rf(@dump_dir)
FileUtils.mkdir_p(File.join(@dump_dir, 'swift'))
FileUtils.mkdir_p(File.join(@dump_dir, 'ruby'))
end
def dump_normalized(relative_path, swift_content, ruby_content)
write_normalized(File.join(@dump_dir, 'swift', relative_path), swift_content)
write_normalized(File.join(@dump_dir, 'ruby', relative_path), ruby_content)
end
def write_normalized(path, content)
FileUtils.mkdir_p(File.dirname(path))
File.write(path, content)
end
def ignored?(path)
return false if @ignore_patterns.empty?
@ignore_patterns.any? { |pattern| File.fnmatch?(pattern, path) }
end
def binary_file?(path)
ext = File.extname(path).downcase
%w[.png .jpg .jpeg .gif .svg .ico .woff .woff2 .ttf .eot].include?(ext)
end
def normalize_content(path, content)
normalized = case File.extname(path).downcase
when '.html', '.htm'
normalize_html_dom(content)
when '.xml'
normalize_xml_dom(content)
when '.json'
normalize_json(content)
else
normalize_text(content)
end
strip_dynamic_values(normalized)
end
def normalize_html_dom(content)
doc = Nokogiri::HTML5(content)
html = doc.to_html
if defined?(HtmlBeautifier) && HtmlBeautifier.respond_to?(:beautify)
html = HtmlBeautifier.beautify(html)
end
html
rescue StandardError
normalize_text(content)
end
def normalize_xml_dom(content)
doc = Nokogiri::XML(content) { |cfg| cfg.noblanks }
doc.to_xml(indent: 2)
rescue StandardError
normalize_text(content)
end
def normalize_json(content)
JSON.pretty_generate(JSON.parse(content))
rescue StandardError
normalize_text(content)
end
def normalize_text(content)
content.gsub(/\s+/, ' ').gsub(/>\s+</, '><').strip
end
def strip_dynamic_values(content)
content.gsub(/<lastBuildDate>.*?<\/lastBuildDate>/, '')
end
# Legacy helper retained for backwards compatibility if needed elsewhere.
def html_save_options
Nokogiri::XML::Node::SaveOptions::AS_XHTML |
Nokogiri::XML::Node::SaveOptions::NO_DECLARATION |
Nokogiri::XML::Node::SaveOptions::FORMAT
end
def xml_save_options
Nokogiri::XML::Node::SaveOptions::AS_XML |
Nokogiri::XML::Node::SaveOptions::NO_DECLARATION |
Nokogiri::XML::Node::SaveOptions::FORMAT
end
def print_summary
puts ""
puts "=" * 60
puts "VALIDATION SUMMARY"
puts "=" * 60
puts ""
puts "Identical files: #{@identical_count}"
puts "Different files: #{@differences.length}"
puts "Missing in Ruby: #{@missing_in_ruby.length}"
puts "Missing in Swift: #{@missing_in_swift.length}"
puts ""
if @differences.any?
puts "Files with differences:"
return unless @show_details
@differences.each do |diff|
puts " #{diff[:path]}"
puts " Swift: #{diff[:swift_size]} bytes (#{diff[:swift_hash][0..7]}...)"
puts " Ruby: #{diff[:ruby_size]} bytes (#{diff[:ruby_hash][0..7]}...)"
end
puts ""
end
if @missing_in_ruby.any?
puts "Missing in Ruby output:"
@missing_in_ruby.each { |path| puts " #{path}" }
puts ""
end
if @missing_in_swift.any?
puts "Missing in Swift output:"
@missing_in_swift.each { |path| puts " #{path}" }
puts ""
end
success = @differences.empty? && @missing_in_ruby.empty? && @missing_in_swift.empty?
puts success ? "✅ VALIDATION PASSED" : "❌ VALIDATION FAILED"
puts ""
exit(success ? 0 : 1)
end
end
options = {
verbose: false,
ignore_patterns: [],
show_details: false,
dump_dir: nil
}
parser = OptionParser.new do |opts|
opts.banner = "Usage: validate-output [options] SWIFT_DIR RUBY_DIR"
opts.on('-v', '--verbose', 'Print a ✓ line for every identical file') do
options[:verbose] = true
end
opts.on('-iPATTERN', '--ignore=PATTERN', 'Ignore files matching the glob (may be repeated)') do |pattern|
options[:ignore_patterns] << pattern
end
opts.on('--details', 'Include byte counts and hashes for differing files') do
options[:show_details] = true
end
opts.on('--dump-normalized=DIR', 'Write normalized Swift/Ruby files to DIR/{swift,ruby}') do |dir|
options[:dump_dir] = dir
end
end
parser.parse!
if ARGV.length != 2
puts parser
exit 1
end
swift_dir = ARGV[0]
ruby_dir = ARGV[1]
unless Dir.exist?(swift_dir)
puts "ERROR: Swift directory not found: #{swift_dir}"
exit 1
end
unless Dir.exist?(ruby_dir)
puts "ERROR: Ruby directory not found: #{ruby_dir}"
exit 1
end
validator = OutputValidator.new(
swift_dir: swift_dir,
ruby_dir: ruby_dir,
verbose: options[:verbose],
ignore_patterns: options[:ignore_patterns],
show_details: options[:show_details],
dump_dir: options[:dump_dir]
)
validator.validate

View file

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

View file

@ -1,24 +0,0 @@
{
"originHash" : "1912cd4185c680b826a0cb106effaca327bfc97ee755dd52a44214c989cc02cc",
"pins" : [
{
"identity" : "ink",
"kind" : "remoteSourceControl",
"location" : "https://github.com/johnsundell/ink.git",
"state" : {
"revision" : "bcc9f219900a62c4210e6db726035d7f03ae757b",
"version" : "0.6.0"
}
},
{
"identity" : "plot",
"kind" : "remoteSourceControl",
"location" : "https://github.com/johnsundell/plot.git",
"state" : {
"revision" : "271926b4413fe868739d99f5eadcf2bd6cd62fb8",
"version" : "0.14.0"
}
}
],
"version" : 3
}

View file

@ -1,36 +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: "samhuri.net",
platforms: [
.macOS(.v14),
.iOS(.v17),
],
products: [
// Products define the executables and libraries produced by a package, and make them visible to other packages.
.library(
name: "samhuri.net",
targets: ["samhuri.net"]),
],
dependencies: [
.package(url: "https://github.com/johnsundell/ink.git", exact: "0.6.0"),
.package(url: "https://github.com/johnsundell/plot.git", exact: "0.14.0"),
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages which this package depends on.
.target(
name: "samhuri.net",
dependencies: [
.product(name: "Ink", package: "ink"),
.product(name: "Plot", package: "plot"),
]
),
.testTarget(
name: "samhuri.netTests",
dependencies: ["samhuri.net"]),
]
)

View file

@ -1,5 +0,0 @@
# samhuri.net
A static site generator for [samhuri.net](https://samhuri.net) using SiteGenerator.
See https://github.com/samsonjs/samhuri.net for details.

View file

@ -1,22 +0,0 @@
//
// Date+Sugar.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-19.
//
import Foundation
extension Date {
var year: Int {
Calendar.current.dateComponents([.year], from: self).year!
}
var month: Int {
Calendar.current.dateComponents([.month], from: self).month!
}
var day: Int {
Calendar.current.dateComponents([.day], from: self).day!
}
}

View file

@ -1,20 +0,0 @@
//
// DirectoryCreating.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-24.
//
import Foundation
protocol DirectoryCreating {
func createDirectory(at url: URL) throws
}
extension FileManager: DirectoryCreating {
func createDirectory(at url: URL) throws {
try createDirectory(at: url, withIntermediateDirectories: true, attributes: [
.posixPermissions: FilePermissions.directoryDefault.rawValue,
])
}
}

View file

@ -1,16 +0,0 @@
//
// FileManager+DirectoryExistence.swift
// samhuri.net
//
// Created by Sami Samhuri on 2020-01-01.
//
import Foundation
extension FileManager {
func directoryExists(at fileURL: URL) -> Bool {
var isDir: ObjCBool = false
_ = fileExists(atPath: fileURL.path, isDirectory: &isDir)
return isDir.boolValue
}
}

View file

@ -1,57 +0,0 @@
//
// FilePermissions.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-24.
//
import Foundation
struct FilePermissions: Equatable, CustomStringConvertible {
let user: Permissions
let group: Permissions
let other: Permissions
var description: String {
[user, group, other].map { $0.description }.joined()
}
static let fileDefault: FilePermissions = "rw-r--r--"
static let directoryDefault: FilePermissions = "rwxr-xr-x"
}
extension FilePermissions {
init?(string: String) {
guard let user = Permissions(string: String(string.prefix(3))),
let group = Permissions(string: String(string.dropFirst(3).prefix(3))),
let other = Permissions(string: String(string.dropFirst(6).prefix(3)))
else {
return nil
}
self.user = user
self.group = group
self.other = other
}
}
extension FilePermissions: RawRepresentable {
var rawValue: Int16 {
user.rawValue << 6 | group.rawValue << 3 | other.rawValue
}
init(rawValue: Int16) {
user = Permissions(rawValue: rawValue >> 6 & 0b111)
group = Permissions(rawValue: rawValue >> 3 & 0b111)
other = Permissions(rawValue: rawValue >> 0 & 0b111)
}
}
extension FilePermissions: ExpressibleByStringLiteral {
init(stringLiteral value: String) {
guard let permissions = FilePermissions(string: value) else {
fatalError("Invalid FilePermissions string literal: \(value)")
}
self = permissions
}
}

View file

@ -1,21 +0,0 @@
//
// FilePermissionsSetting.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-24.
//
import Foundation
protocol FilePermissionsSetting {
func setPermissions(_ permissions: FilePermissions, ofItemAt fileURL: URL) throws
}
extension FileManager: FilePermissionsSetting {
func setPermissions(_ permissions: FilePermissions, ofItemAt fileURL: URL) throws {
let attributes: [FileAttributeKey: Any] = [
.posixPermissions: permissions.rawValue,
]
try setAttributes(attributes, ofItemAtPath: fileURL.path)
}
}

View file

@ -1,35 +0,0 @@
//
// FileWriter.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-24.
//
import Foundation
/// On Linux umask doesn't seem to be respected and files are written without
/// group and other read permissions by default. This class explicitly sets
/// permissions and then it works properly on macOS and Linux.
final class FileWriter {
typealias FileManager = DirectoryCreating & FilePermissionsSetting
let fileManager: FileManager
init(fileManager: FileManager = Foundation.FileManager.default) {
self.fileManager = fileManager
}
}
extension FileWriter: FileWriting {
func write(data: Data, to fileURL: URL, permissions: FilePermissions) throws {
try fileManager.createDirectory(at: fileURL.deletingLastPathComponent())
try data.write(to: fileURL, options: .atomic)
try fileManager.setPermissions(permissions, ofItemAt: fileURL)
}
func write(string: String, to fileURL: URL, permissions: FilePermissions) throws {
try fileManager.createDirectory(at: fileURL.deletingLastPathComponent())
try string.write(to: fileURL, atomically: true, encoding: .utf8)
try fileManager.setPermissions(permissions, ofItemAt: fileURL)
}
}

View file

@ -1,26 +0,0 @@
//
// FileWriting.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-24.
//
import Foundation
protocol FileWriting {
func write(data: Data, to fileURL: URL) throws
func write(data: Data, to fileURL: URL, permissions: FilePermissions) throws
func write(string: String, to fileURL: URL) throws
func write(string: String, to fileURL: URL, permissions: FilePermissions) throws
}
extension FileWriting {
func write(data: Data, to fileURL: URL) throws {
try write(data: data, to: fileURL, permissions: .fileDefault)
}
func write(string: String, to fileURL: URL) throws {
try write(string: string, to: fileURL, permissions: .fileDefault)
}
}

View file

@ -1,69 +0,0 @@
//
// Permissions.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-24.
//
import Foundation
struct Permissions: OptionSet {
let rawValue: Int16
static let none: Permissions = []
// These raw values match those used by Unix file systems and must not be changed.
static let execute = Permissions(rawValue: 1 << 0)
static let write = Permissions(rawValue: 1 << 1)
static let read = Permissions(rawValue: 1 << 2)
init(rawValue: Int16) {
self.rawValue = rawValue
}
init?(string: String) {
guard string.count == 3 else {
return nil
}
self.init(rawValue: 0)
switch string[string.startIndex] {
case "r": insert(.read)
case "-": break
default: return nil
}
switch string[string.index(string.startIndex, offsetBy: 1)] {
case "w": insert(.write)
case "-": break
default: return nil
}
switch string[string.index(string.startIndex, offsetBy: 2)] {
case "x": insert(.execute)
case "-": break
default: return nil
}
}
}
extension Permissions: CustomStringConvertible {
var description: String {
[
contains(.read) ? "r" : "-",
contains(.write) ? "w" : "-",
contains(.execute) ? "x" : "-",
].joined()
}
}
extension Permissions: ExpressibleByStringLiteral {
init(stringLiteral value: String) {
guard let _ = Permissions(string: value) else {
fatalError("Invalid Permissions string literal: \(value)")
}
self.init(string: value)!
}
}

View file

@ -1,14 +0,0 @@
//
// JSONFeed.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-15.
//
import Foundation
struct JSONFeed {
let path: String
let iconPath: String?
let faviconPath: String?
}

View file

@ -1,102 +0,0 @@
//
// JSONFeedWriter.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-10.
//
import Foundation
protocol JSONFeedRendering {
func renderJSONFeedPost(_ post: Post, site: Site) throws -> String
}
final class JSONFeedWriter {
let fileWriter: FileWriting
let jsonFeed: JSONFeed
init(jsonFeed: JSONFeed, fileWriter: FileWriting = FileWriter()) {
self.jsonFeed = jsonFeed
self.fileWriter = fileWriter
}
func writeFeed(site: Site, posts: [Post], to targetURL: URL, with renderer: JSONFeedRendering) throws {
let feed = try buildFeed(site: site, posts: posts, renderer: renderer)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
#if os(Linux)
encoder.outputFormatting = [.prettyPrinted]
#else
encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes]
#endif
let feedJSON = try encoder.encode(feed)
let feedURL = targetURL.appendingPathComponent(jsonFeed.path)
try fileWriter.write(data: feedJSON, to: feedURL)
}
}
private extension JSONFeedWriter {
func buildFeed(site: Site, posts: [Post], renderer: JSONFeedRendering) throws -> Feed {
let author = FeedAuthor(
name: site.author,
avatar: site.imageURL?.absoluteString,
url: site.url.absoluteString
)
return Feed(
title: site.title,
home_page_url: site.url.absoluteString,
feed_url: site.url.appendingPathComponent(jsonFeed.path).absoluteString,
author: author,
authors: [author],
icon: jsonFeed.iconPath.map(site.url.appendingPathComponent)?.absoluteString,
favicon: jsonFeed.faviconPath.map(site.url.appendingPathComponent)?.absoluteString,
items: try posts.map { post in
let url = site.url.appendingPathComponent(post.path)
return FeedItem(
title: post.isLink ? "\(post.title)" : post.title,
date_published: post.date,
id: url.absoluteString,
url: url.absoluteString,
external_url: post.link?.absoluteString,
author: FeedAuthor(name: post.author, avatar: nil, url: nil),
content_html: try renderer.renderJSONFeedPost(post, site: site),
tags: post.tags
)
}
)
}
}
private struct Feed: Codable {
var version = "https://jsonfeed.org/version/1.1"
var language = "en-CA"
let title: String
let home_page_url: String
let feed_url: String
// `author` has been deprecated in favour of `authors`, but `author` remains for backwards
// compatibility.
let author: FeedAuthor
let authors: [FeedAuthor]
let icon: String?
let favicon: String?
let items: [FeedItem]
}
private struct FeedAuthor: Codable {
let name: String
let avatar: String?
let url: String?
}
private struct FeedItem: Codable {
let title: String
let date_published: Date
let id: String
let url: String
let external_url: String?
let author: FeedAuthor
let content_html: String
let tags: [String]
}

View file

@ -1,37 +0,0 @@
//
// HTMLRef.swift
// samhuri.net
//
// Created by Sami Samhuri on 2020-01-02.
//
import Foundation
protocol HTMLRef: ExpressibleByStringLiteral {
// Concrete requirements, must be implemented
var ref: String { get }
// These all have default implementations
init(ref: String)
func url(dir: URL) -> URL
}
extension HTMLRef {
init(stringLiteral value: String) {
self.init(ref: value)
}
func url(dir: URL) -> URL {
// ref is either an absolute HTTP URL or path relative to the given directory.
isHTTPURL ? URL(string: ref)! : dir.appendingPathComponent(ref)
}
}
private extension HTMLRef {
var isHTTPURL: Bool {
ref.hasPrefix("http:") || ref.hasPrefix("https:")
}
}

View file

@ -1,80 +0,0 @@
//
// Month.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-03.
//
import Foundation
struct Month: Equatable {
static let all = names.map(Month.init(_:))
static let names = [
"January", "February", "March", "April",
"May", "June", "July", "August",
"September", "October", "November", "December"
]
let number: Int
init?(_ number: Int) {
guard (1 ... Month.all.count).contains(number) else {
return nil
}
self.number = number
}
init?(_ name: String) {
guard let index = Month.names.firstIndex(of: name) else {
return nil
}
self.number = index + 1
}
init(_ date: Date) {
self.init(date.month)!
}
var padded: String {
String(format: "%02d", number)
}
var name: String {
Month.names[number - 1]
}
var abbreviation: String {
String(name.prefix(3))
}
}
extension Month: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(number)
}
}
extension Month: Comparable {
static func <(lhs: Month, rhs: Month) -> Bool {
lhs.number < rhs.number
}
}
extension Month: ExpressibleByIntegerLiteral {
init(integerLiteral value: Int) {
guard let _ = Month(value) else {
fatalError("Invalid month number in string literal: \(value)")
}
self.init(value)!
}
}
extension Month: ExpressibleByStringLiteral {
init(stringLiteral value: String) {
guard let _ = Month(value) else {
fatalError("Invalid month name in string literal: \(value)")
}
self.init(value)!
}
}

View file

@ -1,30 +0,0 @@
//
// MonthPosts.swift
// samhuri.net
//
// Created by Sami Samhuri on 2020-01-01.
//
import Foundation
struct MonthPosts {
let month: Month
private(set) var posts: [Post]
let path: String
var title: String {
month.padded
}
var isEmpty: Bool {
posts.isEmpty
}
var year: Int {
posts[0].date.year
}
mutating func add(post: Post) {
posts.append(post)
}
}

View file

@ -1,43 +0,0 @@
//
// Post.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-01.
//
import Foundation
struct Post {
let slug: String
let title: String
let author: String
let date: Date
let formattedDate: String
let link: URL?
let tags: [String]
let scripts: [Script]
let styles: [Stylesheet]
let body: String
let excerpt: String
let path: String
var isLink: Bool {
link != nil
}
var templateAssets: TemplateAssets {
TemplateAssets(scripts: scripts, styles: styles)
}
}
extension Post: Comparable {
static func <(lhs: Self, rhs: Self) -> Bool {
lhs.date < rhs.date
}
}
extension Post: CustomDebugStringConvertible {
var debugDescription: String {
"<Post path=\(path) title=\"\(title)\" date=\"\(formattedDate)\" link=\(link?.absoluteString ?? "no")>"
}
}

View file

@ -1,46 +0,0 @@
//
// Posts.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-03.
//
import Foundation
struct PostsByYear {
private(set) var byYear: [Int: YearPosts]
let path: String
init(posts: [Post], path: String) {
byYear = [:]
self.path = path
posts.forEach { add(post: $0) }
}
var isEmpty: Bool {
byYear.isEmpty || byYear.values.allSatisfy { $0.isEmpty }
}
var years: [Int] {
Array(byYear.keys)
}
subscript(year: Int) -> YearPosts {
get {
byYear[year, default: YearPosts(year: year, byMonth: [:], path: "\(path)/\(year)")]
}
set {
byYear[year] = newValue
}
}
mutating func add(post: Post) {
let (year, month) = (post.date.year, Month(post.date))
self[year].add(post: post, to: month)
}
/// Returns an array of all posts.
func flattened() -> [Post] {
byYear.values.flatMap { $0.flattened() }
}
}

View file

@ -1,12 +0,0 @@
//
// Script.swift
// samhuri.net
//
// Created by Sami Samhuri on 2020-01-02.
//
import Foundation
struct Script: HTMLRef, Equatable {
let ref: String
}

View file

@ -1,12 +0,0 @@
//
// Stylesheet.swift
// samhuri.net
//
// Created by Sami Samhuri on 2020-01-02.
//
import Foundation
struct Stylesheet: HTMLRef, Equatable {
let ref: String
}

View file

@ -1,44 +0,0 @@
//
// YearPosts.swift
// samhuri.net
//
// Created by Sami Samhuri on 2020-01-01.
//
import Foundation
struct YearPosts {
let year: Int
var byMonth: [Month: MonthPosts]
let path: String
var title: String {
"\(year)"
}
var isEmpty: Bool {
byMonth.isEmpty || byMonth.values.allSatisfy { $0.isEmpty }
}
var months: [Month] {
Array(byMonth.keys)
}
subscript(month: Month) -> MonthPosts {
get {
byMonth[month, default: MonthPosts(month: month, posts: [], path: "\(path)/\(month.padded)")]
}
set {
byMonth[month] = newValue
}
}
mutating func add(post: Post, to month: Month) {
self[month].add(post: post)
}
/// Returns an array of all posts.
func flattened() -> [Post] {
byMonth.values.flatMap { $0.posts }
}
}

View file

@ -1,52 +0,0 @@
//
// PostMetadata.swift
// samhuri.net
//
// Created by Sami Samhuri on 2020-01-01.
//
import Foundation
struct PostMetadata {
let title: String
let author: String
let date: Date
let formattedDate: String
let link: URL?
let tags: [String]
let scripts: [Script]
let styles: [Stylesheet]
}
extension PostMetadata {
enum Error: Swift.Error {
case deficientMetadata(slug: String, missingKeys: [String], metadata: [String: String])
case invalidTimestamp(String?)
}
nonisolated(unsafe) private static let iso8601Formatter = ISO8601DateFormatter()
init(dictionary: [String: String], slug: String) throws {
let requiredKeys = ["Title", "Author", "Date", "Timestamp"]
let missingKeys = requiredKeys.filter { dictionary[$0] == nil }
guard missingKeys.isEmpty else {
throw Error.deficientMetadata(slug: slug, missingKeys: missingKeys, metadata: dictionary)
}
guard let timestamp = dictionary["Timestamp"],
let date = Self.iso8601Formatter.date(from: timestamp)
else {
throw Error.invalidTimestamp(dictionary["Timestamp"])
}
self.init(
title: dictionary["Title"]!,
author: dictionary["Author"]!,
date: date,
formattedDate: dictionary["Date"]!,
link: dictionary["Link"].flatMap { URL(string: $0) },
tags: dictionary.commaSeparatedList(key: "Tags"),
scripts: dictionary.commaSeparatedList(key: "Scripts").map(Script.init(ref:)),
styles: dictionary.commaSeparatedList(key: "Styles").map(Stylesheet.init(ref:))
)
}
}

View file

@ -1,148 +0,0 @@
//
// PostRepo.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-09.
//
import Foundation
import Ink
struct RawPost {
let slug: String
let markdown: String
private nonisolated(unsafe) static let StripMetadataRegex = try! Regex(#"---\n.*?---\n"#).dotMatchesNewlines()
private nonisolated(unsafe) static let TextifyParenthesesLinksRegex = try! Regex(#"\[([\w\s.-_]*)\]\([^)]+\)"#)
private nonisolated(unsafe) static let TextifyBracketLinksRegex = try! Regex(#"\[([\w\s.-_]*)\]\[[^\]]+\]"#)
private nonisolated(unsafe) static let StripImagesRegex = try! Regex(#"!\[[\w\s.-_]*\]\([^)]+\)"#)
private nonisolated(unsafe) static let WhitespaceRegex = try! Regex(#"\s+"#)
private nonisolated(unsafe) static let StripHTMLTagsRegex = try! Regex(#"<[^>]+>"#)
var excerpt: String {
markdown
.replacing(Self.StripMetadataRegex, with: "")
.replacing(Self.StripImagesRegex, with: "") // must be before links for linked images
.replacing(Self.TextifyParenthesesLinksRegex) { match in match.output[1].substring ?? "" }
.replacing(Self.TextifyBracketLinksRegex) { match in match.output[1].substring ?? "" }
.replacing(Self.StripHTMLTagsRegex, with: "")
.replacing(Self.WhitespaceRegex, with: " ")
.trimmingPrefix(Self.WhitespaceRegex)
.prefix(300)
+ "..."
}
}
final class PostRepo {
let postsPath = "posts"
let recentPostsCount = 10
let feedPostsCount = 30
let fileManager: FileManager
let markdownParser: MarkdownParser
private(set) var posts: PostsByYear!
init(fileManager: FileManager = .default, markdownParser: MarkdownParser = MarkdownParser()) {
self.fileManager = fileManager
self.markdownParser = markdownParser
}
var isEmpty: Bool {
posts == nil || posts.isEmpty
}
var sortedPosts: [Post] {
posts.flattened().sorted(by: >)
}
var recentPosts: [Post] {
Array(sortedPosts.prefix(recentPostsCount))
}
var postsForFeed: [Post] {
Array(sortedPosts.prefix(feedPostsCount))
}
func postDataExists(at sourceURL: URL) -> Bool {
let postsURL = sourceURL.appendingPathComponent(postsPath)
return fileManager.fileExists(atPath: postsURL.path)
}
func readPosts(sourceURL: URL, outputPath: String) throws {
let posts = try readRawPosts(sourceURL: sourceURL)
.map { try makePost(from: $0, outputPath: outputPath) }
self.posts = PostsByYear(posts: posts, path: "/\(outputPath)")
}
}
private extension PostRepo {
func makePost(from rawPost: RawPost, outputPath: String) throws -> Post {
let result = markdownParser.parse(rawPost.markdown)
let metadata = try PostMetadata(dictionary: result.metadata, slug: rawPost.slug)
let path = pathForPost(root: outputPath, date: metadata.date, slug: rawPost.slug)
return Post(
slug: rawPost.slug,
title: metadata.title,
author: metadata.author,
date: metadata.date,
formattedDate: metadata.formattedDate,
link: metadata.link,
tags: metadata.tags,
scripts: metadata.scripts,
styles: metadata.styles,
body: result.html,
excerpt: rawPost.excerpt,
path: path
)
}
func pathForPost(root: String, date: Date, slug: String) -> String {
// format: /{root}/{year}/{month}/{slug}
// e.g. /posts/2019/12/first-post
[
"", // leading slash
root,
"\(date.year)",
Month(date).padded,
slug,
].joined(separator: "/")
}
func readRawPosts(sourceURL: URL) throws -> [RawPost] {
let postsURL = sourceURL.appendingPathComponent(postsPath)
return try enumerateMarkdownFiles(directory: postsURL)
.compactMap { url in
do {
return try readRawPost(url: url)
}
catch {
print("error: Cannot read post from \(url): \(error)")
return nil
}
}
}
func readRawPost(url: URL) throws -> RawPost {
let slug = url.deletingPathExtension().lastPathComponent
let markdown = try String(contentsOf: url)
return RawPost(slug: slug, markdown: markdown)
}
func enumerateMarkdownFiles(directory: URL) throws -> [URL] {
return try fileManager.contentsOfDirectory(atPath: directory.path).flatMap { (name: String) -> [URL] in
let url = directory.appendingPathComponent(name)
if fileManager.directoryExists(at: url) {
return try enumerateMarkdownFiles(directory: url)
}
else {
return url.pathExtension == "md" ? [url] : []
}
}
}
}

View file

@ -1,86 +0,0 @@
//
// PostWriter.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-09.
//
import Foundation
final class PostWriter {
let fileWriter: FileWriting
let outputPath: String
init(outputPath: String = "posts", fileWriter: FileWriting = FileWriter()) {
self.fileWriter = fileWriter
self.outputPath = outputPath
}
}
// MARK: - Post pages
extension PostWriter {
func writePosts(_ posts: [Post], for site: Site, to targetURL: URL, with renderer: PostsRendering) throws {
for post in posts {
let path = [
outputPath,
postPath(date: post.date, slug: post.slug),
].joined(separator: "/")
let fileURL = targetURL.appending(path: path).appending(component: "index.html")
let postHTML = try renderer.renderPost(post, site: site, path: path)
try fileWriter.write(string: postHTML, to: fileURL)
}
}
private func postPath(date: Date, slug: String) -> String {
"\(date.year)/\(Month(date).padded)/\(slug)"
}
}
// MARK: - Recent posts page
extension PostWriter {
func writeRecentPosts(_ recentPosts: [Post], for site: Site, to targetURL: URL, with renderer: PostsRendering) throws {
let recentPostsHTML = try renderer.renderRecentPosts(recentPosts, site: site, path: "/")
let fileURL = targetURL.appendingPathComponent("index.html")
try fileWriter.write(string: recentPostsHTML, to: fileURL)
}
}
// MARK: - Post archive page
extension PostWriter {
func writeArchive(posts: PostsByYear, for site: Site, to targetURL: URL, with renderer: PostsRendering) throws {
let archiveHTML = try renderer.renderArchive(postsByYear: posts, site: site, path: outputPath)
let archiveURL = targetURL.appendingPathComponent(outputPath).appendingPathComponent("index.html")
try fileWriter.write(string: archiveHTML, to: archiveURL)
}
}
// MARK: - Yearly post index pages
extension PostWriter {
func writeYearIndexes(posts: PostsByYear, for site: Site, to targetURL: URL, with renderer: PostsRendering) throws {
for yearPosts in posts.byYear.values {
let yearDir = targetURL.appendingPathComponent(yearPosts.path)
let yearHTML = try renderer.renderYearPosts(yearPosts, site: site, path: yearPosts.path)
let yearURL = yearDir.appendingPathComponent("index.html")
try fileWriter.write(string: yearHTML, to: yearURL)
}
}
}
// MARK: - Monthly post roll-up pages
extension PostWriter {
func writeMonthRollups(posts: PostsByYear, for site: Site, to targetURL: URL, with renderer: PostsRendering) throws {
for yearPosts in posts.byYear.values {
for monthPosts in yearPosts.byMonth.values {
let monthDir = targetURL.appendingPathComponent(monthPosts.path)
let monthHTML = try renderer.renderMonthPosts(monthPosts, site: site, path: monthPosts.path)
let monthURL = monthDir.appendingPathComponent("index.html")
try fileWriter.write(string: monthHTML, to: monthURL)
}
}
}
}

View file

@ -1,81 +0,0 @@
//
// PostsPlugin+Builder.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-15.
//
import Foundation
extension PostsPlugin {
final class Builder {
private let renderer: Renderer
private var path: String?
private var jsonFeed: JSONFeed?
private var rssFeed: RSSFeed?
init(renderer: Renderer) {
self.renderer = renderer
}
func path(_ path: String) -> Self {
precondition(self.path == nil, "path is already defined")
self.path = path
return self
}
func jsonFeed(
path: String? = nil,
iconPath: String? = nil,
faviconPath: String? = nil
) -> Self {
precondition(jsonFeed == nil, "JSON feed is already defined")
jsonFeed = JSONFeed(
path: path ?? "feed.json",
iconPath: iconPath,
faviconPath: faviconPath
)
return self
}
func rssFeed(path: String? = nil) -> Self {
precondition(rssFeed == nil, "RSS feed is already defined")
rssFeed = RSSFeed(path: path ?? "feed.xml")
return self
}
func build() -> PostsPlugin {
let postWriter: PostWriter
if let outputPath = path {
postWriter = PostWriter(outputPath: outputPath)
}
else {
postWriter = PostWriter()
}
let jsonFeedWriter: JSONFeedWriter?
if let jsonFeed = jsonFeed {
jsonFeedWriter = JSONFeedWriter(jsonFeed: jsonFeed)
}
else {
jsonFeedWriter = nil
}
let rssFeedWriter: RSSFeedWriter?
if let rssFeed = rssFeed {
rssFeedWriter = RSSFeedWriter(rssFeed: rssFeed)
}
else {
rssFeedWriter = nil
}
return PostsPlugin(
renderer: renderer,
postRepo: PostRepo(),
postWriter: postWriter,
jsonFeedWriter: jsonFeedWriter,
rssFeedWriter: rssFeedWriter
)
}
}
}

View file

@ -1,56 +0,0 @@
//
// PostsPlugin.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-03.
//
import Foundation
final class PostsPlugin: Plugin {
typealias Renderer = PostsRendering & JSONFeedRendering & RSSFeedRendering
let renderer: Renderer
let postRepo: PostRepo
let postWriter: PostWriter
let jsonFeedWriter: JSONFeedWriter?
let rssFeedWriter: RSSFeedWriter?
init(
renderer: Renderer,
postRepo: PostRepo = PostRepo(),
postWriter: PostWriter = PostWriter(),
jsonFeedWriter: JSONFeedWriter?,
rssFeedWriter: RSSFeedWriter?
) {
self.renderer = renderer
self.postRepo = postRepo
self.postWriter = postWriter
self.jsonFeedWriter = jsonFeedWriter
self.rssFeedWriter = rssFeedWriter
}
// MARK: - Plugin methods
func setUp(site: Site, sourceURL: URL) throws {
guard postRepo.postDataExists(at: sourceURL) else {
return
}
try postRepo.readPosts(sourceURL: sourceURL, outputPath: postWriter.outputPath)
}
func render(site: Site, targetURL: URL) throws {
guard !postRepo.isEmpty else {
return
}
try postWriter.writeRecentPosts(postRepo.recentPosts, for: site, to: targetURL, with: renderer)
try postWriter.writePosts(postRepo.sortedPosts, for: site, to: targetURL, with: renderer)
try postWriter.writeArchive(posts: postRepo.posts, for: site, to: targetURL, with: renderer)
try postWriter.writeYearIndexes(posts: postRepo.posts, for: site, to: targetURL, with: renderer)
try postWriter.writeMonthRollups(posts: postRepo.posts, for: site, to: targetURL, with: renderer)
try jsonFeedWriter?.writeFeed(site: site, posts: postRepo.postsForFeed, to: targetURL, with: renderer)
try rssFeedWriter?.writeFeed(site: site, posts: postRepo.postsForFeed, to: targetURL, with: renderer)
}
}

View file

@ -1,20 +0,0 @@
//
// PostsRendering.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-17.
//
import Foundation
protocol PostsRendering {
func renderArchive(postsByYear: PostsByYear, site: Site, path: String) throws -> String
func renderYearPosts(_ yearPosts: YearPosts, site: Site, path: String) throws -> String
func renderMonthPosts(_ posts: MonthPosts, site: Site, path: String) throws -> String
func renderPost(_ post: Post, site: Site, path: String) throws -> String
func renderRecentPosts(_ posts: [Post], site: Site, path: String) throws -> String
}

View file

@ -1,12 +0,0 @@
//
// RSSFeed.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-15.
//
import Foundation
struct RSSFeed {
let path: String
}

View file

@ -1,29 +0,0 @@
//
// RSSFeedWriter.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-10.
//
import Foundation
protocol RSSFeedRendering {
func renderRSSFeed(posts: [Post], feedURL: URL, site: Site) throws -> String
}
final class RSSFeedWriter {
let fileWriter: FileWriting
let rssFeed: RSSFeed
init(rssFeed: RSSFeed, fileWriter: FileWriting = FileWriter()) {
self.rssFeed = rssFeed
self.fileWriter = fileWriter
}
func writeFeed(site: Site, posts: [Post], to targetURL: URL, with renderer: RSSFeedRendering) throws {
let feedURL = site.url.appendingPathComponent(rssFeed.path)
let feedXML = try renderer.renderRSSFeed(posts: posts, feedURL: feedURL, site: site)
let feedFileURL = targetURL.appendingPathComponent(rssFeed.path)
try fileWriter.write(string: feedXML, to: feedFileURL)
}
}

View file

@ -1,30 +0,0 @@
//
// FeedPostTemplate.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-22.
//
import Foundation
import Plot
private extension Node where Context == HTML.BodyContext {
static func link(_ attributes: Attribute<HTML.LinkContext>...) -> Self {
.element(named: "link", attributes: attributes)
}
}
extension Node where Context == HTML.BodyContext {
static func feedPost(_ post: Post, url: URL, styles: [URL]) -> Self {
.group([
.group(styles.map { style in
.link(.rel(.stylesheet), .href(style), .type("text/css"))
}),
.div(
.p(.class("time"), .text(post.formattedDate)),
.raw(post.body),
.p(.a(.class("permalink"), .href(url), ""))
),
])
}
}

View file

@ -1,22 +0,0 @@
//
// MonthPostsTemplate.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-22.
//
import Foundation
import Plot
extension Node where Context == HTML.BodyContext {
static func monthPosts(_ posts: MonthPosts) -> Self {
.group([
.div(.class("container"),
.h1("\(posts.month.name) \(posts.year)")
),
.group(posts.posts.sorted(by: >).map { post in
.div(.class("container"), self.post(post))
})
])
}
}

View file

@ -1,26 +0,0 @@
//
// PageRenderer+JSONFeed.swift
// samhuri.net
//
// Created by Sami Samhuri on 2020-01-01.
//
import Foundation
import Plot
extension PageRenderer: JSONFeedRendering {
func renderJSONFeedPost(_ post: Post, site: Site) throws -> String {
let url = site.url.appendingPathComponent(post.path)
let context = SiteContext(
site: site,
canonicalURL: url,
subtitle: post.title,
templateAssets: post.templateAssets
)
// Turn relative URLs into absolute ones.
return Node.feedPost(post, url: url, styles: context.styles)
.render(indentedBy: .spaces(2))
.replacingOccurrences(of: "href=\"/", with: "href=\"\(site.url)/")
.replacingOccurrences(of: "src=\"/", with: "src=\"\(site.url)/")
}
}

View file

@ -1,70 +0,0 @@
//
// PageRenderer+Posts.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-22.
//
import Foundation
import Plot
extension PageRenderer: PostsRendering {
func renderArchive(postsByYear: PostsByYear, site: Site, path: String) throws -> String {
let context = SiteContext(
site: site,
canonicalURL: site.url.appending(path: path),
subtitle: "Archive",
description: "Archive of all posts"
)
return render(.archive(postsByYear), context: context)
}
func renderYearPosts(_ yearPosts: YearPosts, site: Site, path: String) throws -> String {
let context = SiteContext(
site: site,
canonicalURL: site.url.appending(path: path),
subtitle: yearPosts.title,
description: "Archive of all posts from \(yearPosts.year)",
pageType: "article"
)
return render(.yearPosts(yearPosts), context: context)
}
func renderMonthPosts(_ posts: MonthPosts, site: Site, path: String) throws -> String {
let subtitle = "\(posts.month.name) \(posts.year)"
let assets = posts.posts.templateAssets
let context = SiteContext(
site: site,
canonicalURL: site.url.appending(path: path),
subtitle: subtitle,
description: "Archive of all posts from \(subtitle)",
pageType: "article",
templateAssets: assets
)
return render(.monthPosts(posts), context: context)
}
func renderPost(_ post: Post, site: Site, path: String) throws -> String {
let context = SiteContext(
site: site,
canonicalURL: site.url.appending(path: path),
subtitle: post.title,
description: post.excerpt,
pageType: "article",
templateAssets: post.templateAssets
)
return render(.post(post, articleClass: "container"), context: context)
}
func renderRecentPosts(_ posts: [Post], site: Site, path: String) throws -> String {
let context = SiteContext(
site: site,
canonicalURL: site.url.appending(path: path),
subtitle: nil,
description: "Recent posts",
pageType: "article",
templateAssets: posts.templateAssets
)
return render(.recentPosts(posts), context: context)
}
}

View file

@ -1,32 +0,0 @@
//
// PageRenderer+RSSFeed.swift
// samhuri.net
//
// Created by Sami Samhuri on 2020-01-01.
//
import Foundation
import Plot
extension PageRenderer: RSSFeedRendering {
func renderRSSFeed(posts: [Post], feedURL: URL, site: Site) throws -> String {
try RSS(
.title(site.title),
.description(site.description),
.link(site.url),
.pubDate(posts[0].date),
.atomLink(feedURL),
.group(posts.map { post in
let url = site.url.appendingPathComponent(post.path)
return .item(
.title(post.isLink ? "\(post.title)" : post.title),
.pubDate(post.date),
.element(named: "author", text: post.author),
.link(url),
.guid(.text(url.absoluteString), .isPermaLink(true)),
.content(try renderJSONFeedPost(post, site: site))
)
})
).render(indentedBy: .spaces(2))
}
}

View file

@ -1,34 +0,0 @@
//
// PostTemplate.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-22.
//
import Foundation
import Plot
extension Node where Context == HTML.BodyContext {
static func post(_ post: Post, articleClass: String = "") -> Self {
.group([
.article(.class(articleClass),
.header(
.h2(postTitleLink(post)),
.time(.text(post.formattedDate)),
.a(.class("permalink"), .href(post.path), "")
),
.raw(post.body)
),
.div(.class("row clearfix"),
.p(.class("fin"), Icons.code())
)
])
}
static func postTitleLink(_ post: Post) -> Self {
.if(post.isLink,
.a(.href(post.link?.absoluteString ?? post.path), "\(post.title)"),
else: .a(.href(post.path), .text(post.title))
)
}
}

View file

@ -1,22 +0,0 @@
//
// ArchiveTemplate.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-21.
//
import Foundation
import Plot
extension Node where Context == HTML.BodyContext {
static func archive(_ postsByYear: PostsByYear) -> Self {
.group([
.div(.class("container"),
.h1("Archive")
),
.group(postsByYear.years.sorted(by: >).map { year in
.yearPosts(postsByYear[year])
}),
])
}
}

View file

@ -1,17 +0,0 @@
//
// PostsAssets.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-31.
//
import Foundation
extension Collection where Element == Post {
var templateAssets: TemplateAssets {
reduce(into: TemplateAssets.empty()) { assets, post in
assets.scripts.append(contentsOf: post.scripts)
assets.styles.append(contentsOf: post.styles)
}
}
}

View file

@ -1,17 +0,0 @@
//
// RecentPostsTemplate.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-22.
//
import Foundation
import Plot
extension Node where Context == HTML.BodyContext {
static func recentPosts(_ posts: [Post]) -> Self {
.div(.class("container"),
.group(posts.map { self.post($0) })
)
}
}

View file

@ -1,51 +0,0 @@
//
// YearPostsTemplate.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-21.
//
import Foundation
import Plot
extension Node where Context == HTML.BodyContext {
static func yearPosts(_ posts: YearPosts) -> Self {
.div(.class("container"),
.h2(.class("year"),
.a(.href(posts.path), .text(posts.title))
),
.group(posts.months.sorted(by: >).map { month in
.monthTitles(posts[month])
})
)
}
static func monthTitles(_ posts: MonthPosts) -> Self {
.group([
.h3(.class("month"),
.a(.href(posts.path), "\(posts.month.name)")
),
.ul(.class("archive"),
.group(posts.posts.sorted(by: >).map { post in
.postItem(post, date: "\(post.date.day) \(posts.month.abbreviation)")
})
),
])
}
}
extension Node where Context == HTML.ListContext {
static func postItem(_ post: Post, date: Node<HTML.TimeContext>) -> Self {
.if(post.isLink, .li(
.a(.href(post.link?.absoluteString ?? post.path), "\(post.title)"),
.time(date),
.a(.class("permalink"), .href(post.path), "")
),
else: .li(
.a(.href(post.path), .text(post.title)),
.time(date)
)
)
}
}

View file

@ -1,14 +0,0 @@
//
// Project.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-02.
//
import Foundation
struct Project {
let title: String
let description: String
let url: URL
}

View file

@ -1,51 +0,0 @@
//
// ProjectsPlugin+Builder.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-19.
//
import Foundation
extension ProjectsPlugin {
final class Builder {
let renderer: ProjectsRenderer
private var path: String?
private var projects: [PartialProject] = []
private var assets: TemplateAssets?
init(renderer: ProjectsRenderer) {
self.renderer = renderer
}
func path(_ path: String) -> Self {
precondition(self.path == nil, "path is already defined")
self.path = path
return self
}
func assets(_ assets: TemplateAssets) -> Self {
precondition(self.assets == nil, "assets are already defined")
self.assets = assets
return self
}
func add(_ title: String, description: String) -> Self {
let project = PartialProject(title: title, description: description)
projects.append(project)
return self
}
func build() -> ProjectsPlugin {
if projects.isEmpty {
print("WARNING: No projects have been added")
}
return ProjectsPlugin(
projects: projects,
renderer: renderer,
projectAssets: assets ?? .empty(),
outputPath: path
)
}
}
}

View file

@ -1,67 +0,0 @@
//
// ProjectsPlugin.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-02.
//
import Foundation
struct PartialProject {
let title: String
let description: String
}
final class ProjectsPlugin: Plugin {
let fileWriter: FileWriting
let outputPath: String
let partialProjects: [PartialProject]
let renderer: ProjectsRenderer
let projectAssets: TemplateAssets
var projects: [Project] = []
init(
projects: [PartialProject],
renderer: ProjectsRenderer,
projectAssets: TemplateAssets,
outputPath: String? = nil,
fileWriter: FileWriting = FileWriter()
) {
self.partialProjects = projects
self.renderer = renderer
self.projectAssets = projectAssets
self.outputPath = outputPath ?? "projects"
self.fileWriter = fileWriter
}
// MARK: - Plugin methods
func setUp(site: Site, sourceURL: URL) throws {
projects = partialProjects.map { partial in
Project(
title: partial.title,
description: partial.description,
url: site.url.appendingPathComponent("\(outputPath)/\(partial.title)")
)
}
}
func render(site: Site, targetURL: URL) throws {
guard !projects.isEmpty else {
return
}
let projectsDir = targetURL.appendingPathComponent(outputPath)
let projectsURL = projectsDir.appendingPathComponent("index.html")
let projectsHTML = try renderer.renderProjects(projects, site: site, path: outputPath)
try fileWriter.write(string: projectsHTML, to: projectsURL)
for project in projects {
let projectURL = projectsDir.appendingPathComponent("\(project.title)/index.html")
let path = [outputPath, project.title].joined(separator: "/")
let projectHTML = try renderer.renderProject(project, site: site, path: path, assets: projectAssets)
try fileWriter.write(string: projectHTML, to: projectURL)
}
}
}

View file

@ -1,14 +0,0 @@
//
// ProjectsTemplateRenderer.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-17.
//
import Foundation
protocol ProjectsRenderer {
func renderProjects(_ projects: [Project], site: Site, path: String) throws -> String
func renderProject(_ project: Project, site: Site, path: String, assets: TemplateAssets) throws -> String
}

View file

@ -1,33 +0,0 @@
//
// PageRenderer+Projects.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-22.
//
import Foundation
import Plot
extension PageRenderer: ProjectsRenderer {
func renderProjects(_ projects: [Project], site: Site, path: String) throws -> String {
let context = SiteContext(
site: site,
canonicalURL: site.url.appending(path: path),
subtitle: "Projects",
templateAssets: .empty()
)
return render(.projects(projects), context: context)
}
func renderProject(_ project: Project, site: Site, path: String, assets: TemplateAssets) throws -> String {
let projectContext = ProjectContext(project: project, site: site, templateAssets: assets)
let context = SiteContext(
site: site,
canonicalURL: site.url.appending(path: path),
subtitle: project.title,
description: project.description,
templateAssets: assets
)
return render(.project(projectContext), context: context)
}
}

View file

@ -1,35 +0,0 @@
//
// ProjectContext.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-19.
//
import Foundation
struct ProjectContext: TemplateContext {
let site: Site
let title: String
let canonicalURL: URL
let description: String
let pageType = "website"
let githubURL: URL
let templateAssets: TemplateAssets
init(project: Project, site: Site, templateAssets: TemplateAssets) {
self.site = site
self.title = project.title
self.canonicalURL = site.url.appending(components: "projects", project.title)
self.description = project.description
self.githubURL = URL(string: "https://github.com/samsonjs/\(title)")!
self.templateAssets = templateAssets
}
var stargazersURL: URL {
githubURL.appendingPathComponent("stargazers")
}
var networkURL: URL {
githubURL.appendingPathComponent("network/members")
}
}

View file

@ -1,51 +0,0 @@
//
// ProjectTemplate.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-19.
//
import Foundation
import Plot
extension Node where Context == HTML.BodyContext {
static func project(_ context: ProjectContext) -> Self {
.group([
.article(.class("container project"),
// projects.js picks up this data-title attribute and uses it to render all the Github stuff
.h1(.id("project"), .data(named: "title", value: context.title), .text(context.title)),
.h4(.text(context.description)),
.div(.class("project-stats"),
.p(
.a(.href(context.githubURL), "GitHub"),
"",
.a(.id("nstar"), .href(context.stargazersURL)),
"",
.a(.id("nfork"), .href(context.networkURL))
),
.p("Last updated on ", .span(.id("updated")))
),
.div(.class("project-info row clearfix"),
.div(.class("column half"),
.h3("Contributors"),
.div(.id("contributors"))
),
.div(.class("column half"),
.h3("Languages"),
.div(.id("langs"))
)
)
),
.div(.class("row clearfix"),
.p(.class("fin"), Icons.code())
),
.group(context.scripts.map { url in
.script(.attribute(named: "defer"), .src(url))
})
])
}
}

View file

@ -1,30 +0,0 @@
//
// ProjectsTemplate.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-19.
//
import Foundation
import Plot
extension Node where Context == HTML.BodyContext {
static func projects(_ projects: [Project]) -> Self {
.group([
.article(.class("container"),
.h1("Projects"),
.group(projects.map { project in
.div(.class("project-listing"),
.h4(.a(.href(project.url), .text(project.title))),
.p(.class("description"), .text(project.description))
)
})
),
.div(.class("row clearfix"),
.p(.class("fin"), Icons.code())
)
])
}
}

View file

@ -1,54 +0,0 @@
//
// MarkdownRenderer.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-02.
//
import Foundation
import Ink
final class MarkdownRenderer: Renderer {
let fileWriter: FileWriting
let markdownParser = MarkdownParser()
let pageRenderer: PageRendering
init(pageRenderer: PageRendering, fileWriter: FileWriting = FileWriter()) {
self.pageRenderer = pageRenderer
self.fileWriter = fileWriter
}
func canRenderFile(named filename: String, withExtension ext: String?) -> Bool {
ext == "md"
}
/// Parse Markdown and render it as HTML, running it through a Stencil template.
func render(site: Site, fileURL: URL, targetDir: URL) throws {
let metadata = try markdownMetadata(from: fileURL)
let mdFilename = fileURL.lastPathComponent
let showExtension = mdFilename == "index.md" || metadata["Show extension"]?.lowercased() == "yes"
let htmlPath: String = if showExtension {
mdFilename.replacingOccurrences(of: ".md", with: ".html")
}
else {
mdFilename.replacingOccurrences(of: ".md", with: "/index.html")
}
let bodyMarkdown = try String(contentsOf: fileURL)
let bodyHTML = markdownParser.html(from: bodyMarkdown).trimmingCharacters(in: .whitespacesAndNewlines)
let url = site.url.appending(path: htmlPath.replacingOccurrences(of: "/index.html", with: ""))
let pageHTML = try pageRenderer.renderPage(
site: site,
url: url,
bodyHTML: bodyHTML,
metadata: metadata
)
let htmlURL = targetDir.appendingPathComponent(htmlPath)
try fileWriter.write(string: pageHTML, to: htmlURL)
}
func markdownMetadata(from url: URL) throws -> [String: String] {
let md = try String(contentsOf: url)
return markdownParser.parse(md).metadata
}
}

View file

@ -1,12 +0,0 @@
//
// PageRendering.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-03.
//
import Foundation
protocol PageRendering {
func renderPage(site: Site, url: URL, bodyHTML: String, metadata: [String: String]) throws -> String
}

View file

@ -1,14 +0,0 @@
//
// Plugin.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-02.
//
import Foundation
protocol Plugin {
func setUp(site: Site, sourceURL: URL) throws
func render(site: Site, targetURL: URL) throws
}

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