Text

On this day

./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 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

Rebuilding only what changed

A publish rebuilds the site, and until 1.6 that meant rebuilding all of it: every page rendered, and every file read back off disk to find out whether its bytes had moved. On an archive of a few thousand posts that is most of a minute, every time, for a change that touched a dozen files.

Since 1.6 the build writes down what it produced -- in .build_cache.json in the installation directory -- and the next build leaves alone the pages whose inputs have not moved. Nothing you do changes: ./blog.sh publish and ./blog.sh rebuild work exactly as before, only quicker. The summary at the end says how many pages were left alone, so you can see it working.

Measured on this project's own archive of 6,639 posts, on the server it lives on: a rebuild that changes nothing now costs a seventh of what it did, and an ordinary publish about two fifths. Backdating is the case it can do least about, and the measurement says so plainly -- a post dated into the early 2000s still costs four fifths of the old build. A post arriving in the middle of the archive shifts every listing page between the front page and where it lands, so those pages really did change and really do have to be built. Importing an archive, which is backdating several thousand times over, is a full build for the same reason.

Read more

A published picture is the archive's own file

Until 1.6 every picture existed twice: once in media.nosync/, and once as a copy of itself under public.nosync/. On this project's own installation that was 1.8 GB counted twice, and every import doubled again.

They are one file under two names now. Nothing about publishing changes, and neither does the archive's copy: deleting a picture from public.nosync/ -- by hand, or by the sweep, or by a deploy with --prune -- drops that name and leaves the archive's. What DOES follow the link is the mode: making the published picture readable makes the original readable too, which is the direction that was wanted, because a picture the web server cannot read was the bug this makes impossible.

It saves disk and it does not save backups. Measured against this project's own: 200 files under two names take 101 MB on the disk and 201 MB in the backup, because the backup copies file by file and stores each name in full. Back up media.nosync/ as you always did -- that is the archive's copy, and it is the one that matters.

Deploying

./blog.sh rebuild = build + deploy with --prune in one step; the publish/edit flows run it for you. The deploy script alone:

./scripts/deploy-web.sh             # only new/changed files
./scripts/deploy-web.sh --dry-run   # print what would happen, touch nothing
./scripts/deploy-web.sh --prune     # also delete files the build no longer generates
./scripts/deploy-web.sh --force     # ignore the manifest, re-upload everything
./scripts/deploy-web.sh --only=A,B  # just the listed files

Things worth knowing:

  • The safety guards. Four of them, all measuring this build against the last build a deploy accepted -- recorded in .deploy_baseline.json before the first byte goes out, so no upload failure can move it:
GuardTrips atWhat it does
File count dropped>20%, at least 8 filesstops
Total bytes dropped>50%, at least 25 MBstops
File count grew>20%, at least 25 filesstops
Total bytes grew>50%says so, continues
Read more

Cron (sidebar widgets and post stats)

scripts/refresh-sidebar.sh refetches the widget JSON (toots, Bluesky, Pixelfed, commits, RSS) and per-post stats, then uploads only those files -- no site rebuild:

*/30 * * * * /path/to/blog.sh/scripts/refresh-sidebar.sh

Every 30 minutes is plenty. The crontab lines in this section are recommendations; what actually runs is whatever the installation's own crontab says -- installing it is step 7 of Getting started. Post stats refresh live for posts younger than ~90 days; older posts get a full refresh about once a week (tracked in .stats_full_refresh_at). A failed fetch keeps the last known content rather than publishing an empty widget -- a one-minute network hiccup never blanks the sidebar. Systems without cron: a systemd timer or launchd job invoking the same script does the same thing. No widgets configured = no cron needed.

The on_this_day card (see On this day) fetches nothing, but its day turns over only here: its file is rewritten when the date changes and left alone on every other tick, in the site root and in every language tree.

Read more

Backup

Back up the per-deployment data -- the engine itself is a git clone and everything generated is rebuildable:

