shenzi
Asset compiler for non-SPA web apps
- Clone
git clone https://git.highenergymagic.org/shenzi.git- Files
- browse the default branch
- Default branch
- master
- Last commit
- 2026-04-10
README.md
Shenzi: Be Prepared
Shenzi is a tool for preparing the final version of web assets for distribution. It can minify CSS and JavaScript, compress PNG files, copy and rename files to their proper locations for distribution, and run external commands to do other processing of files.
In some ways, Shenzi resembles WebPack, Parcel, and other similar tools for bundling up SPA web applications, but unlike them, it does not target SPA use cases. Instead, Shenzi is aimed at preparing assets for use by web apps built with HTMX, Alpine, Hotwire and other low-JavaScript tools. SPA bundlers perform transformations that are useless overhead for a low-JavaScript web app, if they aren’t outright counterproductive. Shenzi focuses on what a low-JavaScript web app actually needs.
The shenzi.toml file
First of all, a shenzi.toml file in your project directory is not
strictly required. If you don’t have a shenzi.toml file, shenzi will
look for the source files it should process in your static
directory, and store the processed files in your dist directory. It
will minify your JavaScript and CSS files (excluding *.min.js and
*.min.css which are assumed to already be minified), compress PNG
images, and copy everything else verbatim. That’s a pretty common use
case, so if you don’t need a configuration file, feel free to not have
one.
However, if you want to do more specific operations, you’ll need to specify them in a shenzi.toml file for your project.
input
The input setting specifies where Shenzi should look for the “raw” or “unbaked” input files. For example:
input = "static"
output
The output setting specifies where Shenzi should store the processed files. This is where the versions of the files which you’ll upload to your static file server get saved.
output = "dist"
rules
Your shenzi.toml file can contain as many rules as you need. They are processed and handled in order from top to bottom, with the first rule which deals with a given file, either as input or output, taking priority over any later rules that deal with the same file. So, for example, if we wanted to copy a pre-minifed version of htmx into our output directory as htmx.js, we could add a rule like this:
[[rules]]
include = "htmx.min.js"
handler = "Copy"
rename = "htmx.js"
That rule copies htmx.min.js in the input directory into htmx.js
in the output directory, and as long as it comes before other rules,
it also prevents other rules from applying to the htmx.min.js input
file and the htmx.js output file.
That means that, if you happen to also have an htmx.js file in your input directory, the default rule which would normally minify it into htmx.js in the output directory will not apply. That output file has already been handled.
Similarly, the default rule which would copy htmx.min.js in the input directory into htmx.min.js in the output directory will not apply, because the input file has already been handled.
include
The include parameter of a rule specifies which files to target
with the rule. It can use glob patterns, so for example include = "img/*.jpg" means that the current rule should apply to all of the
JPG files in the img subdirectory of the input directory.
Other common glob patterns also work, notably the ** pattern
which matches any number of directory names: include = "**/*.xml"
will match all XML files in the input directory, no matter how deeply
nested in the directory structure they are.
One thing to note is that the glob matching does not use Bash or
whatever command shell you use. It’s a cross-platform, independent
globbing implementation which behaves the same everywhere. As such, it
might surprise you in small ways. For example, include = "*" does
not match all files in the input directory, as it would in Bash, but
rather matches the files which do not have an extension in their file
name, as would be expected on Windows. include = "*.*" matches all
files regardless of their extension, again as expected on Windows.
rename (optional)
The optional rename parameter of a rule specifies a new name for the file in the output directory. The value can contain references to the glob matches in the include pattern, using $ to notate replacements.
[[rules]]
include = "**/*.min.js"
handler = "Copy"
rename = "$1$2.js"
In the above example, the $1 is replaced with the content that
matched the ** part of the include pattern, and the $2 is replaced
with the content that matched the * part of the pattern. The names
$1 and $2 refer to the positions of each of the glob parts in
the include pattern: ** is the first glob part, and * is the
second glob part.
This rule specifies that anywhere in the input directory where a file is found with a name ending in .min.js, it should be copied to the output directory with the same path and name, but with plain old .js at the end instead of .min.js
exclude (optional)
The optional exclude parameter of a rule specifies that files which match its glob pattern are not to be processed by this rule, even if they match the include pattern.
handler
The handler parameter specifies how matching files are to be
processed. It can be set to the values "Ignore", "Copy",
"MinifyJs", "MinifyCss", or "CompressPng", and also the more
complex Command value:
[[rules]]
include = "main.css"
handler = {Command = ["tailwindcss", "--minify", "--input", "$infile", "--output", "$outfile"]}
skip_unchanged = false
The above example runs the Tailwind CSS preprocessor on the main.css file in the input directory in order to generate the main.css file in the output directory.
skip_unchanged
If omitted or set to true then files which do not have a
modification time in the input directory greater than the
corresponding file time in the output directory are skipped. If set to
false then the files are always processed.