Add link-post authoring for quick posts from mobile

This commit is contained in:
Sami Samhuri 2026-06-07 18:14:00 -07:00
parent 8844bd8b46
commit 67d3852c6c
5 changed files with 235 additions and 0 deletions

36
bake.rb
View file

@ -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)

63
bin/post-link Executable file
View file

@ -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> | 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"

View file

@ -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"

61
lib/pressa/link_post.rb Normal file
View file

@ -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

74
test/link_post_test.rb Normal file
View file

@ -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