Configure the site -- config/site.yml

Getting started — part 3 of 10

git clone https://github.com/DanielSnor/blog.sh.git myblog
cd myblog

One clone = one site. Everything personal (posts, media, config, secrets) is gitignored, so pulling engine updates later never touches your content -- see Updating the engine.

cp config/site.yml.example config/site.yml

Or let ./setup.sh do it: it seeds the file from this same example -- comments and all -- and fills in the answers you give it, which is the same file you would have written by hand, minus the chance of a tab where a space belongs. It covers the site block, the comments network and the deploy target; ./style.sh covers banner, about, footer, social, colors, fonts, widgets and nav. Both are re-runnable and neither takes anything away from editing the file directly.

If you do edit it directly -- which is a first-class way to use this engine, not a fallback -- run ./blog.sh doctor when you are done. It reads the file you just wrote and reports every problem in it at once, which is the half a hand edit does not get for free: an unclosed quote, a tab, a value in the wrong section, a token in env.sh with no section to use it. It is also the answer when a YAML error names a line that looks perfectly fine -- the parser stops where things stop fitting, which can be a long way from the mistake.

The example is fully commented. The short version:

  • Required: site (title, short_name, description, author, lang, locale, base_url) and banner. That alone is a complete, working site.
  • about and footer introduce the site and close every page; the example ships both filled in. Neither is required: an emptied value takes its section with it -- heading and all -- rather than rendering empty, and a deleted block reads the same as an emptied one.
  • Optional, each activates only when present: analytics, social, widgets (toots / pixelfed / commits / bluesky / rss, each independently), mastodon or bluesky (comments + auto-announce -- exactly one, see step 8), comments (which replies get published -- same step), colors (7 keys per light/dark mode -- omitted keys fall back to the built-in blue palette; whole palettes ship in config/palettes.yml, and ./style.sh shows you a preview -- light and dark side by side -- and writes the one you pick into this section, so you never have to choose fourteen hex values by hand or blind), fonts (the banner title's and claim's font stack and size, plus any .woff2 you put in assets/fonts/ -- omitted keys fall back to the built-in JetBrains Mono at 45px/20px), nav and layout (what the pages are made of -- below), publishing.slots (the times posts usually go out, so scheduling stops asking for a date -- Writing), publishing.allow_partial (on a site of several languages, lets a post out without the words of every one of them, unasked -- Localization), media (convert_heic, remux_video and strip_location, all discussed under Writing from a phone), tag_icons (an icon a tag carries, on its own listing and on the date badge of every post that has it -- The archive), share (the row of controls under a post, drawn in the order you name them -- The archive), write (write: true publishes a page at /write/ to write a post on from a phone -- Writing), and seo (block_ai_crawlers writes a maintained list of the crawlers that collect text to train on into robots.txt, robots_extra is appended verbatim -- off by default, because wanting to be findable in an answer somebody gets from a machine is a legitimate position and the engine does not take it on your behalf either way).

The commits widget reads GitHub unless you give it instance -- the address of a Gitea or Forgejo server (Codeberg, your own git). The address is the whole configuration: nothing else has to be said, because it already answers which kind of host this is. That path costs one request where GitHub costs one per commit, since a Gitea activity item carries the commits it is about.

social, footer.links and nav are lists, and a list key holds a list or nothing at all: emptiness is a legitimate answer everywhere. Anything else under one of them -- a single URL pasted where a list belongs -- is a mistake doctor names and the build reads as empty, rather than a traceback out of an engine file.

