Publishing and comments
Comments are replies to an announcement post -- on exactly one network. No comment database, no moderation queue, no GDPR surface, no spam plugin -- replies live where people already are (Mastodon or Bluesky, whose thread APIs a visitor's browser can read directly -- when the server allows it. GoToSocial does not: reading a thread there needs a token on every request, with nothing to configure, and a Mastodon in secure mode behaves the same. On such a server the live mode cannot work at all, and comments.approval: fav -- where a cron reads the thread WITH a token and only starred replies reach the page -- is the whole of what is available. doctor --online asks the question and says which one you have). The two networks are deliberately exclusive: configuring both would split every post's discussion into two half-threads, so the build refuses it. Cost: comments require a presence on the chosen network, and deleting the announcement deletes the discussion (which unpublish does deliberately, to never leave an announcement pointing at a dead URL).
Moderation, where there is any, is a star on the reply -- and it is off by default. The engine had no answer to a reply written to wound: the thread was published whole, and the only recourse was on the network. comments.approval: fav inverts it -- a reply appears under the article once the author favourites it, from whatever client they already have on their phone. No queue, no dashboard, no second identity system: the moderation interface is the social app the comments already live in, and the approval is one tap in the place you were reading anyway. Two rules keep the result readable rather than merely filtered: the author's own replies need no star (nobody stars themselves, and without it half of every exchange would vanish), and a reply is only shown if everything between it and the announcement is shown too (an approved answer to a rejected comment answers nothing).
Costs, and they are real: - A favourite is public, on both networks. Approving is also endorsing in the eyes of anyone who looks, and every star handed out over the years is retroactively an approval. A private signal (Mastodon bookmarks, Bluesky's saved posts) would not carry that, and is the obvious second mode if this one chafes. - Comments stop being live. The live thread was the justification for the one client-side exception to no third-party requests from the visitor's browser (below), and a moderated thread is no longer live -- the page shows a curated subset either way -- so the exception lapses with it: cron writes comments.json and the page reads it from its own origin. What that changes, and what avatars still do, is said once, there. - The engine now stores other people's words. Modestly: only what was approved, rewritten from the source on every cron run, with no interface that can edit it -- a cache, not a database. But the claim above, that there is nothing to moderate or migrate, holds only while this is off, and a deletion at the source follows at the refresh cron's cadence rather than immediately -- under an old post, somebody's deleted reply can outlive its deletion here by up to a week. That lag is part of this decision's price, not a tunable; the cadences behind it, and how to settle a deletion on the spot, are in Running it. - Turning it on hides every existing comment until the author goes and stars the ones worth keeping. Which is why the default is off and why it stays a per-site decision. - It is not a defence, it is a filter. The reply still stands on the network, under an announcement this site links to. Block, mute and report are still the only things that touch it there.
Everything starts as a draft, and drafts live on the public site behind a SecureRandom token with noindex. The whole point is previewing from a phone or sending the link to a reviewer before publishing -- a localhost-only preview can't do either. Cost: the draft text physically exists on the host; the token (and staying out of every listing and index) is the fence.
A published post can be unlisted, and unlisted is as far as it goes. unlisted: true is the draft's hidden-address idea generalised to a post that is finished -- what it reaches is in Writing. It stops short of being a password deliberately: a static host serves whatever it is asked for, so the only honest way to gate a page would be encrypting it in the browser, and that is a promise this engine will not make (the key would sit in the same page). Cost: anyone holding the link can read it and pass it on. The truth test is the loose one, unlike pinned's strict one, because the two typos are not worth the same: a mistyped pin costs a post its place at the top, a mistyped unlisted would put something into every listing its author meant to keep out of them.
The publish date comes from publishing, not from a field. Publishing means "now", scheduling asks for a date -- so the frontmatter template offers no date: line, which would be a third path to the same decision and the only one whose effect isn't visible where you make it. The parser still honors a hand-typed date: (that's how imports keep their original dates). Cost: backdating is no longer discoverable from the editor; it's a documented escape hatch rather than a suggested field.
The site declares its timezone; TZ and the system zoneinfo do the work. A server clock is usually UTC, which silently made "schedule 10:30" mean 12:30 locally and could date a post written after midnight to the previous day. site.timezone belongs to the site the same way site.lang does, so it lives in config/site.yml rather than in the unversioned env.sh, and every entry point applies it at startup by setting TZ -- Ruby then reads the OS zone database, DST included, with no gem and no timezone table of ours. An unknown zone name aborts, because Ruby's own fallback for one is a silent UTC.
Dates a reader sees are rendered in that zone too, via post_display_time and a getlocal in each sidebar fetcher -- otherwise a post imported as UTC, or a toot posted late in the evening, shows the previous day. post_time itself deliberately stays on the stored offset, because post_path derives the year from it: shifting that would move a post published near midnight on December 31 into another year, changing a live URL. Feeds and the sitemap stay on it for the same reason in reverse -- they carry absolute instants for machines, where the offset is noise. Cost: stored dates aren't rewritten, so a site adopting this re-renders only the posts whose local day actually differs -- a handful on a real archive, none of them changing year.
Importing gets its own wizard, and always previews before it writes. ./import.sh is separate from ./blog.sh because the two have opposite shapes: authoring is a daily loop over one post, importing is a rare bulk operation that drops thousands of files into content.nosync/ at once. The authoring menu stays about authoring, and the irreversible thing needs its own door opened deliberately. Every import runs the adapter in dry-run first and reports what would be written -- counts, the first slugs, and why items were skipped -- because discovering afterwards that 2000 posts got the wrong slugs has no cheap fix. Confirming means typing the post count back, not pressing a key: deleting a single post already makes you type its slug, so a bulk write had the bigger consequence behind the weaker gate. Cost: two entry points to learn, and one shared lib/site_header.rb so their identity blocks can't drift.
A long import narrates itself. Every phase that runs for more than a few seconds prints progress: what is about to be read and how big it is, how many items were found and filtered, then a 12/847 counter per post. Silence during a multi-hour media download is indistinguishable from a hung process, which is the worst thing to hand someone waiting on a tool that writes into their archive. The counting lives in lib/import/, behind callbacks; the escape codes live in the wizard. Cost: importers print more than a script strictly needs, and a piped run throttles to one line per hundred items so logs stay readable.
Deleting is moving to trash/; two posts can never share a URL. There's no database transaction log to lean on, so the engine refuses the two silent data-loss paths: delete is reversible via restore, and the build aborts on a year+slug collision instead of letting one post overwrite the other.
