Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
www
gemini
.publish.lock
14 changes: 14 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
This repository is a Ruby static-site generator (Pressa) that outputs both HTML and Gemini formats.

- Generator code: `lib/pressa/` (entrypoint: `lib/pressa.rb`)
- Web app: `lib/pressa/web/` (Sinatra app, jobs, drafts, preview) with templates and assets in `web/`
- Publish scripts: `bin/post-link`, `bin/publish-draft`, `bin/preview-link`, sharing `bin/lib/common.sh`
- Build/publish/draft tasks: `bake.rb` (delegating to helpers under `lib/pressa/`)
- Tests: `test/`
- Site config: `site.toml`, `projects.toml`
Expand All @@ -23,6 +25,7 @@ Keep new code under the existing `Pressa` module structure (for example `lib/pre
- `bin/bootstrap`: install prerequisites and gems via `rv`.
- `bundle exec bake debug`: build HTML for `http://localhost:8000` into `www/`.
- `bundle exec bake serve`: serve `www/` via WEBrick on port 8000.
- `bundle exec bake web`: run the Pressa web app on `http://localhost:1112`.
- `bundle exec bake watch target=debug`: Linux-only autorebuild loop (`inotifywait` required).
- `bundle exec bake mudge|beta|release`: build HTML with environment-specific base URLs.
- `bundle exec bake gemini`: build Gemini capsule into `gemini/`.
Expand All @@ -41,6 +44,15 @@ Keep new code under the existing `Pressa` module structure (for example `lib/pre
- `bundle exec bake new_draft "Post Title"` creates `public/drafts/<slug>.md`.
- `bundle exec bake drafts` lists available drafts.
- `bundle exec bake publish_draft public/drafts/<slug>.md` moves draft to `posts/YYYY/MM/` and updates `Date` and `Timestamp`.
- `bin/publish-draft <slug>` does the whole thing on the publish host: commit pending edits, pull, `bake publish_draft`, commit, push, `bake publish`.

## Web App
`lib/pressa/web/` is a Sinatra app that owns posting a link, drafts, and preview. It runs on mudge as `pressa-web.service` on `127.0.0.1:1112`, published by Caddy at `http://mudge:7777` and restricted to Tailscale source IPs, and is served by `web/bin/start` (puma, threads only).

- It drives `bin/post-link` and `bin/publish-draft` rather than reimplementing the publish flow, and renders previews through `Posts::PostWriter#post_html` and `Posts::GeminiWriter#post_content` — the same code the build uses.
- Publishing runs inline via `Web::JobRunner`, which drives the script and collects its output for the page. A publish measures about five seconds, most of it the two GitHub round trips, so there is nothing for a queue to do.
- Only one publish may touch the checkout at a time. The `flock` in `bin/lib/common.sh` enforces that across processes, so the phone Shortcut over SSH and the web app can't collide; a blocked publish exits 75 (EX_TEMPFAIL) and the app turns that into a 409 telling you to try again.
- Sinatra and puma live in this repo's Gemfile on purpose. A separate `web/Gemfile` would leave `BUNDLE_GEMFILE` pointing at the wrong one inside `bin/post-link`'s `bundle exec bake`.

## Content and Metadata Requirements
Posts must include YAML front matter. Required keys (enforced by `Pressa::Posts::PostMetadata`) are:
Expand All @@ -61,6 +73,7 @@ Optional keys include `Tags`, `Link`, `Scripts`, and `Styles`.

## Testing Guidelines
- Use Minitest under `test/` (for example `test/posts`, `test/config`, `test/views`).
- `test/bin/publish_scripts_test.rb` runs `bin/post-link` and `bin/publish-draft` for real, against a temporary git repo with a real remote and a stubbed `bake.rb`. It's the slowest file in the suite (~3s of the ~6s total) and it's there because git sequencing bugs in those scripts are invisible to everything else.
- Add regression tests for parser, rendering, feed, and generator behavior changes.
- Before submitting, run:
- `bundle exec bake test`
Expand All @@ -76,6 +89,7 @@ Optional keys include `Tags`, `Link`, `Scripts`, and `Styles`.

## Deployment & Security Notes
- Publish tasks are defined in `bake.rb` via rsync over SSH.
- The web app is deployed from the `mudge.samhuri.net` repo (`config/systemd/pressa-web.service` and the Caddy vhost); the code lives here.
- Current publish host is `mudge` with:
- production HTML: `/var/www/samhuri.net/public`
- beta HTML: `/var/www/beta.samhuri.net/public`
Expand Down
10 changes: 10 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,18 @@ gem "dry-struct", "~> 1.8"
gem "builder", "~> 3.3"
gem "bake", "~> 0.20"

# The Pressa web app under web/. It shares this bundle with bake on purpose:
# it shells out to bin/post-link, which runs `bundle exec bake`, and a separate
# Gemfile would leave BUNDLE_GEMFILE pointing at the wrong one in the child.
group :web do
gem "sinatra", "~> 4.1"
gem "puma", "~> 7.2"
gem "super_good-csrf_protection", "~> 0.2"
end

group :development, :test do
gem "guard", "~> 2.18"
gem "minitest", "~> 6.0"
gem "rack-test", "~> 2.2"
gem "standard", "~> 1.52"
end
28 changes: 28 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ GEM
bake (0.25.0)
bigdecimal
samovar (~> 2.1)
base64 (0.3.0)
bigdecimal (4.1.2)
builder (3.3.0)
coderay (1.1.3)
Expand Down Expand Up @@ -81,7 +82,9 @@ GEM
minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
mustermann (3.1.1)
nenv (0.3.0)
nio4r (2.7.5)
notiffany (0.1.3)
nenv (~> 0.1)
shellany (~> 0.0)
Expand All @@ -97,7 +100,19 @@ GEM
coderay (~> 1.1)
method_source (~> 1.0)
reline (>= 0.6.0)
puma (7.2.1)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.2.7)
rack-protection (4.2.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4)
rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rainbow (3.1.1)
rb-fsevent (0.11.2)
rb-inotify (0.11.1)
Expand Down Expand Up @@ -133,6 +148,13 @@ GEM
samovar (2.5.1)
console (~> 1.0)
shellany (0.0.1)
sinatra (4.2.1)
logger (>= 1.6.0)
mustermann (~> 3.0)
rack (>= 3.0.0, < 4)
rack-protection (= 4.2.1)
rack-session (>= 2.0.0, < 3)
tilt (~> 2.0)
standard (1.55.0)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.0)
Expand All @@ -146,7 +168,9 @@ GEM
lint_roller (~> 1.1)
rubocop-performance (~> 1.26.0)
strscan (3.1.8)
super_good-csrf_protection (0.2.0)
thor (1.5.0)
tilt (2.9.0)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.2.0)
Expand All @@ -173,8 +197,12 @@ DEPENDENCIES
kramdown-parser-gfm (~> 1.1)
minitest (~> 6.0)
phlex (~> 2.3)
puma (~> 7.2)
rack-test (~> 2.2)
rouge (~> 5.0)
sinatra (~> 4.1)
standard (~> 1.52)
super_good-csrf_protection (~> 0.2)

