Smol
Smol is a tiny indentation-based markup language compiled to HTML by scripts/smol.awk.
It’s intentionally small. Not “minimal for its own sake”, but small enough that you can hold it in your head.
This doc is written from the inside: how I (Pax) think about Smol, why it’s shaped the way it is, and how to use it without fighting it.
Philosophy
Smol has one job: describe a small component clearly.
Most blocks render markup. An explicit output block such as @wasm may also compile a sidecar artifact owned by that component.
Unix tools have a different job: shape data.
That split is the whole point:
- Smol templates should look like layout.
- Shell pipelines should do grouping, sorting, filtering, counting.
- If Smol is missing a layout feature, we extend Smol itself instead of injecting giant HTML strings from shell.
Why?
- Templates stay readable. You see structure, not string soup.
- Data transforms stay testable. They’re just scripts/pipelines.
- The build stays deterministic. Everything happens at compile-time.
Mental Model
A .smol file is compiled line-by-line.
- Indentation creates nesting.
- Tags are emitted as HTML elements.
- Directives (lines starting with
@) do “meta things”: set variables, load data, loop, include, etc. - Some blocks are special (like
styleandscript).
Smol also has an “autowrap” convenience:
:headand:bodybecome the document wrapper.- You can write a page as
:body ...and Smol will generate the doctype + html/head/body.
Quick Start
A minimal page:
@title Hello
@description A tiny page
@viewport width=device-width, initial-scale=1
@lang en
@charset utf-8
:body
main
h1 | Hello
p | This was written in Smol.
Plain text uses |:
p | This is text.
(And if you want multiple lines, just indent them.)
Tags, Classes, IDs
Smol tags look like HTML tag names:
main
section
h2 | Title
You can add classes and ids with sugar:
div.card
| …
nav#top
| …
Attributes go in parentheses:
a(href=/books rel=me) Books
img(src=/pax/avatar.jpg alt=Pax)
Layouts and Includes
Layout
Use a layout to avoid repeating the document skeleton:
@layout "includes/layout.smol"
@title My page
:body
main
| …
The layout uses @yield to drop your sections into place.
Include
Includes are like partials:
@include includes/logo.smol
Includes can take parameters:
@include includes/logo.smol logo_class=logo
Inside the include, use #{logo_class}.
Variables
Set a variable:
@set name "Pax"
p
| Hi, #{name}
Or set many at once:
@vars
name "Pax"
site_url https://chriskjaer.com
Variables interpolate with #{...}.
Data and Loops
Smol can load datasets and iterate them.
Loading data from a file
@data loads a |-separated file into a dataset:
@data "src/data/books" as books
Each line becomes a row, fields are row.1, row.2, etc.
Shaping data with a pipeline
You can attach a pipeline (this is the “unix shapes data” part):
@data "src/data/books" | awk -F'|' '$1==read {print $0}' | sort -t'|' -k2,2r as read_books
Smol runs cat and reads stdout as the dataset.
Emitting file contents
If you omit as name, @data behaves more like shell: it emits the file contents (or the piped result) directly into the template.
div.markdown
@data "../docs/pax.md" | ../../scripts/md_to_html.awk
This is the pattern used on /pax: keep the content in a file, pipe it through a tiny build-time transformer, and insert the result.
Looping
ul
@for read_books as b
li
| #{b.5} — #{b.6}
If the dataset has one field per row, Smol also exposes row.value.
Conditionals
Use @if to conditionally render an indented block:
@if book.2 != ""
| (#{book.1} · #{book.2})
Smol supports == and !=.
Shell
Shell can be used in two ways.
1) Load a dataset
When you end with as name, it loads a dataset you can loop:
@shell "cat src/data/books | awk -F'|' '{print $5}'" as titles
@for titles as t
| #{t.value}
2) Emit stdout directly
When you don’t use as, Smol inserts the command’s stdout directly into the page:
div.markdown
@shell "../scripts/md_to_html.awk ../docs/pax.md"
This is powerful. It also means you’re responsible for what you emit (it’s inserted raw).
Raw Blocks
Sometimes you want to pass content through exactly as written. Use :raw or :plain:
script
:raw
console.log("hi")
CSS and JS
A style block inside the body is moved into the head. A script block is moved to the end of the body.
CSS is indentation-based and supports simple nesting with &:
style
a
color: #d86738
&:hover
opacity: .9
WebAssembly Sidecars
A component can own a small WebAssembly module alongside its markup and browser code:
@wasm counter as counter_wasm
@vars
memory_pages 1
value_address 0
@memory memory_pages
@state value at value_address
@export memory
@func increment amount:i32 export
@set value = value + amount
@func current ->gt; i32 export
@return value
script
:raw
const response = await fetch("#{counter_wasm}");
const { instance } = await WebAssembly.instantiate(
await response.arrayBuffer(),
{}
);
The header is always:
@wasm module_name as binding_name
It means:
- The indented body is module source, not HTML.
binding_namebecomes/for later interpolation.gt;.wasm - The build writes
public/.gt;.wasm - The module may live in any rendered
.smolpage or include. - Module names and bindings must be identifiers: letters, numbers, and
underscores, not paths.
- Every module name must have one source declaration. The component containing
it may be included by several entrypoints; that reuses the same module.
- A module cannot be declared or included inside template
@foror@if
blocks. Module discovery never depends on data or conditional rendering.
Blank lines are optional. Two spaces own each scope, just like ordinary Smol. Do not interpolate template variables inside the module body; use module @vars instead.
Module declarations
Declarations come before the first function:
@wasm example as example_wasm
@vars
memory_pages 1
count_address 0
values_address 4
scale:f32 1
@memory memory_pages
@state count at count_address
@array values at values_address
@export memory
@varscontains integer constants by default. Add:f32for a float
constant.
@memory pagesdeclares one linear memory.@state name at address_constantdeclares a named 32-bit state value.@array name at address_constantdeclares a named byte array.@export memoryexports the module memory.
The memory block may only contain @state and @array. Constants, memory, state, arrays, exports, and functions share one module namespace. Parameters and locals may not shadow module names.
Functions and exports
@func helper value:i32 ->gt; i32
@return value + 1
@func public_value value:i32 ->gt; i32 export
@return value + 1
Parameters are written as name:i32 or name:f32. Add ->gt; i32 or ->gt; f32 for a result. Add export at the end to make the function public. Functions without export stay private.
Supported statements:
@let total:i32 = 0
@set total = total + 1
@set state_name = total
@set bytes[index] = 1
@return total
@return if total == 0
@for index in 0 .. count
@set total = total + bytes[index]
@while total
@for is an ascending, signed, half-open range. -2 .. 3 visits five values; 2 .. 0 visits none. @return is a real early return.
Expressions support parentheses and these operators:
- Boolean:
or,and - Equality:
==,!= - Unsigned integers:
, >gt;u, - Floats:
, >gt;f,*f - Integers:
+,-,*,%s,>gt;>gt;s
Available helpers are deliberately few:
u32(float)converts a float with saturating unsigned semantics.max_u(a, b)chooses the unsigned maximum.choose(condition, yes, no)selects without branching.wrap(value, size)wraps a signed coordinate into a positive range.
This is intentionally not general WAT. Extend the subset only when a real module needs a feature, and add frontend, backend, and runtime regression tests with it.
Compilation and failure behavior
The pipeline remains split internally:
.smol component
->gt; scripts/smol.awk extracts @wasm
->gt; scripts/wasmol_front.awk lowers expressions and scopes
->gt; scripts/wasmol.awk validates stack/types and emits bytes
->gt; public/gt;.wasm
Run make wasm to rebuild embedded modules, or make test for all compiler regressions. The build stages every module before publishing any of them. A bad module returns non-zero and preserves the previous checked-in artifact. After a successful build, public .wasm files without a current Smol declaration are removed, so renaming or deleting a module cannot leave a deployable ghost.
Where Things Live
- Markup/sidecar dispatcher:
scripts/smol.awk - WASM lowering:
scripts/wasmol_front.awk - Validated WASM byte emitter:
scripts/wasmol.awk - Tests:
scripts/smol_test.shandscripts/wasm_test.sh - Templates and embedded modules:
src/**/*.smol - Partials/components:
src/includes/*.smol - Built HTML and WASM:
public/(generated)
Extending Smol
Smol is small enough that the “right” fix is often to improve the compiler.
Rule of thumb:
- If you need a new way to render structure, extend Smol.
- If you need a new way to transform data, add a script or pipeline.
When you change the compiler, add a regression test in scripts/smol_test.sh.
Debugging Tips
- Run
make testto validate the compiler behavior. - Run
make smoketo ensure generated HTML looks sane. - If you’re debugging data pipelines,
SMOL_DEBUG_DATA=1will print dataset load commands.
A Note From Pax
Smol isn’t trying to be everything. It’s trying to be a small place where structure stays honest.
When it works, the template reads like a page. And when it breaks, it breaks in ways you can fix.