Repository navigation
Folders and files
| Name | Name | Last commit date | ||
|---|---|---|---|---|
Repository files navigation
SOFFIO(1) System Reference SOFFIO(1)
NAME
soffio - a minimalist, strict static site generator
SYNOPSIS
soffio [-a] [-n] [-baseurl url] [-o dir] [-s dir]... [-t dir]
[-today day] dir
soffio [-n] < text.soffio > text.html
DESCRIPTION
Soffio turns the .soffio texts under dir into a static site in -o:
one HTML page per text, keeping the directory tree, and nothing
else. Before it writes anything it checks the whole site: every
link points at a page, a section or a file that is there; every
note is defined and referenced; no public page points at a private
one. If a text is wrong it says where, file:line, writes nothing
and exits 1; a page whose template fails is reported too, and
soffio exits 1. It has no warnings: what is worth saying is worth
fixing.
With no dir, soffio is a filter: one text on standard input, an
HTML fragment on standard output. Alone, a text can only check its
notes and its links to its own sections; -n checks it and prints
nothing, and the flags about a site are refused.
Soffio knows its markup, links and files, and nothing of what a
site is about: a site that needs more is a program of its own (see
EXTENDING). It uses the Go standard library and nothing else.
OPTIONS
-a all texts: the private ones too, as for a preview with
drafts. Without it, a text with 'visibility: private'
is left out, and a link to it is an error.
-n check everything, templates and the copy of -s
included, and write nothing, as sh -n.
-baseurl url
the site's address, http(s)://host[/path], without a
final '/': the templates get it as .BaseURL, for what
needs a full address. Without it .BaseURL is empty, and
an address made of it starts at the root.
-o dir the output directory. Default public.
-s dir the static files, copied to <o>/static, but hidden
ones: a link to one is an error. -s again adds a
directory; a path in two of them is an error. Without
-s a link or image under /static/ is an error. None
can overlap <o>/static.
-t dir the templates; without it, the built-in ones. The two
are never mixed. It can't be -o: the pages would be
written over the templates.
-today day the day of the build, YYYY-MM-DD, as the templates'
today: their only clock. Without it today is empty,
and the same flags always make the same site.
MARKUP
A text is UTF-8: a header, a blank line, and the body. The header
is 'key: value' lines, keys of letters, digits and _, each once.
Three keys mean something to soffio: 'title', which a page needs;
'layout'; and 'visibility', public or private: each a word, never a
link or a file. Two values mean something in any other key:
'-> target' is a link to a page, written and checked as a link in
the body is ('by: -> ../persone/arp'), and the templates get the ID
of that page; a value under /static/ is a file, checked as an image
is (see LINKS AND FILES). Every other value is a word, as written,
for the templates: a date, a name, a kind are the site's, not
soffio's. A byte order mark at the start is ignored.
== id | Title section (== to ======: h2 to h6)
*text* bold
_text_ italic
(label -> target) link
- text list item
:: img: /static/path | caption image
:: note: id | text note
(*id) note reference
A blank line ends a block; a section or :: line starts one, and a
:: line that is not :: cmd: is refused, as ::img: is. A list
item starts with "- ": "-5 gradi" is text. A * or _ opens only at
the start of a word and closes only at its end: snake_case and
2*3*4 stay as they are. (*x) is a note only if x is a valid ID, so
(*bold*) is bold in parentheses. A link can't hold a link or a note
ref. A backslash makes the next character plain. Section titles are
plain text. A heading skips no level: the title is the h1, the
first section ==, and each one at most one level below the one
before; a section holds the ones of the levels below it, so the
page's outline is in its HTML.
IDs, of texts, their directories, sections and notes, are printable
ASCII: no spaces, no accents, none of / \ : * ? " < > | # % ( ),
no leading dot. Text IDs that differ only in case clash: they would
be one file on Windows and macOS.
LINKS AND FILES
dir/it/about.soffio is the page it/about, written as
<o>/it/about.html: a page has one name, its file's.
Hidden files and directories, as .git or the ._ files macOS leaves,
are skipped; a link to a directory is refused.
A target without a leading '/' is relative to the text's directory,
so it/ and en/ link within themselves with the same words; with one,
it starts from the site root. #section points at a section, of
another page or, alone, of this one. A target under /static/ is a
file in -s; anything else is a page. A target is cleaned, so .. can't
climb out of the site, and must be a valid address. The addresses
soffio writes are relative, so a site works under any base, even
from the disk: a template writes them with href, as the texts do.
An image is a file under /static/, or an address out of the site.
One of the files of -s gets its width and height, read from the
file (jpeg, png, gif, webp), so the browser keeps its place before
it comes, whatever its shape.
THE HTML
A text becomes an HTML fragment, .Content, that says what it is
and holds no word of soffio's own, so it reads well in any
language:
== id | Title <section id="id"><h2>Title</h2> ... </section>,
holding the sections of the levels below it
a paragraph <p>
*text* _text_ <strong> <em>
(label -> target) <a href="...">, relative; nothing more: where
a link opens is the reader's
- text <ul><li>
:: img: <figure><img alt width height loading=lazy>
<figcaption>: the caption is the alt too
(*id) <sup><a role="doc-noteref">1</a></sup>, numbered
in the order the notes are first referred to
:: note: at the end, <section role="doc-endnotes"><ol>,
each <li> with a way back, <a
role="doc-backlink">↩</a>, and no heading
The page around it, <html lang>, <title>, the h1, <main> and the
rest, is the template's.
PRIVATE TEXTS AND FILES
A text with 'visibility: private' is a draft: without -a it is not
written, and a public text that links to it is an error, as the
link would lead nowhere. So the public site is closed: every link
in it leads to a page or a file that is in it.
Everything in -s is published, all of it: soffio does not guess
which file is private. Keep the files only drafts use in a
directory of their own, and give it to the preview alone:
soffio -s static dir the public site
soffio -a -s static -s drafts -o www dir the preview
A public text pointing at a file in drafts is then a missing file,
as a link to a draft is an error: a file in the wrong place is
missing, never published. When a draft goes public, move its
files to static. Build the public site into an empty directory:
soffio never deletes, and what a build with -a wrote would stay.
TEMPLATES
Go html/template files. A page goes through its 'layout' (post means
post.html), or layout.html; a layout that is not there is an error.
A page gets .ID, .Meta (its header, title too: .Meta.title; a key it
doesn't have is "", as one it has empty), .Content, .BaseURL and
.Docs, every page, by ID: range .Docs goes through them in that
order; and .Pages, the same pages in the same order, as a list, for
a site's functions. A page among others has .ID and .Meta.
The templates are the .html files of the directory: layouts and
partials. Every page is a text: a 404 page too, 404.soffio, which
a server gives at any address, so its layout's links start from
the root, as .BaseURL or a '/'.
The functions are what only soffio knows:
doc id the page of an ID, or nothing: a header link's
page is doc .Meta.by
href id target the address of target, written as in a text,
seen from the page id: relative, as in the texts
size path an image's .Width and .Height, as for the
images of the texts, or nothing
today -today, the day of the build, or nothing
{{ with doc .Meta.by }}
<a href="{{ href $.ID (print "/" .ID) }}">{{ .Meta.title }}</a>
{{ end }}
<link rel="stylesheet" href="{{ href .ID "/static/style.css" }}">
<img src="{{ href .ID .Meta.image }}"{{ with size .Meta.image }}
width="{{ .Width }}" height="{{ .Height }}"{{ end }} alt="">
Without -t, soffio uses the template built into it, layout.html,
and says nothing about it, as it says nothing when all is well. It
needs only a title, and has no word of its own: the header's lang
is the page's language if there is one, the text is an <article>
in <main>, a page leads to the home, index, by its title, and the
home lists every page in a <nav>. Pages come by
ID, so a name says where one goes: 2026-10-19-soffio. The same file
is in templates/ of the release archives and of the source: copy it
into a directory of your own to start one for -t.
EXTENDING
Soffio is to a site what Lua's core is to its host: it gives the
mechanisms, the site says the rest, in four ways and no other.
1. Functions for its templates, and 2. a check of its texts, which
reads them and changes none: what it finds is said after what
soffio finds, all of it at once. Both go to Main of package soffio,
which is the command:
func main() { soffio.Main(nil, nil) }
and a site's program, with soffio's flags, checks and output, and
its own:
func main() { soffio.Main(funcs, check) }
A function of soffio's can't be taken.
3. Its templates: every page is a text through a layout of the
site's.
4. Programs of its own on package soffio, which reads the texts as
soffio does: Load gives every Document, whose Lines say where each
header key is, HeaderLinks which hold a link, and Links the
pages it links to; the pages that link to one are Links read the
other way; Links, the function, says them of any Document. Parse,
Check and Render are there too: go doc github.com/jcostd/soffio/v5
says the rest, with examples.
The markup and what a header means are not extended.
A site's program, whole: a check that every date is a date, and a
function that lists the pages, the newest first: newest .Pages.
package main
import (
"errors"
"fmt"
"html/template"
"maps"
"slices"
"strings"
"time"
"github.com/jcostd/soffio/v5"
)
func check(docs map[string]*soffio.Document) error {
var errs []error
for _, id := range slices.Sorted(maps.Keys(docs)) {
d := docs[id]
date := d.Meta["date"]
_, err := time.Parse(time.DateOnly, date)
if date != "" && err != nil {
errs = append(errs, fmt.Errorf("%s:%d: %q is no date",
d.File, d.Lines["date"], date))
}
}
return errors.Join(errs...)
}
func newest(pages []*soffio.Document) []*soffio.Document {
return slices.SortedStableFunc(slices.Values(pages),
func(a, b *soffio.Document) int {
return strings.Compare(b.Meta["date"], a.Meta["date"])
})
}
func main() {
soffio.Main(template.FuncMap{"newest": newest}, check)
}
EXAMPLES
A site, checked, then built into an empty directory, for any
static server:
soffio -n -s static texts && rm -rf public &&
soffio -baseurl https://example.org -s static texts
One text, as HTML, or only checked:
soffio < about.soffio > about.html
soffio -n < about.soffio
A sitemap and a robots.txt are made of the pages, by what publishes
them: the sitemap protocol takes a list of addresses, one a line.
cd public && find . -name '*.html' ! -name 404.html | sort |
sed 's,^\.,https://example.org,' > sitemap.txt
printf 'User-agent: *\nAllow: /\n\nSitemap: %s\n' \
https://example.org/sitemap.txt > robots.txt
LIMITS
No code blocks, tables, ordered or nested lists, raw HTML or h1 in
the body: the title is the h1. No inline markup in section titles.
A line of a block is reported as the line where the block starts.
A * or _ closes at the end of a word even inside a link: in
_see (here -> a_) now_ the italic ends at a_ and the link is lost;
escape it, a\_.
Soffio makes pages and nothing else: no sitemap, robots.txt, feed
or 404 of its own, and no server. It knows no dates, languages or
orders but its IDs': a site that wants them is a program of its own
(see EXTENDING). The links of the texts are checked; those a
template writes are the template's. An svg or avif image gets no
size.
Soffio never deletes: a page that is gone or became private stays
in -o from an earlier build, so build into an empty directory. It
has no version flag: go version -m soffio says which it is, and
the name of its archive.
MACOS
macOS will not run a program downloaded from the internet that
Apple has not notarized. The darwin archives hold unquarantine.sh:
after unpacking, in the Terminal, in the archive's folder:
sh unquarantine.sh
It lifts the quarantine from that folder alone, gives back the
exec bits, and checks that soffio runs.
BUILD
Requires Go 1.26 or higher.
make soffio: static, for the baseline CPU,
stripped
make GOOS=os GOARCH=arch OUT=dir the same for another system
make test go vet, then the tests and their
coverage, 100% of the statements
./release.sh the release archives, one per system,
from a clean tag
ChangeLog.txt says what changed, newest first.
SEE ALSO
go doc github.com/jcostd/soffio/v5, the package and its examples;
https://pkg.go.dev/html/template, the templates;
https://www.sitemaps.org/protocol.html, sitemaps as text;
https://www.w3.org/TR/dpub-aria-1.1/, the roles of the notes;
https://developers.google.com/speed/webp/docs/riff_container,
where a WebP says its size;
https://developer.mozilla.org/docs/Web/HTML, the elements soffio
writes; ChangeLog.txt, what changed and why.
AUTHOR
Written and designed by Jacopo Costantini.
COPYRIGHT AND LICENSE
Copyright (C) 2026 Jacopo Costantini.
Soffio is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published
by the Free Software Foundation, either version 3 of the License,
or (at your option) any later version.
See the LICENSE file in the source distribution for full details.