From 67d3852c6cfc6eeb396155cd2cdeb5a6b37c4bc3 Mon Sep 17 00:00:00 2001 From: Sami Samhuri Date: Sun, 7 Jun 2026 18:14:00 -0700 Subject: [PATCH] Add link-post authoring for quick posts from mobile --- bake.rb | 36 ++++++++++++++++++++ bin/post-link | 63 +++++++++++++++++++++++++++++++++++ lib/pressa.rb | 1 + lib/pressa/link_post.rb | 61 +++++++++++++++++++++++++++++++++ test/link_post_test.rb | 74 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 235 insertions(+) create mode 100755 bin/post-link create mode 100644 lib/pressa/link_post.rb create mode 100644 test/link_post_test.rb diff --git a/bake.rb b/bake.rb index 91a9546..2ef4e76 100644 --- a/bake.rb +++ b/bake.rb @@ -1,6 +1,7 @@ # Build tasks for samhuri.net static site generator require "fileutils" +require "json" require "open3" require "tmpdir" @@ -8,6 +9,8 @@ LIB_PATH = File.expand_path("lib", __dir__).freeze $LOAD_PATH.unshift(LIB_PATH) unless $LOAD_PATH.include?(LIB_PATH) require "pressa/drafts" +require "pressa/link_post" +require "pressa/config/simple_toml" require "pressa/coverage" require "pressa/publish" require "pressa/git" @@ -55,6 +58,39 @@ def serve server.start end +# Create a published link post in posts/YYYY/MM from a JSON payload on stdin: +# {"title": "...", "link": "...", "body": "...", "tags": "tag1, tag2"} +# Reading from stdin keeps URLs, quotes, and multi-line bodies intact regardless +# of shell quoting. Prints the path to the created post. Drives bin/post-link. +def new_link + payload = + begin + JSON.parse($stdin.read) + rescue JSON::ParserError => e + abort "Error: invalid JSON payload on stdin: #{e.message}" + end + + author = payload["author"] || Pressa::Config::SimpleToml.load_file("site.toml")["author"] + post = + begin + Pressa::LinkPost.build( + title: payload["title"], + link: payload["link"], + body: payload["body"], + tags: payload["tags"], + author: + ) + rescue Pressa::LinkPost::Error => e + abort "Error: #{e.message}" + end + + abort "Error: post already exists at #{post.target_path}" if File.exist?(post.target_path) + + FileUtils.mkdir_p(File.dirname(post.target_path)) + File.write(post.target_path, post.content) + puts post.target_path +end + # Create a new draft in public/drafts/. # @parameter title_parts [Array] Optional title words; defaults to Untitled. def new_draft(*title_parts) diff --git a/bin/post-link b/bin/post-link new file mode 100755 index 0000000..d895e78 --- /dev/null +++ b/bin/post-link @@ -0,0 +1,63 @@ +#!/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: +# +# echo | base64 --decode | $HOME/samhuri.net/bin/post-link +# +# Manually, just pipe JSON straight in: +# +# ssh mudge '$HOME/samhuri.net/bin/post-link' <<'JSON' +# {"title": "Magical Wristband", "link": "https://example.net/x", +# "body": "Optional commentary.", "tags": "gear, tech"} +# JSON +# +# It pulls latest, writes the post via `bake new_link`, commits, pushes to +# GitHub, then runs `bake publish` to build HTML + Gemini and rsync to the +# live directories. Everything runs on mudge, so publishing is a local affair. + +set -euo pipefail + +# Non-interactive SSH sessions get a bare PATH; make sure rbenv is reachable. +export PATH="$HOME/.rbenv/bin:$HOME/.rbenv/shims:$PATH" +if command -v rbenv >/dev/null 2>&1; then + eval "$(rbenv init - bash)" +fi + +# 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 + +echo "==> Pulling latest from $REMOTE/$BRANCH" >&2 +git pull --ff-only "$REMOTE" "$BRANCH" >&2 + +echo "==> Creating link post" >&2 +post_path="$(printf '%s' "$payload" | bundle exec bake new_link)" +echo " $post_path" >&2 + +slug="$(basename "$post_path" .md)" +echo "==> Committing and pushing" >&2 +git add "$post_path" +git commit -m "Add link post: $slug" >&2 +git push "$REMOTE" "HEAD:$BRANCH" >&2 + +echo "==> Building and publishing" >&2 +bundle exec bake publish >&2 + +echo "==> Published $post_path" >&2 +# Stdout carries just the path so the caller (Shortcut) can show/use it. +echo "$post_path" diff --git a/lib/pressa.rb b/lib/pressa.rb index a834794..cbce5d3 100644 --- a/lib/pressa.rb +++ b/lib/pressa.rb @@ -7,6 +7,7 @@ require "pressa/utils/markdown_renderer" require "pressa/utils/gemini_markdown_renderer" require "pressa/config/loader" require "pressa/drafts" +require "pressa/link_post" require "pressa/coverage" require "pressa/publish" require "pressa/git" diff --git a/lib/pressa/link_post.rb b/lib/pressa/link_post.rb new file mode 100644 index 0000000..6ea76d2 --- /dev/null +++ b/lib/pressa/link_post.rb @@ -0,0 +1,61 @@ +require "pressa/drafts" + +module Pressa + # Builds a fully-formed, ready-to-publish link post (front matter + body) from + # the handful of fields a phone Shortcut can collect. Pure: returns the target + # path and content; writing the file is the caller's job. + class LinkPost + class Error < StandardError; end + + Result = Data.define(:filename, :target_path, :content) + + def self.build(title:, link:, body: nil, tags: nil, author: Drafts.current_author, now: Time.now) + title = title.to_s.strip + raise Error, "title cannot be empty" if title.empty? + + link = link.to_s.strip + raise Error, "link cannot be empty" if link.empty? + + slug = Drafts.slugify(title) + raise Error, "title cannot be converted to a filename: #{title.inspect}" if slug.empty? + + filename = "#{slug}.md" + target_path = "posts/#{now.strftime("%Y/%m")}/#{filename}" + content = render(title:, link:, body:, tags:, author:, now:) + + Result.new(filename:, target_path:, content:) + end + + def self.render(title:, link:, body:, tags:, author:, now:) + lines = [ + "---", + "Title: #{yaml_quote(title)}", + "Author: #{author}", + "Date: #{yaml_quote(Drafts.ordinal_date(now))}", + "Timestamp: #{now.strftime("%Y-%m-%dT%H:%M:%S%:z")}" + ] + tag_list = normalize_tags(tags) + lines << "Tags: #{tag_list.join(", ")}" unless tag_list.empty? + lines << "Link: #{link}" + lines << "---" + + front_matter = lines.join("\n") + body = body.to_s.strip + body.empty? ? "#{front_matter}\n" : "#{front_matter}\n\n#{body}\n" + end + + def self.normalize_tags(tags) + return [] if tags.nil? + + list = tags.is_a?(Array) ? tags : tags.to_s.split(",") + list.map(&:strip).reject(&:empty?) + end + + # YAML double-quoted scalar so titles with colons, quotes, or backslashes + # round-trip cleanly through the front-matter parser. + def self.yaml_quote(value) + escaped = value.gsub("\\", "\\\\\\\\").gsub('"', '\\"') + %("#{escaped}") + end + end +end diff --git a/test/link_post_test.rb b/test/link_post_test.rb new file mode 100644 index 0000000..814cdbe --- /dev/null +++ b/test/link_post_test.rb @@ -0,0 +1,74 @@ +require "test_helper" +require "pressa/posts/metadata" + +class Pressa::LinkPostTest < Minitest::Test + def setup + @now = Time.new(2026, 6, 7, 14, 30, 0, "-07:00") + end + + def build(**overrides) + defaults = { + title: "Magical Wristband", + link: "https://example.net/magicband", + now: @now, + author: "Sami Samhuri" + } + Pressa::LinkPost.build(**defaults.merge(overrides)) + end + + def test_target_path_uses_slug_and_year_month + post = build + assert_equal("posts/2026/06/magical-wristband.md", post.target_path) + assert_equal("magical-wristband.md", post.filename) + end + + def test_front_matter_is_parseable_and_complete + post = build(body: "Disney's take on the wearable.", tags: "gear, tech") + meta = Pressa::Posts::PostMetadata.parse(post.content) + + assert_equal("Magical Wristband", meta.title) + assert_equal("Sami Samhuri", meta.author) + assert_equal("7th June, 2026", meta.formatted_date) + assert_equal("https://example.net/magicband", meta.link) + assert_equal(%w[gear tech], meta.tags) + assert_includes(post.content, "Disney's take on the wearable.") + end + + def test_timestamp_is_iso8601_with_offset + post = build + meta = Pressa::Posts::PostMetadata.parse(post.content) + assert_equal("2026-06-07T14:30:00-07:00", meta.date.strftime("%Y-%m-%dT%H:%M:%S%:z")) + end + + def test_title_with_special_characters_round_trips + weird = %(Fish: "And" \\ Chips) + post = build(title: weird) + meta = Pressa::Posts::PostMetadata.parse(post.content) + assert_equal(weird, meta.title) + end + + def test_tags_omitted_when_blank + post = build(tags: " ") + refute_includes(post.content, "Tags:") + end + + def test_body_optional_for_link_only_posts + post = build(body: nil) + meta = Pressa::Posts::PostMetadata.parse(post.content) + assert_equal("https://example.net/magicband", meta.link) + end + + def test_blank_title_raises + error = assert_raises(Pressa::LinkPost::Error) { build(title: " ") } + assert_match(/title/i, error.message) + end + + def test_title_with_only_symbols_raises + assert_raises(Pressa::LinkPost::Error) { build(title: "!!!") } + end + + def test_blank_link_raises + error = assert_raises(Pressa::LinkPost::Error) { build(link: " ") } + assert_match(/link/i, error.message) + end +end