Configuring

ostat has no template language: a site’s layouts are procedures. A site is configured by two things beside content/ — a site.json holding its identity, and an html/ directory holding its documents. One binary builds any number of sites.

Identity, in site.json

{
    "base_url": "https://example.com/",
    "title": "Example",
    "description": "…",
    "author": "A Name",
    "locale": "en-us",
    "brand": { "head": "exam", "accent": "ple" }
}

Every key is optional. What the file omits keeps its default, at every level, so a site.json naming nothing but base_url and title is a complete one. A site with no site.json at all still builds, and says in its title that it is unconfigured.

The defaults live in DEFAULT_SITE in src/config.odin and describe nobody’s site. They used to be ostat’s own, which meant a forgotten key silently inherited ostat’s headings and documentation links.

brand is the site name split where the accent colour starts, written out rather than derivedIt used to be derived, by splitting the title at its first dot. That is a rule about one particular domain, and it renders a title like “Dr. Foo” as “Dr” plus an accented “. Foo”..

-base-url overrides whatever the file says, which is how a staging build gets a different address without editing the site.

URLs are root-relative, so a site is assumed to sit at a domain root rather than under a path.

Documents, in html/

html/
  header-01.html      <head>, up to the per-page title
  header-02.html      the rest of it, </head>, <body>
  home.html           the front page
  not-found-404.html  the 404

Each is optional and falls back to a built-in default, so a bare content directory still builds. A site owning header-02.html owns its favicons, stylesheet, fonts and scripts without touching the generator.

The two numbered headers are the two halves of the document head, sandwiching the only part that varies per page: the title, the description, and the per-page meta. The doctype and the <html> element stay in the generator, because lang comes from locale and a fragment able to disagree with site.json would be a second place to say one thing.

home.html is written out whole, with one exception. The list of recent posts is generated, so the fragment says where it goes:

<section id="recent">
  <div class="section-head">
    <h1>Recent</h1>
    <a class="rss-badge" href="/index.xml" title="RSS feed">RSS</a>
  </div>
  <!--ostat:recent-->
</section>

A home.html without that marker is an error, named at load. The marker owns its line, so indenting it the way the surrounding markup is indented is fine.

These were four site.json fields and three structs, and write_home existed to turn them back into the markup they started as. Adding a fourth section to the home page meant editing that procedure and recompiling; a paragraph, a table, or a captioned image had no representation at all. A document is stored as a document now, so there is no schema to extend.

A fragment is written verbatim, which makes it trusted by construction. That is a real difference from site.json, whose strings are escaped or validated because a config file is untrusted input. The site author owns the fragment in a way they do not own an arbitrary config value.

What is code

Two things stay in the source, deliberately.

The shape of the remaining layouts, in render.odin: the single page, the section listing, the breadcrumb. This is the trade “layouts are procedures” makes: no template language to learn, no partials to thread, and a compiler that checks the whole thing — at the cost of recompiling to move a heading.

The class names the generator writes, in notes.odin and highlight.odin. They are a contract with static/css/style.css and have to change together.

Adding a language

highlight.odin holds a table. A lexer is a keyword list and a few delimiters — it produces only the seven token groups the stylesheet distinguishes, and is deliberately approximate:

{
	names = {"python", "py"},
	keywords = {"def", "class", "return", "if", "else"},
	types = {"int", "str", "bytes"},
	builtins = {"len", "print", "range"},
	line_comment = "#",
	quotes = "\"'",
},

Sections

One section is special: the one named by BLOG_SECTION, blog by default. Its pages are the only ones that carry a date, appear in the feed, or reach the home page’s recent list. Everything else is a plain section whose pages are listed alphabetically.

A section is named by the title in its _index.md, and that is what the breadcrumb shows. The directory is a URL segment and nothing more, so a blog/ directory whose _index.md is titled Release Notes is called release notes everywhere a reader sees it, and /blog/ everywhere a browser does.