MiniJx
MiniJx is a template engine that precompiles Jx components into plain Python functions before rendering them. It render pages about 3x faster than regular Jx and does not requires Jx nor Jinja to run; its only dependency is MarkupSafe.
It can be used as a drop-in for existing Jx templates since it supports almost all of Jinja syntax except for some expressions, template inheritance, and, most notably, Jinja extensions. However, you can define any global variable to be treated as a tag (see Custom tags).
The compiler it's implemented in FreePascal and can be used as a regular Python library and as a command line utility.
The wheel includes the compiler binary, so FreePascal is not needed to use it. Wheels are built for Linux (x86_64, aarch64; glibc and musl) and macOS (arm64, x86_64). It is tested on Python 3.13, 3.14 and free-threaded 3.14t: the runtime itself has no C extensions (MarkupSafe's are optional), and one catalog can render from many threads at once.
minijx
[--autoescape=html,jx,xml]
[--tags=cache,...]
[--output=build/] components/ [more/folders/]
minijx --version
--autoescape lists the extensions of the components compiled with autoescape (see Autoescape). The default is html,jx,xml; --autoescape= turns it off. --tags lists the custom tags (see Custom tags). --output writes the modules to a folder of their own (see below).
Import rules:
ui/button.jxis searched in the folders, in the order given../x.jxand../x.jxare relative to the importing file, and cannot leave the folder it was found in.- Prefixed imports (
@ui/button.jx) are not supported.
Every name.jx under the folders becomes a .py next to it. Dots in the name become _, so sitemap.xml.jx becomes sitemap_xml.py. Two sources that would produce the same .py are a compile error.
With --output=build/, the modules go there instead, and the folders of components keep only the .jx files. The modules of each folder go to build/<the folder's name>/, in the same layout: views/pages/home.jx becomes build/views/pages/home.py. When two folders have the same name, the second one gets views-2, and so on. A module records its sources relative to itself, so the project can be moved or deployed as a whole, and a module can be rendered without its .jx (with compiler=False, auto_reload=False). Each module exposes:
def render(
*,
<arguments from {# def #}>,
content="",
_globals=None,
_fills=None,
**attrs
) -> str
The generated module imports only the minijx package. It has no dependency on Jinja or on Jx. Components a file imports are copied into the same module as private functions, so each .py is self contained.
Catalog
minijx.Catalog has the same shape as jx.Catalog. It finds a component's generated module by name and calls its render.
from minijx import Catalog
catalog = Catalog("components/", site_name="Demo")
catalog.compile()
html = catalog.render("pages/home.jx", globals={"user": user}, items=items)
- The catalog uses the compiler bundled with the package, or a
minijxin thePATH.compiler=points it to another binary, andcompiler=Falsedisables compiling: then the modules must be built ahead, e.g. when the application is packaged. - It refuses a binary that generates modules of a format this runtime cannot load, such as a
minijxfrom another version. - Names are paths relative to a folder, with or without
.jx. Folders added withadd_folderare searched in order. - Globals are the catalog's, then the render's, then
assets(assets.render(),render_css(),render_js(),collect_css(),collect_js()), as in Jx._get_random_idis available too. - A component's module is checked the first time it is loaded, in any mode: one that is missing, older than any
.jxcopied into it (itsSOURCES), compiled from another file or with other settings is compiled again when there is a compiler, and raisesComponentNotCompiledErrorif not. Withauto_reload=True(the default) this is checked again on every render, so a changed view is picked up; turn it off in production. A module shipped without its.jxis used as it is. render_string(source, **kwargs)renders a component from its source, compiled once to a temporary folder (its imports are looked for in the catalog's folders).- A syntax error raises
CompileErroronly for components that use the broken file. The rest keep rendering. catalog.compile()compiles every folder at once. It usescompiler=or aminijxin thePATH, and raisesCompileErrorlisting every error.autoescape=is the list of extensions compiled with autoescape;True(the default) is("html", "jx", "xml")andFalseturns it off. A module compiled with another list is stale.output=is the folder for the compiled modules, as--output. By default they go next to each.jx.filters=,tests=andtags=can also be added later, withadd_filters,add_testsandadd_tags, andcatalog.globalsis a dict that can be changed. A new tag makes the modules compiled before it stale.renderreturnsMarkupfor a component compiled with autoescape, andstrotherwise.- Jx's
add_package,asset_resolver,get_assets_folderandcollect_assetsare not supported, since they depend on prefixes.
The language
A component is a .jx file with an optional header and a template. As in Jx, the header is the run of {# def #}, {# import #}, {# css #} and {# js #} comments the file starts with; the same comment further down is an ordinary comment.
{# import "./header.jx" as Header #}
{# import "ui/button.jx" as Button #}
{# css "card.css" #}
{# js "card.js" #}
{# def title, items: list = [], show_footer=true #}
<div {{ attrs.render(class="card") }}>
<Header title={{ title }} count={{ items | length }} />
{{ content }}
{% for item in items if item.visible %}
<p>{{ loop.index }}: {{ item.name | upper }}</p>
{% else %}
<p>No items</p>
{% endfor %}
{% slot footer %}<Button text="OK" />{% endslot %}
</div>
Jinja syntax supported:
- Rendering of variables
{{ }} if/elif/elsefor ... if ... recursivewithelseand the fullloopobjectset name = valuedorawmacro(see below)- Every Jinja builtin filter except
xmlattr,pprintandurlize - custom filters and tests.
Not supported, by design: extends, include, call (a custom tag does its job), importing macros from another file, block set, namespace(), tuple unpacking in set, and Jinja extensions other than do (see Custom tags for block tags like {% cache %}).
{% macro %}
As in Jinja, for markup repeated inside one component:
{% macro render_phone(form, label="Phone") -%}
<div class="nestedform">
{{ form.value.tel_input() }}
{{ form.label.text_input(placeholder=label) }}
</div>
{%- endmacro %}
{% for phone_form in form.phones.forms %}
{{ render_phone(phone_form) }}
{% endfor %}
<template>{{ render_phone(form.phones.empty_form) }}</template>
- A macro sees the component's variables as they are when it is called, can call itself and the macros defined after it, and a
setinside it does not change the variable outside. Defaults are evaluated on each call and can use the parameters before them. - It returns markup with autoescape, so its output is not escaped again.
- Parameters are names or
name=default; a missing argument is aTypeError(Jinja renders it as undefined). - A macro belongs to its component: to share markup between files, make it a component.
caller,varargsandkwargsare not supported: using them in a macro is a compile error.
Custom tags
A catalog can add block tags. The body of a custom tag is not rendered first: it goes to the tag's function as caller, and is rendered only if the function calls it. That is what fragment caching needs:
def cache(key, *, caller, template, expires_in=None):
return app_cache.get_or_set(f"{template}:{key}", caller, expires_in=expires_in)
catalog = Catalog("components/", tags={"cache": cache})
{% cache "sidebar" %}...{% endcache %}
{% cache user, expires_in=300 %}...{% endcache %}
{% cache(user, expires_in=300) %}...{% endcache %}
{% name args %}and{% name(args) %}are the same call. With a space before the parenthesis,{% name (a, b) %}passes one argument, the tuple, as in Jinja.- The function also gets
template: the path of the component the tag is in, e.g."pages/home.jx". Two templates can use the same key without sharing a fragment. - What it returns is not escaped, as with a Jinja extension's block tag; with autoescape,
caller()returns markup. - A name cannot be a builtin statement (
if,for,set,call,macro...), nor start withend. - The tags are compiled in: the modules record them in
TAGS, and one compiled with other tags is stale.
Custom filters and tests
catalog = Catalog(
"components/",
filters={"markdown": markdown, "upper": my_upper},
tests={"admin": lambda user: user.is_admin},
)
As in Jx, a filter receives the filtered value first, and a custom filter can replace a builtin one. map, select, reject, selectattr and rejectattr see the custom filters and tests too. The tests minijx compiles into plain Python (defined, undefined, none, in, callable, sameas and the comparisons like eq or gt) cannot be replaced; the catalog raises ValueError if you try.
The generated code looks filters and tests up by name at render time, so an unknown filter is a KeyError when it runs, not a compile error.
{# def #} defaults and types
As in Jx, a default value is a Python expression, not a Jinja one: 1 | 2 is 3. It can only use literals, true, false, len, max, min, pow, sum, and the names it binds itself (comprehension variables, lambda parameters). Anything else is a compile error. A default that is not a plain literal ([], {"a": 1}, len(x)) is evaluated on each render, so a list is never shared between renders.
As in Jx, an argument annotated with a builtin type is checked on each render, with isinstance, and so is its default value:
{# def title: str, count: int = 0, tags: list[str] = [], user: User = None #}
title must be a str, count an int (True is one, as in Python), and tags a list: of a generic, only the base type is checked. User, str | None and any other annotation are not checked, nor evaluated: they are kept, as text, in the signature of the generated function. A wrong type raises InvalidPropType, a TypeError, with Jx's message: ui/card.jx: count expected int, got str. Checking costs a few nanoseconds per typed argument.
What a component call shows in the template is checked when compiling, with the signature of the component:
- a required argument that is not given:
<Card>` needs the argument `title` (ui/card.jx). Content between the tags counts ascontent. A call withattrs=is not checked, since its values are only known when rendering. - a literal of a type the annotation does not accept:
count="3"is astr, a flag (<Card disabled />) isTrue, and{{ 3 }},{{ -1.5 }},{{ none }},{{ [..] }},{{ {..} }}or{{ (..) }}have their type. With the rules ofisinstance, so{{ true }}is anint. Any other expression is checked when rendering.
Whitespace
The output is the same as Jx's, byte for byte:
- Whitespace control works as in Jinja with its default settings:
{%-,{{-and{#-trim the text before the tag,-%},-}}and-#}the text after it.+does nothing. Newlines are normalised to\n, and one newline at the very end of a file is dropped. - A component's output never starts with whitespace.
- The content between a component's tags is trimmed at both ends.
- The markers inside
{% slot %}and{% fill %}trim their bodies. The ones outside a fill do nothing, since the fill is moved out of the content, and the text around it becomes one piece.
Autoescape
As in Jx, a {{ }} escapes what it renders, unless the value has __html__ (a markupsafe.Markup, the result of | safe or | e, the output of attrs.render()...). The output is the same as Jx's with autoescape on, byte for byte.
Autoescape is decided per component, by the extension before .jx, or jx if there is none:
| file | extension | escaped by default |
|---|---|---|
card.jx |
jx |
yes |
pages/home.html.jx |
html |
yes |
sitemap.xml.jx |
xml |
yes |
emails/welcome.txt.jx |
txt |
no |
data.json.jx |
json |
no |
catalog = Catalog("components/") # html, jx, xml
catalog = Catalog("components/", autoescape=["html"]) # only *.html.jx
catalog = Catalog("components/", autoescape=False) # nothing
Each component keeps its mode when it is copied into another module. The HTML a component passes to another (its content, its fills) is never escaped again: it is written by the template author, not data. That holds between modes too: a .txt.jx page can use an escaped layout.jx, and its content goes in as it is, while the arguments it passes (title={{ subject }}) are escaped by the layout like any other value.
With autoescape:
a ~ bis markup when a part is, and escapes the other parts, as in Jinja.- The body of
{% filter %}and custom tags reaches the function as markup. The result of{% filter %}is escaped unless it is markup too (amarkdownfilter used in{% filter markdown %}must returnMarkup); the result of a custom tag is not, as with Jinja extensions. joinandreplacefollow Jinja's autoescape rules.- The filters
e,escape,forceescapeandsafecannot be replaced: the compiled code trusts them to return markup.
In every mode, attrs.render() escapes & and < in values that are not markup, as Jx does.
Errors
An error while rendering points to the templates, not to the compiled Python, as Jinja does. catalog.render rewrites the traceback: each frame of a compiled module becomes a frame in its .jx, at the line of the construct that failed, with ^^^ under the expression and the template's variables as its locals. Macros, fills, custom tags and recursive loops get frames of their own. The frames of minijx itself are left out.
File "app/views/pages/users.jx", line 12, in template
<Card title={{ user.name }} />
^^^^^
File "app/views/ui/card.jx", line 3, in template
<p>{{ title }} · {{ subtitle.upper() }}</p>
~~~~~~~~~~~~~~^^
AttributeError: 'NoneType' object has no attribute or item 'upper'
A name that is not defined is a KeyError, with a note saying where it was looked for, and a component called without a required argument is a TypeError that names it (<ui/card.jx> missing 1 required keyword-only argument: 'title').
This works because each module has a LINEMAP, where each of its lines comes from, and, in a line that renders several {{ }}, the columns of each one. It costs nothing until a render raises. Calling a module's render directly, without a catalog, shows the compiled code.
Semantics that differ from Jinja
- Strict names. A name that is not an argument, a
setvariable, a loop variable,content,attrs,loopor a Python builtin compiles to_globals["name"]. A missing global raisesKeyError.x is definedandx | default(...)are the exceptions: they compile to a safe lookup. - Attribute access
a.btriesgetattrthengetitem, like Jinja.a["b"]tries the reverse. A miss raisesAttributeError. - Arguments are keyword-only. Builtin types are checked as in Jx (see above); other annotations are only copied into the signature.
- The
CSSandJSmodule constants list the assets of the component and of everything it imports, in Jx's order: the component's own first, then each import's. MINIJX_FORMATmarks the module layout; the catalog treats a module from another minijx version as stale.AUTOESCAPEis the list of extensions the module was compiled with, andESCAPEDwhether itsrenderescapes.LINEMAPandCOMPONENTSare for the tracebacks: where each line comes from in the templates, and the function of each component.
Example app
make example # http://127.0.0.1:8000, PORT=... to change it
A small site in example/ built only with the standard library. It shows layouts with slots, attrs, loops with loop, recursive loops, filters, globals, a sitemap.xml.jx, and a component broken on purpose at /broken. The app compiles everything at startup with catalog.compile(). Edit any .jx in example/components/ and reload the page: the catalog recompiles it. Render times go to the console and to the Server-Timing header.
Benchmark
make bench # or: PYTHONPATH=src ../jx/.venv/bin/python bench/bench_example.py [--reload]
Renders every page of the example app with minijx and with Jx, from the same .jx files and data. Each engine runs with autoescape on and off; before timing, it checks that minijx produces the same HTML as Jx in each mode. It reports the median warm render time per page, the first render with a new catalog, and the time to compile every component with the binary.
Layout
compiler/ FreePascal sources: mjlexer, mjparser, mjexpr (expression tree), mjdefs,
mjgen (Python functions), mjcompiler (modules), minijx.lpr
src/minijx/ Python package: catalog.py, filters.py, tests.py, attrs.py, loop.py,
runtime.py, __main__.py (the `minijx` command), bin/ (the binary)
hatch_build.py wheel build hook
example/ demo app: app.py, components/, static/
bench/ benchmark against Jx
tests/ pytest suite; tests/fixtures has a sample component set
Errors are printed as file:line:col: message on stderr and the exit code is 1. Other files still compile.
Development
make build # needs fpc 3.2+; writes build/minijx and copies it into src/minijx/bin/
make test # pytest: golden tests against Jx, filters/tests against jinja2
make dist # the wheel for this platform and the sdist, in dist/
make test uses ../jx/.venv/bin/python, a Python with jx, jinja2 and pytest. make test JX_PYTHON="uv run --group test python" uses the test dependencies from pyproject.toml instead, with Jx from PyPI.
The wheel build (hatch_build.py) compiles the binary with fpc, or takes it from $MINIJX_BINARY, checks that its version and module format match the runtime's, and tags the wheel py3-none-<platform>. The version is only written in pyproject.toml (uv version --bump patch changes it): the Makefile and the wheel build pass it to fpc in $MINIJX_VERSION, and minijx.__version__ reads it from the package metadata, or from pyproject.toml in a source checkout. The fpc options are in compiler/minijx.cfg, which both use.
MINIJX_TEST_INSTALLED=1 makes the tests import the installed package instead of src/, to test a wheel. On a free-threaded Python, run the tests with PYTHON_GIL=0: the concurrency tests then run truly in parallel, and one of them fails if something turned the GIL back on.
Releasing
The wheels workflow tests every push on Python 3.13, 3.14 and 3.14t, and builds a wheel on each platform and tests it on 3.13 and 3.14t.
Metadata
Release files for minijx 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| minijx-0.1.0.tar.gz | 115.9 kB | Details |
Built distributions (wheels)
| File | Reset | |||
|---|---|---|---|---|
| minijx-0.1.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.musllinux_1_1_x86_64.whl | Python 3 | none | Linux glibc 2.17+ x86-64, Linux musl 1.1+ x86-64 | Details |
| minijx-0.1.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl | Python 3 | none | Linux glibc 2.17+ ARM64, Linux musl 1.1+ ARM64 | Details |
| minijx-0.1.0-py3-none-macosx_11_0_arm64.whl | Python 3 | none | macOS 11.0+ ARM64 | Details |
| minijx-0.1.0-py3-none-macosx_10_12_x86_64.whl | Python 3 | none | macOS 10.12+ x86-64 | Details |
Total release size: 973.0 kB
Release files / minijx-0.1.0.tar.gz
| Download URL | minijx-0.1.0.tar.gz |
|---|---|
| Size | 115.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
a9e886c72cf52dae567b21bc985c7ad44b8de03856a165ac477f7d2a90561af8
|
|
BLAKE2b-256 checksum How to use checksums |
5c319202fc8e1012c73433d0982c23ada0d24f3a3729dac2fbf368593b4f5bc2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.20 {"installer":{"name":"uv","version":"0.12.20","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / minijx-0.1.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.musllinux_1_1_x86_64.whl
| Download URL | minijx-0.1.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.musllinux_1_1_x86_64.whl |
|---|---|
| Size | 181.5 kB |
| Tags | Linux glibc 2.17+ x86-64 Linux musl 1.1+ x86-64 Python 3 |
|
SHA-256 checksum How to use checksums |
b82b60f9510725f821b76f4eeeee9ae05c65955481d2e39502552f0b876d6431
|
|
BLAKE2b-256 checksum How to use checksums |
2220a1306b710b65b2f30e2b1a80cb222567a197d82f5d1d5548230154932562
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.20 {"installer":{"name":"uv","version":"0.12.20","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / minijx-0.1.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl
| Download URL | minijx-0.1.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl |
|---|---|
| Size | 176.4 kB |
| Tags | Linux glibc 2.17+ ARM64 Linux musl 1.1+ ARM64 Python 3 |
|
SHA-256 checksum How to use checksums |
669d4621350502ecb98efa7237e80ccf0f656bb4c01aee58a40ca7f88891ffd7
|
|
BLAKE2b-256 checksum How to use checksums |
3047dbe3f8dd5b5b984ed20c612e687e5ef34fe950fa4aacb1a0274aa3d17088
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.20 {"installer":{"name":"uv","version":"0.12.20","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / minijx-0.1.0-py3-none-macosx_11_0_arm64.whl
| Download URL | minijx-0.1.0-py3-none-macosx_11_0_arm64.whl |
|---|---|
| Size | 243.7 kB |
| Tags | Python 3 macOS 11.0+ ARM64 |
|
SHA-256 checksum How to use checksums |
6037d5b5a61bf4ac8d2749cafd6196cf46b3fb67480d41579e2e0005bbc1d181
|
|
BLAKE2b-256 checksum How to use checksums |
8e966e11238f574fcd84ca1414bb49b0f30c51b7a7e81d417de6d19b6369ec08
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.20 {"installer":{"name":"uv","version":"0.12.20","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / minijx-0.1.0-py3-none-macosx_10_12_x86_64.whl
| Download URL | minijx-0.1.0-py3-none-macosx_10_12_x86_64.whl |
|---|---|
| Size | 255.6 kB |
| Tags | Python 3 macOS 10.12+ x86-64 |
|
SHA-256 checksum How to use checksums |
a36bb7d6545a903845619b9cf29e0b2e4d397eab691fdc50122eac3e81802d1c
|
|
BLAKE2b-256 checksum How to use checksums |
fc203ea9d626166d339cacb80149ab84f90bab54677ed6d051a6045cfa70dd4c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.20 {"installer":{"name":"uv","version":"0.12.20","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|