This will probably always be a work in progress; I never stop bikeshedding and I somehow keep overengineering even the simplest things.
Common tasks live behind make:
make datafetches Goodreads RSS + writessrc/data/books.make htmlbuilds + minifies HTML intopublic/(depends ondata).make buildalias formake html.make devstarts a local server and rebuilds on changes.make smokeruns quick checks againstpublic/output.make cleanremoves generated HTML.make testruns Smol regressions and a fixture-backed full-site build.make fmtnormalizes indentation and trims trailing whitespace in smol files.make lintrunsshellcheck+shfmtwhen installed (otherwise warns).make doctorprints tool versions + sanity checks.make cf-tailtails Cloudflare Pages failing logs (requires env vars).
Books page:
- Requirements: POSIX shell,
awk, and standard Unix tools. No language runtime install. - Source: Goodreads shelves
read,to-read,currently-reading. - Fetch/transform:
scripts/fetch_books_rows.shvalidates and parses each RSS page into normalized rows.make datastages all three shelves in one temporary generation and atomically publishes only a complete combined dataset. - Parser:
scripts/goodreads_rss_to_rows.awkrejects malformed, oversized, DTD/entity-bearing, wrong-shelf, or unexpectedly namespaced XML before emitting rows. Only Goodreads' Atom link and XHTML meta elements are allowed. - Goodreads text stays plain in data/JSON and is HTML-escaped at the Smol rendering boundary.
- JSON:
scripts/books_json.shconvertssrc/data/booksintopublic/books.json. - Wrapper:
scripts/goodreads_sync.shruns fetch+transform (manual use). - An empty
currently-readingshelf is valid; failures for the requiredreadandto-readshelves still stop the sync.
Deployment hygiene:
public/robots.txt,public/sitemap.xml, andpublic/404.htmlare static.public/_redirectssends legacy/favicon.icorequests to the SVG favicon.- GitHub Actions runs tests and non-mutating shell lint on pull requests.
- A weekly scheduled workflow creates an empty refresh commit so Cloudflare Pages rebuilds the Goodreads-backed book list even when the site code is unchanged.
Smol is a tiny HAML-ish markup language compiled by scripts/smol.awk. The
site templates live in src/ (for example src/index.smol and src/books.smol)
and compile into public/. Shared partials live in src/includes/.
If you want syntax highlighting in Neovim, grab the smol syntax file from my
dotfiles here: https://github.com/chriskjaer/dotfiles/blob/master/common/config/nvim/syntax/smol.vim
Smol syntax overview:
tagcreates elements (preferred), with.classand#idsugar.%tagstill works.- Attributes go in parentheses, like
(key="value" other="value"). - Plain text uses
| some text(bare words are tags now). - Raw blocks use
:rawor:plainfor pass-through content. - Comments start with
-#.
Smol CSS lives inside any style block and follows the same indentation rules:
- Indent selectors and properties.
- Nest selectors with
&(for example,&:hover). - Start at-rules with
@media ....
Smol also has a tiny top-level DSL for wrapping the page and keeping things compact:
:headand:bodybecome the document wrapper, so you can skip writing<!doctype>,html,head, andbodyby hand.@title,@description,@viewport,@lang,@charset, and@meta(...)generate the usual<head>tags for you.@varslets you set multiple values at once, and@set name valueis there for one-offs. Use#{name}to interpolate.@include file.smoldrops another smol file in place (relative to the file doing the include). You can pass parameters inline like@include includes/logo.smol logo_class=logo.
Data + unixy pipelines:
@data "path" as nameloads a|-separated file into a dataset you can loop.- You can also attach a pipeline:
@data "path" | awk ... | sort ... as name. Smol runscat <path> | <pipeline>and treats each output line as a row, splitting on|into fields. @shell "cmd ..." as nameloads a dataset from a command’s stdout.@for name as rowiterates the dataset; use#{row.1},#{row.2}etc.@if lhs == rhs/@if lhs != rhsconditionally renders an indented block.
This is the preferred way to keep templates “unixy”: do transforms via shell pipelines at build-time, and let Smol stay the layout engine.
Smol philosophy:
- Smol describes components and their build artifacts: markup by default, with
explicit sidecar output such as
@wasm. - Unix tools shape data.
- Output modes stay isolated: markup goes through
smol.awk;@wasmbodies go through the small validated WASM frontend and backend. - If a template needs a capability Smol doesn’t have, extend/fix
scripts/smol.awkand add a regression test inscripts/smol_test.shrather than injecting HTML strings.
Example (from src/books.smol): group “Read” by year without writing
intermediate .smol files:
@shell "cat src/data/books | awk -F'|' -f scripts/books_read_years.awk | sort -r" as read_years@for read_years as y@shell "cat src/data/books | awk -F'|' -v YEAR=#{y.value} -f scripts/books_read_for_year.awk | sort -t'|' -k1,1r" as read_books@for read_books as book@if book.2 != ""| (#{book.1} · #{book.2})
One small convenience: any style block found in the body is moved up into the
head, and any script block is moved to the end of the body.
Minify also strips a bit more: safe attribute quotes are removed and leading indentation in text nodes is trimmed.
The favicon is a tiny SVG at public/favicon.svg, wired up in the head.
The background runs a tiny Game of Life in WebAssembly. Its markup, loader, and
module source live together in src/includes/life.smol:
@wasm life as life_wasm
@vars
memory_pages 47
cells_address 1048584
@memory memory_pages
@state width at width_address
@array cells at cells_address
@export memory
@func ptr -> i32 export
@return cells_address
script
:raw
fetch("#{life_wasm}")
@wasm name as binding declares a named sidecar module. The block is removed
from HTML, binding becomes /<name>.wasm, and the build writes
public/<name>.wasm. Modules may live in any rendered .smol file, but names
must be safe identifiers and each name must have one source declaration. A
shared component may be rendered by several entrypoints. Modules cannot be
declared or indirectly included inside template @for or @if blocks.
Inside @wasm:
@varsgroups constants.@memoryowns nested@stateand@arraydeclarations.@export memoryexports linear memory.- Add
exportto a function declaration to export it next to its definition. @let,@set,@for,@while,@return, and infix expressions form the deliberately small executable subset.
scripts/smol.awk extracts and dedents the module. Then
scripts/wasmol_front.awk lowers it to a small stack IR, and
scripts/wasmol.awk validates that IR and emits WASM. All stages use POSIX AWK
and ordinary shell tools. No package manager, JavaScript build tool, WAT
compiler, or extra runtime is needed.
To rebuild every embedded module:
make wasm (or run scripts/wasm_build.sh directly).
The backend validates declarations, numeric ranges, operand stacks, types,
branches, and function results. Module builds are staged, invalid source fails
closed without replacing the previous artifact, and artifacts whose declaration
was removed are deleted after a successful build. The generated
public/life.wasm is checked in so the browser can fetch it directly.