mirror of
https://github.com/samsonjs/samhuri.net.git
synced 2026-03-25 09:05:47 +00:00
Migrate repository to integrated Ruby site generator
This commit is contained in:
parent
cb42616c91
commit
d67d1488b9
122 changed files with 101 additions and 3851 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,3 +1,2 @@
|
|||
www
|
||||
Tests/*/actual
|
||||
bin/gensite
|
||||
|
|
|
|||
51
Makefile
51
Makefile
|
|
@ -1,51 +0,0 @@
|
|||
all: debug
|
||||
|
||||
debug:
|
||||
@echo
|
||||
bin/build-gensite
|
||||
bin/gensite . www http://localhost:8000
|
||||
|
||||
mudge:
|
||||
@echo
|
||||
bin/build-gensite
|
||||
bin/gensite . www http://mudge:8000
|
||||
|
||||
beta: clean_blog
|
||||
@echo
|
||||
bin/build-gensite
|
||||
bin/gensite . www https://beta.samhuri.net
|
||||
|
||||
release: clean_blog
|
||||
@echo
|
||||
bin/build-gensite
|
||||
bin/gensite . www
|
||||
|
||||
publish: release
|
||||
@echo
|
||||
bin/publish --delete www/
|
||||
|
||||
publish_beta: beta
|
||||
@echo
|
||||
bin/publish --beta --delete www/
|
||||
|
||||
clean: clean_blog
|
||||
|
||||
clean_blog:
|
||||
@echo
|
||||
rm -rf www/* www/.htaccess
|
||||
|
||||
clean_swift:
|
||||
@echo
|
||||
rm -rf gensite/.build
|
||||
rm -rf $(HOME)/Library/Developer/Xcode/DerivedData/gensite-*
|
||||
rm -rf samhuri.net/.build
|
||||
rm -rf $(HOME)/Library/Developer/Xcode/DerivedData/samhuri-*
|
||||
|
||||
serve:
|
||||
@echo
|
||||
cd www && python3 -m http.server --bind localhost
|
||||
|
||||
watch:
|
||||
bin/watch
|
||||
|
||||
.PHONY: debug beta release publish publish_beta clean clean_blog clean_swift serve watch
|
||||
268
Readme.md
268
Readme.md
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 \
|
||||
build-essential \
|
||||
git \
|
||||
gnupg2 \
|
||||
libc6-dev \
|
||||
libcurl4 \
|
||||
libedit2 \
|
||||
libgcc-s1 \
|
||||
libpython3.12 \
|
||||
libsqlite3-0 \
|
||||
libstdc++-14-dev \
|
||||
libxml2 \
|
||||
libz3-dev \
|
||||
inotify-tools \
|
||||
libffi-dev \
|
||||
libyaml-dev \
|
||||
pkg-config \
|
||||
tzdata \
|
||||
uuid-dev \
|
||||
zlib1g-dev
|
||||
|
||||
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
|
||||
|
||||
echo "*** installing inotify-tools for watch script"
|
||||
sudo apt install -y inotify-tools
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
13
bin/watch
13
bin/watch
|
|
@ -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
5
gensite/.gitignore
vendored
|
|
@ -1,5 +0,0 @@
|
|||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
/.swiftpm
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
{
|
||||
"object": {
|
||||
"pins": [
|
||||
{
|
||||
"package": "Ink",
|
||||
"repositoryURL": "https://github.com/johnsundell/ink.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "bcc9f219900a62c4210e6db726035d7f03ae757b",
|
||||
"version": "0.6.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "Plot",
|
||||
"repositoryURL": "https://github.com/johnsundell/plot.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "271926b4413fe868739d99f5eadcf2bd6cd62fb8",
|
||||
"version": "0.14.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"version": 1
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
// swift-tools-version:6.1
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "gensite",
|
||||
platforms: [
|
||||
.macOS(.v14),
|
||||
.iOS(.v17),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../samhuri.net"),
|
||||
],
|
||||
targets: [
|
||||
.executableTarget( name: "gensite", dependencies: [
|
||||
"samhuri.net",
|
||||
]),
|
||||
.testTarget(name: "gensiteTests", dependencies: ["gensite"]),
|
||||
]
|
||||
)
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
# gensite
|
||||
|
||||
A binary to build [samhuri.net](https://samhuri.net) using SiteGenerator.
|
||||
|
||||
See https://github.com/samsonjs/samhuri.net for details.
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
//
|
||||
// main.swift
|
||||
// gensite
|
||||
//
|
||||
// Created by Sami Samhuri on 2019-12-01.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import samhuri_net
|
||||
|
||||
guard CommandLine.arguments.count >= 3 else {
|
||||
let name = CommandLine.arguments[0]
|
||||
FileHandle.standardError.write("Usage: \(name) <site dir> <target dir>\n".data(using: .utf8)!)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
let sourcePath = CommandLine.arguments[1]
|
||||
var isDir: ObjCBool = false
|
||||
let sourceExists = FileManager.default.fileExists(atPath: sourcePath, isDirectory: &isDir)
|
||||
guard sourceExists, isDir.boolValue else {
|
||||
FileHandle.standardError.write("error: Site path \(sourcePath) does not exist or is not a directory\n".data(using: .utf8)!)
|
||||
exit(2)
|
||||
}
|
||||
|
||||
let targetPath = CommandLine.arguments[2]
|
||||
|
||||
let siteURLOverride: URL?
|
||||
if CommandLine.argc > 3, CommandLine.arguments[3].isEmpty == false {
|
||||
let urlString = CommandLine.arguments[3]
|
||||
guard let url = URL(string: urlString) else {
|
||||
FileHandle.standardError.write("error: invalid site URL \(urlString)\n".data(using: .utf8)!)
|
||||
exit(4)
|
||||
}
|
||||
siteURLOverride = url
|
||||
}
|
||||
else {
|
||||
siteURLOverride = nil
|
||||
}
|
||||
|
||||
do {
|
||||
let sourceURL = URL(fileURLWithPath: sourcePath)
|
||||
let targetURL = URL(fileURLWithPath: targetPath)
|
||||
let site = samhuri.net(siteURLOverride: siteURLOverride)
|
||||
try site.generate(sourceURL: sourceURL, targetURL: targetURL)
|
||||
exit(0)
|
||||
}
|
||||
catch {
|
||||
FileHandle.standardError.write("error: \(error)\n".data(using: .utf8)!)
|
||||
exit(-1)
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
@testable import gensite
|
||||
import Testing
|
||||
|
||||
struct gensiteTests {
|
||||
@Test func example() {
|
||||
#expect(true)
|
||||
}
|
||||
}
|
||||
191
pressa/README.md
191
pressa/README.md
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
6
samhuri.net/.gitignore
vendored
6
samhuri.net/.gitignore
vendored
|
|
@ -1,6 +0,0 @@
|
|||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
/.swiftpm
|
||||
xcuserdata/
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"]),
|
||||
]
|
||||
)
|
||||
|
|
@ -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.
|
||||
|
|
@ -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!
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)!
|
||||
}
|
||||
}
|
||||
|
|
@ -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?
|
||||
}
|
||||
|
|
@ -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]
|
||||
}
|
||||
|
|
@ -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:")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)!
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")>"
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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:))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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] : []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
//
|
||||
// RSSFeed.swift
|
||||
// samhuri.net
|
||||
//
|
||||
// Created by Sami Samhuri on 2019-12-15.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct RSSFeed {
|
||||
let path: String
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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), "∞"))
|
||||
),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
})
|
||||
])
|
||||
}
|
||||
}
|
||||
|
|
@ -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)/")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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])
|
||||
}),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) })
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
})
|
||||
])
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
)
|
||||
])
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue