Tagarchitecture13

Content model

How the engine works inside -- one page, following a post's life from keystroke to visitor. The why behind these shapes lives in Decisions.

$EDITOR (markdown) → CLI parse → JSON blocks → build (ERB) → static HTML → deploy backend → host
                                      ↑                                        ↑
                            importers target the                      cron refreshes widget
                            same schema                               JSON + post stats

One post = one JSON file at content.nosync/posts/<year>/<slug>.json:

  • slug, title, date (ISO 8601), tags, state (published/draft), source (which platform it came from), optionally mastodon_url (its comment toot), draft_token and created_at (drafts), type (explicit content type override).
  • content is an array of typed blocks: text (with subtype heading1-6/quote), list, table, code, image, video, audio, file, link, chat, hr.
  • Inline formatting (bold/italic/strikethrough/code/link) is stored as codepoint offset ranges into plain text, not nested HTML -- the same NPF-style shape Tumblr's API uses, which is what the importers naturally produce.
  • Media lives next to its post in media.nosync/<year>/<slug>/, referenced by bare filename; nothing is ever hotlinked.
Read more

The markdown round-trip

Markdown exists only at the editing boundary; storage and build never see it:

Read more

Undoing an edit (lib/post_versions.rb)

Deleting a post was always reversible -- it goes to trash/ and restore brings it back -- but editing one was not, and editing is what happens every day: a paragraph dropped and saved, a round-trip through markdown that could not express something, a paste into the wrong place. So the post as it stood is copied aside immediately before it is overwritten, by whoever is doing the overwriting: PostWriter.write for a re-import, the edit and restore paths in scripts/manage_post.rb for everything a person does by hand.

The store is deliberately plain: content.nosync/versions/<year>/<slug>/, one whole post JSON per file, named for the second it was kept (20260813-174204-118.json) so that sorting the directory is sorting by time and no index has to be maintained. Ten per post; the eleventh drops the oldest, because the newest copy answers "what did this say before I broke it" in nine cases out of ten and an unbounded history of an archive this size is a second archive. PostVersions.move carries the directory into the trash with the post and back out on a restore, or a restored post would come back with amnesia and a deleted one would leave its history orphaned where nothing points at it.

Read more

Importing

An import splits along the line between what every platform needs and what only one does, so that adding a source means writing its mapping, not another copy of the plumbing every existing adapter shares:

Read more

Exporting (lib/exporter.rb)

The mirror of the importers, and a much smaller thing: one source (this archive), one destination format, no adapters. ./blog.sh export walks content.nosync and writes a Jekyll-shaped tree -- _posts/<date>-<slug>.md, _drafts/<slug>.md, pages at the root, media copied under assets/<year>/<slug>/. It only reads: nothing in the archive is touched, which is what lets it be the one command that still works on an installation somebody is leaving (hence its own entry point in scripts/export.rb, needing neither env.sh nor a config that parses).

Three details carry the design:

Read more

Checking the archive (lib/checker.rb)

./blog.sh check is doctor's counterpart and deliberately a separate command: doctor answers "is this installation sound", reads a handful of config values and takes a second, while this walks every post and every media file there is. Rolled into one command the fast half would stop being run, which is the half somebody runs before every deploy.

Read more

Counting (lib/stats.rb)

./blog.sh stats is the same shape as check and export: its own entry point, the archive on disk as its only input, no configuration it could die on. lib/stats.rb counts and returns a plain Hash -- it never prints, colours or translates -- and scripts/stats.rb renders that either as a screen for a person or, with --json, verbatim for whatever reads it next. That split is what keeps the two outputs from drifting: there is one set of numbers and two ways of showing it.

It reuses what already answers each question rather than re-deriving it: PostText.plain for words (the same text the search index is built from), ContentType.dominant for what a post is, FileSize.human for sizes, and the build's own 200-words-per-minute constant for reading time -- two places telling a reader how long something takes must not disagree.

On this day (lib/on_this_day.rb)

One module decides which posts from earlier years stand on a date and which of them the card shows; the command (scripts/on_this_day.rb), the build and the cron all call it, so what the author sees in the terminal is what a reader gets. It is data in, data out: entries (day, year, time, titled, word count, plus whatever the caller carries) and a date go in, windows of rows come out. It never reads the config and never decides a language.

The split between build and cron is the part worth knowing. What a post is called and where it lives depends on the language -- a translation's own title and address, a fallback's, the site's -- and only the build knows that. The day, on the other hand, changes at midnight, when only the cron runs. So every build run writes a catalogue for its language beside the build cache (.on-this-day.<lang>.json: every listed post with its selection facts, its title and its address in that language, and the tree it belongs to), plus today's file; the cron re-chooses from the catalogues and rewrites a file only when its content changed. A build with the card off removes its catalogue, and the run that owns the site root removes catalogues of languages the site stopped publishing -- otherwise the cron would keep writing a tree nobody builds.

Read more

Build pipeline (build/build_blog.rb)

The script is the pass itself; the domains it calls live beside it, one file each -- build/blocks.rb (a content block becomes HTML), build/output.rb (writing, pruning, and getting a file into public.nosync), build/feeds.rb (RSS, Atom, JSON Feed, sitemap), build/discovery.rb (the search index and what feeds it), build/cards.rb (link and social cards), build/languages.rb (which language a run renders, where its reader is sent, and the checks a language configuration has to pass), build/tags.rb (the tag pages and the index of them), build/archive.rb (the archive map and a page per year) and lib/series.rb (what a series is and what order its parts go in, which the CLI needs too). The phases in tags.rb and archive.rb are functions with their inputs spelled out -- the posts, the tag map -- called from the point in the pass where they always ran, so the pages are written in the order they always were.

⚠️ Moving a method out of build_blog.rb is not a free refactor: the templates in templates/ are rendered against the script's own binding, so every helper they call has to stay reachable from there. A helper that moves into a module keeps a one-line wrapper at the top level, or the build dies on the first page that uses it.

A single linear pass, no framework:

Read more

The terminal UI

lib/tui.rb is the whole UI layer: colors, single-keypress choices, arrow-key menus, the screens they live on and a spinner -- pure stdlib (io/console), plain VT100 sequences, no terminfo and no gems. Four deliberate constraints:

Read more

Deploy (scripts/deploy_web.rb + lib/deploy_backend/)

The deploy script owns the what; backends own the how:

Read more

The client side

Small single-purpose vanilla JS files, no framework, no third-party origins. util.js loads first because everything else stands on it: Blog.escapeHtml, which every piece of foreign data goes through before it reaches innerHTML, and Blog.formatDate, which takes its locale from window.BLOG_I18N so a date the browser writes matches the ones the build wrote. Then, in load order:

Read more

Security

The threat model is a static site with no server of its own: nothing here listens on a port, so what remains to get wrong is what the pages carry, what the working directory holds, and the one thing that arrives from outside -- a post sent over the SSH the machine already has.

Read more