RUBY VERSION
ruby 4.0.5
Expand Down
46 changes: 21 additions & 25 deletions bake.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
$LOAD_PATH.unshift(LIB_PATH) unless $LOAD_PATH.include?(LIB_PATH)

require "pressa/drafts"
require "pressa/drafts/publisher"
require "pressa/link_post"
require "pressa/open_graph"
require "pressa/config/simple_toml"
Expand All @@ -33,7 +34,7 @@
GEMINI_PUBLISH_DIR = "/var/gemini/samhuri.net".freeze
STATIC_PUBLISH_DIR = "/var/www/static.samhuri.net/public".freeze
WATCHABLE_DIRECTORIES = %w[public posts lib].freeze
LINT_TARGETS = %w[bake.rb Gemfile lib test].freeze
LINT_TARGETS = %w[bake.rb Gemfile lib test web/config.ru].freeze
BUILD_TARGETS = %w[debug mudge beta release gemini].freeze

# Generate the site in debug mode (localhost:8000)
Expand Down Expand Up @@ -61,6 +62,12 @@ def gemini
build("https://samhuri.net", output_format: "gemini", target_path: "gemini")
end

# Run the Pressa web app locally.
# @parameter port [String] Port to bind on 127.0.0.1 (default: 1112).
def web(port: "1112")
exec("bundle", "exec", "puma", "-t", "0:16", "-b", "tcp://127.0.0.1:#{port}", "web/config.ru")
end

