{% 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.
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.
AppSetup is a thin pass-through to FastAPI. What it adds is attribution: a
misbehaving plugin is named rather than found by elimination.
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.
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.
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.
<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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.