Catalog

The Catalog is the central manager for your components. It handles loading, caching, and rendering components from one or more folders.

Basic Setup

from jx import Catalog

catalog = Catalog("components/")

This creates a catalog that loads components from the components/ folder.

Constructor Options

catalog = Catalog(
    folder="components/",       # Optional initial folder
    jinja_env=None,             # Custom Jinja2 environment
    extensions=None,            # Extra Jinja2 extensions
    filters=None,               # Custom template filters
    tests=None,                 # Custom template tests
    auto_reload=True,           # Auto-detect file changes
    asset_resolver=None,        # Asset URL resolver callback
    file_ext=".jx",             # Component file extension
    bytecode_cache=None,        # Keep compiled components between runs
    **globals                   # Global template variables
)

folder

Optional path to a component folder. Shortcut for calling add_folder():

# These are equivalent:
catalog = Catalog("components/")

catalog = Catalog()
catalog.add_folder("components/")

file_ext

The extension Jx uses to discover component files. Defaults to .jx.

Set it to keep a different convention (for example .jinja for older projects):

catalog = Catalog("components/", file_ext=".jinja")

auto_reload

When True (default), Jx checks if component files have changed and reloads them automatically. Great for development.

For production, set to False to skip file modification checks:

catalog = Catalog("components/", auto_reload=False)

bytecode_cache

Compiling a component is most of the cost of loading it, and Jx already keeps every compiled component in memory for the life of the process. Pass a jinja2.BytecodeCache to keep them between processes as well:

import jinja2

catalog = Catalog(
    "components/",
    auto_reload=False,
    bytecode_cache=jinja2.FileSystemBytecodeCache(".jx-cache"),
)

The first run compiles and writes; later runs read what is already there. On the 21 components of this documentation site, start-up goes from 31 ms to 4 ms.

This only pays off where a process boundary is crossed:

  • a development server that restarts on every change
  • a deploy, or a worker that did not fork from an already warm parent
  • short-lived processes: serverless, CI, a CLI that renders once

In a long-running single process it changes nothing, because the in-memory cache already answers every request after the first.

Any jinja2.BytecodeCache works, including MemcachedBytecodeCache, which lets several workers or machines share one cache.

An entry is dropped when the component changes, and also when anything that changes generated code changes: the Jinja version, autoescape, the extension list, or the delimiters. You do not have to clear the cache by hand after an upgrade.

Setting bytecode_cache on a jinja_env you pass in works too. Before this option existed it was silently ignored, because Jinja only consults it from a template loader and Jx does not use one.

globals

Variables available to all components:

catalog = Catalog(
    "components/",
    site_name="My App",
    current_year=2026,
    debug=True,
)
components/footer.jx
<footer>
  © {{ current_year }} {{ site_name }}
</footer>

filters

Custom Jinja2 filters:

def format_price(value):
    return f"${value:,.2f}"

def pluralize(count, singular, plural=None):
    plural = plural or f"{singular}s"
    return singular if count == 1 else plural

catalog = Catalog(
    "components/",
    filters={
        "price": format_price,
        "pluralize": pluralize,
    }
)
<span>{{ product.price | price }}</span>
<span>{{ count }} {{ count | pluralize("item") }}</span>

tests

Custom Jinja2 tests:

def is_admin(user):
    return user.role == "admin"

catalog = Catalog(
    "components/",
    tests={"admin": is_admin}
)
{% if user is admin %}
  <a href="/admin">Admin Panel</a>
{% endif %}

extensions

Extra Jinja2 extensions to load:

catalog = Catalog(
    "components/",
    extensions=["jinja2.ext.i18n", "jinja2.ext.loopcontrols"]
)

Note: The jinja2.ext.do extension is always enabled (required for attrs manipulation).

jinja_env

Use an existing Jinja2 environment instead of creating a new one:

from jinja2 import Environment

env = Environment()
env.globals["my_func"] = my_function
env.filters["my_filter"] = my_filter

catalog = Catalog("components/", jinja_env=env)

This is useful when integrating with frameworks that provide their own Jinja environment.

asset_resolver

Optional callback for transforming component asset URLs. Receives (url, prefix) and returns the resolved URL string. Only invoked for components whose prefix has a registered assets folder (see add_folder).

def my_resolver(url, prefix):
    return f"/static/{prefix}/{url}"

catalog = Catalog("components/", asset_resolver=my_resolver)

Adding Folders

add_folder(path, prefix="", assets=None)

Add a folder of components to the catalog:

catalog = Catalog()
catalog.add_folder("components/")
catalog.add_folder("layouts/")

The optional assets parameter specifies a folder containing CSS/JS files for components in this folder. When set, the asset_resolver callback is used to transform asset URLs at render time (see Installable Packages for details).

Components are imported by their path relative to the folder:

{#import "button.jx" as Button #}
{#import "forms/input.jx" as Input #}

Using Prefixes

Prefixes namespace components, useful for third-party libraries:

catalog.add_folder("components/")
catalog.add_folder("vendor/ui-kit/", prefix="ui")
catalog.add_folder("vendor/icons/", prefix="icons")

Import prefixed components with @prefix/:

{#import "button.jx" as Button #}
{#import "@ui/modal.jx" as Modal #}
{#import "@icons/check.jx" as CheckIcon #}

Multiple Folders, Same Prefix

If you add multiple folders with the same prefix (or no prefix), they're treated as one namespace. If both contain a component with the same path, the first one added wins:

catalog.add_folder("my-components/")      # Has button.jx
catalog.add_folder("fallback-components/") # Also has button.jx

# "button.jx" resolves to my-components/button.jx

Rendering

render(relpath, globals=None, **kwargs)

Render a component by its path:

html = catalog.render("page.jx", title="Hello", user=current_user)

Arguments:

  • relpath - Path to the component (e.g., "pages/home.jx")
  • globals - Dict of variables available to this component and all its imports
  • **kwargs - Arguments passed directly to the component
# Pass data as keyword arguments
html = catalog.render(
    "user-profile.jx",
    user=user,
    posts=posts,
    show_email=True,
)

# Or use globals for values needed by child components too
html = catalog.render(
    "page.jx",
    globals={"request": request, "csrf_token": token},
    title="Dashboard",
)

render_string(source, globals=None, **kwargs)

Render a component from a string (not a file):

source = """
{#def name #}
<h1>Hello, {{ name }}!</h1>
"""

html = catalog.render_string(source, name="World")
# <h1>Hello, World!</h1>

Useful for:

  • Testing components
  • Dynamic templates from a database
  • Simple one-off renders

Note: String components can use absolute imports but not relative imports (no file path to resolve from).


Introspection

list_components()

Returns a list of all registered component paths:

paths = catalog.list_components()
# ["button.jx", "card.jx", "forms/input.jx"]

get_signature(relpath)

Returns a component's signature, including its arguments and metadata:

sig = catalog.get_signature("button.jx")

Returns a dictionary with:

  • required - dict of required argument names mapped to their type (or None)
  • optional - dict of optional arguments mapped to (default_value, type or None)
  • slots - tuple of slot names
  • css - tuple of CSS file URLs
  • js - tuple of JS file URLs
sig = catalog.get_signature("modal.jx")
# {
#     "required": {"title": str},
#     "optional": {"size": ("md", str)},
#     "slots": ("header", "footer"),
#     "css": ("modal.css",),
#     "js": ("modal.js",),
# }

collect_assets(output)

Copies all registered package assets to an output folder. For each prefix that has a registered assets folder (see add_folder), files are copied to <output>/<prefix>/:

copied = catalog.collect_assets("static/vendor")
# [("ui", Path("button.css")), ("ui", Path("button.js")), ...]

Returns a list of (prefix, relative_path) tuples for every file copied.


Framework Integration

Flask

from flask import Flask, url_for
from jx import Catalog

app = Flask(__name__)
catalog = Catalog(
    "components/",
    auto_reload=app.debug,
    url_for=url_for,  # Make url_for available in components
)

@app.route("/")
def home():
    return catalog.render("pages/home.jx", products=get_products())

FastAPI

from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from jx import Catalog

app = FastAPI()
catalog = Catalog("components/", auto_reload=True)

@app.get("/", response_class=HTMLResponse)
def home(request: Request):
    return catalog.render(
        "pages/home.jx",
        globals={"request": request},
        products=get_products(),
    )

Sharing Jinja Environment

If your framework has its own Jinja environment with filters, globals, etc., pass it to the catalog:

# Flask example
from flask import Flask

app = Flask(__name__)
app.jinja_env.filters["my_filter"] = my_filter
app.jinja_env.globals["my_global"] = my_global

catalog = Catalog("components/", jinja_env=app.jinja_env)

Now your components have access to everything registered in Flask's environment.


Production Settings

For production, disable auto-reload.

import os

catalog = Catalog(
    "components/",
    auto_reload=os.environ.get("DEBUG", "false").lower() == "true",
)

Or based on your framework's debug setting:

# Flask
catalog = Catalog("components/", auto_reload=app.debug)

# FastAPI
catalog = Catalog("components/", auto_reload=settings.debug)

Built-in Template Globals

In addition to any globals you pass to the constructor, Jx automatically provides these functions to all components:

_get_random_id(prefix="id")

Generates a unique string suitable for HTML element IDs. Useful for form elements, popovers, and other components that require unique IDs to function correctly:

components/popover.jx
{#def label, content #}

{% set popover_id = _get_random_id("popover") %}

<button popovertarget="{{ popover_id }}">{{ label }}</button>
<div id="{{ popover_id }}" popover>{{ content }}</div>

Each call returns a different ID like popover-a1b2c3d4e5f6..., so you can use it as a default without requiring the caller to pass an explicit ID:

components/input.jx
{#def name, label="", id="" #}

{% set input_id = id or _get_random_id(name) %}

{% if label %}
  <label for="{{ input_id }}">{{ label }}</label>
{% endif %}
<input id="{{ input_id }}" name="{{ name }}" {{ attrs.render() }} />