mirror of
https://github.com/samsonjs/samhuri.net.git
synced 2026-06-23 04:44:54 +00:00
Add link-post authoring for quick posts from mobile
This commit is contained in:
parent
8844bd8b46
commit
67d3852c6c
5 changed files with 235 additions and 0 deletions
36
bake.rb
36
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)
|
||||
|
|
|
|||
63
bin/post-link
Executable file
63
bin/post-link
Executable 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"
|
||||
|
|
@ -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
61
lib/pressa/link_post.rb
Normal 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
74
test/link_post_test.rb
Normal 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
|
||||
Loading…
Reference in a new issue