Update readme, actually render drafts, and add scripts to manage drafts

This commit is contained in:
Sami Samhuri 2025-06-06 12:11:01 -07:00
parent 9db609e8d8
commit eba6e2c12f
No known key found for this signature in database
8 changed files with 396 additions and 13 deletions

3
.zed/settings.json Normal file
View file

@ -0,0 +1,3 @@
{
"file_scan_exclusions": ["public/tweets/", "www", ".DS_Store", ".git"]
}

238
Readme.md
View file

@ -4,26 +4,23 @@ The source code for [samhuri.net](https://samhuri.net).
## Overview ## Overview
This is a custom static site generator written in Swift and geared towards blogging. As is tradition it gets a lot more attention than my actual blog. 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.
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: Some features:
- Plugin-based architecture, including plugins for rendering posts and projects
- Uses Markdown for posts, rendered using [Ink][] and [Plot][] by [@johnsundell][] - Uses Markdown for posts, rendered using [Ink][] and [Plot][] by [@johnsundell][]
- Supports the notion of a link post - Supports the notion of a link post
- Generates RSS and JSON feeds - Generates RSS and JSON feeds
- Generates an archive page that lists all posts - Runs on Linux and macOS, requires Swift 6.0+
- Generates listings for each year and month as well
- Runs on Linux and macOS
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 to render source files from the following directories: 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.
- drafts: flat directory of Markdown files that are rendered into `www/drafts/` 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).
- 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/`
The entry points to everything live in the Makefile and the bin/ directory so those are good starting points for exploration. I may or may not document anything else about this project as it's not really intended to be a reusable library. However you should be able to fork it and make it your own without doing a ton of work as I tried not to hardcode my personal info. 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.
If what you want is an artisinal, hand-crafted, static site generator for your personal blog then this might be a decent starting point.
[samhuri.net directory]: https://github.com/samsonjs/samhuri.net/tree/main/samhuri.net [samhuri.net directory]: https://github.com/samsonjs/samhuri.net/tree/main/samhuri.net
[gensite]: https://github.com/samsonjs/samhuri.net/tree/main/gensite [gensite]: https://github.com/samsonjs/samhuri.net/tree/main/gensite
@ -31,6 +28,225 @@ If what you want is an artisinal, hand-crafted, static site generator for your p
[Plot]: https://github.com/johnsundell/plot [Plot]: https://github.com/johnsundell/plot
[@johnsundell]: https://github.com/johnsundell [@johnsundell]: https://github.com/johnsundell
### Post format
Posts are formatted with Markdown, and require this front-matter (build will fail without these fields):
```
---
Title: What's Golden
Author: Chali 2na
Date: 5th June, 2025
Timestamp: 2025-06-05T09:41:42-07:00
Tags: Ruby, C, structs, interop
Link: https://example.net/chali-2na/whats-golden # For link posts
---
```
## Getting started
Clone this repo and build my blog:
```bash
git clone https://github.com/samsonjs/samhuri.net.git
cd samhuri.net
make debug
```
Start a local development server:
```bash
make serve # http://localhost:8000
make watch # Auto-rebuild on file changes (Linux only)
```
## 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
```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
```
## Customizing for your site
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]
}
```
There are `Renderer`s that plugins use to transform files, e.g. Markdown to HTML:
```swift
protocol Renderer {
func canRenderFile(named filename: String, withExtension ext: String?) -> Bool
func render(site: Site, fileURL: URL, targetDir: URL) throws
}
```
And this is the `Plugin` protocol:
```swift
protocol Plugin {
func setUp(site: Site, sourceURL: URL) throws
func render(site: Site, targetURL: URL) throws
}
```
Your site plus its renderers and plugins defines everything that it can do.
```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()
}
}
}
```
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.
[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 ## License
Released under the terms of the [MIT license](https://sjs.mit-license.org). Released under the terms of the [MIT license](https://sjs.mit-license.org).

94
bin/new-draft Executable file
View file

@ -0,0 +1,94 @@
#!/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__

70
bin/publish-draft Executable file
View file

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

View file

@ -12,12 +12,12 @@ public extension samhuri {
public func generate(sourceURL: URL, targetURL: URL) throws { public func generate(sourceURL: URL, targetURL: URL) throws {
let renderer = PageRenderer() let renderer = PageRenderer()
let site = buildSite(renderer: renderer) let site = makeSite(renderer: renderer)
let generator = try SiteGenerator(sourceURL: sourceURL, site: site) let generator = try SiteGenerator(sourceURL: sourceURL, site: site)
try generator.generate(targetURL: targetURL) try generator.generate(targetURL: targetURL)
} }
func buildSite(renderer: PageRenderer) -> Site { func makeSite(renderer: PageRenderer) -> Site {
let projectsPlugin = ProjectsPlugin.Builder(renderer: renderer) let projectsPlugin = ProjectsPlugin.Builder(renderer: renderer)
.path("projects") .path("projects")
.assets(TemplateAssets(scripts: [ .assets(TemplateAssets(scripts: [