Checking the archive

The archive — part 6 of 9

./blog.sh check            # walks every post and every media file
./blog.sh check --online   # also asks whether the links that leave the site still answer
./blog.sh check --json     # the findings as data: every one of them, no screen

Both answer as data when asked: doctor --json and check --json print one object each, every finding in it, for a program rather than a reader -- and check --languages --json prints the table of languages the same way.

doctor asks whether the installation is sound and takes a second; this asks whether the archive is, and walks all of it -- two commands so the fast half keeps being run, and it is the fast half that belongs before a deploy. check reads the content on disk, so it works before a build has ever run, and it names a post and a slug for every finding rather than a file under public.nosync: something to go and fix.

The one thing it looks at outside the archive is config/site.yml, and for the same reason: this is the command people run before a build, and a config the build will refuse is not something to find out afterwards. It reports that file in doctor's words, so the two never describe it differently.

What it looks for, each with a line saying what to do about it:

  • A config/site.yml that is missing, empty, will not parse, or cannot be opened. The build stops on all four and doctor explains all four; check used to read the config only to pick the language it printed in and say nothing about it, so an edited config could be answered with "the archive is sound" and an exit code of 0. The parse error carries the line and column Psych knew about.
  • A post file that will not read, a date nothing can parse, a post whose text is not a list of blocks, a slug or a draft token that is not one path segment (a slash or a .. in it), tags that are not a list, a block whose pictures, poster, list items or formatting are not a list, or a type: the engine does not know. The build refuses to run on all but the last, or writes the page nowhere good, so check says so first: without this it counted the archive minus the broken file and called the rest sound. The unknown type is stored on the post and read by nobody -- no listing, no menu entry, no icon -- and the fix line points at the tag route, which is usually what somebody reaching for a ninth type wanted: a tag named in nav: has a listing, pagination, a menu entry and a feed.
  • A post file the build never reads. Posts are read from exactly content.nosync/posts/<folder>/<post>.json; one lying directly in posts/ or a level deeper -- an rsync with the wrong trailing slash, an export unpacked one folder too far in -- is in the archive and on no page, and neither the build nor check used to say so. A warning, with the path. Hidden files are left alone.
  • A file a queue move stepped aside and a crash left parked. The parking name is dotted precisely so no listing shows it -- which also means nothing would ever find one again without this. What to do with it depends on whether the post inside is still in the archive somewhere: a copy of a post that landed is a leftover to compare and delete, a parked file that is the only copy of its post is one to put back under a free name, and the finding says which of the two it is looking at. It never tells you to write over the post standing at that name.
  • Media a post asks for and hasn't got -- a video's poster image included -- usually an import whose download failed. The page renders a hole. A file that is there and useless -- empty, unreadable, or a folder under the picture's name -- is reported the same way, with a sentence that says which it is; and a video whose index sits at the end of the file is noted, since it plays but makes every reader wait for the whole download before the first frame.
  • Images stored as 1px or smaller. The build treats those as tracking pixels and drops them together with their caption, so the page loses both without saying so.
  • Internal links pointing at nothing. Typically a permalink left over from an import, or a slug renamed back before renames left a redirect behind.
  • Links written relative to the post rather than to the site -- ./?item=another-post, photo/index.php?gallery=3, ../about/. A dynamic site could afford those; a static one resolves them inside the post's own address, and a static host ignores a query string, so the link answers 200 with the page the reader is already on. Nothing breaks and nobody arrives, which is why these survive audits: rewrite them as rooted paths. A bare #fragment is left alone -- resolving against its own page is the point of it.
  • Media directories no post owns -- left by a deleted or renamed post, or an import that ran twice. Nothing links to them; they cost disk, not correctness, which is why they are a warning. Names are compared the way macOS and iCloud compare them, ignoring letter case and unicode form, so a directory restored in another form is not called orphaned while the post is reading its pictures from it.
  • Files in a post's own media directory that the post no longer names -- a source that dropped a picture leaves its file behind, because an import only ever adds. A warning too; dotfiles are left alone.
  • Two series whose names differ by a character or two -- usually one series with a typo that quietly founded its own. A warning; names differing only in digits are left alone (rok-2025 next to rok-2026 is two year-series, not a typo).
  • One old address claimed by two posts. Whichever renders last wins and the other's readers land on it.
  • Two posts that would be served at one address. The build refuses to run at all in this state, so this is the one finding that stands between you and a site that cannot be rebuilt. Two addresses that differ only in letter case or in how an accent is encoded (Praha and praha) are reported as well, as a warning: on a Linux server they are two, on macOS and in iCloud they are one folder, and one of the posts is missing from the site.
  • A post that looks like a copy of another -- x-2 beside x in the same year, with the same title, the same moment and the same content (text compared by its words, every other block -- a link, a quote, a list, a picture -- compared whole). That is what importing an export back into an archive that already held it leaves behind: every hand-written post a second time, each under its own address, so nothing else here would object. A warning, not an error, and deliberately narrow -- a slug that ends in a number beside the same slug without one is ordinary (an importer that keeps the source's post id in the slug makes exactly that), so all four have to agree before it says anything. Two link posts that open with the same line and point at different articles are not copies, and are not reported. Delete the numbered copy; the original keeps its address.
  • A redirect_from the build will not serve -- one whose first segment belongs to the site itself (/tag/..., /posts/...), or whose shape no directory can be made of. The build says so once, in the middle of a log nobody keeps, and the old address quietly 404s; worse, a link pointing at it used to pass as sound. The same goes for an old address -- in redirect_from or former_slugs -- that a live post or page now stands at: the build serves the live one and skips the redirect in one line of its log, so every link to the old address reaches the wrong post.
  • Text carrying HTML entities instead of the characters they stand for. journalists &amp; writers reads as journalists &amp; writers on the page: the build escapes it again, correctly, because as far as it knows the ampersand is what you wrote. Twitter escapes &, < and > in its archive without saying so, and the importer only learned to decode them in 1.4 -- so an archive imported before that carries them, and upgrading cannot help, since by then they are in the posts. Reported rather than corrected: somebody writing about html has every right to &amp; in their text.
  • Two posts asking for one address in one language -- a translation and another post that both claim /de/…, which the build would resolve by serving one of them and dropping the other. And a text filed under the site's OWN language in translations, which nothing reads: in that language the post is its own title and text.
  • A config/site.<lang>.yml that nothing reads -- written for a language the site does not publish, carrying a key the engine does not know, or translating something site.yml does not have. All of them do nothing at all, and all of them look like the work is done. And one whose menu or footer links go to other places than site.yml's: a translation changes the words, not where they lead. A word for a tag no published post carries is worth a look rather than an error: it shows nowhere, and nothing is broken. Worth a look in the same way: a name for a series nobody writes, a menu item or a footer link with no word of its own (shown under the site's), a title given as nothing (the site's own stands), a fallback naming a language the site does not publish, and a language with a borrowed interface that does not say what it is called (language_name).
  • A language named in site.locales that the engine has no locale file for. A typo there is not a typo in one language: the build stops on it in EVERY language of the site, its own included, because the menu that offers the other languages needs each one's name. A language the engine has no words for may borrow another's: ui_language: <code> at the top of that language's own file, config/site.<code>.yml.
  • site.fallback or site.ui_language in config/site.yml. That is where a language's own settings were kept before 1.9 gave each language a file; the build refuses the old place by name, in every language. Each value goes to the top of its language's config/site.<code>.yml.

It only reports, unless you ask it not to. On its own -- and that is how cron runs it -- nothing here deletes a directory or rewrites a post: the value of the tool is that its output can be trusted, and a checker that also acts has to be trusted twice. --repair is where the asking happens, one finding at a time and never without a key press; it is described below, and it earns its trust the hard way -- it adds rather than rewrites where it can, it moves files to the trash rather than deleting them, and it keeps a version of a post before changing it. Twenty findings of each kind are listed and the rest counted, so one bad import can't bury everything else. It exits non-zero on errors only, never on warnings, so it can hang off cron and speak up only when something is actually broken.

--online additionally asks the web about every link that leaves the site. It takes minutes rather than a second, which is why it has to be asked for by name. What it reports is deliberately narrow: a host that no longer resolves, and a page answering 404 or 410. A timeout, a refused connection, a 5xx or a 403 is the web saying "not right now", and reporting those would turn one flaky evening into forty findings that are all fine tomorrow. Anything that looks dead is confirmed with a second request before it is believed, because some servers answer a HEAD with 404 and a GET with 200 for the same address. Verdicts are remembered in tmp/link-check.json for two weeks, so running it again next week only asks about the links it hasn't seen lately; deleting that file just means the next run asks about everything.

--repair walks the findings and offers, for each one, the single repair that finding allows -- nothing is applied without a key press. A dead link to an old address is repaired on the target post, by writing that address into its redirect_from: one added line, your own text untouched, and every link to the old address answered at once, including the ones from outside the site that no check can see. That holds for an address of the web a post came from. One the site makes itself -- under /posts/, or under the folder of a language -- is an address no redirect_from may answer at: check says so in its advice, no repair is offered, and what answers there is the post's own old address, former_slugs (the wrong year in a link to /posts/2025/a-post/ is fixed in the link). A link written relative to the post is rewritten to the address it means. A media directory or file no post references is moved to the trash the engine already uses -- trash/<year>/<slug>/media/ -- and ./blog.sh restore <slug> puts it back; the repair pass never deletes anything. A file whose name differs from the one on disk only in letter case or unicode form is not a leftover at all: the pass offers to write the name the directory actually uses into the post, and never touches the file. Text carrying HTML entities is decoded one post at a time, and the offer says how many places in that post change and shows the first -- it is one decision for the whole post, so a post with an imported &amp; and a deliberate one is worth a look first. A post that changed after it was checked (edited in another window, or from the phone) is refused with a sentence rather than decoded on the strength of a scan it no longer matches.

Where the right answer is a matter of judgement -- two posts claiming one old address, an image the author has to look at, a link to something this archive never had, a slug two posts share across two years, or a target that is still a draft -- it says so and passes over. A second run proposes nothing, because the findings it repaired are gone -- except for text an import escaped more than once, which comes off one layer per decision; the run says so when a layer is left, and the next one offers it. Run ./blog.sh rebuild afterwards to put the changes on the site.

The screen shows at most twenty findings of a kind and totals the rest in a "...and N more" line, which is right for reading and useless for acting on: a script that wants to add redirect_from for every dead link cannot work from a summary. --json prints all of them instead, each with the kind it is (link_dead, media_stray, series_similar...) and the data it is about -- the post's slug, the address, the file. The exit code is the same in both modes: 0 when the archive is sound, 1 when something in it is not.