# Start local development server
def serve
require "webrick"
Expand Down Expand Up @@ -180,44 +187,33 @@ def new_draft(title)
puts content
end

# Publish a draft by moving it to posts/YYYY/MM and updating dates.
# Publish a draft by moving it to posts/YYYY/MM and updating dates. Prints the
# published post's path on stdout and progress on stderr, so bin/publish-draft
# can capture the path without parsing prose.
# @parameter input_path [String] Draft path or filename in public/drafts.
def publish_draft(input_path)
drafts = Pressa::Drafts.new(dir: DRAFTS_DIR)
if input_path.strip.empty?
puts "Usage: bake publish_draft <draft-path-or-filename>"
puts
puts "Available drafts:"
warn "Usage: bake publish_draft <draft-path-or-filename>"
warn ""
warn "Available drafts:"
available = Dir.glob("#{DRAFTS_DIR}/*.md").map { |path| File.basename(path) }
if available.empty?
puts " (no drafts found)"
warn " (no drafts found)"
else
available.each { |draft| puts " #{draft}" }
available.each { |draft| warn " #{draft}" }
end
abort
end

draft_path_value, draft_file =
result =
begin
drafts.resolve_input(input_path)
rescue Pressa::Drafts::Error => e
Pressa::Drafts::Publisher.new(drafts_dir: DRAFTS_DIR).publish(input_path)
rescue Pressa::Drafts::Publisher::Error => e
abort "Error: #{e.message}"
end
abort "Error: File not found: #{draft_path_value}" unless File.exist?(draft_path_value)

now = Time.now
content = File.read(draft_path_value)
content.sub!(/^Date:.*$/, "Date: #{Pressa::Drafts.ordinal_date(now)}")
content.sub!(/^Timestamp:.*$/, "Timestamp: #{now.strftime("%Y-%m-%dT%H:%M:%S%:z")}")

target_dir = "posts/#{now.strftime("%Y/%m")}"
FileUtils.mkdir_p(target_dir)
target_path = "#{target_dir}/#{draft_file}"

File.write(target_path, content)
FileUtils.rm_f(draft_path_value)

puts "Published draft: #{draft_path_value} -> #{target_path}"
warn "Published draft: #{result.draft_path} -> #{result.target_path}"
puts result.target_path
end

# Watch content directories and rebuild on every change.
Expand Down
48 changes: 48 additions & 0 deletions bin/lib/common.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Shared setup for the scripts that write to this checkout. Source it, don't
# run it.

# Non-interactive SSH sessions get a bare PATH and skip .profile, so rv is
# never reachable. Source .profile (which already puts rv on PATH via
# ~/.cargo/env) rather than guessing rv's install location, then load its
# Ruby env explicitly since that's only wired into interactive shells via
# .bashrc.
[ -f "$HOME/.profile" ] && . "$HOME/.profile"
eval "$(rv shell env bash)"

# Run from the repo root regardless of where the calling script lives.
REPO="${SAMHURI_REPO:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
cd "$REPO"

# Local clones here name the GitHub remote "github"; a fresh clone on the server
# names it "origin". Prefer github, fall back to origin, unless overridden.
REMOTE="${SAMHURI_REMOTE:-$(git remote | grep -qx github && echo github || echo origin)}"
BRANCH="${SAMHURI_BRANCH:-main}"

# Serialise everything that writes to the checkout. The phone Shortcut over SSH
# and the Pressa web app both end up in these scripts, and two publishes at once
# against one git repo would corrupt something. Exit 75 (EX_TEMPFAIL) says "try
# again shortly" rather than "that failed".
acquire_publish_lock() {
local lock_file="${SAMHURI_LOCK_FILE:-$REPO/.publish.lock}"

if ! command -v flock >/dev/null 2>&1; then
echo "==> flock unavailable, running without the publish lock" >&2
return 0
fi

exec 9>"$lock_file"
if ! flock -n 9; then
echo "Error: another publish is already running, try again shortly" >&2
exit 75
fi
}

