SeriesThe archive9

Importing from another platform

The archive — part 1 of 9

./import.sh opens its own wizard: pick a source, and it reads the whole thing in dry-run first and tells you what would be written -- how many posts and media files, the first few slugs, and how many items it skipped and why. Nothing is written until you confirm, and confirming means typing the number of posts rather than pressing a key -- an answer you can't give without having read the preview. The sources cover blog and newsletter platforms (WordPress, Blogger, Ghost, Medium, Substack, a Jekyll/Hugo tree, ...), the social networks (Twitter/X, Mastodon, Bluesky, Instagram, ...), podcast feeds, and the Wayback Machine for a blog whose platform no longer exists at all. The wizard groups them by that question -- a blog you published, a network you posted to, a dead site -- and the full list lives in Importing.

Every source also runs without the wizard, for cron or a scripted migration -- one scripts/migrate_<source>.rb each, e.g. scripts/migrate_bluesky.rb <handle>, scripts/migrate_instagram.rb <export-dir>, scripts/migrate_wayback.rb <https://dead-blog.example>, scripts/migrate_feed.rb <export.xml | feed-url>. Those skip the preview and write immediately; see the README.

Read more

Taking your content elsewhere

The archive — part 2 of 9

./blog.sh export ~/my-blog-export        # everything, drafts included
./blog.sh export ~/public-copy --no-drafts
./blog.sh export ~/somewhere --dry-run   # counts, writes nothing

Writes the archive as a tree of markdown files with YAML front matter, in Jekyll's layout: _posts/2026-05-01-slug.md, _drafts/slug.md, pages at the root, media copied under assets/<year>/<slug>/. Without a directory it writes to tmp/export. A directory that already has something in it is refused until you repeat the command with --force, which writes alongside what is there -- an export never deletes anything, at either end. With --force, files an earlier export of this site wrote are refreshed in place; anything else -- a cloned site's own _config.yml, a post of somebody else's under the same file name, a different picture under the same path -- is left exactly as it is, the post of ours goes beside it under another name, and the summary lists what was left alone.

A post that cannot be written -- a stray byte from an old import, a slug that is not one path segment -- is named at the end and the rest of the archive still comes out; the command then exits 1, so a backup made by cron is seen to be short.

Read more

Reading the archive

The archive — part 3 of 9

Three pages the build writes for you, all from the posts themselves -- there is nothing to configure and nothing to keep up to date.

/archive/ is a map of the site in two levels. The first is a row per year with a strip of twelve months, each month shaded by how much was written in it; the second, /archive/<year>/, is one line per post, grouped by month, oldest first. No excerpts and no pictures: this is an index, and its whole point is that twenty years fit on one screen. Pagination cannot do that -- it is anchored to the oldest post, so /page/128/ says nothing about whether it holds 2009 or 2014. Every post's date badge links into the month it belongs to, so a reader who finds one post can see what surrounded it.

Under the map a legend says what the shading means -- the four steps between "less" and "more", in the map's own colours. A year's page opens with that year's row of the map again, behind the words "jump to a month": a month with posts is a link to its section further down the page, shaded as it is on the map.

A year nobody wrote in gets a row on the map and no page of its own: an empty page is an invitation to a dead end. A post is filed under the year of its ADDRESS, so /archive/2025/ and /posts/2025/ always agree.

Read more

Giving a tag its own icon

The archive — part 4 of 9

A tag can carry an icon, and it shows up in two places: the heading of its /tag/<name>/ listing, and the date badge of every post that has the tag, where it replaces the content-type icon rather than joining it.

tag_icons:
  - tag: "fotografie"
    icon: "image"
  - tag: "kolo"
    icon_svg: '<svg viewBox="0 0 24 24" ...>...</svg>'

A tag is matched here by its address -- the /tag/<name>/ its listing lives at -- so how the name is spelled, here or in a post, makes no difference: sci-fi, Sci Fi and sci_fi are one tag, share one page and wear one icon. A tag that leaves nothing to be addressed by, an emoji or a piece of punctuation, has no page and takes no icon either.

The order is the priority. Most posts carry more than one tag -- 68% of them on the archive this engine was built around -- and the tag a post was given FIRST is usually the one an importer added, not a subject: twitter opens 1256 posts there. So the first entry in this list that a post has is the one it wears, and the site owner decides once instead of post by post.

Read more

Emptying the trash and the versions

The archive — part 5 of 9

The engine keeps two things after you are done with them, and both have a way back: a deleted post waits in trash/ for ./blog.sh restore, and every save keeps the previous text of a post, offered by the version picker in ./blog.sh props. Neither had a way OUT before 1.6 -- the only way to empty either was rm on the server.

./blog.sh empty trash       # deletes every trashed post for good
./blog.sh empty versions    # keeps each post's newest version, removes the older ones

Both say how much they are about to delete and confirm by having you type that number -- the way delete has you type the slug. A trash has no slug to repeat back, and the count is the one number you have just been shown, so typing it means having read it. Anything else, including an empty answer, cancels and deletes nothing.

empty trash takes the media of a trashed post with it, and covers both shapes the trash has had: today's trash/<year>/<slug>/ and the flat trash/<slug>/ of an installation older than that.

Read more

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:

Read more

Counting the archive

The archive — part 7 of 9

./blog.sh stats           # a screen: posts by year and kind, words, tags, media, sources
./blog.sh stats --json    # the same figures, for whatever reads them next

Reads the archive on disk -- no build, no network, no env.sh -- and comes back in seconds even on a large archive. What it says, and why each line is there:

  • The archive: posts split into published, drafts, scheduled and pages. The four add up to the total; a page that is also a draft counts once, as a page.
  • By year and What they are: where the archive is thickest, and what it is made of (the dominant content type per post, the same one the /type/ listings use).
  • Words: total, mean and median. Both averages, deliberately -- an archive half made of imported tweets pulls the mean far above the middle, and only the pair says so.
  • Tags, Media (files on disk, or what the posts ask for when the media directory is not here) and Where it came from -- the platforms the archive is actually made of, which is a different number from how many the importers support.

--json prints the same figures unlocalized and unrounded, so the numbers can go into a post, a cron job or a graph.

On this day

The archive — part 8 of 9

./blog.sh on-this-day                    # today: every post from earlier years, and what the card shows
./blog.sh on-this-day --date 12-24       # another day of this year
./blog.sh on-this-day --date 2030-12-24  # ...or of another year, ages counted from then
./blog.sh on-this-day --json             # the same, as data

The same selection feeds a sidebar card, switched on by its name alone:

widgets:
  on_this_day:
    # heading: "On this day"    # optional -- the engine says it in every language

(or in ./style.sh, under the sidebar). The card reads the site's own archive, so there is no account to give and nothing to fetch. What it shows, and why each rule is there:

Read more

Letting a reader pass a post on

The archive — part 9 of 9

The old way to do this was a row of buttons for Facebook, Twitter and Pinterest, each with one address to send somebody to. The fediverse has no such address: the destination is the READER's instance, and a page has no way to know it.

So the block is a list you write, and it holds three different kinds of thing:

share:
  - mastodon
  - bluesky
  - email
  - copy
  - system

Leave share: out and no block appears. The order here is the order they are drawn in.

Read more