From eba6e2c12f5fe84250664834002dcb21e4eacd19 Mon Sep 17 00:00:00 2001 From: Sami Samhuri Date: Fri, 6 Jun 2025 12:11:01 -0700 Subject: [PATCH] Update readme, actually render drafts, and add scripts to manage drafts --- .zed/settings.json | 3 + Readme.md | 238 +++++++++++++++++- bin/new-draft | 94 +++++++ bin/publish-draft | 70 ++++++ .../mach-o-symbol-and-relocation-tables.md | 0 ...-obscurity-is-still-not-a-best-practice.md | 0 .../drafts}/the-case-for-native.md | 0 .../Sources/samhuri.net/samhuri.net.swift | 4 +- 8 files changed, 396 insertions(+), 13 deletions(-) create mode 100644 .zed/settings.json create mode 100755 bin/new-draft create mode 100755 bin/publish-draft rename {drafts => public/drafts}/mach-o-symbol-and-relocation-tables.md (100%) rename {drafts => public/drafts}/security-through-obscurity-is-still-not-a-best-practice.md (100%) rename {drafts => public/drafts}/the-case-for-native.md (100%) diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 0000000..ca88c76 --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,3 @@ +{ + "file_scan_exclusions": ["public/tweets/", "www", ".DS_Store", ".git"] +} diff --git a/Readme.md b/Readme.md index 55c6e49..463ade3 100644 --- a/Readme.md +++ b/Readme.md @@ -4,26 +4,23 @@ The source code for [samhuri.net](https://samhuri.net). ## 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: +- 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 -- Generates an archive page that lists all posts -- Generates listings for each year and month as well -- Runs on Linux and macOS +- Runs on Linux and macOS, requires Swift 6.0+ -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: 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/` +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 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. - -If what you want is an artisinal, hand-crafted, static site generator for your personal blog then this might be a decent starting point. +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 @@ -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 [@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 Released under the terms of the [MIT license](https://sjs.mit-license.org). diff --git a/bin/new-draft b/bin/new-draft new file mode 100755 index 0000000..5e93aa7 --- /dev/null +++ b/bin/new-draft @@ -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__ diff --git a/bin/publish-draft b/bin/publish-draft new file mode 100755 index 0000000..b754287 --- /dev/null +++ b/bin/publish-draft @@ -0,0 +1,70 @@ +#!/usr/bin/env ruby -w + +require 'fileutils' + +def usage + puts "Usage: #{$0} " + 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}" diff --git a/drafts/mach-o-symbol-and-relocation-tables.md b/public/drafts/mach-o-symbol-and-relocation-tables.md similarity index 100% rename from drafts/mach-o-symbol-and-relocation-tables.md rename to public/drafts/mach-o-symbol-and-relocation-tables.md diff --git a/drafts/security-through-obscurity-is-still-not-a-best-practice.md b/public/drafts/security-through-obscurity-is-still-not-a-best-practice.md similarity index 100% rename from drafts/security-through-obscurity-is-still-not-a-best-practice.md rename to public/drafts/security-through-obscurity-is-still-not-a-best-practice.md diff --git a/drafts/the-case-for-native.md b/public/drafts/the-case-for-native.md similarity index 100% rename from drafts/the-case-for-native.md rename to public/drafts/the-case-for-native.md diff --git a/samhuri.net/Sources/samhuri.net/samhuri.net.swift b/samhuri.net/Sources/samhuri.net/samhuri.net.swift index 088cbf6..9ff8439 100644 --- a/samhuri.net/Sources/samhuri.net/samhuri.net.swift +++ b/samhuri.net/Sources/samhuri.net/samhuri.net.swift @@ -12,12 +12,12 @@ public extension samhuri { public func generate(sourceURL: URL, targetURL: URL) throws { let renderer = PageRenderer() - let site = buildSite(renderer: renderer) + let site = makeSite(renderer: renderer) let generator = try SiteGenerator(sourceURL: sourceURL, site: site) try generator.generate(targetURL: targetURL) } - func buildSite(renderer: PageRenderer) -> Site { + func makeSite(renderer: PageRenderer) -> Site { let projectsPlugin = ProjectsPlugin.Builder(renderer: renderer) .path("projects") .assets(TemplateAssets(scripts: [