sajt
a tool and template system for constructing simple textual resources like personal websites or project documentation
sajt is single-binary zero-dependency static site generator taking .md, .gmi, .html, and
plaintext files as input, passing each input file to a defined template file for
transformations and outputs .html.
sajt's executable is around 990kB and the same file can be run on most
modern operating systems (osx, linux, windows, *BSD) with either AMD64 or
ARM64 architectures.
The template syntax is small, comprised of 8 commands. The commands are focused to enable transformation operations useful for documentation websites and other types of static websites.
sajt can be called in two different modalities: in standalone mode and in a config-based mode.
Standalone: As a standalone cli that applies a single template file to a set of input files, saving output in a specified directory.
./sajt.com --template ./simple-html.sajt.html --out index.html ./black-lentil-daal-daal-makhani.md
Config-based: A complete website is described by a config file (TOML). Different sets of input files can use different template files. It can also copy over any required extras (stylesheets, images, etc) to the different destination directories. See the configuration section for more information.
./sajt.com --config config.toml
Goals
Simple syntax
The intention is to separate content files from templates in as simple a manner as possible. This is enabled by providing a tight set of commands, for fast iteration & creation.
For simple sites
Striving to cover the needs of small projects, personal websites, and tool documentation, sajt
tries to strike a balance between being featureful while being small enough to understand
quickly. This is not a full-blown CMS, nor is it a complex pipeline for nesting together
thousands of different files; it is a simple way to transform content files into websites (or
other files) by way of user-authored templates.
Emphasizing portability & ease of use
Public documentation sites often have a development pipeline which must be maintained in order to transform fixes and improvements from drive-by volunteers into the final website.
Instead of choosing a route that forces volunteers to understand and install a
complicated set of development tools, or permanently tie your project to a
resource intensive continuous integration system, sajt provides an
alternative. A small and portable binary that can be included directly in a
project's repository, possible to run by contributors comfortable enough
with the terminal to type:
sajt.com --config config.toml.
Inspirations
sajt takes inspiration from its predecessors—mustache, ctemplate, et—while striving to
minimize added complexity. It goes further to combine its syntax ideas with a tool offering
reminiscent of static site generator systems like Hugo but with radically fewer features.
Configuration
For the configuration settings and format, see config.html.
Malleability
This software can be changed after the fact without needing to be recompiled. The binary's structure allows its contents to be zipped out, edited, and zipped back in again.
Read more about sajt's malleability.
Example
The documentation for sajt is built using sajt. Here's what that looks like.
As input files we use the markdown files of sajt's repository: README, config-docs, and
malleability.
Template
The template website.sajt.html has the following contents:
<!DOCTYPE html>
${set page ${OUTPUT_NAME}}
${set title sajt / ${replace page /%.html/ }}
${set if_index ${equal page index.html => index }}
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1">
<meta charset="utf8">
<link rel="stylesheet" href="links/style.css">
${comment if index.html -> just display "sajt". otherwise display sajt / *page name*}
<title>${pick if_index : sajt title}</title>
</head>
<body>
<header>
<div id="logo">sajt</div>
<nav>
<ul>
<li><a href="./">readme</a></li>
<li><a href="config.html">config</a></li>
<li><a href="malleability.html">malleability</a></li>
</ul>
</nav>
</header>
<main>
${CONTENT}
</main>
</body>
</html>
Config
The config below is saved as config.toml.
[general]
root = "./"
dirFiles = "/home/cblgh/code/sajt/"
dirOutput = "dist"
template = "website.sajt.html"
extras = ["links/"]
suffix = "html"
[website]
replace = { README%.md = "./", config%-docs%.md = "config.html", malleability%.md = "malleability.html" }
files = ["./", "config.html", "malleability.html"]
rename = { ./ = "index.html", config.html = "config.html"}
replacesearches the input file for each key in the table, replacing any hits with the corresponding value.renamereconfigures the output file per input file (e.g. the input fileREADME.ndwill be saved as the filenameindex.html)
Running sajt.com --config config.toml turns the markdown files into sajt's documentation website.
Commands
The syntax is succinct and familiar to many, presenting itself as:
${COMMAND}
Where COMMAND starts with one of the 8 commands listed below.
The syntax ${} is used to delineate commands from text otherwise present in
template or content files. Most commands have one or more arguments. A command
may, as one of its arguments, nest additional ${COMMAND} invocations.
| set | get | paste | equal |
| pick | replace | map | comment |
The dictionary of internal machinations
Internally sajt maintains a dictionary of keys mapping to values as a means to store and
reference template variables. For most commands, if any of the operands corresponds to a key in
the dictionary, then the dictionary value is what is operated on. If there is no matching key,
then the string content of the key is what the command operates on.
Note: commands interpret any operands evaluating to the empty string ('') as false.
Set
${set key value}
set associates a key with a value. The value operand may be a variable, in which case the
variable's value is what the key will reference.
Get
${var}
The simplest command, get returns the associated value if var is a key in the dictionary. If
var is not a key, then the key itself is returned as a string. Get is the only command
without an explicit command name.
Paste
${paste path-to-file}
paste attempts to load a file from disk and include its entire text at the point of the
paste command.
All paste commands are evaluated before processing any other commands. This applies to
paste commands referenced by other paste commands as well, for a depth of up to three levels
of indirection.
path-to-file is relative to the directory of the current template file as:
path.join(<template-file-dir>, <path-to-file>)
by default, paste doesn't interpret the file being pasted, instead opting to include it
verbatim. if you want to interpret the file in any of sajt's supported file formats (currently
.md, .gmi) then you can use the syntax:
${paste with-parse path-to-file.<ext>}
paste also has an optional base directory that can be set:
${paste path-to-filename, with-alt-base-dir}
This second alternative makes path-to-file relative to with-alt-base-dir instead of
template-file-dir. In addition to strings, the base directory may be stored in template
variables known at runtime—either set as a config dictionary key or the template variables
listed in the configuration's section *Template variables). For example:
{paste myfile.txt, INPUT_DIRECTORY}
Equal
${equal arg1 arg2 => arg3}
equal evaluates the two first operands, and if they are equal to each other, returns the third
operand. If the evaluation is false, the empty string is returned.
Pick
${pick arg1 : arg2 arg3}
pick evaluates the first argument and conditionally returns one of the other two arguments.
If arg1 is a non-zero length string, then arg2 is returned. If arg1 is the empty string,
then arg3 is returned. To those familiar, it is a conditional ternary operator.
Replace
${replace arg /pattern/ cmd}
replace operates on its input string arg and selects regions of it using pattern. The
final operand, cmd, is a command mode that determines the output of the replacement operation
on the selected region.
Current command modes are: upper, keep, <string>.
uppermodifies the input string, uppercasing the selection.keeponly keeps the parts of the input string matchingpattern.splitsplit string on pattern and return a list<string>replaces the matched selection with the specified string.
Note: <pattern> is a matching pattern from
Lua). The following "magic characters" need to be
escaped: ^$()%.[]*+-?. To escape a magic character, for example -, preface the character with %: %-
Note: If possible, <pattern> will be interpreted as the contents of a variable
referenced by the name <pattern>. Failing to find a variable by that name, <pattern> is
interpreted as a text string expressing a Lua matching pattern.
Note: split allows splitting an input file by separators and operating the files different parts -- we use a separator of ::: inside the content file.
Content:
# title 1
section text section text section text section text section text section text, lots of it.
:::
## title 2
after the separator: more text more text more text more text more text more text more text!
Template:
<html>
<head>
<link type="text/css" rel="stylesheet" href="/links/style.css">
</head>
<body>
${set parts ${replace CONTENT /:::/ split}}
<article>${parts[1]}</article>
<div class="cool-element">insert some visual flair to break up the text</div>
<article>${parts[2]}</article>
</body>
</html>
Map
${map <list> <line or lines of input, referencing other commands and/or ${ITEM}>}
map iterates over the contents of a stored variable whose value is a comma-separated list
(${set list a, b, c, etc}). In each iteration, the ephemeral dictionary key ITEM is set to the
currently iterated variable value and may be used in the block.
After map has finished evaluation, the result is the concatenation of all evaluated blocks.
Example:
${set mylist one, two, three}
${map mylist here is item ${ITEM}. the first letter is: ${replace ITEM /./ keep}.
}
Output:
here is item one. the first letter is: o.
here is item two. the first letter is: t.
here is item three. the first letter is: t.
Note: the closing brace sitting on its own line gives us a newline separating each evaluated block.
Comment
${comment setting a list like this, using comma-separated values, lets us use the map command on the key}
${set mylist one, two, three}
comment lets you comment your template files with reminders or instructions which will not appear in the output.
Future work
empty
Known issues
Markdown
- Minor issues are likely to be discovered due to using and modifying an older Markdown
parser (initially written ca 2014).
- TODO (2025-11-05): Markdown checkboxes
* [x] blaor- [ ] baneed to be parsed and rendered.
- TODO (2025-11-05): Markdown checkboxes
Discovered issues can, and hopefully will, be mitigated by improving the markdown.lua parser.
Paths
- Production and handling of absolute paths need to be patched in a way that is compatible with
Windows. Currently
/is the root, while on Windows the root is produced asC/.