{% extends "base.html" %} {% from "_parts.html" import code, defs, files, lesson, prose, stage %} {% from "ui/data.html" import badge, card, code_block, link %} {% from "ui/layout.html" import page_header, stack %} {% from "ui/table.html" import cell, table %} {# The rail in base.html is built from this list, so a section renamed here is renamed in the navigation. Ids match the anchors `lesson()` emits. #} {% set sections = [ {"id": "why", "num": "01", "title": "What a plugin is"}, {"id": "mount", "num": "02", "title": "mount(): the app side"}, {"id": "extend", "num": "03", "title": "extend(): Jinja, and every render"}, {"id": "rules", "num": "04", "title": "Order, collisions, and limits"}, {"id": "auth", "num": "05", "title": "The auth plugin"}, {"id": "write", "num": "06", "title": "The flash plugin"}, {"id": "apidocs", "num": "07", "title": "The API console"}, {"id": "charts", "num": "08", "title": "Charts"}, {"id": "admin", "num": "09", "title": "The admin"}, ] %} {% block content %} {% call stack(8) %} {% set intro %} Some things are not a macro. A plugin adds middleware, an exception handler, or a value every template gets — in one object, registered in one place. {% endset %} {{ page_header("Plugins", intro) }} {# ============================================================== 01 #} {% set t01 %}Two contribution points, because the kit has two moments.{% endset %} {% call lesson("why", "01", "What a plugin is", t01) %} {% call prose() %}

A macro covers what a page draws. It does not cover what happens before the handler runs, or what every template should know without a route passing it along. Sessions need both. Locales and feature flags need the second. A flash message needs only the first.

A plugin is one object with two optional methods. mount() runs when the app is constructed; extend() runs when the Jinja environment is built. A plugin that only puts a value in every template writes only the second, and one that only touches the request on its way past writes only the first.

{% endcall %} {{ code("config = FjkitConfig( template_dir=APP_DIR / \"templates\", plugins=(auth, flash), ) mount_fjkit(app, config)", label="app/main.py") }} {% call prose() %}

That is the whole registration. mount_fjkit() already reads this config for the static mount and the environment, so a plugin needs no second call and no import order to get right.

The split between the two methods stays visible on purpose. Hiding it would be friendlier until someone's mount() silently never ran.

{% endcall %} {% endcall %} {# ============================================================== 02 #} {% set t02 %}Middleware, handlers, routes — attributed to the plugin that added them.{% endset %} {% call lesson("mount", "02", "mount(): the app side", t02) %} {% call prose() %}

AppSetup is a thin pass-through to FastAPI. What it adds is attribution: a misbehaving plugin is named rather than found by elimination.

{% endcall %} {{ defs("AppSetup", [ ("add_middleware(cls, **options)", "Starlette runs middleware in reverse registration order, so a plugin listed later in plugins= wraps the ones before it"), ("add_exception_handler(exc, handler)", "note that an exception raised inside middleware never reaches one — Starlette's handler layer sits inside the user stack"), ("include_router(router)", "for a plugin that ships endpoints of its own"), ("mount_static(url, directory)", "for a plugin that ships assets, mounted under a name derived from the plugin's"), ("warn(message)", "startup configuration checks; the message is prefixed with the plugin's name and raised as PluginWarning"), ("app, config", "the live FastAPI app and the FjkitConfig, for the checks the two above cannot express"), ]) }} {% call prose() %}

warn() is for the combinations only the plugin can recognise — an in-memory session store under a production config, say. That works on your laptop and logs users out at random behind four workers, and nothing in the traceback points at the cause.

{% endcall %} {% endcall %} {# ============================================================== 03 #} {% set t03 %}The per-request context is the part that was missing.{% endset %} {% call lesson("extend", "03", "extend(): Jinja, and every render", t03) %} {% call prose() %}

FjkitConfig.globals is resolved once per process. That is right for a version string and useless for the signed-in user. add_context_processor() is the other half: a callable run once per render, with the live request.

{% endcall %} {{ defs("EnvSetup", [ ("add_template_dir(path)", "searched after the app's templates and before the kit's — a plugin may replace a kit macro, never a file the app wrote"), ("add_global(name, value)", "one value for the whole process"), ("add_filter(name, fn)", "a Jinja filter"), ("add_context_processor(fn, provides=[…])", "merged into every template's context, once per render"), ]) }} {{ code("def extend(self, setup: EnvSetup) -> None: setup.add_context_processor( lambda request: {\"session\": getattr(request.state, \"auth\", None)}, provides=[\"session\"], )", label="the auth plugin's whole extend()") }} {% call prose() %}

Two constraints, both deliberate. A processor is synchronous: it runs inside Templates.page(), which a def handler reaches from the threadpool. A plugin needing I/O does it in middleware and leaves the result on request.state, which is what the line above reads.

provides= is required rather than inferred. Without it, two plugins fighting over user are discoverable only by rendering a page and noticing. With it, the clash is a startup error naming both.

add_template_dir() is how a plugin that ships UI ships it; section 07 is the worked example. One thing goes with it: the Tailwind build scans an explicit list of directories in fjkit.css, so a plugin inside the kit adds its template directory to that list. Miss it and nothing fails — the templates render, every utility class in them is absent from the stylesheet, and the page looks broken.

{% endcall %} {% endcall %} {# ============================================================== 04 #} {% set t04 %}Everything a plugin can collide over fails at startup, not in production.{% endset %} {% call lesson("rules", "04", "Order, collisions, and limits", t04) %} {% call stage("Template search order", "First match wins.") %} {% call table([{"label": "Searched"}, {"label": "Belongs to"}, {"label": "May shadow"}]) %} {{ cell("1", tone="muted", numeric=true) }} {{ cell("the app's template_dir", tone="strong") }} {{ cell("everything — this is fjkit eject") }} {{ cell("2", tone="muted", numeric=true) }} {{ cell("each plugin, in plugins= order", tone="strong") }} {{ cell("the kit's macros, never the app's files") }} {{ cell("3", tone="muted", numeric=true) }} {{ cell("the kit", tone="strong") }} {{ cell("nothing") }} {% endcall %} {% endcall %} {{ defs("Refused at startup", [ ("two plugins with one name", "they would collide on request.state. and make every later error ambiguous"), ("two plugins claiming one global, filter or context key", "the error names both, and says to rename one or drop one"), ("a context key a processor did not declare", "caught under strict_undefined, so it surfaces in development"), ]) }} {% set no_markup %} There is no hook to inject markup into the shell. With one, any plugin could put a <script> on every page, and both "no build step on your side" and the closed vocabulary would leave through it. A plugin that ships UI ships templates, and they pass fjkit check like everything else. {% endset %} {{ defs("What a plugin cannot do", [ ("inject markup", no_markup), ("depend on another plugin", "order is the order you wrote; there is no resolution step, because nothing has needed one yet"), ("open a connection at startup", "no lifespan hook — a plugin takes a client the app already built, which is why RedisStore has a client parameter"), ]) }} {% endcall %} {# ============================================================== 05 #} {% set t05 %}The browser holds a cookie. The server holds the token.{% endset %} {% call lesson("auth", "05", "The auth plugin", t05, tag="fjkit.auth") %} {% call prose() %}

The first plugin in the box, and the reason the seam exists. The browser gets one opaque, signed, HttpOnly cookie. The access token, the refresh token and your claims stay in a session store on the server.

This is what an htmx app wants: no htmx:configRequest hook attaching a header, no refresh-and-retry glue, no token in the DOM. The browser sends the cookie on every request because that is what browsers do.

{% endcall %} {{ code("auth = AuthPlugin( secret=os.environ[\"FJKIT_SECRET\"], store=RedisStore(redis.from_url(REDIS_URL)), source=MyOIDCSource(), trusted_origins=[\"https://app.example.com\"], ) mount_fjkit(app, FjkitConfig(template_dir=…, plugins=(auth,)))", label="app/main.py") }} {{ defs("Three seams, replaceable independently", [ ("SessionStore", "MemoryStore for development, RedisStore for production, or your own — five async methods"), ("TokenSource", "how credentials become a session: a password check, an OIDC code exchange, an upstream token swap. LocalSource is the default and has no upstream at all"), ("Csrf", "a cookie is an ambient credential, so CSRF returns. SameSite=Lax plus an exact Origin comparison — the two miss different things"), ]) }} {% call prose() %}

Routes see none of it. Put Depends(auth.required) on a router and its handlers keep the signatures they had; the session is on request.state.auth, and in every template as session.

{% endcall %} {% call stage("How an anonymous request is refused", "The same question @render asks about the wire, asked about how to say no.") %} {% call table([{"label": "Who asked"}, {"label": "Answer"}, {"label": "Why not 401"}]) %} {{ cell("htmx", tone="strong") }} {{ cell(badge("401 + HX-Redirect", variant="info")) }} {{ cell("— htmx acts on HX-Redirect before it reads the status, so this one is both") }} {{ cell("a browser navigation", tone="strong") }} {{ cell(badge("303", variant="info")) }} {{ cell("a 401 with no body is a blank page; people want the login form") }} {{ cell("a script, curl, a service", tone="strong") }} {{ cell(badge("401", variant="outline")) }} {{ cell("— this is the caller 401 is for") }} {% endcall %} {% endcall %} {% call prose() %}

Only the middle row cannot be a 401: a plain navigation has no way to act on one, so it would land on a blank page. htmx is the interesting case. It reads HX-Redirect and returns before it looks at the status code, so the status stays honest while the browser still moves the whole page.

A navigation is recognised by Sec-Fetch-Mode: navigate, or failing that by Accept mentioning text/html. Anything else is the caller a route in render_mode="auto" answers in JSON. The 401 carries no WWW-Authenticate, although RFC 9110 requires the field: every registered scheme is wrong here, and Basic would make the browser open its own credential dialog — the login form this plugin exists to replace.

{% endcall %} {{ defs("Worth knowing before you reach for it", [ ("one store read per request", "the price of revoking a session by deleting a row, and of keeping refresh tokens off the wire"), ("refresh is locked", "four swaps firing at once would otherwise send four refreshes, and an upstream that rotates refresh tokens revokes the family when the second arrives"), ("the session id does not change on refresh", "concurrent swaps would each return a different Set-Cookie, the last would win, and the rest would be orphaned"), ("login routes are async def", "they await the store, and a real TokenSource awaits an identity provider. Routes that only render stay def"), ]) }} {% endcall %} {# ============================================================== 06 #} {% set t06 %}A message that outlives the response that raised it.{% endset %} {% call lesson("write", "06", "The flash plugin", t06, tag="fjkit.flash") %} {% call prose() %}

A redirect loses a message. HX-Trigger names an event for the page to fire, and the page is replaced before the header arrives; a plain navigation involves no JavaScript at all. The cookie jar is the only thing that crosses.

So a flash is written as a short-lived signed cookie on the response that redirects, read back on the request that lands, queued into fjkit.messages like any other message, and cleared once it has been shown — which is why it appears once and not again on reload. The toaster region in ui/shell.html is already there and already draws that queue, so registering the plugin is the entire setup and no template in your app changes.

{% endcall %} {{ code("flash = FlashPlugin(secret=os.environ[\"FJKIT_SECRET\"]) auth = AuthPlugin(secret=…, trusted_origins=…, flash=flash) config = FjkitConfig(template_dir=…, plugins=(flash, auth))", label="app/main.py") }} {% call prose() %}

auth is handed the flash rather than importing it. Neither plugin depends on the other: auth works without one, and a route that just saved something wants flash.add(response, "Saved") with no session in sight. The app connects them, for the same reason there is no dependency resolution in section 04.

It has no Jinja side: no extend(), and not one context key of its own. The cookie is queued into fjkit.messages from middleware, so a template never learns that a message arrived by cookie rather than from the handler it is rendering. That is what the layering the Learn page describes buys — one loop in the shell, not one per delivery mechanism.

The interesting half is when the cookie is cleared: once messages.shown(request) is true, meaning a template iterated the queue or the queue was serialised into an HX-Trigger. A page load fires more requests than the page — an htmx poll, a prefetch — and if "shown" meant "this request carried the cookie", any of them would eat the message and leave the page it belonged to empty.

{% endcall %} {{ code("def mount(self, setup: AppSetup) -> None: setup.add_middleware(_FlashMiddleware, plugin=self) # what that middleware does, on the way in and on the way back out: carried = self.plugin.load(request) if carried: messages.extend(request, carried) # queued like any other message response = await call_next(request) if carried and messages.shown(request): self.plugin.clear(response) # only once it reached a page", label="fjkit/flash.py") }} {% call prose() %}

Raise one from anywhere — flash.add(response, "Saved", category="success") — and it appears as a toast on whatever page the browser lands on next, with no route on that page knowing anything about it. That is the test of whether something should be a plugin at all: could a route have passed it instead? Here it could not, because the route that raises the message is not the route that renders it.

{% endcall %} {{ defs("Duck-typed, not inherited", [ ("name", "a str attribute — required, and the only one"), ("mount / extend", "implement either, both, or neither; missing ones are simply not called"), ("no base class", "Plugin is a Protocol, so a plugin never imports fjkit to be one"), ]) }} {% endcall %} {# ============================================================== 07 #} {% set t07 %}Sign in on the page, and every call below goes out as you.{% endset %} {% call lesson("apidocs", "07", "The API console", t07, tag="fjkit_apidocs") %} {% call prose() %}

Swagger UI's Authorize dialog can express only what an OpenAPI document can describe: an API key, an HTTP scheme, an OAuth2 flow. An app whose sessions are section 05's — a signed HttpOnly cookie over a server-side token — is none of those. Its Try it out could not send one anyway: it runs fetch() in the browser, and that cookie is the one JavaScript may not read.

So both halves move to the server. Signing in is a Python object your app supplies, which for a fjkit.auth app is the TokenSource your login page already calls. Sending a request is an in-process replay through your own app, carrying the caller's own cookie through the whole middleware stack, so token refresh, revocation and the Origin check are the ones you already wrote rather than a second implementation living in a docs page.

{% endcall %} {% call prose() %}

The console is its own distribution — an app that renders pages has no reason to carry a replacement for Swagger UI — so it is one install away:

{% endcall %} {{ code("uv add fjkit-apidocs", label="shell") }} {{ code("from fjkit_apidocs import ApiDocsPlugin docs = ApiDocsPlugin(title=\"Board API\", home_url=\"/\") mount_fjkit(app, FjkitConfig(template_dir=…, plugins=(auth, docs)))", label="app/main.py") }} {% call prose() %}

That is the whole of the wiring. No route to write, no template to render, no static file to serve: the plugin brings its own router and templates, and finds the AuthPlugin in the same plugins tuple to wire the sign-in panel to. Pass flow= instead when the app authenticates some other way, and FastAPI(docs_url=None, redoc_url=None) when you want only this one.

{% endcall %} {% call stage("The half Swagger cannot do", "Both read the same /openapi.json. They differ after that.") %} {% call table([{"label": "Question"}, {"label": "Swagger UI"}, {"label": "fjkit_apidocs"}]) %} {{ cell("Signing in", tone="strong") }} {{ cell("the four schemes the document can describe") }} {{ cell("your app's own code — a Python object") }} {{ cell("Where the call runs", tone="strong") }} {{ cell("in the browser, as fetch()") }} {{ cell("in the app, as an ASGI replay") }} {{ cell("What it carries", tone="strong") }} {{ cell("what JavaScript can reach") }} {{ cell("your session cookie, HttpOnly and all") }} {{ cell("Gating the page", tone="strong") }} {{ cell(badge("not its job", variant="outline")) }} {{ cell("dependencies=[Depends(auth.required)]") }} {% endcall %} {% endcall %} {{ defs("What each argument decides", [ ("url=\"/api-docs\"", "where the page lives. Not /docs: FastAPI has claimed that before a plugin mounts, the first route registered wins, and a docs page that silently never renders is worse than a different URL — so the plugin warns at startup if the path is taken"), ("flow=", "how someone signs in from the page. Defaults to the AuthPlugin sharing the config, and to a panel that only says \"anonymous\" when there is none"), ("dependencies=", "put on every route the plugin adds — this is how the page is gated. Empty is right for a public API and wrong for an internal service"), ("try_it=False", "renders the reference and drops the console, for an environment where replaying a request server-side is not acceptable"), ("home_url=", "a way back out, in the sidebar's footer. The operation list has taken the sidebar, so without it the page is a room with no door"), ]) }} {% call prose() %}

It is also the kit's worked example of section 03's add_template_dir(). The console's templates live beside its code, in fjkit_apidocs/templates/apidocs/, and extend() hands that directory to the loader. The inner apidocs/ keeps the names at apidocs/page.html, which lets your app replace any one of them by writing templates/apidocs/<name>.html of its own — the app's directory is searched first.

{% endcall %} {{ defs("Worth knowing before you reach for it", [ ("its routes are out of your document", "include_in_schema=False, for the same reason FastAPI keeps /docs out of it: these are a way of reading the API, not part of it"), ("a call is capped", "64 KB of body and 30 seconds, both arguments. The console is a reading tool, not a load generator"), ("form bodies become fields", "an Annotated[str, Form()] route gets one input per field rather than a text box, so nobody is asked to hand-write urlencoding. A JSON body stays a single editable example, prefilled from the schema"), ("nothing on the page can read the credential", "signing in is a form your app defines, not a box to paste a token into — which is the same property that makes the cookie worth having"), ]) }} {% endcall %} {# ============================================================== 08 #} {% set t08 %}Python decides the shape of the figure. The browser decides its chrome.{% endset %} {% call lesson("charts", "08", "Charts", t08, tag="fjkit_charts") %} {% call prose() %}

Basecoat has a chart component and the kit does not ship it, because a chart is the one component that cannot be markup and tokens: something has to turn numbers into paths, and that something is 1.1 MB of JavaScript. Making it a plugin is what keeps that number off every page that draws nothing.

Registering it gives an app the chart macro, the theming bridge, the Plotly bundle and the two <script> lines that load them. Nothing to download and nothing to mount: the bundle ships inside this wheel and is served from the plugin's own static mount.

{% endcall %} {{ code("uv add fjkit-charts", label="shell") }} {{ code("from fjkit_charts import ChartsPlugin mount_fjkit(app, FjkitConfig(template_dir=…, plugins=(ChartsPlugin(),)))", label="app/main.py") }} {% call prose() %}

The router builds one Chart per figure and the page draws them. There is nothing in between: no chart endpoint, no JSON the browser fetches, no client state.

{% endcall %} {{ files([ {"id": "charts-router", "label": "router.py", "lang": "python", "source": "from fjkit_charts import Chart, figure_of Chart( id=\"by-owner\", title=\"Workload\", summary=\"Ana holds 5 of the 12 open tasks.\", figure=figure_of(go.Figure(...)), )", "note": "figure_of() drops Plotly's default template on the way through — 7,621 bytes carrying 111 colour literals — and validates the rest, so a trace the browser cannot draw fails here rather than rendering an empty box. It duck-types on to_plotly_json(), so plotly stays out of the kit's dependencies and a plain dict also works."}, {"id": "charts-page", "label": "charts/page.html", "lang": "jinja", "source": "{% from \"charts/macros.html\" import chart, chart_scripts %} {% for item in charts %}{{ chart(item) }}{% endfor %} {% block scripts %}{{ chart_scripts() }}{% endblock %}", "note": "chart_scripts() is called by the page, never by the shell. A plugin may not inject markup into ui/shell.html, and this is the case that rule was written for: a hook letting this one add a