social is the row of icons in the footer. Each entry takes name, url and either icon (a name from the built-in set: the network marks mastodon, pixelfed, linkedin, github, gitea, forgejo, codeberg, gitlab, bluesky, instagram, threads, facebook, x, youtube, rss, email -- or any of the general drawings the engine ships for tag icons, globe for somebody's other site among them, listed in operations.md) or icon_svg (your own markup), plus an optional rel that is passed through to the rendered link. rel: "me" on the Mastodon entry is what gets your site verified -- the green check mark next to it on your profile: Mastodon fetches the address from your profile's metadata field and accepts it only if that page links back to the profile with rel="me". The footer is on every page, so pointing the profile field at your home page is enough; the entry's url has to be the profile as Mastodon shows it (https://instance/@handle). Several entries may carry it. Bluesky verifies domains a different way (DNS or /.well-known), so rel: "me" does nothing for a Bluesky entry.

site.lang selects locales/<lang>.yml for every generated string -- en, cs and de ship with the engine; a partial locale falls back to English per key. Adding another language is data, not code -- see Localization.

site.locales is the list of languages the site publishes, and it is only needed when that is more than one. The language in site.lang keeps the site root; every other one is built into a root of its own (/de/), with one copy of /assets/ and one writing app shared between them. The words of a post in another language are written with ./blog.sh translate (see Writing), and ./blog.sh rebuild produces every language in one go. Leave the key out and nothing about the site changes -- no switcher, no alternates, nothing in the markup.

site.timezone (an IANA name like Europe/Prague) is the zone every timestamp the engine writes is expressed in. Set it if you'll ever publish from a server, because a server's clock is usually UTC: without it, schedule reads "10:30" as 10:30 UTC, and a post written after midnight local time can be dated to the previous day. Omit it to use the machine's own zone. A name the system doesn't know is refused at startup rather than silently treated as UTC. It also governs the dates readers see, including the sidebar widgets, whose sources report UTC. Existing posts keep the stored offset -- setting this later rewrites no history, it only changes the day shown for posts whose local day genuinely differs (and never a post's URL).

The palette and the header's type

colors.light and colors.dark take the same seven keys -- bg, text, meta_text, accent, nav_bg, border, pill_bg -- and the build compiles them into assets/css/colors.css. Everything else the stylesheet needs is derived from those seven rather than configured separately: the card background, the nav's text and border, the hover states of links and badges, the search field's background. That is why there are seven keys and not twenty, and why a palette cannot end up internally inconsistent. Omit the section entirely and the site uses blog.sh's own blue (DEFAULT_COLORS).

assets/css/site.css is the other half, and it is not generated: layout and structure, plus the few colors that are fixed because they work against any palette -- white on accent surfaces, the dark scrims behind the banner overlays and the lightbox, the search field's grey placeholder. Changing colors: is configuration; editing that file is editing an engine file, with the pull conflicts that implies (see step 9).

fonts.banner_title and fonts.banner_claim take a CSS font stack each, fonts.banner_title_size and fonts.banner_claim_size any CSS length, and they compile into that same generated stylesheet. Drop a .woff2 into assets/fonts/, declare it under fonts.faces, and the header is in your own type; say nothing and it stays self-hosted JetBrains Mono at 45px/20px. Narrow screens scale from whatever size is configured, so there is no second pair of keys to keep in sync.

The menu, the regions and a stylesheet of your own

Four more settings decide what the pages are made of rather than what they look like. Each has a default that leaves a site saying nothing exactly as it was, so none of them has to be touched.

nav: replaces the menu bar the build otherwise derives from the content types that actually have posts. An entry takes a label plus either a tag (a tag's slug, which becomes /tag/<slug>/) or a url taken as written -- so an item can point at a post, at a generated page, or off the site entirely:

nav:
  - { label: "Home", url: "/" }
  - { label: "Photographs", tag: "photo" }

An entry missing either half is skipped rather than rendered as an empty link. An empty list is a decision rather than a mistake: the entries and the button that opens them are gone, which is also how a site turns the menu off, so there is no second key for that. The bar itself stays, since the search field is rendered inside it. nav: with nothing under it at all -- the key left standing after its entries were deleted -- reads the same way, because a key that is written down is an answer; leave the key out entirely to ask the engine for its own menu again.

layout.sidebar and layout.hero switch whole regions on and off. The sidebar is on unless you say otherwise -- though it is only drawn when something would stand in it, so a site with no about text and no widgets gets its full width back without having to say so; the hero -- the post's first usable image lifted out of the text and run full width above the title -- is off unless you ask, because it reshapes every post page it touches and a site is entitled to keep the shape it has. A single post can still ask for one either way with hero: in its own header (see Writing). They are switches for regions, not for how a region looks: what things look like belongs in a stylesheet, and a key per visual property would turn this file into a stylesheet written in YAML.

site.extra_css is that stylesheet -- one path, or a list of them, loaded after the site's own, so a skin can live in a file of yours instead of in an edited engine file with the pull conflicts that implies (step 9). Local paths only (/assets/css/mine.css): every page carries style-src 'self', so a stylesheet on another host would be discarded by the browser without an error you would ever see -- the page would simply render undressed -- and the build refuses it out loud instead.

site.page_size is how many posts a listing page holds; 10 without it. Worth setting once, before the first deploy: pagination is anchored to the oldest post precisely so page contents never change as new posts arrive, and changing the size later renumbers every page in the archive.

Four starting points

Every key above is optional except the handful in site: and banner:, which is easy to say and hard to act on when you are looking at six hundred commented lines for the first time. So: four kinds of site, and the keys each one actually turns on. Everything not named stays at its default, and a default is a working site.

Written here rather than shipped as four site.yml variants on purpose. A copy of the config is a copy that goes stale the first time a key is added, and then somebody starts from a file that quietly disagrees with the engine. The reference is config/site.yml.example; this is a reading order through it.

A plain blog. site: (title, author, base_url, lang, timezone) and banner:. That is the whole minimum, and the file says so at the top. Add about:, footer: and social: when you want the chrome filled in.

A photo blog. As above, plus media.convert_heic: true if the pictures come off an iPhone, and media.remux_video: true if there are videos and ffmpeg is on the machine. Leave media.strip_location alone: it is on by default and that is the setting that keeps your address out of the pictures. Consider a smaller site.page_size -- a listing card is measured in height, not characters, so a page of photo posts is several times taller than a page of text ones.

A newsletter. publishing.slots: so posts go out on a schedule rather than when you happen to be at the keyboard, and cron to run it (see operations). The announce settings live in env.sh, not here. comments.approval: fav if replies should appear only once you have favourited them.

A Fediverse-facing site. comments: pointed at your instance, widgets: for the sidebar, and the Mastodon or Bluesky credentials in env.sh. seo.block_ai_crawlers: true if you would rather the text was not scraped for training; it writes the robots rules and nothing else.