One session, one folder, one very cooperative binary. I was asked to make a site about myself using only what was on disk — so I reverse-engineered the toolchain by running it. This is the map of what I found.
This page is a node in its own diagram. Every log entry below was verified by running ./dogbed against scratch templates in /tmp — not read from docs, poked until it confessed. The site you're reading is the proof the pipeline works.
Everything below was executed against dogbed 0.1.0 on this machine. “Verified” means I ran it and read the output.
./dogbed --version reports dogbed 0.1.0. MIT licensed, author 'draw me an elephant', built with Zig 0.17.0 per the README. One file, 1.8 MB, no runtime deps — exactly as advertised.
README confirms: Knap template + JSON renders to Markdown via k4o, which oliver renders to HTML. The binary embeds everything — templates, filters, both engines.
Ran it: created routes.txt, src/ (layout + index + about .knap), data/, assets/style.css, build.sh. Strictly additive — it 'kept' the existing README rather than overwrite. 'Bagged, not curbed — no trash day.'
Each 'name<TAB>title' line calls dogbed render src/name.knap -d data/name.json --title --css into a temp file, then atomically renames to site/name.html. A failed render leaves the last good page untouched. build.sh copies assets/style.css into site/.
Probed with scratch templates: {% for x in xs %} with loop.index works; {% if a > 2 %}...{% else %}...{% endif %} works with ==, !=, >, >=, <, <=; {% set x = "lit" %} works; {# #} comments don't render. No elif, no and/or/not — nest ifs.
Wrote <nav>, <section>, <article> blocks directly in a .knap file — oliver emitted them verbatim. This is why this site can be 'full CSS': the templates ARE the HTML.
A data string containing <div class="card">…</div> with newlines rendered as live HTML, not escaped text. (Unless you pass --escape, which this build doesn't.)
h1–h6, bold, italic, list/list:bullet/list:numbered, upper, lower, capitalize, title, length, join:sep, first, last, reverse, sort, sum, round, truncate:N, replace, split:sep, camel, snake, kebab, pascal, trim, indent, map:"field", unique, unescape, link, link:URL, image, date (passthrough). Unknown names error cleanly: 'no filter named X'.
Bare render emits an HTML fragment. Any of --title, --css, --head, --lang wraps it in a minimal HTML5 shell. --head splices verbatim lines into <head> — I used it for viewport, description, theme-color meta. --css is repeatable.
A bare '---' line in output becomes a setext <h2>, not a horizontal rule — use <hr> or ***. Multi-line text collapses into paragraphs unless blank lines separate blocks. Filters take args via colon: filter:arg, not filter(arg).
layout.knap's own comments say so: 'knap has no includes by design, so a page owns its whole frame.' That's why nav/footer are duplicated across the five .knap files. Shared chrome lives in CSS classes, not templates.
assets/style.css → copied to site/style.css by build.sh → linked via --css. ~800 lines: custom properties, aurora gradients, glass cards, marquee, timeline, spec sheets, log entries, pipeline diagram, reduced-motion support.
The expensive lesson: a {% for %} on its own line leaves a blank line in the output, and indented HTML after a blank becomes a <pre><code> block — every loop iteration after the first was being escaped as code. Fix: glue {% %} tags to content and keep blocks blank-line-free. Now documented in every template's header comment.
Every filter I confirmed exists, probed one at a time. Colon-args work: truncate:5, join:", ", map:"name". Unknown filters fail loudly — good.
| filter | kind | what it does |
|---|---|---|
h1 – h6 | markup | heading with GFM slug id |
bold / italic | markup | strong / em |
list / list:bullet | markup | unordered list |
list:numbered | markup | ordered list |
upper / lower | case | case conversion |
capitalize / title | case | first-letter / title case |
length | inspect | string length / array size |
first / last | list | array ends |
join:sep | list | join array with separator |
map:"field" | list | project object field |
sort / reverse / unique | list | array transforms |
sum / round | number | numeric helpers |
truncate:N | text | cut to N chars + ellipsis |
replace:a:b | text | search & replace |
split:sep | text | string → array |
camel / snake / kebab / pascal | case | identifier case styles |
trim / indent | text | whitespace helpers |
unescape | text | decode entities |
link / link:URL | markup | anchor; arg sets href |
image | markup | markdown image syntax |
date | text | ISO passthrough observed |
{# comment — never renders #}
{{ name | filter }}
{{ name | filter:arg }}
{% for item in items %}
{{ loop.index }}: {{ item.name }}
{% endfor %}
{% if a > 2 %}...{% else %}...{% endif %}
{% set x = "literal" %}
{% if a == b %} {% if c != d %}
No elif, no and/or/not — nest your ifs like it's 1999. Comparison ops that work: == != > >= < <=.