diff --git a/.gitignore b/.gitignore index f6a50d58..fcb95dd5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ www gemini +.publish.lock diff --git a/AGENTS.md b/AGENTS.md index 0f7a3bcf..634557d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` @@ -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/`. @@ -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/.md`. - `bundle exec bake drafts` lists available drafts. - `bundle exec bake publish_draft public/drafts/.md` moves draft to `posts/YYYY/MM/` and updates `Date` and `Timestamp`. +- `bin/publish-draft ` 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: @@ -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` @@ -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` diff --git a/Gemfile b/Gemfile index 0e828e9c..aa59a39e 100644 --- a/Gemfile +++ b/Gemfile @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock index effdd11f..2c803697 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -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) @@ -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) @@ -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) @@ -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) @@ -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 diff --git a/bake.rb b/bake.rb index 1646f7b4..db347a4a 100644 --- a/bake.rb +++ b/bake.rb @@ -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" @@ -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) @@ -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" @@ -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 " - puts - puts "Available drafts:" + warn "Usage: bake publish_draft " + 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. diff --git a/bin/lib/common.sh b/bin/lib/common.sh new file mode 100644 index 00000000..d9fa5b51 --- /dev/null +++ b/bin/lib/common.sh @@ -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" +} diff --git a/bin/post-link b/bin/post-link index 12e5ce7d..dda1f255 100755 --- a/bin/post-link +++ b/bin/post-link @@ -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 --decode | $HOME/samhuri.net/bin/post-link # @@ -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 @@ -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" diff --git a/bin/preview-link b/bin/preview-link index de8b0ca3..c29c62ad 100755 --- a/bin/preview-link +++ b/bin/preview-link @@ -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 diff --git a/bin/publish-draft b/bin/publish-draft new file mode 100755 index 00000000..5983a4a7 --- /dev/null +++ b/bin/publish-draft @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# +# Publish a draft from public/drafts and deploy, the draft counterpart to +# bin/post-link. Takes the draft's filename (or slug) as its only argument: +# +# ssh mudge '$HOME/samhuri.net/bin/publish-draft tree-well-protocol' +# +# Pending edits to the draft are committed before the pull so the working tree +# is clean going in -- the web app writes drafts straight to the checkout, and +# `git pull --ff-only` is entitled to refuse otherwise. Then it moves the draft +# into posts/YYYY/MM via `bake publish_draft`, commits, pushes, and runs +# `bake publish` to build HTML + Gemini and rsync to the live directories. +# +# Prints the published post's path on stdout, progress on stderr. + +set -euo pipefail + +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/common.sh" + +draft="${1:-}" +if [ -z "$draft" ]; then + echo "Usage: bin/publish-draft " >&2 + exit 1 +fi +draft="$(basename "$draft" .md)" +draft_path="public/drafts/$draft.md" + +if [ ! -f "$draft_path" ]; then + echo "Error: no draft at $draft_path" >&2 + exit 1 +fi + +acquire_publish_lock + +# Commit just this draft, if it has uncommitted work: the web app writes drafts +# straight into the checkout, and `git pull --ff-only` won't run over a dirty +# tree. Other drafts are deliberately left alone -- publishing one is no reason +# to commit half-finished work on the others. --porcelain rather than +# `git diff` because a draft created in the web app is untracked, and git diff +# calls untracked files clean; that left it uncommitted here and then broke the +# `git add` below, once publish_draft had moved it out from under us. +if [ -n "$(git status --porcelain -- "$draft_path")" ]; then + echo "==> Committing pending edits to $draft" >&2 + git add -- "$draft_path" + git -c commit.gpgsign=false commit -m "Update draft: $draft" >&2 +fi + +echo "==> Pulling latest from $REMOTE/$BRANCH" >&2 +git pull --ff-only "$REMOTE" "$BRANCH" >&2 + +echo "==> Publishing draft $draft" >&2 +# publish_draft puts the published path on stdout and its progress on stderr. +post_path="$(bundle exec bake publish_draft "$draft_path")" + +echo "==> Committing and pushing" >&2 +git add "$post_path" "$draft_path" +# Skip commit signing: non-interactive SSH can't unlock a passphrased key. +git -c commit.gpgsign=false commit -m "Publish draft: $draft" >&2 +git push "$REMOTE" "HEAD:$BRANCH" >&2 + +echo "==> Building and publishing" >&2 +# Already on the publish host, so rsync locally rather than SSH back to self. +SAMHURI_PUBLISH_HOST=local bundle exec bake publish >&2 + +echo "==> Published $post_path" >&2 +echo "$post_path" diff --git a/lib/pressa/drafts/publisher.rb b/lib/pressa/drafts/publisher.rb new file mode 100644 index 00000000..d0b0c335 --- /dev/null +++ b/lib/pressa/drafts/publisher.rb @@ -0,0 +1,67 @@ +require "fileutils" +require "pressa/drafts" +require "pressa/posts/metadata" + +module Pressa + class Drafts + # Moves a draft out of the drafts directory and into posts/YYYY/MM, stamped + # with its publication date. + class Publisher + class Error < StandardError; end + + class NotFound < Error; end + + class Conflict < Error; end + + class Invalid < Error; end + + Result = Data.define(:draft_path, :target_path) + + def initialize(drafts_dir: Drafts::DEFAULT_DIR, posts_dir: "posts") + @drafts = Drafts.new(dir: drafts_dir) + @posts_dir = posts_dir + end + + def publish(input_path, now: Time.now) + draft_path, filename = resolve(input_path) + raise NotFound, "file not found: #{draft_path}" unless File.exist?(draft_path) + + content = self.class.rewrite(File.read(draft_path), now:) + target_path = File.join(@posts_dir, now.strftime("%Y/%m"), filename) + raise Conflict, "post already exists at #{target_path}" if File.exist?(target_path) + + FileUtils.mkdir_p(File.dirname(target_path)) + File.write(target_path, content) + FileUtils.rm_f(draft_path) + + Result.new(draft_path:, target_path:) + end + + # Stamps the draft with the date it's being published on, then checks the + # result against the same parser the build uses. bin/publish-draft commits + # and pushes before it builds, so a draft that can't become a valid post + # has to fail here rather than halfway through a deploy. + def self.rewrite(content, now:) + updated = content + .sub(/^Date:.*$/, "Date: #{Drafts.ordinal_date(now)}") + .sub(/^Timestamp:.*$/, "Timestamp: #{now.strftime("%Y-%m-%dT%H:%M:%S%:z")}") + + begin + Posts::PostMetadata.parse(updated) + rescue => e + raise Invalid, "draft cannot be published: #{e.message}" + end + + updated + end + + private + + def resolve(input_path) + @drafts.resolve_input(input_path) + rescue Drafts::Error => e + raise Error, e.message + end + end + end +end diff --git a/lib/pressa/open_graph.rb b/lib/pressa/open_graph.rb index 64133eed..3277e4f6 100644 --- a/lib/pressa/open_graph.rb +++ b/lib/pressa/open_graph.rb @@ -1,16 +1,24 @@ +require "cgi" require "net/http" require "uri" module Pressa - # Best-effort scraper for OpenGraph metadata on a linked page, used to fill - # in an Image for link posts. Never raises: network failures, timeouts, and - # missing tags all just resolve to a nil image so post creation never blocks - # on a flaky or slow third-party site. + # Best-effort scraper for OpenGraph metadata on a linked page: an Image for + # link posts, plus the title and description used to prefill the link form. + # Never raises: network failures, timeouts, and missing tags all just resolve + # to nil fields so post creation never blocks on a flaky or slow third-party + # site. class OpenGraph - Result = Data.define(:image) + Result = Data.define(:title, :description, :image) USER_AGENT = "samhuri.net-link-preview/1.0".freeze MAX_REDIRECTS = 5 + # One budget for the whole fetch, redirects included. Publishing blocks on + # this, and a per-hop timeout let a hanging server hold the request open for + # five seconds of connect plus five of read, six hops deep. The slowest real + # site measured 0.85s, so five seconds is generous. + TOTAL_TIMEOUT_SECONDS = 5 + TITLE_ELEMENT = /]*>([^<]*)<\/title>/i def self.fetch(url, http_get: method(:http_get)) html = http_get.call(url) @@ -22,10 +30,16 @@ def self.fetch(url, http_get: method(:http_get)) end def self.extract(html, base_url:) + title = text_field(html, "og:title") || clean(html[TITLE_ELEMENT, 1]) + description = text_field(html, "og:description") || text_field(html, "description") image = meta_content(html, "og:image") || meta_content(html, "twitter:image") - return nil if image.nil? + return nil if title.nil? && description.nil? && image.nil? - Result.new(image: resolve(image, base_url:)) + Result.new(title:, description:, image: image && resolve(image, base_url:)) + end + + def self.text_field(html, property) + clean(meta_content(html, property)) end def self.meta_content(html, property) @@ -37,19 +51,33 @@ def self.meta_content(html, property) content&.strip&.then { |value| value.empty? ? nil : value } end + def self.clean(value) + return nil if value.nil? + + unescaped = CGI.unescapeHTML(value).strip + unescaped.empty? ? nil : unescaped + end + def self.resolve(image, base_url:) URI.join(base_url, image).to_s rescue URI::InvalidURIError, URI::InvalidComponentError image end - def self.http_get(url, redirects_left: MAX_REDIRECTS) + def self.http_get(url, redirects_left: MAX_REDIRECTS, timeout: TOTAL_TIMEOUT_SECONDS, deadline: nil) return nil if redirects_left < 0 + deadline ||= monotonic_now + timeout + remaining = deadline - monotonic_now + return nil unless remaining > 0 + uri = URI.parse(url) return nil unless uri.is_a?(URI::HTTP) - Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 5) do |http| + Net::HTTP.start( + uri.host, uri.port, use_ssl: uri.scheme == "https", + open_timeout: remaining, read_timeout: remaining + ) do |http| response = http.get(uri.request_uri, "User-Agent" => USER_AGENT) case response @@ -59,9 +87,12 @@ def self.http_get(url, redirects_left: MAX_REDIRECTS) location = response["location"] return nil unless location - http_get(URI.join(url, location).to_s, redirects_left: redirects_left - 1) + # Redirects inherit the budget rather than getting a fresh one. + http_get(URI.join(url, location).to_s, redirects_left: redirects_left - 1, deadline:) end end end + + def self.monotonic_now = Process.clock_gettime(Process::CLOCK_MONOTONIC) end end diff --git a/lib/pressa/posts/gemini_writer.rb b/lib/pressa/posts/gemini_writer.rb index 2b4742e5..b14406ca 100644 --- a/lib/pressa/posts/gemini_writer.rb +++ b/lib/pressa/posts/gemini_writer.rb @@ -60,9 +60,10 @@ def write_posts_index(target_path:) Utils::FileWriter.write(path: File.join(target_path, "posts", "feed.gmi"), content:) end - private - - def write_post(post:, target_path:) + # The gemtext for one post, exactly as write_posts would write it. The + # preview endpoint renders drafts through this so what you see is what + # the capsule gets. + def post_content(post:) rows = ["# #{post.title}", "", "#{post.formatted_date} by #{post.author}", ""] if post.link_post? @@ -78,8 +79,14 @@ def write_post(post:, target_path:) rows << "=> #{web_url_for("#{post.path}/")} Read on the web" if include_web_link?(post) rows << "" + rows.join("\n") + end + + private + + def write_post(post:, target_path:) file_path = File.join(target_path, post.path.sub(%r{^/}, ""), "index.gmi") - Utils::FileWriter.write(path: file_path, content: rows.join("\n")) + Utils::FileWriter.write(path: file_path, content: post_content(post:)) end def post_link_line(post) diff --git a/lib/pressa/posts/repo.rb b/lib/pressa/posts/repo.rb index f9d03883..fa5b7ceb 100644 --- a/lib/pressa/posts/repo.rb +++ b/lib/pressa/posts/repo.rb @@ -1,7 +1,6 @@ -require "kramdown" require "pressa/posts/models" require "pressa/posts/metadata" -require "pressa/utils/rouge_html_formatter" +require "pressa/utils/markdown_renderer" module Pressa module Posts @@ -22,24 +21,13 @@ def read_posts(posts_dir) PostsByYear.new(by_year: @posts_by_year) end - private - - def enumerate_markdown_files(dir, &block) - Dir.glob(File.join(dir, "**", "*.md")).each(&block) - end - - def read_post(file_path) - content = File.read(file_path) + # Builds a Post from raw markdown, no file needed, so drafts and + # unsaved link posts can be rendered exactly the way the build renders + # published ones. + def build_post(content:, slug:) metadata = PostMetadata.parse(content) - body_markdown = content.sub(/\A---\s*\n.*?\n---\s*\n/m, "") - html_body = render_markdown(body_markdown) - - slug = File.basename(file_path, ".md") - path = generate_path(slug, metadata.date) - excerpt = generate_excerpt(body_markdown) - Post.new( slug:, title: metadata.title, @@ -49,25 +37,25 @@ def read_post(file_path) link: metadata.link, tags: metadata.tags, image: metadata.image, - body: html_body, + body: render_markdown(body_markdown), markdown_body: body_markdown, - excerpt:, - path: + excerpt: generate_excerpt(body_markdown), + path: generate_path(slug, metadata.date) ) end + private + + def enumerate_markdown_files(dir, &block) + Dir.glob(File.join(dir, "**", "*.md")).each(&block) + end + + def read_post(file_path) + build_post(content: File.read(file_path), slug: File.basename(file_path, ".md")) + end + def render_markdown(markdown) - Kramdown::Document.new( - markdown, - input: "GFM", - hard_wrap: false, - syntax_highlighter: "rouge", - syntax_highlighter_opts: { - line_numbers: false, - wrap: true, - formatter: Pressa::Utils::RougeHTMLFormatter - } - ).to_html + Utils::MarkdownRenderer.render_html(markdown) end def generate_path(slug, date) diff --git a/lib/pressa/posts/writer.rb b/lib/pressa/posts/writer.rb index ac4e6143..a1409e94 100644 --- a/lib/pressa/posts/writer.rb +++ b/lib/pressa/posts/writer.rb @@ -23,6 +23,24 @@ def write_posts(target_path:) end end + # The full HTML page for one post, exactly as write_posts would write it, + # so the preview endpoint shows the real page rather than an + # approximation of it. + def post_html(post:) + content_view = Views::PostView.new(post:, site: @site, article_class: "container") + + render_layout( + page_subtitle: post.title, + canonical_url: @site.url_for(post.path), + content: content_view, + page_description: post.excerpt, + page_type: "article", + page_image: post.image, + page_tags: post.tags, + page_published_time: post.date.iso8601 + ) + end + def write_recent_posts(target_path:, limit: 10) recent = @posts_by_year.recent_posts(limit) content_view = Views::RecentPostsView.new(posts: recent, site: @site) @@ -110,21 +128,8 @@ def write_tag_page(tag:, target_path:) end def write_post(post:, target_path:) - content_view = Views::PostView.new(post:, site: @site, article_class: "container") - - html = render_layout( - page_subtitle: post.title, - canonical_url: @site.url_for(post.path), - content: content_view, - page_description: post.excerpt, - page_type: "article", - page_image: post.image, - page_tags: post.tags, - page_published_time: post.date.iso8601 - ) - file_path = File.join(target_path, post.path.sub(/^\//, ""), "index.html") - Utils::FileWriter.write(path: file_path, content: html) + Utils::FileWriter.write(path: file_path, content: post_html(post:)) end def write_year_index(year:, year_posts:, target_path:) diff --git a/lib/pressa/utils/markdown_renderer.rb b/lib/pressa/utils/markdown_renderer.rb index 3b05b044..0e57b6b3 100644 --- a/lib/pressa/utils/markdown_renderer.rb +++ b/lib/pressa/utils/markdown_renderer.rb @@ -12,6 +12,22 @@ module Utils class MarkdownRenderer EXCERPT_LENGTH = 300 + # The one place markdown becomes HTML, shared with PostRepo and the + # preview endpoint so all three agree on GFM and syntax highlighting. + def self.render_html(markdown) + Kramdown::Document.new( + markdown, + input: "GFM", + hard_wrap: false, + syntax_highlighter: "rouge", + syntax_highlighter_opts: { + line_numbers: false, + wrap: true, + formatter: Pressa::Utils::RougeHTMLFormatter + } + ).to_html + end + def can_render_file?(filename:, extension:) extension == "md" end @@ -71,17 +87,7 @@ def parse_content(content) end def render_markdown(markdown) - Kramdown::Document.new( - markdown, - input: "GFM", - hard_wrap: false, - syntax_highlighter: "rouge", - syntax_highlighter_opts: { - line_numbers: false, - wrap: true, - formatter: Pressa::Utils::RougeHTMLFormatter - } - ).to_html + self.class.render_html(markdown) end def render_layout(site:, page_subtitle:, canonical_url:, body:, page_description:, page_type:) diff --git a/lib/pressa/web/app.rb b/lib/pressa/web/app.rb new file mode 100644 index 00000000..43009423 --- /dev/null +++ b/lib/pressa/web/app.rb @@ -0,0 +1,341 @@ +require "json" +require "sinatra/base" +require "super_good/csrf_protection" +require "pressa" +require "pressa/link_post" +require "pressa/open_graph" +require "pressa/posts/repo" +require "pressa/posts/tag_index" +require "pressa/web/draft_store" +require "pressa/web/job_runner" +require "pressa/web/preview" + +module Pressa + module Web + # Pressa's web front end, hosted on mudge behind Caddy on the tailnet. + # + # It owns posting a link, drafts, and preview, and it does so by driving + # the same bin/ scripts the phone Shortcut drives over SSH rather than + # reimplementing the flow. Publishing runs inline: it measures about five + # seconds, and the flock those scripts take is what stops two of them + # running at once, so there is nothing here for a job queue to do. + class App < Sinatra::Base + class ConfigurationError < StandardError; end + + WEB_ROOT = File.expand_path("../../../web", __dir__) + REPO_ROOT = File.expand_path("../../..", __dir__) + TAG_CHIP_LIMIT = 24 + # bin/post-link and bin/publish-draft take an flock, so a second publish + # exits 75 (EX_TEMPFAIL) instead of running. That's "try again shortly", + # not a failure. + BUSY_EXIT_STATUS = 75 + + # There are no cookies or sessions here, so what a malicious page would be + # riding isn't a login -- it's this machine's position on the tailnet. + # Sec-Fetch-* are forbidden header names, so page JavaScript can't forge + # them; a request without them didn't come from a browser and can't be the + # confused deputy a CSRF needs, which is what keeps curl and the phone + # Shortcut working. + use SuperGood::CSRFProtection + + # The middleware guards unsafe methods, which is the right default. It + # leaves GETs alone, but /link/metadata is a GET that makes this server + # fetch a URL the caller chose, so a cross-site page could use it to probe + # the tailnet blind. Rather than keep a second copy of the rule, ask the + # middleware how it would treat the request if it were a POST. + CROSS_ORIGIN_PROBE = SuperGood::CSRFProtection.new(->(_env) { [200, {}, []] }) + + # Rendering every post to count tags takes a beat, and the link form is + # the page that has to feel instant on a phone, so the chips are cached + # until a post is added or edited. A plain hash rather than a Sinatra + # setting: this is a mutable cache, not configuration, and `set` defines + # methods. + TAG_CACHE = {} + + # Built once at boot, by web/config.ru. Nothing builds them per request: + # it costs about six milliseconds, and the memoisation that saved those + # six milliseconds was the ugliest code in this file. + def self.configure_sites! + set :html_site, build_site("html") + set :gemini_site, build_site("gemini") + end + + def self.build_site(output_format) + Pressa.create_site(source_path: repo_root, url_override: site_url, output_format:) + end + + set :root, WEB_ROOT + set :views, File.join(WEB_ROOT, "views") + set :public_folder, File.join(WEB_ROOT, "public") + set :bind, ENV.fetch("BIND_ADDRESS", "127.0.0.1") + set :port, ENV.fetch("PORT", "1112") + set :host_authorization, {permitted_hosts: ["pressa", "mudge", "localhost", "127.0.0.1"]} + + set :repo_root, REPO_ROOT + set :site_url, ENV.fetch("PRESSA_SITE_URL", "https://samhuri.net") + set :link_scraper, OpenGraph + set :html_site, nil + set :gemini_site, nil + set :author, nil + + helpers do + def h(text) = Rack::Utils.escape_html(text.to_s) + + def repo_path(*parts) = File.join(settings.repo_root, *parts) + + def drafts = DraftStore.new(dir: repo_path("public", "drafts")) + + def preview_renderer = Preview.new(html_site:, gemini_site:) + + def html_site = settings.html_site || missing_site!(:html_site) + + def gemini_site = settings.gemini_site || missing_site!(:gemini_site) + + def missing_site!(key) + raise ConfigurationError, "#{key} was never set; web/config.ru builds it at boot" + end + + def author = settings.author || html_site.author + + # Rendering every post to count tags takes a beat, and the link form + # is the page you want instant on a phone, so it's cached until a post + # is added or edited. + def tag_chips + files = Dir.glob(repo_path("posts", "**", "*.md")) + key = [settings.repo_root, files.length, files.map { File.mtime(it) }.max] + return TAG_CACHE[:tags] if TAG_CACHE[:key] == key + + posts = Posts::PostRepo.new.read_posts(repo_path("posts")) + tags = Posts::TagIndex.from_posts_by_year(posts).counts.keys.first(TAG_CHIP_LIMIT) + TAG_CACHE[:key] = key + TAG_CACHE[:tags] = tags + tags + end + + def link_form + { + title: params[:title].to_s.strip, + link: params[:link].to_s.strip, + # Browsers normalize textarea line breaks to CRLF on submit, per + # the HTML spec, even though nothing here ever inserts one. + body: params[:body].to_s.gsub("\r\n", "\n").strip, + tags: normalize_tags(params[:tags]), + image: params[:image].to_s.strip + } + end + + def normalize_tags(value) + value.to_s.split(",").map { it.strip.downcase }.reject(&:empty?).join(", ") + end + + def link_post_source(form) + LinkPost.build( + title: form[:title], link: form[:link], body: form[:body], tags: form[:tags], + image: form[:image].empty? ? nil : form[:image], author: + ) + end + + # Runs one of the publish scripts inline. Returns nil when it worked, + # or the status to halt with when it didn't, leaving @error and @log + # for the page to render. + def run_publish(command:, stdin_data: nil) + @log = [] + @published = JobRunner.run(command:, stdin_data:, chdir: settings.repo_root) { @log << it } + nil + rescue JobRunner::Failed => e + busy = e.exit_status == BUSY_EXIT_STATUS + @error = busy ? "Something else is publishing right now. Try again in a moment." : e.message + busy ? 409 : 500 + end + + def cross_origin_request? + status, = CROSS_ORIGIN_PROBE.call(request.env.merge("REQUEST_METHOD" => "POST")) + status == 403 + end + + def json_error(status, message) = halt(status, {error: message}.to_json) + end + + # --- posting a link ---------------------------------------------------- + + get "/" do + @form = {} + @tags = tag_chips + @published = params[:published] + erb :link + end + + post "/link" do + @form = link_form + @tags = tag_chips + + if @form[:title].empty? || @form[:link].empty? + @error = "A URL and a title are both required." + halt 422, erb(:link) + end + + payload = @form.reject { |_key, value| value.to_s.empty? }.to_json + status = run_publish(command: [repo_path("bin", "post-link")], stdin_data: payload) + # A failure keeps the form and the log on screen so it can be retried. + halt status, erb(:link) if status + + # Success redirects so a reload can't publish the same post twice. + redirect to("/?published=#{Rack::Utils.escape(@published)}"), 303 + end + + get "/link/metadata" do + halt 403, "Forbidden" if cross_origin_request? + + content_type :json + url = params[:url].to_s.strip + json_error(400, "missing url") if url.empty? + + found = settings.link_scraper.fetch(url) + return "{}" unless found + + {title: found.title, description: found.description, image: found.image}.compact.to_json + end + + # --- preview ----------------------------------------------------------- + + post "/preview" do + content_type :json + + begin + source, slug = preview_source + result = preview_renderer.render(source, slug:) + rescue Preview::Error, LinkPost::Error => e + json_error(422, e.message) + end + + {title: result.title, html: result.html, gemtext: result.gemtext}.to_json + end + + # --- drafts ------------------------------------------------------------ + + get "/drafts" do + @drafts = drafts.list + @published = params[:published] + erb :drafts + end + + post "/drafts" do + slug = + begin + drafts.create(params[:title].to_s) + rescue DraftStore::Conflict => e + @drafts = drafts.list + @error = e.message + halt 409, erb(:drafts) + rescue DraftStore::InvalidTitle + @drafts = drafts.list + @error = "That title doesn't make a usable filename." + halt 422, erb(:drafts) + end + + redirect to("/drafts/#{slug}"), 303 + end + + get "/drafts/:slug" do + @slug = params[:slug] + @source = find_draft(@slug) + @version = drafts.version(@slug) + erb :draft + end + + # The editor holds raw markdown, so its own front matter is the only + # place a readable title can come from. + def self.draft_title(source, slug) + source[/^Title:\s*(.+)$/, 1]&.strip&.then { it.empty? ? nil : it } || slug + end + + post "/drafts/:slug" do + @slug = params[:slug] + find_draft(@slug) + @source = params[:source].to_s + @version = params[:version].to_s + + if @version.empty? + @error = "This form is out of date. Reload the page, then paste your text back in." + halt 422, erb(:draft) + end + + begin + drafts.write(@slug, @source, expected_version: @version) + rescue DraftStore::Stale => e + @version = e.current_version + @conflict = e.current_content + @error = "This draft changed on disk since you opened it. Your text is still below; " \ + "what's on disk now is underneath it." + halt 409, erb(:draft) + end + + redirect to("/drafts/#{@slug}"), 303 + end + + post "/drafts/:slug/publish" do + @slug = params[:slug] + @source = find_draft(@slug) + @version = drafts.version(@slug) + + status = run_publish(command: [repo_path("bin", "publish-draft"), @slug]) + halt status, erb(:draft) if status + + # The draft is gone now, so there's nothing left to edit -- and the + # redirect keeps a reload from trying to publish it again. + redirect to("/drafts?published=#{Rack::Utils.escape(@published)}"), 303 + end + + post "/drafts/:slug/delete" do + find_draft(params[:slug]) + drafts.delete(params[:slug]) + redirect to("/drafts"), 303 + end + + # --- odds and ends ----------------------------------------------------- + + get "/tags" do + content_type :json + tag_chips.to_json + end + + get "/up" do + "ok" + end + + not_found do + next if response.body.any? + + erb :not_found + end + + helpers do + def find_draft(slug) + drafts.read(slug) + rescue DraftStore::Error + halt 404, not_found_page("No draft named #{h(slug)}.") + end + + def not_found_page(message) + @message = message + erb :not_found + end + + def preview_source + raw = params[:source].to_s + unless raw.strip.empty? + return [raw.gsub("\r\n", "\n"), preview_slug] + end + + post = link_post_source(link_form) + [post.content, File.basename(post.filename, ".md")] + end + + def preview_slug + slug = params[:slug].to_s + slug.match?(DraftStore::SLUG_PATTERN) ? slug : "preview" + end + end + end + end +end diff --git a/lib/pressa/web/draft_store.rb b/lib/pressa/web/draft_store.rb new file mode 100644 index 00000000..9cc1bdf7 --- /dev/null +++ b/lib/pressa/web/draft_store.rb @@ -0,0 +1,108 @@ +require "digest" +require "fileutils" +require "pressa/drafts" +require "pressa/drafts/repo" + +module Pressa + module Web + # The drafts directory as the web app sees it: list, read, write, create, + # delete. Slugs arrive from URLs, so every path goes through the same + # validation before it reaches the filesystem. + class DraftStore + class Error < StandardError; end + + class NotFound < Error; end + + class Conflict < Error; end + + class InvalidSlug < Error; end + + class InvalidTitle < Error; end + + # Raised when the draft changed between the editor loading it and the + # save arriving. Carries what's on disk so nothing has to be lost. + class Stale < Error + attr_reader :current_content, :current_version + + def initialize(current_content:, current_version:) + @current_content = current_content + @current_version = current_version + super("the draft changed on disk since it was opened") + end + end + + # Exactly what Drafts.slugify produces, so nothing that could climb out + # of the drafts directory can name a file. + SLUG_PATTERN = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/ + + def initialize(dir: Drafts::DEFAULT_DIR) + @dir = dir + end + + def list + return [] unless Dir.exist?(@dir) + + Drafts::Repo.new(dir: @dir).read_entries + end + + def read(slug) + File.read(path(slug)) + rescue Errno::ENOENT + raise NotFound, "no draft named #{slug}" + end + + # A digest of the draft as it stands, handed to the editor and returned + # with the save so a second tab can't quietly overwrite the first. + def version(slug) + Digest::SHA256.hexdigest(read(slug)) + end + + def write(slug, content, expected_version:) + target = path(slug) + raise NotFound, "no draft named #{slug}" unless File.exist?(target) + + current = File.read(target) + current_version = Digest::SHA256.hexdigest(current) + unless expected_version == current_version + raise Stale.new(current_content: current, current_version:) + end + + # Browsers normalize textarea line breaks to CRLF on submit, per the + # HTML spec, even though nothing here ever inserts one. + File.write(target, content.gsub("\r\n", "\n")) + end + + def create(title, now: Time.now) + title = title.to_s.strip + slug = Drafts.slugify(title) + raise InvalidTitle, "title cannot be converted to a filename: #{title.inspect}" unless slug.match?(SLUG_PATTERN) + + target = File.join(@dir, "#{slug}.md") + raise Conflict, "a draft already exists at #{target}" if File.exist?(target) + + FileUtils.mkdir_p(@dir) + File.write(target, drafts.render_template(title, now:)) + slug + end + + def delete(slug) + target = path(slug) + raise NotFound, "no draft named #{slug}" unless File.exist?(target) + + FileUtils.rm_f(target) + end + + def path(slug) + raise InvalidSlug, "invalid draft name: #{slug.inspect}" unless slug.to_s.match?(SLUG_PATTERN) + + File.join(@dir, "#{slug}.md") + end + + private + + def drafts + @drafts ||= Drafts.new(dir: @dir) + end + end + end +end diff --git a/lib/pressa/web/job_runner.rb b/lib/pressa/web/job_runner.rb new file mode 100644 index 00000000..ec48f0ef --- /dev/null +++ b/lib/pressa/web/job_runner.rb @@ -0,0 +1,62 @@ +require "open3" + +module Pressa + module Web + # Runs one of the existing publish scripts and streams its progress. + # + # bin/post-link already knows how to pull, write, commit, push, build, and + # rsync; the web app runs that same script rather than a second copy of the + # flow. The scripts follow the convention that stderr is progress and + # stdout is the answer (the post path, the preview URL), so stderr lines + # are streamed to the caller as they arrive and stdout becomes the result. + module JobRunner + class Failed < StandardError + attr_reader :exit_status + + def initialize(message, exit_status:) + @exit_status = exit_status + super(message) + end + end + + # Yields each output line as it arrives and returns the command's stdout. + # Reads whole lines, which suits the line-oriented scripts it runs. + def self.run(command:, stdin_data: nil, chdir: nil, env: {}, &on_line) + stdout_lines = [] + log_tail = nil + + options = chdir ? {chdir: chdir} : {} + status = Open3.popen3(env, *command, **options) do |stdin, stdout, stderr, wait_thread| + stdin.write(stdin_data) if stdin_data + stdin.close + + open_streams = [stdout, stderr] + until open_streams.empty? + ready, = IO.select(open_streams) + ready.each do |stream| + line = stream.gets + if line.nil? + open_streams.delete(stream) + next + end + + line = line.chomp + stdout_lines << line if stream == stdout + log_tail = line unless line.strip.empty? + on_line&.call(line) + end + end + + wait_thread.value + end + + unless status.success? + raise Failed.new(log_tail || "#{command.first} exited with status #{status.exitstatus}", + exit_status: status.exitstatus) + end + + stdout_lines.join("\n").strip + end + end + end +end diff --git a/lib/pressa/web/preview.rb b/lib/pressa/web/preview.rb new file mode 100644 index 00000000..2c6e647e --- /dev/null +++ b/lib/pressa/web/preview.rb @@ -0,0 +1,53 @@ +require "pressa/posts/gemini_writer" +require "pressa/posts/models" +require "pressa/posts/repo" +require "pressa/posts/writer" + +module Pressa + module Web + # Renders one post's source as both a web page and a gemtext capsule page, + # through the same writers the build uses, so formatting problems show up + # before publishing rather than after. + class Preview + class Error < StandardError; end + + Result = Data.define(:title, :html, :gemtext) + + def initialize(html_site:, gemini_site:) + @html_site = html_site + @gemini_site = gemini_site + end + + def render(source, slug:) + post = + begin + Posts::PostRepo.new.build_post(content: source, slug:) + rescue => e + raise Error, e.message + end + + Result.new( + title: post.title, + html: html_writer.post_html(post:), + gemtext: gemini_writer.post_content(post:) + ) + end + + private + + # Neither post_html nor post_content consults the index, and a preview + # has no site to index anyway. + def empty_index + Posts::PostsByYear.new(by_year: {}) + end + + def html_writer + @html_writer ||= Posts::PostWriter.new(site: @html_site, posts_by_year: empty_index) + end + + def gemini_writer + @gemini_writer ||= Posts::GeminiWriter.new(site: @gemini_site, posts_by_year: empty_index) + end + end + end +end diff --git a/test/bin/publish_scripts_test.rb b/test/bin/publish_scripts_test.rb new file mode 100644 index 00000000..e31d581c --- /dev/null +++ b/test/bin/publish_scripts_test.rb @@ -0,0 +1,191 @@ +require "test_helper" +require "fileutils" +require "open3" +require "tmpdir" + +# The publish scripts are what the phone Shortcut runs over SSH and what the +# web app spawns, and they're the part of this repo that git bugs hide in: a +# `git diff` that called untracked drafts clean once made publishing a newly +# created draft fail every time, and nothing in the Ruby suite could see it. +# +# So these run the real scripts against a real repo with a real remote. Only +# bake is stubbed, since its tasks are unit tested on their own. +class PublishScriptsTest < Minitest::Test + SOURCE_REPO = File.expand_path("../..", __dir__) + + STUB_BAKE = <<~RUBY_SOURCE + require "fileutils" + + def new_link + File.write("last-payload.json", $stdin.read) + path = "posts/2026/08/stub-link.md" + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, "a link post\\n") + puts path + end + + def publish_draft(input_path) + name = File.basename(input_path) + path = File.join("posts/2026/08", name) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, File.read(input_path)) + FileUtils.rm_f(input_path) + warn "Published draft: \#{input_path} -> \#{path}" + puts path + end + + def publish + File.write("published.marker", "deployed") + warn "Site built successfully" + end + RUBY_SOURCE + + # Building the repo and its remote costs more than running the scripts, so + # it's built once and copied per test rather than rebuilt each time. + def self.template + @template ||= begin + root = Dir.mktmpdir("pressa-scripts-template") + at_exit { FileUtils.remove_entry(root) rescue nil } # rubocop:disable Style/RescueModifier + remote = File.join(root, "remote.git") + work = File.join(root, "work") + git = ->(*args, chdir: work) do + out, status = Open3.capture2e("git", *args, chdir:) + raise "git #{args.join(" ")} failed: #{out}" unless status.success? + end + + git.call("init", "-q", "--bare", remote, chdir: root) + git.call("clone", "-q", remote, work, chdir: root) + git.call("config", "user.email", "sami@example.net") + git.call("config", "user.name", "Sami Samhuri") + + FileUtils.cp_r(File.join(SOURCE_REPO, "bin"), work) + %w[Gemfile Gemfile.lock .ruby-version].each { FileUtils.cp(File.join(SOURCE_REPO, it), work) } + File.write(File.join(work, "bake.rb"), STUB_BAKE) + FileUtils.mkdir_p(File.join(work, "public/drafts")) + FileUtils.mkdir_p(File.join(work, "posts")) + + File.write(File.join(work, "README.md"), "seed\n") + git.call("add", "-A") + git.call("commit", "-qm", "seed") + git.call("branch", "-M", "main") + git.call("push", "-q", "origin", "main") + root + end + end + + def setup + skip "git is required" unless system("git", "--version", out: File::NULL, err: File::NULL) + + @root = Dir.mktmpdir + FileUtils.cp_r(File.join(self.class.template, "."), @root) + @remote = File.join(@root, "remote.git") + @work = File.join(@root, "work") + # The clone records an absolute path to the remote it came from. + git!("remote", "set-url", "origin", @remote) + end + + def teardown + FileUtils.remove_entry(@root) if @root + end + + def git!(*args, chdir: @work) + out, status = Open3.capture2e("git", *args, chdir:) + raise "git #{args.join(" ")} failed: #{out}" unless status.success? + out + end + + def run_script(name, *args, stdin: "") + Open3.capture3( + {"SAMHURI_REPO" => @work}, File.join(@work, "bin", name), *args, + stdin_data: stdin, chdir: @work + ) + end + + def write_draft(slug, title: "Tree Well Protocol", body: "Never ride alone.") + File.write(File.join(@work, "public/drafts/#{slug}.md"), <<~MARKDOWN) + --- + Author: Jane Doe + Title: #{title} + Date: unpublished + Timestamp: 2026-06-01T09:00:00-07:00 + --- + + #{body} + MARKDOWN + end + + def commit_subjects = git!("log", "--format=%s").lines.map(&:chomp) + + def pushed_subjects = git!("--git-dir", @remote, "log", "--format=%s", "main", chdir: @root).lines.map(&:chomp) + + # --- post-link ------------------------------------------------------------ + + def test_post_link_writes_commits_pushes_and_deploys + payload = %({"title":"Tree Well Protocol","link":"https://powder.example.net/x"}) + stdout, _stderr, status = run_script("post-link", stdin: payload) + + assert_predicate(status, :success?) + assert_equal("posts/2026/08/stub-link.md", stdout.strip) + assert_equal(payload, File.read(File.join(@work, "last-payload.json"))) + assert_includes(commit_subjects, "Add link post: stub-link") + assert_includes(pushed_subjects, "Add link post: stub-link") + assert(File.exist?(File.join(@work, "published.marker")), "should have run bake publish") + end + + def test_post_link_refuses_an_empty_payload + _stdout, stderr, status = run_script("post-link", stdin: " \n") + + refute_predicate(status, :success?) + assert_match(/empty payload/, stderr) + assert_equal(["seed"], commit_subjects) + end + + # --- publish-draft -------------------------------------------------------- + + def test_publish_draft_commits_an_untracked_draft_before_publishing_it + write_draft("tree-well-protocol") + stdout, _stderr, status = run_script("publish-draft", "tree-well-protocol") + + assert_predicate(status, :success?) + assert_equal("posts/2026/08/tree-well-protocol.md", stdout.strip) + assert_includes(commit_subjects, "Update draft: tree-well-protocol") + assert_includes(commit_subjects, "Publish draft: tree-well-protocol") + assert_includes(pushed_subjects, "Publish draft: tree-well-protocol") + refute(File.exist?(File.join(@work, "public/drafts/tree-well-protocol.md"))) + end + + def test_publish_draft_leaves_other_drafts_uncommitted + write_draft("other-draft", title: "Other") + git!("add", "public/drafts/other-draft.md") + git!("commit", "-qm", "add other draft") + File.write(File.join(@work, "public/drafts/other-draft.md"), "half-finished edit\n") + + write_draft("tree-well-protocol") + _stdout, _stderr, status = run_script("publish-draft", "tree-well-protocol") + + assert_predicate(status, :success?) + assert_equal("half-finished edit\n", File.read(File.join(@work, "public/drafts/other-draft.md"))) + refute_empty(git!("status", "--porcelain", "--", "public/drafts/other-draft.md")) + end + + def test_publish_draft_accepts_a_filename_as_well_as_a_slug + write_draft("tree-well-protocol") + _stdout, _stderr, status = run_script("publish-draft", "tree-well-protocol.md") + + assert_predicate(status, :success?) + end + + def test_publish_draft_refuses_a_draft_that_is_not_there + _stdout, stderr, status = run_script("publish-draft", "nope") + + refute_predicate(status, :success?) + assert_match(%r{no draft at public/drafts/nope\.md}, stderr) + end + + def test_publish_draft_needs_an_argument + _stdout, stderr, status = run_script("publish-draft") + + refute_predicate(status, :success?) + assert_match(/Usage:/, stderr) + end +end diff --git a/test/drafts/publisher_test.rb b/test/drafts/publisher_test.rb new file mode 100644 index 00000000..484f2bc4 --- /dev/null +++ b/test/drafts/publisher_test.rb @@ -0,0 +1,124 @@ +require "test_helper" +require "fileutils" +require "tmpdir" +require "pressa/drafts/publisher" +require "pressa/posts/metadata" + +class Pressa::Drafts::PublisherTest < Minitest::Test + def setup + @root = Dir.mktmpdir + @drafts_dir = File.join(@root, "public/drafts") + @posts_dir = File.join(@root, "posts") + FileUtils.mkdir_p(@drafts_dir) + @now = Time.new(2026, 6, 7, 14, 30, 0, "-07:00") + end + + def teardown + FileUtils.remove_entry(@root) + end + + def publisher = Pressa::Drafts::Publisher.new(drafts_dir: @drafts_dir, posts_dir: @posts_dir) + + def write_draft(slug = "tree-well-protocol", **overrides) + fields = { + "Author" => "Jane Doe", "Title" => "Tree Well Protocol", + "Date" => "unpublished", "Timestamp" => "2026-06-01T09:00:00-07:00", "Tags" => "snowboarding" + }.merge(overrides) + front = fields.compact.map { |key, value| "#{key}: #{value}" }.join("\n") + path = File.join(@drafts_dir, "#{slug}.md") + File.write(path, "---\n#{front}\n---\n\nNever ride alone in deep snow.\n") + path + end + + def test_publishes_the_draft_into_posts_by_year_and_month + write_draft + result = publisher.publish("tree-well-protocol.md", now: @now) + + assert_equal(File.join(@posts_dir, "2026/06/tree-well-protocol.md"), result.target_path) + assert(File.exist?(result.target_path)) + end + + def test_stamps_the_publication_date_and_timestamp + write_draft + result = publisher.publish("tree-well-protocol.md", now: @now) + metadata = Pressa::Posts::PostMetadata.parse(File.read(result.target_path)) + + assert_equal("7th June, 2026", metadata.formatted_date) + assert_equal("2026-06-07T14:30:00-07:00", metadata.date.strftime("%Y-%m-%dT%H:%M:%S%:z")) + end + + def test_keeps_the_rest_of_the_draft_intact + write_draft + result = publisher.publish("tree-well-protocol.md", now: @now) + published = File.read(result.target_path) + + assert_includes(published, "Never ride alone in deep snow.") + assert_includes(published, "Title: Tree Well Protocol") + assert_includes(published, "Tags: snowboarding") + end + + def test_removes_the_draft_once_published + draft_path = write_draft + result = publisher.publish("tree-well-protocol.md", now: @now) + + refute(File.exist?(draft_path)) + assert_equal(draft_path, result.draft_path) + end + + def test_accepts_a_full_path_as_well_as_a_bare_filename + write_draft + result = publisher.publish(File.join(@drafts_dir, "tree-well-protocol.md"), now: @now) + + assert(File.exist?(result.target_path)) + end + + def test_refuses_a_path_that_is_already_published + error = assert_raises(Pressa::Drafts::Publisher::Error) do + publisher.publish("posts/2026/06/tree-well-protocol.md", now: @now) + end + + assert_match(/already published/, error.message) + end + + def test_refuses_a_draft_that_is_not_there + assert_raises(Pressa::Drafts::Publisher::NotFound) { publisher.publish("nope.md", now: @now) } + end + + # bin/publish-draft commits and pushes before the build runs, so a draft that + # can't become a valid post has to fail here rather than at build time. + def test_refuses_a_draft_with_no_date_line + draft_path = write_draft("no-date", "Date" => nil) + + error = assert_raises(Pressa::Drafts::Publisher::Invalid) { publisher.publish("no-date.md", now: @now) } + + assert_match(/Date/, error.message) + assert(File.exist?(draft_path), "the draft should be left alone") + end + + def test_refuses_a_draft_with_no_timestamp_line + write_draft("no-timestamp", "Timestamp" => nil) + + error = assert_raises(Pressa::Drafts::Publisher::Invalid) { publisher.publish("no-timestamp.md", now: @now) } + + assert_match(/Timestamp/, error.message) + end + + def test_refuses_a_draft_with_no_front_matter + File.write(File.join(@drafts_dir, "bare.md"), "Just some notes.\n") + + assert_raises(Pressa::Drafts::Publisher::Invalid) { publisher.publish("bare.md", now: @now) } + end + + def test_refuses_to_overwrite_an_existing_post + write_draft + existing = File.join(@posts_dir, "2026/06/tree-well-protocol.md") + FileUtils.mkdir_p(File.dirname(existing)) + File.write(existing, "the original post\n") + + error = assert_raises(Pressa::Drafts::Publisher::Conflict) { publisher.publish("tree-well-protocol.md", now: @now) } + + assert_match(%r{posts/2026/06/tree-well-protocol\.md}, error.message) + assert_equal("the original post\n", File.read(existing)) + assert(File.exist?(File.join(@drafts_dir, "tree-well-protocol.md")), "the draft should be left alone") + end +end diff --git a/test/open_graph_test.rb b/test/open_graph_test.rb index b15656ba..32afa64f 100644 --- a/test/open_graph_test.rb +++ b/test/open_graph_test.rb @@ -1,5 +1,6 @@ require "test_helper" require "pressa/open_graph" +require "socket" class Pressa::OpenGraphTest < Minitest::Test def test_extract_returns_og_image_resolved_against_base_url @@ -27,10 +28,10 @@ def test_extract_falls_back_to_twitter_image assert_equal("https://cdn.example.net/tw.png", result.image) end - def test_extract_returns_nil_when_no_image_meta_present + def test_extract_leaves_image_nil_when_no_image_meta_present html = "No image here" - refute(Pressa::OpenGraph.extract(html, base_url: "https://example.net")) + assert_nil(Pressa::OpenGraph.extract(html, base_url: "https://example.net").image) end def test_extract_handles_single_quoted_attributes @@ -40,6 +41,47 @@ def test_extract_handles_single_quoted_attributes assert_equal("https://cdn.example.net/single.png", result.image) end + def test_extract_returns_og_title + html = %(ignored) + + result = Pressa::OpenGraph.extract(html, base_url: "https://trails.example.net") + assert_equal("Ride the Lightning Rail", result.title) + end + + def test_extract_falls_back_to_the_title_element + html = " Powder Day Protocol " + + result = Pressa::OpenGraph.extract(html, base_url: "https://powder.example.net") + assert_equal("Powder Day Protocol", result.title) + end + + def test_extract_returns_og_description + html = %() + + result = Pressa::OpenGraph.extract(html, base_url: "https://powder.example.net") + assert_equal("A field guide to tree wells.", result.description) + end + + def test_extract_falls_back_to_the_meta_description + html = %() + + result = Pressa::OpenGraph.extract(html, base_url: "https://powder.example.net") + assert_equal("Notes from the lift line.", result.description) + end + + def test_extract_unescapes_html_entities_in_text_fields + html = %() + + %() + + result = Pressa::OpenGraph.extract(html, base_url: "https://beats.example.net") + assert_equal("Bikes & Boards", result.title) + assert_equal("Trent's workshop", result.description) + end + + def test_extract_returns_nil_when_the_page_offers_nothing + refute(Pressa::OpenGraph.extract("hi", base_url: "https://example.net")) + end + def test_fetch_uses_injected_http_get_and_extracts_image html = %() result = Pressa::OpenGraph.fetch("https://example.net/post", http_get: ->(_url) { html }) @@ -58,4 +100,44 @@ def test_fetch_returns_nil_instead_of_raising_on_network_errors assert_nil(result) end + + # --- giving up ------------------------------------------------------------ + # + # Publishing blocks on this fetch, so a hanging third-party server used to be + # able to hold the request open for the better part of a minute: five seconds + # of connect plus five of read, per hop, across six possible hops. + + def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC) + + def test_http_get_gives_up_on_a_server_that_accepts_and_never_answers + server = TCPServer.new("127.0.0.1", 0) + accepting = Thread.new { loop { server.accept } } + started = monotonic + + url = "http://127.0.0.1:#{server.addr[1]}/" + result = Pressa::OpenGraph.fetch(url, http_get: ->(u) { Pressa::OpenGraph.http_get(u, timeout: 0.5) }) + elapsed = monotonic - started + + assert_nil(result) + assert_operator(elapsed, :<, 3, "should have given up on its own budget, took #{elapsed}s") + ensure + accepting&.kill + server&.close + end + + def test_http_get_does_not_start_a_request_once_the_budget_is_spent + server = TCPServer.new("127.0.0.1", 0) + accepting = Thread.new { loop { server.accept } } + + result = Pressa::OpenGraph.http_get("http://127.0.0.1:#{server.addr[1]}/", deadline: monotonic - 1) + + assert_nil(result) + ensure + accepting&.kill + server&.close + end + + def test_the_whole_fetch_shares_one_budget_across_redirects + assert_equal(5, Pressa::OpenGraph::TOTAL_TIMEOUT_SECONDS) + end end diff --git a/test/posts/gemini_writer_test.rb b/test/posts/gemini_writer_test.rb new file mode 100644 index 00000000..8f2b8e34 --- /dev/null +++ b/test/posts/gemini_writer_test.rb @@ -0,0 +1,69 @@ +require "test_helper" +require "tmpdir" +require "pressa/posts/gemini_writer" +require "pressa/posts/repo" + +class Pressa::Posts::GeminiWriterTest < Minitest::Test + def site + @site ||= Pressa::Site.new( + author: "Sami Samhuri", + email: "sami@samhuri.net", + title: "samhuri.net", + description: "blog", + url: "https://samhuri.net", + output_format: "gemini", + output_options: Pressa::GeminiOutputOptions.new + ) + end + + def post(content) + Pressa::Posts::PostRepo.new.build_post(content:, slug: "tree-well-protocol") + end + + def link_post_source + <<~MARKDOWN + --- + Title: Tree Well Protocol + Author: Jane Doe + Date: 7th June, 2026 + Timestamp: 2026-06-07T14:30:00-07:00 + Link: https://powder.example.net/tree-wells + --- + + Never ride alone in deep snow. + MARKDOWN + end + + def writer(posts) + by_month = Pressa::Posts::MonthPosts.new( + month: Pressa::Posts::Month.new(name: "June", number: 6, padded: "06"), + posts: + ) + posts_by_year = Pressa::Posts::PostsByYear.new( + by_year: {2026 => Pressa::Posts::YearPosts.new(year: 2026, by_month: {6 => by_month})} + ) + Pressa::Posts::GeminiWriter.new(site:, posts_by_year:) + end + + def test_post_content_renders_a_single_post_as_gemtext + entry = post(link_post_source) + gemtext = writer([entry]).post_content(post: entry) + + assert_includes(gemtext, "# Tree Well Protocol") + assert_includes(gemtext, "7th June, 2026 by Jane Doe") + assert_includes(gemtext, "=> https://powder.example.net/tree-wells") + assert_includes(gemtext, "Never ride alone in deep snow.") + assert_includes(gemtext, "=> /posts Back to posts") + end + + def test_post_content_matches_what_write_posts_puts_on_disk + Dir.mktmpdir do |tmpdir| + entry = post(link_post_source) + gemini_writer = writer([entry]) + gemini_writer.write_posts(target_path: tmpdir) + on_disk = File.read(File.join(tmpdir, "posts/2026/06/tree-well-protocol/index.gmi")) + + assert_equal(on_disk, gemini_writer.post_content(post: entry)) + end + end +end diff --git a/test/posts/repo_test.rb b/test/posts/repo_test.rb index 6d60e25f..87b3e556 100644 --- a/test/posts/repo_test.rb +++ b/test/posts/repo_test.rb @@ -105,4 +105,53 @@ def test_read_posts_merges_multiple_posts_in_same_month assert_equal(["Second Post", "First Post"], month_posts.sorted_posts.map(&:title)) end end + + def test_build_post_builds_a_post_from_content_without_touching_the_filesystem + content = <<~MARKDOWN + --- + Title: Tree Well Protocol + Author: Jane Doe + Date: 7th June, 2026 + Timestamp: 2026-06-07T14:30:00-07:00 + Tags: snowboarding, safety + Link: https://powder.example.net/tree-wells + --- + + Never ride alone in deep snow. + MARKDOWN + + post = repo.build_post(content:, slug: "tree-well-protocol") + + assert_equal("Tree Well Protocol", post.title) + assert_equal("Jane Doe", post.author) + assert_equal("tree-well-protocol", post.slug) + assert_equal("/posts/2026/06/tree-well-protocol", post.path) + assert_equal(["snowboarding", "safety"], post.tags) + assert_equal("https://powder.example.net/tree-wells", post.link) + assert_includes(post.body, "

Never ride alone in deep snow.

") + assert_equal("Never ride alone in deep snow.\n", post.markdown_body) + end + + def test_read_posts_and_build_post_agree + Dir.mktmpdir do |tmpdir| + posts_dir = File.join(tmpdir, "posts", "2026", "06") + FileUtils.mkdir_p(posts_dir) + + content = <<~MARKDOWN + --- + Title: Lift Line Notes + Author: Fat Mike + Date: 7th June, 2026 + Timestamp: 2026-06-07T14:30:00-07:00 + --- + + Chairlift conversations, collected. + MARKDOWN + + File.write(File.join(posts_dir, "lift-line-notes.md"), content) + from_disk = repo.read_posts(File.join(tmpdir, "posts")).all_posts.first + + assert_equal(from_disk.to_h, repo.build_post(content:, slug: "lift-line-notes").to_h) + end + end end diff --git a/test/posts/writer_test.rb b/test/posts/writer_test.rb index 4625bd5c..a3aa901c 100644 --- a/test/posts/writer_test.rb +++ b/test/posts/writer_test.rb @@ -164,4 +164,23 @@ def test_write_tag_pages_writes_each_tag_page refute_includes(html, "Linked") end end + + def test_post_html_renders_a_single_post_without_writing_a_file + post = posts_by_year.all_posts.find { it.slug == "regular-post" } + html = writer.post_html(post:) + + assert_includes(html, "

regular body

") + assert_includes(html, "Regular") + refute_includes(html, "linked body") + end + + def test_post_html_matches_what_write_posts_puts_on_disk + Dir.mktmpdir do |tmpdir| + writer.write_posts(target_path: tmpdir) + post = posts_by_year.all_posts.find { it.slug == "regular-post" } + on_disk = File.read(File.join(tmpdir, "posts/2024/10/regular-post/index.html")) + + assert_equal(on_disk, writer.post_html(post:)) + end + end end diff --git a/test/web/app_test.rb b/test/web/app_test.rb new file mode 100644 index 00000000..f254457c --- /dev/null +++ b/test/web/app_test.rb @@ -0,0 +1,510 @@ +require "test_helper" +require "fileutils" +require "json" +require "rack/test" +require "tmpdir" +require "pressa/web/app" + +class Pressa::Web::AppTest < Minitest::Test + include Rack::Test::Methods + + POST_SOURCE = <<~MARKDOWN + --- + Title: Tree Well Protocol + Author: Jane Doe + Date: 7th June, 2026 + Timestamp: 2026-06-07T14:30:00-07:00 + Tags: snowboarding, safety + --- + + Never ride alone in deep snow. + MARKDOWN + + DRAFT_SOURCE = <<~MARKDOWN + --- + Author: Fat Mike + Title: Lift Line Notes + Date: unpublished + Timestamp: 2026-06-07T14:30:00-07:00 + Tags: + --- + + Chairlift conversations, collected. + MARKDOWN + + def setup + @root = Dir.mktmpdir + FileUtils.mkdir_p(File.join(@root, "posts/2026/06")) + FileUtils.mkdir_p(File.join(@root, "public/drafts")) + File.write(File.join(@root, "posts/2026/06/tree-well-protocol.md"), POST_SOURCE) + File.write(File.join(@root, "public/drafts/lift-line-notes.md"), DRAFT_SOURCE) + + @metadata = nil + write_bin("post-link", "cat > /dev/null; echo ran >> ran.log; echo '==> Building' >&2; echo posts/2026/06/new-post.md") + write_bin("publish-draft", "echo ran >> ran.log; echo '==> Publishing' >&2; echo \"posts/2026/06/$1.md\"") + end + + # The app runs the repo's real scripts, so tests stand in fake ones rather + # than injecting a command runner. + def write_bin(name, script) + path = File.join(@root, "bin", name) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, "#!/bin/sh\n#{script}\n") + FileUtils.chmod(0o755, path) + end + + # The scripts append to ran.log, so a test can assert one never ran. + def publish_attempts + path = File.join(@root, "ran.log") + File.exist?(path) ? File.read(path).lines.size : 0 + end + + # Stands in for Pressa::OpenGraph. + def scraper = self + + def fetch(_url) = @metadata + + def teardown + FileUtils.remove_entry(@root) + end + + def build_site(output_format) + options = (output_format == "gemini") ? Pressa::GeminiOutputOptions.new : Pressa::HTMLOutputOptions.new + Pressa::Site.new( + author: "Sami Samhuri", email: "sami@samhuri.net", title: "samhuri.net", + description: "blog", url: "https://samhuri.net", output_format:, output_options: options + ) + end + + def app + @app ||= Class.new(Pressa::Web::App) do + set :environment, :test + set :show_exceptions, false + set :raise_errors, true + set :host_authorization, {permitted_hosts: []} + end.tap do |klass| + klass.set(:repo_root, @root) + klass.set(:html_site, build_site("html")) + klass.set(:gemini_site, build_site("gemini")) + klass.set(:author, "Sami Samhuri") + klass.set(:link_scraper, scraper) + end + end + + def json_body = JSON.parse(last_response.body) + + # --- posting a link ------------------------------------------------------ + + def test_the_home_page_is_the_link_form + get "/" + + assert_predicate(last_response, :ok?) + assert_includes(last_response.body, %(name="link")) + assert_includes(last_response.body, %(name="title")) + end + + def test_the_home_page_offers_tags_already_in_use_as_chips + get "/" + + assert_includes(last_response.body, "snowboarding") + assert_includes(last_response.body, "safety") + end + + # Rendering straight from the POST meant a reload republished the post. + def test_posting_a_link_publishes_and_redirects_to_the_result + post "/link", link: "https://powder.example.net/tree-wells", title: "Tree Well Protocol", + body: "Never ride alone.", tags: "Snowboarding, Safety" + + assert_equal(303, last_response.status) + assert_includes(last_response.headers["Location"], "published=posts%2F2026%2F06%2Fnew-post.md") + assert_equal(1, publish_attempts) + end + + def test_the_result_shows_on_the_page_the_redirect_lands_on + get "/", published: "posts/2026/06/new-post.md" + + assert_predicate(last_response, :ok?) + assert_includes(last_response.body, "posts/2026/06/new-post.md") + assert_includes(last_response.body, "Published") + end + + def test_a_successful_publish_leaves_an_empty_form_to_land_on + get "/", published: "posts/2026/06/new-post.md" + + refute_includes(last_response.body, %(value="https://powder.example.net/tree-wells")) + end + + def test_posting_a_link_sends_the_form_to_the_script_as_json_on_stdin + write_bin("post-link", "cat > payload.json; echo posts/x.md") + post "/link", link: "https://powder.example.net/tree-wells", title: "Tree Well Protocol", + body: "Never ride alone.\r\nSecond line.", tags: "Snowboarding, safety , " + + payload = JSON.parse(File.read(File.join(@root, "payload.json"))) + + assert_equal("Tree Well Protocol", payload["title"]) + assert_equal("https://powder.example.net/tree-wells", payload["link"]) + assert_equal("Never ride alone.\nSecond line.", payload["body"]) + assert_equal("snowboarding, safety", payload["tags"]) + end + + def test_a_failing_publish_shows_the_error_and_the_log + write_bin("post-link", "echo '==> Pulling' >&2; echo 'fatal: not a git repository' >&2; exit 128") + post "/link", link: "https://powder.example.net/x", title: "Tree Well Protocol" + + assert_equal(500, last_response.status) + assert_includes(last_response.body, "fatal: not a git repository") + assert_includes(last_response.body, "==> Pulling") + end + + def test_a_failing_publish_keeps_what_was_typed + write_bin("post-link", "exit 128") + post "/link", link: "https://powder.example.net/x", title: "Tree Well Protocol", body: "Never ride alone." + + assert_includes(last_response.body, "Never ride alone.") + end + + # The scripts flock the checkout; exit 75 is EX_TEMPFAIL, meaning something + # else holds it -- the phone Shortcut over SSH, most likely. + def test_a_publish_that_cannot_get_the_lock_says_to_try_again + write_bin("post-link", "echo 'Error: another publish is already running' >&2; exit 75") + post "/link", link: "https://powder.example.net/x", title: "Tree Well Protocol" + + assert_equal(409, last_response.status) + assert_includes(last_response.body, "Try again in a moment") + end + + def test_a_link_without_a_url_or_title_is_rejected_and_the_form_comes_back_filled_in + post "/link", link: "", title: "", body: "Never ride alone." + + assert_equal(422, last_response.status) + assert_includes(last_response.body, "Never ride alone.") + assert_equal(0, publish_attempts) + end + + # --- link metadata ------------------------------------------------------- + + def test_link_metadata_returns_what_the_scraper_found + @metadata = Pressa::OpenGraph::Result.new( + title: "Tree Wells", description: "A field guide.", image: "https://powder.example.net/cover.png" + ) + get "/link/metadata", url: "https://powder.example.net/tree-wells" + + assert_predicate(last_response, :ok?) + assert_equal("Tree Wells", json_body["title"]) + assert_equal("A field guide.", json_body["description"]) + assert_equal("https://powder.example.net/cover.png", json_body["image"]) + end + + def test_link_metadata_is_an_empty_object_when_the_page_offers_nothing + get "/link/metadata", url: "https://powder.example.net/bare" + + assert_predicate(last_response, :ok?) + assert_empty(json_body) + end + + def test_link_metadata_needs_a_url + get "/link/metadata" + + assert_equal(400, last_response.status) + end + + # --- preview ------------------------------------------------------------- + + def test_preview_renders_link_form_fields_as_html_and_gemtext + post "/preview", link: "https://powder.example.net/tree-wells", title: "Tree Well Protocol", + body: "Never ride alone.", tags: "snowboarding" + + assert_predicate(last_response, :ok?) + assert_includes(json_body["html"], "Never ride alone.") + assert_includes(json_body["gemtext"], "# Tree Well Protocol") + assert_includes(json_body["gemtext"], "=> https://powder.example.net/tree-wells") + end + + def test_preview_renders_raw_draft_source + post "/preview", source: DRAFT_SOURCE + + assert_predicate(last_response, :ok?) + assert_includes(json_body["gemtext"], "# Lift Line Notes") + assert_includes(json_body["html"], "Chairlift conversations, collected.") + end + + def test_preview_reports_bad_source_rather_than_blowing_up + post "/preview", source: "no front matter here\n" + + assert_equal(422, last_response.status) + assert(json_body["error"]) + end + + # --- jobs ---------------------------------------------------------------- + + # --- drafts -------------------------------------------------------------- + + def test_drafts_are_listed_newest_first + get "/drafts" + + assert_predicate(last_response, :ok?) + assert_includes(last_response.body, "Lift Line Notes") + end + + def test_creating_a_draft_redirects_to_its_editor + post "/drafts", title: "Tree Well Protocol" + + assert_equal(303, last_response.status) + assert_match(%r{/drafts/tree-well-protocol\z}, last_response.headers["Location"]) + assert(File.exist?(File.join(@root, "public/drafts/tree-well-protocol.md"))) + end + + def test_creating_a_draft_that_already_exists_is_refused + post "/drafts", title: "Lift Line Notes" + + assert_equal(409, last_response.status) + end + + def test_creating_a_draft_with_an_unusable_title_is_refused + post "/drafts", title: "!!!" + + assert_equal(422, last_response.status) + end + + def test_the_editor_is_headed_by_the_drafts_title_not_its_slug + get "/drafts/lift-line-notes" + + assert_includes(last_response.body, "

Lift Line Notes

") + assert_includes(last_response.body, "lift-line-notes.md") + end + + def test_the_editor_falls_back_to_the_slug_when_there_is_no_title + File.write(File.join(@root, "public/drafts/lift-line-notes.md"), "no front matter\n") + get "/drafts/lift-line-notes" + + assert_includes(last_response.body, "

lift-line-notes

") + end + + def test_the_editor_shows_the_draft_source + get "/drafts/lift-line-notes" + + assert_predicate(last_response, :ok?) + assert_includes(last_response.body, "Chairlift conversations, collected.") + end + + def draft_path = File.join(@root, "public/drafts/lift-line-notes.md") + + def current_version + get "/drafts/lift-line-notes" + last_response.body[/name="version" value="([a-f0-9]+)"/, 1] + end + + def test_the_editor_carries_a_version_of_what_it_loaded + refute_nil(current_version) + end + + def test_saving_a_draft_writes_it_back + post "/drafts/lift-line-notes", + source: "---\nTitle: Lift Line Notes\n---\n\nRewritten.\n", version: current_version + + assert_equal(303, last_response.status) + assert_includes(File.read(draft_path), "Rewritten.") + end + + def test_a_save_from_a_stale_editor_is_refused_and_keeps_both_versions + stale = current_version + File.write(draft_path, "---\nTitle: Lift Line Notes\n---\n\nThe other tab got here first.\n") + + post "/drafts/lift-line-notes", source: "My slower edit.\n", version: stale + + assert_equal(409, last_response.status) + assert_includes(last_response.body, "My slower edit.") + assert_includes(last_response.body, "The other tab got here first.") + assert_includes(File.read(draft_path), "The other tab got here first.") + refute_includes(File.read(draft_path), "My slower edit.") + end + + def test_a_save_with_no_version_at_all_is_refused + post "/drafts/lift-line-notes", source: "From a tab opened before the deploy.\n" + + assert_equal(422, last_response.status) + assert_includes(last_response.body, "From a tab opened before the deploy.") + refute_includes(File.read(draft_path), "From a tab opened before the deploy.") + end + + def test_publishing_a_draft_runs_the_script_and_redirects_to_the_result + post "/drafts/lift-line-notes/publish" + + assert_equal(303, last_response.status) + assert_includes(last_response.headers["Location"], "published=posts%2F2026%2F06%2Flift-line-notes.md") + assert_equal(1, publish_attempts) + end + + def test_a_failing_draft_publish_shows_the_error + write_bin("publish-draft", "echo 'Error: no draft' >&2; exit 1") + post "/drafts/lift-line-notes/publish" + + assert_equal(500, last_response.status) + assert_includes(last_response.body, "Error: no draft") + end + + def test_deleting_a_draft_removes_it + post "/drafts/lift-line-notes/delete" + + assert_equal(303, last_response.status) + refute(File.exist?(File.join(@root, "public/drafts/lift-line-notes.md"))) + end + + def test_an_unknown_draft_is_a_404 + get "/drafts/nope" + + assert_equal(404, last_response.status) + end + + def test_a_draft_slug_that_could_escape_the_drafts_directory_is_a_404 + get "/drafts/..%2F..%2Fsite" + + assert_equal(404, last_response.status) + end + + # --- odds and ends ------------------------------------------------------- + + def test_tags_are_available_as_json + get "/tags" + + assert_predicate(last_response, :ok?) + assert_includes(json_body, "snowboarding") + end + + # What web/config.ru does at boot, and the only thing that builds a Site. + def test_configure_sites_builds_both_from_the_repos_own_config + %w[site.toml projects.toml].each do |config| + FileUtils.cp(File.expand_path("../../#{config}", __dir__), File.join(@root, config)) + end + app.set(:html_site, nil) + app.set(:gemini_site, nil) + app.set(:author, nil) + app.configure_sites! + + post "/preview", source: DRAFT_SOURCE + + assert_predicate(last_response, :ok?) + assert_includes(json_body["gemtext"], "# Lift Line Notes") + assert_equal("Sami Samhuri", app.html_site.author) + end + + def test_a_missing_site_configuration_says_where_to_set_it + app.set(:html_site, nil) + + error = assert_raises(Pressa::Web::App::ConfigurationError) do + post "/preview", source: DRAFT_SOURCE + end + + assert_match(/config\.ru/, error.message) + end + + def test_an_unknown_page_is_a_404 + get "/nowhere" + + assert_equal(404, last_response.status) + assert_includes(last_response.body, "Not here") + end + + def test_there_is_a_health_check + get "/up" + + assert_predicate(last_response, :ok?) + end + + # --- cross-origin protection --------------------------------------------- + # + # There are no cookies or sessions here, so the ambient credential a malicious + # page would be riding is this machine's position on the tailnet. Sec-Fetch-* + # are forbidden header names, so page JavaScript can't forge them. + + def sec_fetch(site) = {"HTTP_SEC_FETCH_SITE" => site} + + def test_a_cross_site_publish_is_refused + post "/link", {link: "https://powder.example.net/x", title: "Tree Well Protocol"}, sec_fetch("cross-site") + + assert_equal(403, last_response.status) + assert_equal(0, publish_attempts) + end + + def test_a_same_site_publish_is_refused_too + post "/link", {link: "https://powder.example.net/x", title: "Tree Well Protocol"}, sec_fetch("same-site") + + assert_equal(403, last_response.status) + assert_equal(0, publish_attempts) + end + + def test_a_same_origin_publish_goes_through + post "/link", {link: "https://powder.example.net/x", title: "Tree Well Protocol"}, sec_fetch("same-origin") + + assert_equal(303, last_response.status) + end + + def test_a_user_initiated_publish_goes_through + post "/link", {link: "https://powder.example.net/x", title: "Tree Well Protocol"}, sec_fetch("none") + + assert_equal(303, last_response.status) + end + + def test_a_request_with_no_browser_headers_goes_through + post "/link", link: "https://powder.example.net/x", title: "Tree Well Protocol" + + assert_equal(303, last_response.status) + end + + def test_an_older_browser_falls_back_to_the_origin_header + post "/link", {link: "https://powder.example.net/x", title: "Tree Well Protocol"}, + {"HTTP_ORIGIN" => "https://evil.example.net"} + + assert_equal(403, last_response.status) + assert_equal(0, publish_attempts) + end + + def test_a_matching_origin_header_goes_through + post "/link", {link: "https://powder.example.net/x", title: "Tree Well Protocol"}, + {"HTTP_ORIGIN" => "http://example.org"} + + assert_equal(303, last_response.status) + end + + def test_cross_site_draft_deletion_is_refused + post "/drafts/lift-line-notes/delete", {}, sec_fetch("cross-site") + + assert_equal(403, last_response.status) + assert(File.exist?(File.join(@root, "public/drafts/lift-line-notes.md"))) + end + + def test_cross_site_draft_publishing_is_refused + post "/drafts/lift-line-notes/publish", {}, sec_fetch("cross-site") + + assert_equal(403, last_response.status) + assert_equal(0, publish_attempts) + end + + def test_reading_pages_cross_site_is_still_allowed + get "/", {}, sec_fetch("cross-site") + + assert_predicate(last_response, :ok?) + end + + # The one GET that gets the same treatment: it makes this server fetch a URL + # the caller chose, so cross-site callers could use it to probe the tailnet. + def test_cross_site_link_metadata_is_refused + get "/link/metadata", {url: "http://100.64.0.1:9091/"}, sec_fetch("cross-site") + + assert_equal(403, last_response.status) + end + + def test_same_origin_link_metadata_is_allowed + @metadata = Pressa::OpenGraph::Result.new(title: "Tree Wells", description: nil, image: nil) + get "/link/metadata", {url: "https://powder.example.net/tree-wells"}, sec_fetch("same-origin") + + assert_predicate(last_response, :ok?) + assert_equal("Tree Wells", json_body["title"]) + end + + def test_link_metadata_still_works_without_browser_headers + get "/link/metadata", url: "https://powder.example.net/bare" + + assert_predicate(last_response, :ok?) + end +end diff --git a/test/web/draft_store_test.rb b/test/web/draft_store_test.rb new file mode 100644 index 00000000..8df5bf19 --- /dev/null +++ b/test/web/draft_store_test.rb @@ -0,0 +1,161 @@ +require "test_helper" +require "fileutils" +require "tmpdir" +require "pressa/web/draft_store" + +class Pressa::Web::DraftStoreTest < Minitest::Test + def setup + @tmpdir = Dir.mktmpdir + @dir = File.join(@tmpdir, "drafts") + Dir.mkdir(@dir) + end + + def teardown + FileUtils.remove_entry(@tmpdir) + end + + def store = @store ||= Pressa::Web::DraftStore.new(dir: @dir) + + def write_draft(slug, title:, timestamp: "2026-06-07T14:30:00-07:00", body: "TKTK") + File.write(File.join(@dir, "#{slug}.md"), <<~MARKDOWN) + --- + Author: Sami Samhuri + Title: #{title} + Date: unpublished + Timestamp: #{timestamp} + Tags: + --- + + #{body} + MARKDOWN + end + + def test_list_is_empty_when_there_are_no_drafts + assert_empty(store.list) + end + + def test_list_returns_drafts_newest_first + write_draft("lift-line-notes", title: "Lift Line Notes", timestamp: "2026-06-01T09:00:00-07:00") + write_draft("tree-wells", title: "Tree Wells", timestamp: "2026-06-07T09:00:00-07:00") + + assert_equal(["Tree Wells", "Lift Line Notes"], store.list.map(&:title)) + assert_equal(["tree-wells", "lift-line-notes"], store.list.map(&:slug)) + end + + def test_read_returns_the_raw_markdown + write_draft("tree-wells", title: "Tree Wells", body: "Never ride alone.") + + assert_includes(store.read("tree-wells"), "Never ride alone.") + assert_includes(store.read("tree-wells"), "Title: Tree Wells") + end + + def test_read_raises_for_an_unknown_draft + assert_raises(Pressa::Web::DraftStore::NotFound) { store.read("nope") } + end + + def test_write_replaces_the_contents_of_an_existing_draft + write_draft("tree-wells", title: "Tree Wells") + store.write("tree-wells", "---\nTitle: Tree Wells\n---\n\nRewritten.\n", + expected_version: store.version("tree-wells")) + + assert_includes(store.read("tree-wells"), "Rewritten.") + end + + def test_write_raises_for_an_unknown_draft + assert_raises(Pressa::Web::DraftStore::NotFound) do + store.write("nope", "content", expected_version: "whatever") + end + end + + def test_write_normalizes_the_crlf_browsers_send_from_a_textarea + write_draft("tree-wells", title: "Tree Wells") + store.write("tree-wells", "line one\r\nline two\r\n", expected_version: store.version("tree-wells")) + + assert_equal("line one\nline two\n", store.read("tree-wells")) + end + + # --- concurrent edits ----------------------------------------------------- + + def test_version_is_stable_for_unchanged_contents + write_draft("tree-wells", title: "Tree Wells") + + assert_equal(store.version("tree-wells"), store.version("tree-wells")) + end + + def test_version_changes_once_the_draft_is_written + write_draft("tree-wells", title: "Tree Wells") + before = store.version("tree-wells") + store.write("tree-wells", "something else\n", expected_version: before) + + refute_equal(before, store.version("tree-wells")) + end + + def test_write_refuses_a_version_that_is_out_of_date + write_draft("tree-wells", title: "Tree Wells") + stale = store.version("tree-wells") + store.write("tree-wells", "the other tab got here first\n", expected_version: stale) + + error = assert_raises(Pressa::Web::DraftStore::Stale) do + store.write("tree-wells", "my slower edit\n", expected_version: stale) + end + + assert_equal("the other tab got here first\n", store.read("tree-wells")) + assert_equal("the other tab got here first\n", error.current_content) + assert_equal(store.version("tree-wells"), error.current_version) + end + + def test_write_refuses_a_missing_version + write_draft("tree-wells", title: "Tree Wells") + + assert_raises(Pressa::Web::DraftStore::Stale) do + store.write("tree-wells", "no token at all\n", expected_version: nil) + end + + refute_includes(store.read("tree-wells"), "no token at all") + end + + def test_rewriting_identical_content_is_not_a_conflict + write_draft("tree-wells", title: "Tree Wells") + same = store.read("tree-wells") + store.write("tree-wells", same, expected_version: store.version("tree-wells")) + + assert_equal(same, store.read("tree-wells")) + end + + def test_create_writes_a_draft_from_the_title_and_returns_its_slug + slug = store.create("Tree Well Protocol") + + assert_equal("tree-well-protocol", slug) + assert_includes(store.read(slug), "Title: Tree Well Protocol") + assert_includes(store.read(slug), "Date: unpublished") + end + + def test_create_refuses_to_clobber_an_existing_draft + store.create("Tree Well Protocol") + + assert_raises(Pressa::Web::DraftStore::Conflict) { store.create("Tree Well Protocol") } + end + + def test_create_rejects_a_title_with_no_usable_slug + assert_raises(Pressa::Web::DraftStore::InvalidTitle) { store.create("!!!") } + assert_raises(Pressa::Web::DraftStore::InvalidTitle) { store.create(" ") } + end + + def test_delete_removes_the_draft + store.create("Tree Well Protocol") + store.delete("tree-well-protocol") + + assert_empty(store.list) + end + + def test_slugs_that_could_escape_the_drafts_directory_are_refused + outside = File.expand_path("../secrets.md", @dir) + File.write(outside, "not yours") + + ["../secrets", "..%2Fsecrets", "sub/dir", "/etc/passwd", "tree wells", ""].each do |slug| + assert_raises(Pressa::Web::DraftStore::InvalidSlug, "expected #{slug.inspect} to be refused") do + store.read(slug) + end + end + end +end diff --git a/test/web/job_runner_test.rb b/test/web/job_runner_test.rb new file mode 100644 index 00000000..39bb4a07 --- /dev/null +++ b/test/web/job_runner_test.rb @@ -0,0 +1,78 @@ +require "test_helper" +require "tmpdir" +require "pressa/web/job_runner" + +class Pressa::Web::JobRunnerTest < Minitest::Test + def sh(script) = ["sh", "-c", script] + + def test_returns_stdout_as_the_result + result = Pressa::Web::JobRunner.run(command: sh("echo posts/2026/06/tree-well-protocol.md")) + + assert_equal("posts/2026/06/tree-well-protocol.md", result) + end + + def test_streams_progress_lines_to_the_block_as_they_arrive + lines = [] + Pressa::Web::JobRunner.run(command: sh("echo '==> Pulling' >&2; echo '==> Building' >&2")) do |line| + lines << line + end + + assert_equal(["==> Pulling", "==> Building"], lines) + end + + def test_the_log_carries_stdout_too_so_nothing_is_hidden + lines = [] + Pressa::Web::JobRunner.run(command: sh("echo progress >&2; echo the-result")) { lines << it } + + assert_equal(["progress", "the-result"], lines.sort) + end + + def test_sends_stdin_data_to_the_process + result = Pressa::Web::JobRunner.run(command: sh("cat"), stdin_data: %({"title":"Ride On"})) + + assert_equal(%({"title":"Ride On"}), result) + end + + def test_runs_in_the_given_directory + Dir.mktmpdir do |tmpdir| + result = Pressa::Web::JobRunner.run(command: sh("pwd"), chdir: tmpdir) + + assert_equal(File.realpath(tmpdir), File.realpath(result)) + end + end + + def test_passes_environment_variables_through + result = Pressa::Web::JobRunner.run( + command: sh("echo $SAMHURI_PUBLISH_HOST"), env: {"SAMHURI_PUBLISH_HOST" => "local"} + ) + + assert_equal("local", result) + end + + def test_raises_with_the_last_log_line_when_the_command_fails + error = assert_raises(Pressa::Web::JobRunner::Failed) do + Pressa::Web::JobRunner.run(command: sh("echo 'fatal: not a git repository' >&2; exit 128")) + end + + assert_equal("fatal: not a git repository", error.message) + assert_equal(128, error.exit_status) + end + + def test_raises_with_the_exit_status_when_the_command_says_nothing + error = assert_raises(Pressa::Web::JobRunner::Failed) do + Pressa::Web::JobRunner.run(command: sh("exit 23")) + end + + assert_match(/exited with status 23/, error.message) + end + + def test_streams_lines_before_the_command_finishes + seen_early = false + Pressa::Web::JobRunner.run(command: sh("echo first >&2; sleep 0.2; echo second >&2")) do |line| + seen_early = true if line == "first" + raise "second arrived before first was streamed" if line == "second" && !seen_early + end + + assert(seen_early) + end +end diff --git a/test/web/preview_test.rb b/test/web/preview_test.rb new file mode 100644 index 00000000..5acfd58a --- /dev/null +++ b/test/web/preview_test.rb @@ -0,0 +1,110 @@ +require "test_helper" +require "pressa/web/preview" + +class Pressa::Web::PreviewTest < Minitest::Test + def build_site(output_format) + options = + if output_format == "gemini" + Pressa::GeminiOutputOptions.new + else + Pressa::HTMLOutputOptions.new + end + + Pressa::Site.new( + author: "Sami Samhuri", + email: "sami@samhuri.net", + title: "samhuri.net", + description: "blog", + url: "https://samhuri.net", + output_format:, + output_options: options + ) + end + + def preview + @preview ||= Pressa::Web::Preview.new( + html_site: build_site("html"), + gemini_site: build_site("gemini") + ) + end + + def link_post_source + <<~MARKDOWN + --- + Title: Tree Well Protocol + Author: Jane Doe + Date: 7th June, 2026 + Timestamp: 2026-06-07T14:30:00-07:00 + Tags: snowboarding, safety + Link: https://powder.example.net/tree-wells + --- + + Never ride alone in deep snow. + MARKDOWN + end + + def test_renders_html_and_gemtext_for_the_same_source + result = preview.render(link_post_source, slug: "tree-well-protocol") + + assert_equal("Tree Well Protocol", result.title) + assert_includes(result.html, "

Never ride alone in deep snow.

") + assert_includes(result.html, "Tree Well Protocol") + assert_includes(result.gemtext, "# Tree Well Protocol") + assert_includes(result.gemtext, "=> https://powder.example.net/tree-wells") + assert_includes(result.gemtext, "Never ride alone in deep snow.") + end + + def test_html_is_a_whole_page_not_a_fragment + result = preview.render(link_post_source, slug: "tree-well-protocol") + + assert_match(/\A/i, result.html) + end + + def test_gemtext_flags_raw_html_the_capsule_cannot_render + source = link_post_source.sub( + "Never ride alone in deep snow.", + "

Never ride alone in deep snow.

" + ) + result = preview.render(source, slug: "tree-well-protocol") + + assert_includes(result.gemtext, "Read on the web") + end + + def test_renders_an_unpublished_draft + source = <<~MARKDOWN + --- + Author: Fat Mike + Title: Lift Line Notes + Date: unpublished + Timestamp: 2026-06-07T14:30:00-07:00 + Tags: + --- + + Chairlift conversations, collected. + MARKDOWN + + result = preview.render(source, slug: "lift-line-notes") + + assert_equal("Lift Line Notes", result.title) + assert_includes(result.gemtext, "unpublished by Fat Mike") + assert_includes(result.html, "Chairlift conversations, collected.") + end + + def test_raises_a_preview_error_for_source_without_front_matter + error = assert_raises(Pressa::Web::Preview::Error) do + preview.render("Just some notes.\n", slug: "notes") + end + + assert_match(/front-matter/i, error.message) + end + + def test_raises_a_preview_error_when_required_fields_are_missing + source = "---\nTitle: Half a Post\n---\n\nBody.\n" + + error = assert_raises(Pressa::Web::Preview::Error) do + preview.render(source, slug: "half-a-post") + end + + assert_match(/Author/, error.message) + end +end diff --git a/web/bin/start b/web/bin/start new file mode 100755 index 00000000..c4653cc4 --- /dev/null +++ b/web/bin/start @@ -0,0 +1,10 @@ +#!/bin/bash +set -euo pipefail + +# Threads, not workers: the single-publish-at-a-time guard is an in-process +# mutex, so a second puma worker would happily start a second publish against +# the same git checkout. bin/post-link takes an flock as well, which is what +# keeps the phone Shortcut's SSH path from racing this one. +cd "$(dirname "${BASH_SOURCE[0]}")/.." +exec "${RV:-/home/sjs/.cargo/bin/rv}" run bundle exec puma \ + -t 0:16 -b "tcp://${BIND_ADDRESS:-127.0.0.1}:${PORT:-1112}" config.ru diff --git a/web/config.ru b/web/config.ru new file mode 100644 index 00000000..7efc326d --- /dev/null +++ b/web/config.ru @@ -0,0 +1,8 @@ +lib_path = File.expand_path("../lib", __dir__) +$LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include?(lib_path) + +require "pressa/web/app" + +Pressa::Web::App.configure_sites! + +run Pressa::Web::App diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 00000000..14ab6c98 --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web/public/style.css b/web/public/style.css new file mode 100644 index 00000000..e811cc24 --- /dev/null +++ b/web/public/style.css @@ -0,0 +1,185 @@ +:root { + --bg: #fbfaf8; + --panel: #ffffff; + --ink: #1d1b18; + --quiet: #6b6559; + --line: #ded8cc; + --accent: #8a3f2a; + --ok: #2f6b3f; + --bad: #a32c1e; + --running: #9a6b12; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #171614; + --panel: #201e1b; + --ink: #ece7dd; + --quiet: #9c948a; + --line: #35322d; + --accent: #e2825f; + --ok: #7cc48c; + --bad: #e8806f; + --running: #d8ab52; + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + padding: 0 0 3rem; + background: var(--bg); + color: var(--ink); + font: 16px/1.5 ui-sans-serif, -apple-system, "Segoe UI", system-ui, sans-serif; + -webkit-text-size-adjust: 100%; +} + +.bar { + display: flex; + align-items: baseline; + gap: 1rem; + padding: 0.75rem 1rem; + padding-top: max(0.75rem, env(safe-area-inset-top)); + border-bottom: 1px solid var(--line); + background: var(--panel); + position: sticky; + top: 0; + z-index: 2; +} + +.brand { + font-weight: 700; + letter-spacing: 0.02em; + color: var(--accent); + text-decoration: none; +} + +.bar nav { display: flex; gap: 1rem; margin-left: auto; } +.bar nav a { color: var(--quiet); text-decoration: none; } +.bar nav a.on { color: var(--ink); font-weight: 600; } + +main { max-width: 46rem; margin: 0 auto; padding: 1rem; } + +h1 { font-size: 1.35rem; margin: 1rem 0; } +h2 { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--quiet); margin: 0 0 0.4rem; } + +.quiet { color: var(--quiet); } + +.card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 10px; + padding: 1rem; + margin-bottom: 1rem; +} + +.card.bad { border-color: var(--bad); } +.card.good { border-color: var(--ok); } +.card.good strong { color: var(--ok); } +.card.bad p { margin: 0 0 0.5rem; } +.card.bad p:last-child { margin-bottom: 0; } + +.stack { display: flex; flex-direction: column; gap: 0.85rem; } +.row { display: flex; gap: 0.5rem; } +.row input { flex: 1; } + +label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 0.85rem; color: var(--quiet); } + +input[type="text"], input[type="url"], textarea { + width: 100%; + padding: 0.65rem 0.7rem; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--bg); + color: var(--ink); + /* 16px keeps iOS Safari from zooming the whole page on focus. */ + font-size: 16px; + font-family: inherit; +} + +textarea { resize: vertical; } +#draft-source { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 14px; } + +input:focus, textarea:focus { outline: 2px solid var(--accent); outline-offset: 1px; } + +button { + font: inherit; + font-weight: 600; + padding: 0.7rem 1.1rem; + min-height: 2.75rem; + border: 1px solid var(--accent); + border-radius: 8px; + background: var(--accent); + color: var(--panel); + cursor: pointer; +} + +button.secondary { background: transparent; color: var(--accent); } +button.danger { background: transparent; color: var(--bad); border-color: var(--bad); } +button:disabled { opacity: 0.6; cursor: default; } + +.actions { display: flex; gap: 0.6rem; } +.actions.spread { justify-content: space-between; } + +.chips { display: flex; flex-wrap: wrap; gap: 0.4rem; } + +/* A lone chip in a flex column would stretch to full width and read as a + primary action; it's a small affordance. */ +.stack > .chip { align-self: flex-start; } + +.chip { + font-size: 0.8rem; + font-weight: 500; + padding: 0.35rem 0.6rem; + min-height: 0; + border: 1px solid var(--line); + border-radius: 999px; + background: transparent; + color: var(--quiet); +} + +.rows { list-style: none; margin: 0; padding: 0; } +.rows li { border-bottom: 1px solid var(--line); } +.rows li:last-child { border-bottom: 0; } +.rows a { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.6rem; + padding: 0.8rem 0.2rem; + color: inherit; + text-decoration: none; +} + +pre { + margin: 0; + padding: 0.75rem; + max-height: 60vh; + overflow: auto; + border-radius: 8px; + background: var(--bg); + border: 1px solid var(--line); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 13px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; +} + +.preview-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.75rem; } +.panes { display: grid; gap: 1rem; grid-template-columns: 1fr; } +.panes iframe { width: 100%; height: 24rem; border: 1px solid var(--line); border-radius: 8px; background: #fff; } + +/* Side by side is the point, but only once there's room for two columns. */ +@media (min-width: 52rem) { + .panes { grid-template-columns: 1fr 1fr; } +} + +/* Break the preview out of the reading column so the web pane gets enough + width to render something other than the mobile layout. */ +@media (min-width: 60rem) { + .preview { margin-inline: -6rem; } +} + +p.bad { color: var(--bad); } diff --git a/web/views/_preview.erb b/web/views/_preview.erb new file mode 100644 index 00000000..946b066a --- /dev/null +++ b/web/views/_preview.erb @@ -0,0 +1,49 @@ + + + diff --git a/web/views/draft.erb b/web/views/draft.erb new file mode 100644 index 00000000..81c886b7 --- /dev/null +++ b/web/views/draft.erb @@ -0,0 +1,38 @@ +

<%= h(Pressa::Web::App.draft_title(@source, @slug)) %>

+

<%= h(@slug) %>.md

+ +
+ + + +
+ + +
+
+ +<% if @conflict %> +
+ What's on disk now +
<%= h(@conflict) %>
+
+<% end %> + +<%= erb :_preview, layout: false %> + +
+
+ +
+
+ +
+
+ + diff --git a/web/views/drafts.erb b/web/views/drafts.erb new file mode 100644 index 00000000..31a115d7 --- /dev/null +++ b/web/views/drafts.erb @@ -0,0 +1,21 @@ +

Drafts

+ +
+ + +
+ +<% if @drafts.empty? %> +

No drafts yet.

+<% else %> + +<% end %> diff --git a/web/views/layout.erb b/web/views/layout.erb new file mode 100644 index 00000000..2c761daa --- /dev/null +++ b/web/views/layout.erb @@ -0,0 +1,42 @@ + + + + + + <%= @page_title ? "#{@page_title} — Pressa" : "Pressa" %> + + "> + + +
+ Pressa + +
+ +
+ <% if @published %> +
+ Published +

<%= h(@published) %>

+
+ <% end %> + + <% if @error %> +
+

<%= h(@error) %>

+
+ <% end %> + + <% if @log && !@log.empty? %> +
> + Publish log +
<%= h(@log.join("\n")) %>
+
+ <% end %> + <%= yield %> +
+ + diff --git a/web/views/link.erb b/web/views/link.erb new file mode 100644 index 00000000..8c3461ac --- /dev/null +++ b/web/views/link.erb @@ -0,0 +1,98 @@ +

Post a link

+ + + +<%= erb :_preview, layout: false %> + + diff --git a/web/views/not_found.erb b/web/views/not_found.erb new file mode 100644 index 00000000..7c0a0502 --- /dev/null +++ b/web/views/not_found.erb @@ -0,0 +1,3 @@ +

Not here

+

<%= h(@message || "That page doesn't exist.") %>

+

Back to the link form