Configuration
config/site.yml (identity, versioned) vs env.sh (secrets, mode 600, never in git). Split by sensitivity, not by topic: the site config is meant to sync across environments so local and production render identically; tokens are meant to exist only where they're used.
Config written by the engine is edited as text, never dumped from a parsed hash. Loading config/site.yml, changing a key and writing it back is one line of Ruby and would have destroyed the thing that makes the file usable: most of its lines are comments and commented-out blocks, only a fraction are keys. The rest is the documentation for every setting the engine has, plus the commented-out blocks you uncomment when you want a widget or a custom font -- and real sites hand-edit around it (sean.cz keeps an <img> inside about.html's folded scalar). So lib/config_writer.rb substitutes values line by line and leaves every other byte identical, and a config that doesn't exist yet is seeded from the example verbatim so every key it will ever set is already present to be substituted into. Cost: a text editor for a structured format, which is only safe because it is anchored (key name plus the exact indentation its parent implies, searched only inside that parent) and verified afterwards by reparsing the file and comparing the values back. Prose comments in the template parse as keys otherwise -- # Optional: posts per listing page would index as Optional:.
./setup.sh asks for credentials; ./import.sh deliberately does not. The import wizard reads TUMBLR_API_KEY from the environment because a bulk import is not the place to be handling a token, and there is a documented file for it. Setup is that file's other end -- it exists to create env.sh -- so refusing to ask would leave the one job it cannot delegate undone. The token is read without echo, masked in the diff it shows back, written into a file created mode 600, and verified against the instance before it is kept. Cost: one dialog in this engine handles a secret, so that is one place to be careful about rather than none.
A site is customized by its configuration, not by editing the engine. There is one release, and it either runs as shipped or runs the way a site asked for in config/site.yml -- so switching off the sidebar, naming your own menu, loading your own stylesheet or turning on the hero are all settings, and none of them is a reason to hold a modified copy of a template. That matters because a modified template is not a private matter: git pull either refuses it or silently reverts it, and the site's own look is what gets lost.
Two layers carry it. Configuration switches whole structural regions on and off (layout.sidebar, layout.hero, nav:), and a stylesheet of the site's own (site.extra_css) does everything about how those regions look. Neither can rot: the engine knows about the first, and a CSS rule that stops matching simply stops applying.
Cost, and it is a real one: the engine has to anticipate. Every future "I want it different" is either a new key or an answer of no, and the line between them has to hold -- keys name regions and modes, never individual visual properties, or this file becomes a stylesheet written in YAML. The palette made the same trade first (7 keys, everything else derived, promote one only when reality demands it).
The rejected alternative: letting a site override whole templates from a directory of its own. It would answer every wish at once, which is exactly why it is the wrong shape here -- it is not "a setting", it is a fork with better manners, and a forked template is frozen in time. The site quietly stops receiving whatever the engine adds to that template later, and nothing says so.
Editing a post is undoable, and not because a setting says so. Deleting a post has always gone to trash/; editing one was final, and editing is what happens every day. The previous text is now kept before an overwrite -- by an edit, and by a re-import, which is the more dangerous of the two because it replaces the whole archive at once and nobody reads a few thousand posts afterwards. There is no key to turn it off: a safety net with a switch is off exactly when it is needed, since nobody turns it on before the mistake, and none of the engine's other guards (the trash, the slug-collision abort, the deploy guards) are optional either -- only the destructive direction ever is. Field-only writes make no version, or a history of pin toggles would bury the one entry anybody wants. Cost: ten copies of the text per edited post, and media is not versioned -- so a version old enough can name an image the post no longer has, which is what the cap is for.
A series is not a tag. Tags are many per post and unordered; a series is one and has an order, which is the whole of the difference and enough of it. Ordering is by date with series_part: as the override, the same shape type: uses -- publishing out of order is rare enough that the date is the right default and common enough that there has to be a way to say otherwise. Previous and next are offered only within a series and never across the archive: on a site assembled from many imported platforms the chronological neighbour of an essay is a tweet from fifteen years earlier, and calling that "next" is noise wearing the clothes of navigation. Cost: one more field, and a series of one is silently not a series (it is a post).
A feed per tag, only for the tags the menu names. Generating one for every tag means thousands of files on a real archive, rebuilt and re-diffed on every build, that nobody will ever fetch -- the exact inverse of "nothing renders that wasn't asked for". A tag in nav: is the site saying this is a subject it publishes on, which is the same statement as "somebody might want to follow just this". Cost: a site with the derived type menu gets no tag feeds at all, which is the intended answer rather than an oversight -- it has not named a subject yet.
robots.txt can say no to AI crawlers, and the engine does not decide which way. seo.block_ai_crawlers writes out a maintained list; seo.robots_extra is appended verbatim for the ones no list will be current about. Off by default, because wanting to be findable in a machine-generated answer is a legitimate position and not the engine's to take for a site. And it is documented as a request: some of these crawlers honour robots.txt and some do not, so anything here that said "blocks" would be a lie. Cost: a list in the engine goes stale, and updating it is a release note.
A listing's heading marks what it is; it doesn't say it twice. Tag and search headings used to be sentences with a colon ("Tag: archive"). The word is now its own element beside the value, hidden from sight by the stylesheet -- clipped rather than display: none, since that would take it out of the accessibility tree as well, and a screen reader on a tag page would then announce a bare name with no hint of where it is. The value carries the marker instead: a tag wears the pill shape tags wear everywhere else, a query sits behind the magnifier from the nav. Content-type listings get no marker at all, and never had one -- a marker belongs where the value is arbitrary text, which a tag name and a search query are and a closed set of eight labels is not. The window title keeps the word: a browser tab and a search result have neither stylesheet nor pill, so there it is the only thing telling two pages apart. Cost: one more element in the markup, and a site that wants the word back has to undo one rule rather than translate a string.
Colors are 7 keys per mode; everything else is derived. Across every palette this engine actually shipped, the other custom properties never varied independently -- so they're computed in one function instead of being twenty more config keys. Cost: a future palette needing an independent value (a too-light accent, say) means promoting it to a config key then, not configuring it today.
i18n falls back per key, not per file. A partial translation degrades into English word by word instead of breaking pages -- which is what makes shipping a community locale a low-stakes contribution. The /markdown/ cheat sheet localizes the same way: per-language source files, English fallback.
banner.claim is a separate field from site.description, not the same value reused. site.description feeds <meta name="description"> and the RSS <description> too, so it has to stay plain text -- a literal <br> there would leak broken markup into a feed reader or a search result snippet. banner.claim exists purely so the banner overlay can have a manual line break (or any other markup) without touching those other, stricter consumers; it's optional, and Markdown or raw HTML, same trust level as about.html/footer.note_html (site.yml is owner-edited config, not visitor input). Cost: one more field to know about, and the two can drift out of sync if a site changes its description without updating the claim override to match.
The site's own chrome speaks Markdown, and keeps accepting HTML. about.html, footer.note_html, footer.copyright and banner.claim are the only texts on the site written outside a post, and until 1.2.1 they were also the only ones that had to be written in HTML -- a Markdown blog asking for a hand-typed <a href> in the one field that introduces its author. They go through lib/markdown_parser.rb now, the same parser the posts use. Raw HTML still passes through untouched (apply_formatting(..., escape: false)), which is why nothing has to be migrated: existing configs keep rendering byte for byte, and an <img> stays the way to put a photo in a bio. Cost: two renderers for one syntax -- the chrome's block vocabulary is deliberately smaller than a post's (no images, video, audio, attachments or tables, all of which resolve against a post's media directory or need more width than a 260px sidebar has), so a table pasted into a bio renders as a paragraph of pipes rather than a table. And the passthrough means a typo in hand-written HTML still reaches the page, exactly as before.
Keyboard focus is one ring for the whole site, not a design per control. The alternative was to give each control its own indicator, matched to the ground it sits on. One ring is easier to recognise -- a reader learns what "you are here" looks like once -- and it is one rule to keep in step instead of a dozen. Where the ring has to move inside the control it is because the ground outside it is not something the stylesheet can vouch for (a banner photograph, the lightbox's black) or is the accent the ring is drawn in (the active menu item, the date badge), not because those controls wanted a look of their own. Cost: the ring is the accent, and it is not derived from what is behind it -- a palette whose accent sits close to its card background gets a quiet ring on the page body, and a skin that repaints a control's ground has to look at the ring itself rather than getting a new one for free.
The phone's search field is ordered ahead of the menu it is written after. In the markup the bar reads button, menu, search -- which is the order a desktop wants. On a phone the menu is 100% wide and wraps to its own line, so left alone the field would land on a third line and move down the screen every time the menu opened; order puts it back beside the button. Cost: the usual price of ordering, that Tab follows the markup and not the eye -- with the menu open, Tab goes from the button down to the menu items and only then back up to the field. Closed, the items are display: none and there is nothing to disagree about, which is the state the bar is in almost always.
The images a lightbox can open are made into buttons by the script, not by the build. tabindex and role="button" are added at runtime rather than written into the HTML, so a page served without its JavaScript -- blocked, failed, still loading -- shows pictures rather than things that announce themselves as pressable and then do nothing when pressed. Cost: the tab order of a photo-heavy post is only correct once the script has run, and a reader tabbing in the first few hundred milliseconds walks past images that are about to become buttons.
Once the top bar became sticky, the menu repeated under the content went. The bar answers the question the bottom menu existed for -- how a reader who has just finished an article gets anywhere -- for the whole page, search field included, and keeping both put two identical menus on one phone screen. There is no key to bring it back: a repeat of a bar that is always on screen is not a preference somebody might hold, and a key nobody would ever set is a key that rots. The line the bottom menu drew between content and footer is drawn by the stylesheet now. Cost: an appearance change that arrives unasked and has no way back short of a site carrying its own template.
The sticky rule is scoped to the page's own bar, not to nav. A post carries a second <nav> inside <main> for its pagination, and there is nothing sticky about "older posts". .wrap > nav says which one is the menu bar. Cost: a structural selector rather than a class -- move the bar out of .wrap and the stickiness follows the markup instead of the intent.
Search ranks with four numbers, and draws fifty. A post whose title is the query is a better answer than one that mentioned the word once, whatever their dates -- so the hits are scored (title above text, whole word above part of a word) and date decides only the ties, which is where the archive-is-a-diary argument for chronological results was right. The scale is four constants rather than a term-frequency model, because the whole of it has to be explainable in one sentence to be maintainable in a 150-line script. Cost: no BM25, no term frequency -- forty mentions do not beat one, and the index's single folded blob cannot tell a tag from a paragraph, so a post tagged with the query ranks no higher than one that says it in passing. Both are fixable by putting more fields in the index, and both would cost every visitor the bytes.
The search address is replaced, not pushed. Typing rewrites ?q= on the current history entry rather than adding one. Pushing would give every keystroke its own entry, and Back would then walk the query backwards a letter at a time without ever leaving the page -- the behaviour people mean when they complain a site "traps" the back button. Cost: Back does not undo a search; it leaves /search/ altogether, and there is no way to return to the query you had two words ago. Which is the trade: the address is worth having so a search can be sent to somebody and come back to, and it is not worth having at the price of the back button.
A result list has a ceiling, and the count above it does not. Fifty cards, with the true total still printed above them. Showing everything cost a real archive visible time and tens of thousands of page elements; showing fifty and lying about the total would be worse than either. There is no "show more" and no pagination: a reader who has not found it in fifty ranked answers is better served by narrowing the query, and every extra control here is one more thing to translate and maintain. Cost: the 51st answer is unreachable except by asking a better question -- which is only fair because the list is ranked, and would have been indefensible while it was in date order.
The front page's h1 is there and not visible. Every listing heading became an h1 (it was an h2, a sibling of the post titles it introduces), which left the front page as the only listing with no heading to promote -- it announces nothing, being the site itself. A visible one would put a word on every front page built with this engine that nobody asked for, so it carries the site's title clipped out of sight, the same way the "Tag"/"Search" labels are. Cost: text in the page that nobody sees -- which means it can go stale without anyone noticing, and a tool reading the page as text rather than rendering it will find a title where there used to be none.
The banner's overlay lines are a column, not two pinned corners. The site name and the claim used to be positioned absolutely against opposite corners of the picture, which reads well and holds only as long as there is picture between them -- and the height of that picture is the one thing the engine does not decide. On a phone it runs out. Rather than buying a little room (smaller type, thinner insets) and hoping, the two went into one box laid out as a column, where overlapping is not a state the layout can reach. Cost: one more element in the markup of every page, and the insets moved out of the two lines into the box around them -- so a stylesheet that positioned .banner-title or .banner-claim itself has to be looked at. In return, the corners are described in one place instead of four, and a banner short enough to run out of room crowds its lines instead of printing one through the other.
Reduced motion is answered with one blanket rule, not a list of the things that move. prefers-reduced-motion switches off transitions and animations for everything, rather than naming the declarations that actually carry movement -- and most of the engine's do not, they fade a colour. A list would be more precise on the day it was written and wrong on the first day somebody added a rule and did not think of it, which is exactly the kind of upkeep a preference like this cannot depend on. Cost: a skin's own transitions go off with the engine's, whether or not they move anything, and an animation a site genuinely needs under that preference (a loading indicator that means something) has to ask for itself back in its own media query.