read_payload() {
local payload
payload="$(cat)"
if [ -z "${payload//[[:space:]]/}" ]; then
echo "Error: empty payload on stdin" >&2
exit 1
fi
printf '%s' "$payload"
}
34 changes: 8 additions & 26 deletions bin/post-link
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
#!/usr/bin/env bash
#
# Create and publish a link post from a JSON payload on stdin, then deploy.
# Designed to be invoked over SSH from a phone Shortcut on the Tailscale network.
# The Shortcut base64-encodes the JSON (so quotes in the title/body can't break
# shell quoting) and the SSH command decodes it back onto our stdin:
# Driven by the Pressa web app on mudge, and directly over SSH from a phone
# Shortcut on the Tailscale network. The Shortcut base64-encodes the JSON (so
# quotes in the title/body can't break shell quoting) and the SSH command
# decodes it back onto our stdin:
#
# echo <base64> | base64 --decode | $HOME/samhuri.net/bin/post-link
#
Expand All @@ -20,29 +21,10 @@

set -euo pipefail

# Non-interactive SSH sessions get a bare PATH and skip .profile, so rv is
# never reachable. Source .profile (which already puts rv on PATH via
# ~/.cargo/env) rather than guessing rv's install location, then load its
# Ruby env explicitly since that's only wired into interactive shells via
# .bashrc.
[ -f "$HOME/.profile" ] && . "$HOME/.profile"
eval "$(rv shell env bash)"
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/common.sh"

# Run from the repo root regardless of where the script lives.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO="${SAMHURI_REPO:-$(cd "$SCRIPT_DIR/.." && pwd)}"
cd "$REPO"

# Local clones here name the GitHub remote "github"; a fresh clone on the server
# names it "origin". Prefer github, fall back to origin, unless overridden.
REMOTE="${SAMHURI_REMOTE:-$(git remote | grep -qx github && echo github || echo origin)}"
BRANCH="${SAMHURI_BRANCH:-main}"

payload="$(cat)"
if [ -z "${payload//[[:space:]]/}" ]; then
echo "Error: empty payload on stdin" >&2
exit 1
fi
payload="$(read_payload)"
acquire_publish_lock

echo "==> Pulling latest from $REMOTE/$BRANCH" >&2
git pull --ff-only "$REMOTE" "$BRANCH" >&2
Expand All @@ -65,5 +47,5 @@ echo "==> Building and publishing" >&2
SAMHURI_PUBLISH_HOST=local bundle exec bake publish >&2

echo "==> Published $post_path" >&2
# Stdout carries just the path so the caller (Shortcut) can show/use it.
# Stdout carries just the path so the caller (Shortcut, web app) can show/use it.
echo "$post_path"
26 changes: 7 additions & 19 deletions bin/preview-link
Original file line number Diff line number Diff line change
Expand Up @@ -11,29 +11,17 @@
# "body": "Optional commentary.", "tags": "gear, tech"}
# JSON
#
# Prints the preview URL on stdout, progress on stderr. Used by the
# dashboard's "Publish Beta" button.
# Prints the preview URL on stdout, progress on stderr. The Pressa web app
# renders its own preview in-process instead; this is the CLI/SSH equivalent,
# and the only one that shows the post inside a full site build.

set -euo pipefail

# Non-interactive SSH sessions get a bare PATH and skip .profile, so rv is
# never reachable. Source .profile (which already puts rv on PATH via
# ~/.cargo/env) rather than guessing rv's install location, then load its
# Ruby env explicitly since that's only wired into interactive shells via
# .bashrc.
[ -f "$HOME/.profile" ] && . "$HOME/.profile"
eval "$(rv shell env bash)"
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/common.sh"

# Run from the repo root regardless of where the script lives.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO="${SAMHURI_REPO:-$(cd "$SCRIPT_DIR/.." && pwd)}"
cd "$REPO"

payload="$(cat)"
if [ -z "${payload//[[:space:]]/}" ]; then
echo "Error: empty payload on stdin" >&2
exit 1
fi
payload="$(read_payload)"
# Writes into posts/ and rebuilds www/, so it takes the same lock as publishing.
acquire_publish_lock

echo "==> Building preview" >&2
# `bake mudge` logs its own build progress to stdout (not stderr), so only the
Expand Down
Loading
Loading