Blog (fw blog)
A generic static-blog generator: a folder of Markdown files and one JSON config become a site (server/blog.py).
It is used for writing.fuzzyworld.net (and til.fuzzyworld.net).
It runs on the laptop, builds into a clean temporary folder and uploads only that folder with rsync, so
nothing in the source folder (drafts, notes, config) is ever published.
fw blog list ~/Blog/fw-blog.json # every source file: URL, title, date, tags, published or not
fw blog build ~/Blog/fw-blog.json --out DIR [--no-htaccess] # write the site into DIR (other options are refused)
fw blog preview ~/Blog/fw-blog.json [port] # build and serve it locally (no redirects, no feed content type)
fw blog deploy ~/Blog/fw-blog.json --dry-run # then, for real: --yes (or answer the prompt)
fw blog watch ~/Blog/fw-blog.json [--once] [--dry-run] # mirror to git and auto-deploy edits
fw blog watch install|uninstall ~/Blog/fw-blog.json [--print] # the launchd agent that runs it
Python-Markdown and Pygments are installed into ~/fw/pylib on the laptop the first time fw blog runs.
What is published
- Posts: only those with
published: true, in the file's front matter or in the config'spoststable keyed by file name (front matter wins). - Pages (files in
pages_dir) are opt-in the same way: a page needspublished: truein its front matter (the importer writes it). - Never reaches the output: text under
## Spellingand## Removed writing, and every HTML comment (except<!--more-->). - The build stops (rather than risk a leak) when a heading that looks private is written another way (
##Spelling, an underlined heading) or a code fence is never closed.
What it builds
| Output | Notes |
|---|---|
Posts at /YYYY/MM/DD/<slug>/ |
|
/blog/ |
every post by year and month, with excerpts and a "Continue reading" link |
/tag/<slug>/ |
|
| Pages | |
Atom feed at /feed/ |
server/atom.py |
.htaccess |
redirects from redirects and wp_ids, no script execution, a security policy that allows only the site's own blog.js |
robots.txt |
blocks AI crawlers |
| Comments | as static text |
Reader features (server/site-blog/blog.js): a light/dark switch (the choice is kept in the browser; without
one the system setting decides) and a reading-progress bar on posts.
Config
title, base_url and source are required; unknown keys are an error. The full list is at the top of
server/blog.py.
| Group | Keys |
|---|---|
| Identity | title, tagline, author, copyright ({years} and {author} are filled in), url, base_url |
| Content | source, include (source file patterns, default *.md), pages_dir, media, body_section, line_breaks, posts, tags, home, nav, reading_wpm, timezone |
| Output | feed, redirects, wp_ids, rewrite_hosts, site |
| Comments | comments, comments_site, comments_mailbox, comments_form |
| Deploy | deploy, mirror, autodeploy |
Paths: source and comments are relative to the config file's folder; pages_dir and media are relative to source.
site puts the site-wide bar above the blog's own bar (as on code and apps):
{"name": "fuzzyworld", "url": "https://fuzzyworld.net/", "nav": [[label, url], ...], "current": "Writing"}.
"nav": "gen" takes the fuzzyworld nav from NAV in server/gen.py (the one source) and also shows the shared
four-column footer (gen.site_footer(), with the blog's copyright and Feed line under it). The landing page's nav
and footer are built from the same functions by gen.py landing in install.sh (<!--SITE-NAV-->,
<!--SITE-FOOTER-->). The landing page's text is Markdown in server/site/landing.md, rendered into the
<!--LANDING-TEXT--> spot of server/site/index.html.
Safety checks
| Check | Rule |
|---|---|
| Slugs | posts, pages, tags and the feed path may use only letters, digits and . _ % - |
.htaccess values |
redirects, wp_ids, home and feed.path may not contain spaces or control characters, even percent-encoded |
deploy.dir |
must be a site folder such as writing.fuzzyworld.net (relative to the login folder); ~, ., /, .. are refused |
| Media | symbolic links in the media folder are skipped |
Deploy and upload safety
fw blog deploy audits the built folder first and refuses anything that is not a generated file or an allowlisted
media type (png jpg jpeg gif webp svg pdf epub kpf mp3 mp4 zip). It then rsyncs only that folder, and keeps
.well-known.
- It asks for confirmation on every real upload. It holds the watcher's lock only for the upload itself, not while the question is open. Ctrl-C at the question exits quietly with 1.
--dry-runshows what would be sent.- The watcher limits the upload to 600 s; a manual deploy has no time limit.
Comments by email
Set "comments_site": "writing" (or "til"; a key of server/comments/sites.json) and
"comments_mailbox": "<address>" in the config. Each post then gets a "Comment by email" link (subject
Comment [writing:/2024/05/01/slug/]) and a container that blog.js fills from /_comments/<post path>.json.
Moderating
fw comments list|show|approve [--trust]|reject|remove|untrust|publish|export|ingest|imap-check runs on the server
(or use the admin panel).
list --jsonprints the pending comments in full plus the 20 newest approved ones, with each post's URL and whether the sender is trusted, never a sender hash.fw adminuses it.- A cron job runs
ingesthourly (operations.md). fw blog deployleaves/_comments/alone.
The mailbox
ingest reads the comments mailbox:
- Hosted mailbox over IMAP when
~/fw/comments/imap.jsonexists:{"host", "user", "password", "folder"}, mode 600, created by hand on the server. TLS with certificate check. Processed mail moves to the folderscomments-doneandcomments-bad(with the server's folder prefix such asINBOX.added when it needs one);done_folderandbad_folderin the file override the names. - Otherwise a Maildir folder
~/Maildir/.comments. fw comments imap-checkprints only header names and the Authentication-Results result tokens, to learn the rightauthserv_id.
Trust and limits
ingestaccepts a message only when the topmostAuthentication-Resultsheader shows a DMARC pass for the From domain (or an aligned DKIM pass), and was written by the receiving mail server. That server's name must be set asauthserv_idinserver/comments/sites.json. Empty means ingest refuses to read anything. Set it in the repo copy, because an install overwrites the server's.- That trust holds only if the receiving server always adds its own header above anything the sender supplied.
- The address is never shown. Only a salted hash is kept (losing
~/fw/comments/secretinvalidates trusted senders). - Limits: 4000 characters, 5 comments per sender per 24 hours, 200 pending.
- State lives in
~/fw/comments/, outside every web root.
The optional form
comments_form (an https action) adds a plain HTML form for the endpoint server/comments/post.php. It is off,
and the endpoint is not installed (hosting tiers). It is written, and its tests pass
on the server's PHP; an optional self_origin key in sites.json names the origin its no-JavaScript confirm page
posts from.
The watcher (fw blog watch)
server/blog_watch.py mirrors the blog source into a git repo and auto-deploys edits that change no URLs, for ONE
blog config. Run it on ONE machine only. The MacBook Air just keeps writing through Dropbox, and its edits are
captured by the mirror on the watching machine. A manual fw blog deploy on another machine does not update this
machine's baseline; the result is a harmless re-deploy or a hold.
- One tick every 60 s: the launchd agent runs
fw blog watch <config> --once. Without--onceit loops. --dry-runshows what a tick would do now and writes nothing.--oncenever runspip: if Markdown or Pygments are missing it exits 1. Run anotherfw blogcommand once to install them.
Config keys (top level only)
"autodeploy": { "quiet_seconds": 300 }, // whole number, 60 or more; turns the deploy guard on
"mirror": {
"repo": "~/code/blog-mirror", // required; ~ expanded; relative = relative to the config's folder
"branch": "dropbox-mirror", // default; may not be main, master or HEAD
"remote": "origin", // no remote means no push
"exclude": ["*.tmp"] // extra rsync patterns; no `..`, none starting with ! + - :
}
autodeploy and mirror each work without the other. The quiet period is autodeploy.quiet_seconds; without an
autodeploy key it is a fixed 300 s, also for the mirror.
State
In ~/.fw-blog-watch/<config folder, lowercased>-<8 hex of the config path's hash>/ (FW_BLOG_WATCH_DIR replaces
the whole path; FW_BLOG_WATCH_NOTIFY=0 turns the macOS notifications off, and the tests set it).
| File | Contents |
|---|---|
state.json |
|
watch.log |
one line per decision |
deployed.json |
the baseline: {files: {path: sha256}, time, generator, config, target} |
hashes.json |
a size+mtime cache so media are not read every minute |
lock |
|
launchd.log |
One tick
- Lock. Take a non-blocking
flockonlock. If it is held, exit quietly; held for over 30 minutes gives one "watch tick stuck" notification. - Fingerprint the source (sha256 of every file's path and content).
.DS_Store,.dropbox*,.gitand Dropbox "conflicted copy" files are ignored; links are not followed. - Wait for quiet. A changed fingerprint restarts the timer; nothing happens until the folder has been quiet for the quiet period.
- Only
sourceis fingerprinted. Editing the config or the comments file does not start a tick (the next source change, or a manualfw blog deploy, picks it up). - A "conflicted copy" file blocks everything (notification, retried each tick). - If the source changes while the mirror or the build runs, the tick stops ("changed during build") and the new quiet period applies. - Mirror (if configured), below.
- Deploy guard (only with
autodeploy), below.
Mirror
The repo must be a git repo with the mirror branch checked out, no merge or rebase in progress, and a clean tree.
Git-ignored files count as changes, so rsync --delete can never remove them. Otherwise the tool notifies and
stops the tick (it is retried; it never overwrites). Only an absent or empty folder is auto-initialised
(git init -b <branch>).
Then:
rsync -a --deletesource into the repo (your excludes, then the protected.git);git add -A;- a commit
Mirror of <source folder> at <UTC time>if anything changed (your git identity; no trailers; hooks skipped; other branches are never touched); - with
remote, pushrefs/heads/<branch>:refs/heads/<branch>(never forced). A failed push is notified, but the deploy goes on.
If a step fails halfway (rsync exit 23/24 is normal while Dropbox syncs, and 24 is retried silently; git add
or the commit can also fail), the tool records exactly what it left behind in state.json as mirror_dirty: the
repo's real path, HEAD, the git status --porcelain --ignored listing, and a hash of those files' contents. The same
record is made after a successful run that leaves git-ignored files copied from a source .gitignore.
- A dirty tree is accepted on a later tick only if the repo, HEAD, the listing and the contents are all still exactly that.
- The record disappears after a run that leaves nothing behind.
- Any other difference (an edited tracked file, a new path) is refused until you look at it, and your edit is never overwritten.
Deploy guard
Builds into a temporary folder (same code as fw blog build) and compares with deployed.json.
| Situation | Result |
|---|---|
| no baseline | held back with a notification |
the generator hash changed (blog.py, atom.py, gen.py, the shared CSS/icon, site-blog/*; fixed when the process starts) |
held back |
| the config changed (hash of the loaded config) | held back |
| the deploy target changed | held back |
| any output file added or removed | held back |
| same paths, no content difference | logged "no visible change" |
| same paths, some content changed | deployed through the same function as fw blog deploy (audit and rsync, no question) |
Held-back changes wait for you to run fw blog deploy <config>, which rewrites all of these baselines. So code,
template and config changes always wait for a manual deploy.
Notifications and failures
- macOS notifications (
osascript) are sent for held-back changes, failures, conflict files, mirror problems and unexpected errors, never for a successful deploy. - A build or deploy failure notifies once and is not retried until the next source change.
- An unexpected exception is logged and notified once, and the tick still exits 0 (so launchd does not throttle the agent).
- Every subprocess has a timeout (git 120 s, mirror and deploy rsync 600 s, osascript 10 s). A timeout is an ordinary failure.
Manual deploys while the watcher runs
A manual real fw blog deploy (not --dry-run):
- takes the same lock (if a tick holds it: "a watch tick is running, try again in a minute");
- refuses if the watcher deployed while you were answering the question (run it again);
- rewrites
deployed.json, which keeps the baseline current; - clears the watcher's "handled" state so the next tick re-evaluates.
The launchd agent
install writes ~/Library/LaunchAgents/net.fuzzyworld.fw-blog-watch-<folder>-<hash>.plist (every 60 s and at
load, low priority) and loads it with launchctl bootstrap gui/$UID (retried up to 5 times). uninstall unloads
and removes it. install --print only prints the plist.
WordPress import
lib/wp_import.py (fw blog import-wp <config> --wp URL) reads the public REST API.
- Skips password-protected posts.
- Matches each post to a source file: similarity of 0.95 or more uses your file; otherwise the WordPress text is written to
wordpress/<date>-<slug>.md. A non-ASCII slug gives a readable file name but keeps its URL. - Writes comments (name, date and text only, into the
commentsfile next to the config), pages (withpublished: true), media and the config entries. --dry-runwrites only a cache andwordpress/_report.txt. A second run changes nothing.
fw blog check <config> lists posts whose built text differs from WordPress.
Tests
python3 -m unittest discover tests covers the blog, the WordPress importer, the watcher, the comments and the runner.