PathWhy
content.nosync/posts/the posts -- the one thing that's truly irreplaceable
content.nosync/versions/what each edited post said before its last ten saves, which is what [v] restores from (Properties and actions). It sits beside the content and dies with it, so a backup of the posts alone keeps the archive and loses the undo
media.nosync/their images and videos. A published picture has been a hardlink to this file since 1.6, so a backup still stores it once per name -- this is the copy that matters (A published picture is the archive's own file)
config/site.ymlsite identity and integrations
config/site.<lang>.ymlwhat the site says about itself in each further language: its title, menu, the words for its tags and series, and where a language without a locale borrows its interface. Gitignored like site.yml, so a fresh clone has none -- and a site that names ui_language in one does not build without it
assets/images/header.png, assets/images/favicon.pngyour banner and icon -- gitignored, so a fresh clone brings back the engine's defaults instead, silently (Banner and favicon)
env.shtokens (or re-create them; mind the file's 600 mode in backups too)
trash/optional -- deleted-but-recoverable posts
config/palettes.ymlonly if you added a palette of your own -- the file itself ships with the engine
Read more

Troubleshooting

SymptomCause and fix
Anything at all, and you need to know what you're running./blog.sh version -- it needs neither env.sh nor a config, on purpose.
The site looks wrong, or you want to change how it looks./style.sh -- a menu over everything that decides how the site looks and what it says about itself; it lists its own sections. The bio, the footer note, the copyright line and the banner's claim are Markdown, the same as a post, and raw HTML still works in them; a multi-paragraph value needs YAML's literal block (`-) -- the notes in config/site.yml.example` show why. Picking a palette offers a preview (light and dark side by side, uploaded to the site too so a phone can see it) before anything is written -- temporary on purpose, the next build takes it down -- and the run ends by offering a rebuild.
Anything config-shaped, and you want the whole picture./blog.sh doctor -- it reads whatever is on disk and reports every problem at once, each with a fix line. It runs on a config too broken for anything else to load, including one whose YAML won't parse, and needs neither env.sh nor a valid config. Add --online to also ask whether the feeds, the analytics script and the access token still answer, and whether the deploy target does -- with what stands in its root that the site did not put there, and --strip-location to clean the location out of photos saved before the engine did it on the way in (Writing from a phone); rebuild afterwards, since the site is built from what it cleans.
Anything archive-shaped -- a hole where a picture should be, a link that leads nowhere./blog.sh check -- it walks every post and every media file and says what is broken inside the archive, each finding with a fix line; --online additionally asks whether the links that leave the site still answer (Checking the archive). It only ever reports: nothing is deleted or rewritten, so it is safe to run at any time, including from cron.
config/site.yml is not valid YAMLThe message names the line and column. Almost always a tab where spaces belong, a missing quote, or a colon inside an unquoted value (title: Colon: here). ./blog.sh doctor says the same thing without stopping at the first problem.
A save aborted and took your text with itIt didn't: the text is in .last-edit.md, and the next add/edit offers it back -- [r] opens the editor on it, [d] throws it away, [c] leaves it alone. Text from an interrupted edit <slug> is only offered to that same post: restoring it into an add would make a second post out of it, so the offer names the command that does continue it.
Missing env.sh./setup.sh, or copy the template by hand: cp env.sh.example env.sh && chmod 600 env.sh. An unedited copy works locally.
Missing config/site.ymlSame two ways: ./setup.sh, or cp config/site.yml.example config/site.yml and fill it in -- the build refuses to guess.
Duplicate year/slug ... build stoppedTwo posts resolve to the same URL and media directory. Rename one slug; the build aborts rather than silently overwriting one with the other.
Deploy stopped with a "% drop/increase" messageOne of the four guards (Deploying) -- broken build until proven otherwise, --force only when the change is intended.
Deploy or save stopped naming an oversized fileThe one file-size limit (Deploying) -- shrink the file, or take it out of the post and link to it instead.
N deploys in a row have not finishedSomething is refused every time; the failures listed under that line say which (Deploying).
Surfer ... refused the access token (HTTP 401)Surfer 7 no longer takes access tokens. Create an app password in your Cloudron profile (Profile -> App Passwords) and set SURFER_USERNAME and SURFER_PASSWORD in env.sh (Getting started).
Surfer ... refused the username and password (HTTP 401)SURFER_USERNAME is the Cloudron username, not the e-mail, and SURFER_PASSWORD an app password, not the Cloudron sign-in password. A refused sign-in stops the deploy at the first file, and ./scripts/deploy-web.sh then exits with 4 rather than 1, so a script can tell "fix env.sh" from "try again".
Mastodon API returned 401 / toot was not createdMASTODON_ACCESS_TOKEN missing, expired, or lacking the write:statuses scope. The post itself is fine -- fix the token and use ./blog.sh toot <slug>.
Posting to Bluesky failed / announcement not sentBLUESKY_APP_PASSWORD missing, revoked, or it's the account password instead of an app password (Settings → Privacy and security → App Passwords). The post itself is fine -- fix it and use ./blog.sh bluesky <slug>.
Every comment disappeared after turning on comments.approvalExpected until you star them -- moderation publishes only favourited replies (Cron). If starring changes nothing either, ./blog.sh doctor --online catches the usual cause: a token without read:statuses.
A comment you starred still isn't on the siteIt arrives with the next refresh-sidebar.sh run; an older post waits for the full pass, which --full does now (Cron). Check the cron job is actually installed.
comments.approval is on but MASTODON_ACCESS_TOKEN is not setThe cron run refused to publish rather than emptying every thread; the pages keep their last known comments until the token is there (Cron). Same for BLUESKY_APP_PASSWORD on a Bluesky site.
Sidebar widget disappeared from the pageIts fetch returned nothing repeatedly (refresh-sidebar logs say which) -- the widget card hides when its JSON is empty/unreachable. Check the instance/feed URL in config/site.yml.
Read more

What every import does

The long version of The archive → Importing: per-source walkthroughs, what each import keeps and skips, and how to check -- or undo -- the result. Everything here was verified against real archives, not just read from API docs.

Two ways in, same mapping underneath:

./import.sh                              # wizard: pick a source, preview, confirm
ruby scripts/migrate_<source>.rb <arg>   # scriptable: writes immediately, for cron

The wizard always reads the whole source in dry-run first and reports what would be written -- posts, media files, the first slugs, and every skipped item with its reason -- then asks you to type the post count to confirm. The scripts skip the preview; sample with LIMIT=20 instead, which stops after twenty written posts. Re-running either way is safe: posts are matched on source.platform/account/original_id and overwritten in place, never duplicated.

Read more

The sources

beehiiv

ruby scripts/migrate_beehiiv.rb <posts.csv>

In beehiiv: Settings → Export Data → Export Posts → Export All Posts, the posts CSV. One file, no media: each row carries the entire EMAIL as HTML, and most of the import is undoing that -- slicing out the real content, dropping template variables, tracking pixels and the unsubscribe footer, turning the layout tables back into prose. The subtitle becomes the first paragraph, YouTube thumbnails become video blocks, and images download from beehiiv's CDN with the email's quality=80 rewritten to full quality. Paid posts import in full -- the export is yours -- but a premium issue arrives as a draft tagged beehiiv-premium rather than published, so nothing your subscribers paid for goes public without you deciding it should.

Two things worth knowing, both the CSV's: the only date in it is created_at -- beehiiv does not export the publish date, so a long-scheduled archive can sit slightly early on the timeline -- and tags ride in a semicolon-separated column that not every export populates.

Blogger

Read more

Checking the result

The preview already told you the counts; after the real run, spot-check the built pages (./blog.sh preview), and remember two date rules: imported posts keep their original dates, so they land in the archive rather than on the homepage, and dates a reader sees render in site.timezone -- set it before importing if the machine's clock isn't in your zone (see Getting started).

Then run ./blog.sh check (operations.md → Checking the archive), because a body's own links came across exactly as they were written. Where they point at your old posts that is right: with KEEP_PERMALINKS the import wrote a redirect at each of those addresses, so they answer here too and check accepts them. Where they point at the old platform's own furniture it is not -- a WordPress archive still carries /wp-admin/..., /wp-content/..., /category/... and /feed/ inside its post bodies, and after the move those paths address the new site, which has nothing there. check names every one with its post ("links to X, which nothing on this site answers at"); on a blog that linked to itself a lot there will be dozens, and each is a decision -- rewrite it, drop it, or point it at the Wayback capture.

Read more

Undoing an import

Select on the source triple, never on "everything except what I wrote":

ruby -rjson -rfileutils -e '
n = 0
Dir.glob("content.nosync/posts/*/*.json").each do |f|
  p = JSON.parse(File.read(f, encoding: "utf-8"))
  s = p["source"] || {}
  next unless s["platform"] == "PLATFORM" && s["account"] == "ACCOUNT"
  d = File.join("media.nosync", File.basename(File.dirname(f)), p["slug"])
  FileUtils.rm_rf(d) if Dir.exist?(d)
  File.delete(f); n += 1
end
puts "removed #{n}"'

Fill in PLATFORM/ACCOUNT from any imported post's JSON, then rebuild and deploy with --prune --force (the guard will flag the shrink -- that's it working). The same selector is why re-importing later is safe.

Troubleshooting

SymptomWhat it means
N media file(s) could not be downloadedThe URLs are dead at the source -- old CDNs disappear (every distilleryimage*.instagram.com link from 2012 resolves to nothing). The posts were written without those images; nothing to fix on your side.
Tumblr API returned 401 UnauthorizedWrong TUMBLR_API_KEY or blog name.
Many skipped (not a post) from a WXRNormal -- WordPress's own menu items and revisions travel in the same export. Attachments and pages have their own lines, and so does every custom post type, named after itself (wp:portfolio) -- if you recognize one of those as a section of your blog, its posts stayed behind.
skipped (error) with stderr linesThose items were malformed at the source; the rest imported. Re-run after a fix overwrites in place.
The source stopped answering after N item(s)The platform died mid-run. Everything written so far is saved; re-run once the source recovers -- posts are matched on their source id, so nothing duplicates.
cannot move '<slug>' into <year>: a different post already owns ...A re-imported item's date moved into a year where another post has the same slug. That one item is skipped, nothing was touched; rename one of the slugs and re-run.
The same id-less post appears twice after a re-importThe item carries neither guid nor link, so it can't be matched. Delete the extra copy; better, give the item a guid at the source.
The deploy stops with a % increase warningThe guard doing its job after a bulk change -- re-run with --force once the numbers look right.
An imported post shows a shifted datesite.timezone wasn't set and the machine runs UTC -- set it and rebuild; stored dates don't change, only the rendered day.

What a skin is, and what it is not

Two sites in the wild wear a look the engine never shipped: one dressed as a Hugo theme, one as Ghost. Neither forked the engine, neither edited a template, and both survive git pull untouched. This guide is what those two cost to build -- the mechanism first, then the three places where a skin and the engine can quietly disagree, and last the checks that would have caught every mistake made along the way.

It is not a CSS tutorial. Everything here is specific to this engine.

A skin is one stylesheet of yours, loaded after the engine's own, that repaints what is already on the page.

Before writing one, spend ten minutes with ./style.sh. A surprising amount of what looks like skinning is a setting: the colour palette (seven shipped, or your own), the banner and what is written over it, the menu, the sidebar and its widgets, whether a post opens with a lead image. A site that only wants different colours does not want a skin at all -- it wants a palette, and the palette keeps working when the engine changes.

Read more

Where the skin lives

site.extra_css in config/site.yml -- one path, or a list of them, loaded in order after colors.css and site.css:

site:
  extra_css:
    - "/assets/css/skin.css"

Local paths only. Every page carries style-src 'self', so a stylesheet on another host would be dropped by the browser with no error you would ever see -- the page would simply render undressed. The build refuses a remote path out loud instead of letting that happen quietly.

The file is yours, in your working copy, and the engine never writes to it. That is the whole reason this key exists: an edited site.css means a merge conflict on every update, and the first conflict you resolve badly is the one that silently drops a rule the engine added.

Read more

The one rule: your stylesheet loads last

Everything else in this guide follows from it. Loading last means your rules win at equal specificity -- including the rules the engine wrote for narrow screens.

That is the trap, and it does not look like one, because on the machine where the skin is written the phone is not on screen.

A worked example, from a real skin. The engine floats a table of contents beside the article and, under 700px, stops floating it: the chapters go back to being a block above the text, because at that width there is no room beside anything. A skin gave the contents a percentage width to match its own measure. On the desktop it was too wide and overhung the article. The fix -- a fixed width and a mirrored gutter -- made the desktop right and the phone absurd: the article came out 155px wide on a 390px screen, because the skin's percentage was still winning below 700px, where the engine had already said width: auto and meant it.

Wrap an override in the range where it applies

@media (min-width: 861px) {
  .toc { width: 350px; }
}
Read more

Styling one page of a listing

CSS cannot read an address, so for a stylesheet the front page and /page/2/ used to be the same document -- which is why the two skins here both wanted a lead card or a profile block on the first page and neither could have one.

Since 1.3.2 a listing's <body> says which it is:

.page-first .post-list-item:first-child { /* the lead card */ }
.page-cont  .archive-note { /* only on the continuations */ }

page-first is the page that lives at the listing's own address, page-cont is everything under /page/N/, and both are emitted for every listing -- the front page, a tag, a series, a content type. A post page carries a bare <body>, so a rule scoped to either class cannot leak onto one.

Two of them exist rather than one on purpose: a single mark on the continuations would force you to write the first page's look unconditionally and then take it back property by property, and a rule that undoes another is the kind a later change quietly stops undoing. Scope positively instead.

Read more

What the markup marks for you

A stylesheet can only style what it can select. Each of these was one run of text, or one element among identical ones, until a skin needed to tell the parts apart -- so each is marked. Most look exactly as they did until a rule of yours says otherwise; the page dates and the count are new words on the page, and site.css styles those two.

The parts of the series sentence. "Nový Sean.cz — part 3 of 19" is three spans: .series-note__name, .series-note__sep and .series-note__part. On the series' own listing every card repeats the name the heading has just said:

.listing-heading--series ~ .post-list-item .series-note__name,
.listing-heading--series ~ .post-list-item .series-note__sep { display: none; }

The joiner is a span of its own so that hiding the name does not leave a dash opening the line.

The year of an archive page. Its heading says a kind and a value, as a tag's and a series' do: .listing-heading__kind is the word "Archive", .listing-heading__value is the year, and the year alone is the link back to the map of years. Whatever sets the value apart on .listing-heading--tag now works on .listing-heading--archive too.

Read more

Structures the engine leans on

Three rules in site.css are not decoration -- other things are built on top of them. A skin may change all three, but it should know what it is paying.

The bar at z-index 5, and the switch beneath it at 3

.wrap > nav is position: sticky with z-index: 5; the back-to-top button sits at 10 and the lightbox at 100, and the theme switch on the banner sits at 3.

Unstick the bar -- a perfectly reasonable thing for a skin to want -- and the theme switch can stop working while still looking fine. .wrap is a grid, and on a grid item z-index takes effect without position. The bar keeps its layer, its transparent padding still covers the corner where the switch sits, and every click lands on the bar instead. Nothing errors; the button simply does nothing.

If your skin moves the bar, give the switch a layer above it:

#theme-toggle { z-index: 6; }

This exact bug shipped, and it was reported by somebody else. It survived because the switch had been exercised by setting the theme directly rather than by clicking it -- see Checking a skin below.

Read more

Checking a skin

The failures above have one thing in common: each was invisible in the way the skin was being looked at.

Click every control at least once, for real. The theme switch, the menu button on a phone width, an image that opens the lightbox, the search field, the back-to-top button, and the share row under a post -- its Mastodon button opens a question with an input and an error line, and three of the controls are hidden until a script shows them. Setting state directly -- flipping a data attribute to photograph the dark theme -- proves the CSS and proves nothing about whether the control is reachable.

Measure at three widths, not one. A wide desktop, something around the breakpoint, and a phone. The engine's own reflow happens at 700px, so a skin that has never been seen between 700px and its own breakpoint has an untested range.

Look at both schemes. A skin that only defines colours for one of them inherits the other from the palette, which is usually fine and occasionally a white-on-white surprise.

Three traps in browser tooling, all of which have cost an afternoon here:

Read more

Two skins that exist

One engine, two skins
Read more

When a skin is the wrong tool

If you find yourself replacing structure rather than appearance -- moving regions, adding a block the engine does not build, needing markup that does not exist -- a stylesheet has stopped being the cheap option. At that point editing the template is more honest, and you should know the price you have just agreed to: every engine update can conflict there, and docs/decisions.md explains what the templates promise and what they do not.

The other honest answer is that the engine may simply be missing a setting. If a skin has to fight a region into hiding, say so -- a switch for a region is a reasonable thing to ask for, and cheaper for everyone than the CSS that fights it.

How language selection works

Two different things are called "another language" here, and they are independent:

  • Translating the ENGINE -- the menu, the labels, the dates, the wizards. That is this page, and it is one file of data.
  • Publishing the SITE in more than one language -- posts with a text per language, a root per language, a switcher. That is Publishing in more than one language at the foot of this page.

A site can do either without the other: a Czech blog whose engine speaks Czech publishes one language, and a blog published in Czech and German needs both locales to exist first.

How to translate blog.sh -- the generated site and the CLI wizards -- into a language it doesn't ship yet. No code changes are involved: a language is data, and a partial translation is useful from day one.

Read more

What a complete localization consists of

  1. locales/<lang>.yml -- copy locales/en.yml and translate the values (never the keys). The sections, roughly in order of what a visitor vs. an author sees:
SectionWho sees it
date_format, date_time_formateveryone -- strftime formats for every rendered date
thousands_separator, decimal_pointnobody reads these as words: they are how a number is written, not a message. English keeps , and ., German swaps them, Czech separates thousands with a non-breaking space. ./blog.sh stats formats its numbers through them, and every file size the engine prints -- beside an attachment on a page as well -- takes its decimal mark from decimal_point
language_namesite visitors -- the language's name in itself (Deutsch), which the language switcher shows to a reader of any other language
nav, post, pagination, tag, tags, type, series, index, archive, search, not_found, markdown_page, ui, redirectsite visitors -- the chrome, the listings, the tag index and the archive map, a post's own furniture (reading time, contents, series navigation), the 404 page and the one line an old address shows while it forwards
chromesite visitors -- the headings the engine says when the site writes none of its own: "About", "Links", "Find me on", each widget's "Recent toots"
sharesite visitors -- the row of controls under a post, including the question the Mastodon button asks and the two lines the copy button swaps between
jssite visitors -- shipped into the browser for client-rendered strings; js.date_locale is a BCP-47 tag (de-DE) and must agree with date_format, or server- and client-rendered dates diverge
buildauthors -- what ruby build/build_blog.rb says while it renders: both lines it signs off with, and everything it names as not built -- a post, a page, a tag, a redirect, a picture a post's media folder does not hold
cliauthors -- ./blog.sh, the wizard, $EDITOR hints
posterauthors -- what the CLI says when an announcement cannot be sent or its numbers cannot be fetched
doctor, check, stats, exportauthors -- the commands that report on the installation and the archive. doctor and check pair each finding with a fix line, and the fix is a sentence telling somebody what to do, so it is worth as much care as the finding
setup, style, wizardauthors -- the questions in ./setup.sh and ./style.sh, plus the plumbing both share
cron, importauthors -- scheduled publishing and ./import.sh
lockauthors -- what a command says when another run holds the site
pair, on_this_dayauthors -- ./blog.sh pair (the code an app connects with, the devices let in) and ./blog.sh on-this-day (what the day holds from earlier years); the card's own words for a reader are under chrome and js
language_fileauthors -- the build's and check's sentences about a config/site.<lang>.yml whose menu or footer links go elsewhere
Read more

Rules that keep a translation working

  • Placeholders survive verbatim. %{count}, %{slug}, %{path} -- same set as the English string, spelled exactly. A renamed placeholder isn't substituted and the reader sees %{cout} in the output.
  • Prompts keep their trailing space. A string ending ": " puts the cursor one space after the colon; drop the space and input sticks to it.
  • There is no plural system, on purpose. Write count phrases so one wording works for any number -- the way the Czech locale phrases around its three plural forms, or a neutral Posts: %{count} shape. Don't invent one:/many: variants; nothing reads them.
  • Some terms stay untranslated: repost, boost, reblog name three distinct mechanisms on three networks and are established loanwords; Markdown and RSS are names.
  • Use your language's own typography -- quotes („…", «…», “…”), dashes, spacing. The English text is the meaning, not the punctuation.
  • Multi-line messages may reflow. Match the content, not the line count.