Writing and publishing
Day-to-day usage of an installed site: writing, deploying, cron, backup and what to do when something fails. For the zero-to-deployed path see Getting started.
./blog.sh with no arguments opens a numbered menu; every command also works directly (./blog.sh add, edit, publish, ...). The flow is built around drafts:
addopens$EDITORwith a frontmatter template (title, tags, type) -- the in-editor hint links to the/markdown/syntax reference on your own site. Saving always creates a draft, and the draft appears at a hidden/draft/<token>/<slug>/address withnoindex-- invisible in every listing, shareable by URL (that's the point: open the preview on a phone or send it to someone before publishing).
Saving a draft builds the site and deploys it. Not only the draft's own page: the deploy is the ordinary one, so everything the build produced goes to the target, the front page included. It has to -- the preview is a real address on the real site, and so is the answer a phone goes and asks for. There is no way to keep a draft off the target while its preview is expected to work. See Getting started → Pick a deploy target for what that means for a target that already holds something. 2. The CLI then asks: publish / schedule / keep as draft / back to editing. Publishing sets the date to that moment (scheduling asks for one instead), moves the post to its real URL and -- with a comments network configured (Mastodon or Bluesky, see Getting started) -- sends the announcement post that replies-as-comments hang off. 3. edit <slug> round-trips the stored post back to Markdown in your editor. A save that would drop content markdown can't express (an imported embed, a link card) warns and asks before proceeding.
A line reading //--more--// splits a post in two: what it says about itself, and what it actually says. The listing card, the link card and the announcement take the first half; the post's own page shows everything. Without the marker a card is cut to fit a size budget, which lands wherever the budget runs out rather than on a sentence you chose. 4. unpublish <slug> returns a post to draft and deletes its announcement on the network (an announcement pointing at a dead URL helps nobody). The next publish gets a fresh date. delete <slug> moves the post and its media to trash/ -- restore <slug> brings it back. Trash keeps only the most recent deletion per slug. The same slug can exist in several years (backdating makes that easy); every slug-addressed command -- edit, delete, toot, publish, ... -- then first lists the matching posts (date, type, state, title) and asks which one you mean, so a delete can't silently land on the older post. A number picks, anything else cancels. 5. toot <slug> (Mastodon sites) or bluesky <slug> (Bluesky sites) (re-)sends the announcement for an already published post -- typically an imported one that never had one. An existing announcement is never overwritten.
A post's header can say more than the template offers. add prefills title, tags and type, because that is what every post needs; the parser accepts a few more keys, and edit brings back whichever ones the post already carries, so each can be read as well as changed:
series: Namefiles the post into a series. A series with two or more published parts gets a listing of its own at/series/<slug>/-- a series of one is just a post, and the listing appears when the second part does -- and with it a line in the index of series at/series/. The listing reads from part one: a long series is paged in reading order,/series/<slug>/holds the first parts and/series/<slug>/page/2/,/page/3/… the ones after, so the "part 1 of 30" link on every post opens where the series starts. (Up to 1.7 a series of twenty parts or more opened at its last page instead, and its/page/N/addresses held other parts than they do now.) A series whose name is too long to fit an address gets no listing; its parts still say which part they are, without a link. The build puts the series' name and "part 2 of 5" on each post in it -- and on each card of the series' own listing -- with the way to the part before and the part after -- within the series only, since a post's chronological neighbours across a whole archive are rarely what a reader wants next. Parts are ordered by date, which is what a series written in order needs;series_part: 3is for the one written out of order or inserted afterwards. The draft preview answers the series question at writing time: a name with published parts shows how many and which part this would become, and a name no published post carries yet is called out as "a typo, or the first part?" -- so a misspelling doesn't quietly found a second series.checkcatches the older ones (below).
A number is a claim on a slot, not a label: the part is inserted at the position it names among the ones ordered by date, so a series whose numbers don't add up -- two parts both told 2, a number past the last part -- gives one of them a different place than it asked for. props says so where it happens, showing the number the post carries and, after it, the place the page will actually give it. Where the two agree, which is the ordinary case, only the number is shown. - link: gives the post a link card: the address it is about, drawn above the text. link_title: and link_description: are the words on it, and a post with no title: of its own is named by the card. It is a header key rather than a line in the body because a paragraph that is only a link already means something else -- an ordinary link in ordinary prose -- and because the card belongs to the post, not to a paragraph of it. edit writes all three lines back out, so a save cannot drop a card the author never touched. The address is a whole http:///https:// one, or one rooted at this site (/posts/2026/some-post/) for a card about another post here -- which is also the shape check --repair leaves behind when it straightens a relative link out of an import. - publish: yes publishes the post the moment add <file> writes it -- the date settled, the announcement sent, the site rebuilt -- the road publish <slug> --yes takes at a desk. Only the file route reads it; the wizard never publishes directly. Absent, or anything but yes/true/1, is a draft. - edits: <slug> makes the file not a new post but a new text for that post -- the save edit does, with every question it would ask turned into a refusal, because nobody is there to answer it. base: beside it is the digest ./blog.sh drafts --json or edit <slug> --json handed out with the text; when the post has changed since, the file is refused (changed) instead of undoing whatever happened in between. A draft may come without base:; a published post may not (base_required), and saving one rebuilds and deploys. What the new text leaves out is taken out: a paragraph, a picture or a rule its author removed is the edit, and a picture the text stops naming is deleted from the post's media. A picture the text names by a name the post already has is the post's own file -- so a new one sent under that name is not used (the answer says so in warnings); a picture is replaced by sending the new one under a name of its own. Taking everything out is not an edit: a file with no text under its header is refused (empty), as a new one is, because that is also what a program sends when it never had the text -- of a post that had a body; one that is a title and its tags, or a link card, which travels in the header, has none to begin with, and is saved. What the text leaves as it was is saved as it was stored -- the very block, with whatever an import put on it that markdown cannot say (a paragraph's kind, a video's provider) -- so an edit changes what was edited and nothing else. A block the text did change is made again from the text, and keeps what the text could not have said about it -- the kind an import gave a paragraph, the address a video was first at -- where the save can tell which stored block it rewrote: between two blocks kept as stored, the rewritten blocks are paired in order with the stored ones of their kind when there are as many of each. A paragraph inserted beside a corrected one makes two for one, and both are saved as written. What is refused as a loss (content_lost) is what the author never saw going, and it is one of two things. A block the text has no line for at all -- a second link card, a player from somewhere the engine does not know -- is lost by any save of that text: such a post is marked editable: false when its text is handed out, and editing it is for the desk, where edit asks. And a block the edit rewrote may have held more than its words -- a mention an import made of @somebody, small print: that edit is refused too, naming the thing, while the same post edited anywhere else is saved, with that block as it stood. This is how an app saves a post it opened (see Writing from a phone), and a way for a script to edit without the $EDITOR trick. - toc: true / toc: false overrides the table of contents. A post with four headings or more gets one on its own -- that is the length at which a reader starts scrolling to look for something rather than reading down. Say false on a post that has the headings but not the shape, true to force one below the threshold. - hero: true / hero: false decides whether the post's first usable image runs full width above the title, with the date moving into a byline under the picture. Without the key the post follows layout.hero for the whole site (Getting started). A 1px image is never lifted, and a post with no usable picture simply keeps the ordinary shape -- there is no second template to keep in step.
Two things the site says without being asked. Every card and every post page carries how long the post takes to read, counted at 200 words a minute -- the same figure ./blog.sh stats reports; a post too short to time says "under a minute". And the build writes a noindex /404.html in the site's own chrome, menu and search field included, so a dead end is somewhere to go on from. Under its sentence stand a search field of its own -- filled, where the browser runs scripts, with the words of the address that was not found -- and pills back to the front page, to the archive and to the index of tags; an address that names a year the archive has gets a pill straight to that year. The page is one file for every missing address, served by your host: it is written in the site's first language whatever language the address was in.
Backdating isn't part of that flow -- publishing means "now" -- but the frontmatter parser still honors a date: line you type in by hand (the template just doesn't offer one). To publish a draft into the past:
./blog.sh edit muj-post # add a line between the --- markers: date: 2019-11-17 10:00
./blog.sh publish muj-post # -> "Date kept from frontmatter: Nov 17, 2019 10:00"
The date takes any format Time.parse reads and is interpreted in site.timezone. The post's URL year follows the date -- the JSON and the media directory move into that year -- and the auto-announcement asks for confirmation first when the date is more than a day off. A backdated post is ordered by its date, so it can skip the homepage and RSS entirely and land straight in the archive; the CLI says so when it happens. If another post already owns that year/slug combination, publishing refuses rather than overwrite it. (schedule is the opposite direction on purpose: it refuses past dates.)
Scheduled publishing: in the post-save dialog, choose [s] and enter the publish date and time directly -- the publish-scheduled cron then publishes the draft (toot included) once that date arrives, keeping it as the post's date. The standalone ./blog.sh schedule <slug> asks the same question, so either route works. The time you type is read in site.timezone (Getting started) -- worth setting before you schedule anything from a server, whose clock is usually UTC. A past date is refused (that would just mean "publish now", and publish is for that). Running schedule on an already scheduled draft cancels it; list shows scheduled drafts as [SCHEDULED].
Two kinds of post are published by that cron without being announced, and it says so per post, naming ./blog.sh toot <slug> for sending the announcement by hand: a post dated more than a day from now (a backfill -- an old thread given a page, a post imported and then queued -- reads as news in a live timeline), and a post that already carries an announcement of its own, which happens when it was published once, unpublished and put back in the queue. Announcing that one again would leave the first thread live with its replies while the post pointed at a second, empty one. Several posts falling due in the same tick -- after the cron has been down, say -- are published oldest first, in the order the queue was arranged in.
In the terminal
The CLI adapts to where it runs. In an interactive terminal you get arrow-key menus (digits still quick-select, typing a slug still works), single-keypress answers without Enter, colored state markers and a QR code of the draft preview URL -- after a save, and on [q] in a draft's properties -- point your phone's camera at the screen instead of retyping a token. A menu longer than the terminal is tall scrolls, showing your position in the list next to the hint; Page Up, Page Down, Home and End work in every list.
The screens hold still. Menus, the queue, the archive, a post's properties and every picker repaint over themselves instead of printing another copy on each keypress -- and not on the alternate screen, so the last screen and your scrollback survive. Resizing straightens a waiting menu, the queue or the properties screen on the spot; a picker or the archive browser keeps the size it opened with until you leave it.
Enter and Esc mean the same everywhere. Enter takes what the question offers -- the capital letter in [Y/n], the named [Enter] …, the row under the cursor. Esc leaves without doing anything: it never builds, deploys, opens the editor or writes a file, and in a question about a setting it keeps the current value. A question you type an answer into is read a whole line at a time, so there it is Esc and then Enter: the current value stays, and "publish when?" is left without a date.
The three question-and-answer wizards -- ./setup.sh, ./style.sh and ./import.sh -- keep the section you are in and the answers already given above the question, so a half-finished run says where you are.
Piped, scripted or cron runs get the plain line-based prompts unchanged, with no escape codes in the output. Colors honor NO_COLOR and TERM=dumb.
./blog.sh preview [<port>] serves the built site locally (default port 8000) when you want to look at it without deploying.
Properties and actions
./blog.sh props <slug> (in the wizard: pick a post, then v) shows everything about one post in one place -- state, type, tags, the pin, the announcement -- and offers the guarded actions:
- published: unpublish, (re-)announce, pin/unpin, rename the slug, review the old addresses that redirect here, delete;
- draft: publish (asked first -- with a network configured it announces too, which nothing takes back), schedule (or reschedule, or cancel the schedule), rename the slug, delete -- and the screen names the draft's preview address, with
[q]for its QR code, so a draft can be read on a phone without opening it in the editor first; - either:
[e]opens the post's properties -- which series it is in and which part of it, its tags, its type, and the three flags (out of the listings, lead image, chapter list); - either, once there is one:
[v]restores what the post said before one of its recent saves.
Properties are what the post IS, not what it says, and until 1.7 the only way to change one was edit -- the whole article open in an editor to add a series somebody forgot. Worse, on a post whose blocks markdown cannot all write down (an imported embed, a link card that is not the first block) that edit asks whether it may drop them, which is a great deal of risk for one word of metadata.
[e] writes each answer as it is given and rebuilds once on the way out. Two of the rows are pickers rather than prompts, on purpose: the series row lists the series the site already has, with how many posts carry each, because a series typed a second time is a second series and check only notices afterwards -- and the type row lists the eight the engine knows plus the way back to letting the content decide, which is what a post with no type of its own has always done. The tags row prints the site's most-used tags above the line it asks for, for the same reason.
The lead image and the chapter list have three states, not two: on, off, and whatever the site does -- which is what a post that says nothing about them takes, and a different thing from saying no.
props <slug> --json prints the same screen as one object and exits: state, date, address, type, tags, series, the flags, which languages the post is written in, whether and where it was announced (announces says which of the six cases it is), the old addresses -- and actions, the keys the screen would offer this post, by name (publish, schedule, unschedule, unpublish, announce, pin, properties, rename, addresses, versions, delete). A program reads that instead of re-deriving the screen's rules, then calls the command it wants. A slug nobody has, or one that lives in two years, is refused as an object ("ok": false, not_found or ambiguous_slug) with a zero exit, the way add --json refuses; the slug is required, because an object for an answer means nobody is there to pick one.
Undoing an edit is what [v] is for. Every edit and every translate keeps the previous text first, up to ten of them per post, and [v] lists them newest first with the line under the cursor showing what that version said -- its title, or its opening words when it has none -- so the choice is made by recognising the text rather than by reading timestamps. The key appears only on a post that has been edited at least once.
Choosing one is itself undoable: the current text is kept as a version before it is replaced, so a wrong choice is one [v] away from being walked back -- which is why a single key confirms it. Only the text comes back -- images are not versioned, and the line above the list says so. The text of every language the version holds comes back with it; a language the post was given since is left as it is, and so is the address each language serves the post at. A version that will not parse stays in the list without a preview. Versions travel with the post into the trash and back out again.
Type and tags are shown here but edited in the frontmatter of edit, prefilled with their current values -- one keystroke away from the text they describe. The pin is the exception: it is a switch, not a value, so [c] flips it right here (the pinned: header line keeps working too). The pinned post is also marked [PINNED] in every list and picker, so it can be found without remembering it. A plain draft shows no time on purpose: a draft has none until publishing or scheduling gives it one.
Keeping a post out of the listings is the unlisted: true line in its frontmatter. The post keeps its ordinary address, its date and its redirects, and drops out of the homepage, the tag and type archives, the feeds, the sitemap and the search index; its page is noindex. It is the draft's hidden address, generalised to something finished -- for a post meant for the few people you send the link to. It is not a password: a static site hands over whatever is asked for, so anyone with the link can read it and forward it. If that is not good enough, the answer is not to publish it. Publishing an unlisted post announces nothing -- by hand or from cron, and --force does not open that door, because an announcement cannot be recalled once a server has it; to announce the post, take the flag off first. props says so on an unlisted post, and the line comes back with its current value the next time you edit, so the state can be read as well as changed.
Renaming a slug never breaks a link. The old address stays on the site as a one-page redirect to the new one, recorded in the post itself (former_slugs), so it survives edits, re-imports and full rebuilds. A rename costs one extra page per old address -- not a 404. Two things to know: unpublishing takes the redirects off the site along with the post (they return when it does), and deleting the post deletes its old addresses' redirects with it. Renaming a draft is free -- nothing is published yet -- but its preview URL changes, so share the new link. One consequence the redirect can't cover: feed readers identify posts by their URL, so a renamed post may appear once more as a new item in subscribers' readers. The redirect keeps every clicked link working; what a reader app shows is its own business.
Moving a post to another year -- editing its date across a New Year -- moves its public address the same way a rename does, and records the old one the same way. The link from before the edit keeps working.
Old addresses can also be given up, with [a] in the same dialog: it lists every address that redirects here and drops the one you pick. There is one situation where that is the only cure rather than a preference: if a NEW post has since taken an old address, the build refuses to overwrite a live page with a redirect stub -- correctly -- and says so on every build. The entry can never do anything again, and [a] marks exactly that entry as "taken by another post". Dropping any other one is a decision, not a repair: that link stops working for good.
The wizard menu lists six activities -- a new post, a post in hand, the scheduled-post queue, the archive browser, the trash and a rebuild -- not every command: publish, schedule, unpublish, delete and the announcement live in this dialog (and the draft dialog) instead of being menu items. Every CLI command still exists unchanged -- ./blog.sh unpublish <slug> works exactly as before; only the menu stopped listing it.
