{% 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. The plugins an app registers come first; the seam for writing one comes after, because fewer readers need it. #} {% set sections = [ {"id": "auth", "num": "01", "title": "The auth plugin", "group": "Ready-made plugins"}, {"id": "write", "num": "02", "title": "The flash plugin", "group": "Ready-made plugins"}, {"id": "charts", "num": "03", "title": "Charts", "group": "Ready-made plugins"}, {"id": "apidocs", "num": "04", "title": "The API console", "group": "Ready-made plugins"}, {"id": "admin", "num": "05", "title": "The admin", "group": "Ready-made plugins"}, {"id": "why", "num": "06", "title": "What a plugin is", "group": "Write your own"}, {"id": "mount", "num": "07", "title": "mount(): the app side", "group": "Write your own"}, {"id": "extend", "num": "08", "title": "extend(): Jinja, and every render", "group": "Write your own"}, {"id": "rules", "num": "09", "title": "Order, collisions, and limits", "group": "Write your own"}, ] %} {% block content %} {% call stack(8) %} {% set intro %} Plugins integrate the features most apps need into a fjkit app: sign-in, flash messages, charts, an API console, an admin. 01–05 are the ones that ship with fjkit or beside it, and how to register each. 06–09 show how to write your own. {% endset %} {{ page_header("Plugins", intro) }} {# ============================================================== 01 #} {% set t01 %}The browser holds a cookie. The server holds the token.{% endset %} {% call lesson("auth", "01", "The auth plugin", t01, tag="fjkit.auth") %} {% call prose() %}

Sign-in for an htmx app, shipped inside fjkit as fjkit.auth. 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. A handler that wants the session takes it as a parameter, session: Session = Depends(auth.required), or Depends(auth.optional) on a page that is also public; code that holds the plugin and a request calls auth.session(request); every template has it as session.

Those are the places to read it, and the reason is an outage. While the identity provider is unreachable, a request whose token has already expired is answered 503 by whatever asks for the session, and a handler that asks for nothing, such as a logout, runs. The middleware's own request.state.auth is None on that request, so a handler reading it directly would take a signed-in user for an anonymous one.

{% 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 %} {# ============================================================== 02 #} {% set t02 %}A message that outlives the response that raised it.{% endset %} {% call lesson("write", "02", "The flash plugin", t02, 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=…) config = FjkitConfig(template_dir=…, plugins=(flash, auth))", label="app/main.py") }} {% call prose() %}

auth declares uses = ("flash",) and takes whichever plugin is registered under that name, which is why flash is listed first. Pass flash= to hand it one configured differently from the one your routes use; an explicit argument outranks the lookup. The dependency runs one way and is optional: auth works without a flash, and a route that just saved something wants flash.add(response, "Saved") with no session in sight.

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 %} {# ============================================================== 03 #} {% set t03 %}Python decides the shape of the figure. The browser decides its chrome.{% endset %} {% call lesson("charts", "03", "Charts", t03, tag="fjkit_charts") %} {% call prose() %}

Charts are drawn with {{ link("Plotly", "https://plotly.com/python/") }}.

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

A route returns a list of Chart objects. The page draws each one with the chart() macro and loads Plotly with chart_scripts(). The browser fetches no separate chart data.

{% endcall %} {{ files([ {"id": "charts-router", "label": "app/routers/workload.py", "lang": "python", "source": "import plotly.graph_objects as go from fastapi import APIRouter from fjkit import render from fjkit_charts import Chart router = APIRouter() @router.get(\"/workload\") @render(\"workload/page.html\") def workload(): owners, counts = [\"Ana\", \"Ben\", \"Cy\"], [5, 4, 3] return {\"charts\": [ Chart( id=\"by-owner\", title=\"Open tasks by owner\", summary=\"Ana has 5 of the 12 open tasks.\", figure=go.Figure(go.Bar(x=owners, y=counts)), ), ]}", "note": "A Chart has four fields you set: id, title, summary and figure. summary is required: it is the text a screen reader reads, and the text shown when JavaScript is off. figure takes the go.Figure itself; Chart validates it, so an invalid trace fails here instead of rendering an empty box."}, {"id": "charts-page", "label": "app/templates/workload/page.html", "lang": "jinja", "source": "{% extends \"base.html\" %} {% from \"charts/macros.html\" import chart, chart_scripts %} {% block content %} {% for item in charts %}{{ chart(item) }}{% endfor %} {% endblock %} {% block scripts %}{{ chart_scripts() }}{% endblock %}", "note": "chart(item) draws one figure in a card. chart_scripts() loads Plotly for this page only, so pages without charts do not download it."}, ]) }} {% set f_fields %}any attribute Plotly documents for those types: {{ link("bar", "https://plotly.com/python/reference/bar/") }}, {{ link("scatter", "https://plotly.com/python/reference/scatter/") }}, {{ link("pie", "https://plotly.com/python/reference/pie/") }} and {{ link("layout", "https://plotly.com/python/reference/layout/") }}. Chart checks only the trace type; every other key passes through unchecked, so a misspelt key is silently ignored by Plotly{% endset %} {{ defs("Chart.figure", [ ("what it accepts", "a go.Figure. A plain dict is rejected"), ("it removes layout.template", "plotly.py always writes a default template, and that template holds 111 colour values. Without it, charts.js sets axis text, grid lines and backgrounds from the page's theme tokens, so the chart follows light and dark mode"), ("it validates the figure", "an invalid figure raises a validation error in the route, instead of drawing an empty box in the browser"), ("trace types", "bar, scatter and pie: the types in the bundled Plotly build. Any other type fails validation"), ("fields you can set", f_fields), ("fields to leave out", "colours: marker_color, line_color, font colours, backgrounds. The series use Plotly's palette and the theme sets the rest. Chart does not reject them; call assert_no_colour_in(charts) in your tests to catch them"), ]) }} {{ defs("The division of labour", [ ("the traces, and what they mean", "Python, in Chart.figure. The service that computed the numbers is the only thing that knows which of them is a bar"), ("axis text, grid lines, tick colour, the gaps between pie slices", "the browser. charts.js resolves them from the live tokens on every draw, because Plotly's defaults are a dark grey on a light grey — a chart drawn for a white page, on a page that may be dark. A theme toggle is a redraw"), ("the series colours", "Plotly's own palette, and the theme does not touch them. The figure carries no colour at all: a hex code there would fail the rule fjkit check applies to templates, and be wrong in one of the two schemes"), ("the height", "Chart.height, applied by the same script that draws. A style=\"\" attribute in the markup is the other way an app starts writing CSS"), ("the text alternative", "Chart.summary, written from the same numbers the traces were built from. Plotly's SVG is a thousand unlabelled elements, so the caption is what a screen reader gets — and without JavaScript it is the whole content"), ]) }} {{ defs("What each argument decides", [ ("url=\"/_fjkit-charts\"", "where this plugin's own assets are served. Keep it outside config.static_url: Starlette matches the first mount whose prefix fits, so a path nested inside the kit's static mount is swallowed by it and 404s against a directory that does not hold these files"), ("plotly_url=", "serve Plotly from somewhere other than the copy in the wheel — a path your app already serves, or a URL. The default is the vendored bundle under this plugin's own url, cache-stamped like every other kit asset"), ]) }} {{ defs("Worth knowing before you reach for it", [ ("summary is a required field", "not a keyword someone forgets. A chart with no text alternative is invisible to a screen reader, and the macro cannot invent the sentence"), ("the basic bundle, not the full one", "scatter, bar and pie. plotly.js-dist-min is 4.85 MB of 3D, maps, financial and statistical traces most dashboards never draw, and picking the bundle is the only size knob Plotly offers without a build step"), ("being in a wheel is not being on a page", "which distribution holds the bundle decides what an install carries; §4.2 budgets what a page downloads. An app that never draws a chart installs neither"), ("the pin lives with the bytes", "PLOTLY_VERSION is in fjkit_charts and scripts/vendor_plotly.py fetches it. The kit no longer knows Plotly exists"), ]) }} {% endcall %} {# ============================================================== 04 #} {% set t04 %}Sign in on the page, and every call below goes out as you.{% endset %} {% call lesson("apidocs", "04", "The API console", t04, 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 01'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, declares uses = ("auth",), and wires the sign-in panel to whatever the app registered under that name. 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") }} {% 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 08'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 %} {# ============================================================== 05 #} {% set t05 %}Register a model; get the list, the form and the routes between them.{% endset %} {% call lesson("admin", "05", "The admin", t05, tag="fjkit_admin") %} {% call prose() %}

Section 02 of the charter puts an admin generator outside the kit, and the reason is the one thing it cannot do without: read your schema. fjkit imports no ORM and is not going to. That rules the feature out of this wheel; it does not rule it out.

So fjkit-admin is shaped like the API console above. It owns a URL prefix, ships its templates under its own admin/ namespace so an app can shadow any one of them, renders inside whatever base_template names, and gates every route through dependencies. What it adds on top is the data layer the kit must not carry: a sessionmaker, and the SQLAlchemy models its views register.

{% endcall %} {{ code("uv add fjkit-admin", label="shell") }} {{ code("from fjkit_admin import AdminPlugin, ModelAdmin, action class TaskAdmin(ModelAdmin, model=Task): list_display = (\"title\", \"status\", \"project\", \"due\") search_fields = (\"title\", \"notes\") list_filter = (\"status\", \"project\") ordering = (\"-created\",) @action(\"Mark done\", confirm=\"Mark the selected tasks done?\") def mark_done(self, request, session, tasks): for task in tasks: task.done = True return f\"{len(tasks)} marked done\" admin = AdminPlugin(SessionLocal, views=(TaskAdmin,), base_template=\"base.html\") mount_fjkit(app, FjkitConfig(template_dir=…, plugins=(auth, admin)))", label="app/main.py") }} {% call prose() %}

That is a list with search, filters, sortable headers, pagination and bulk actions, plus the add, change and delete forms — all of it the kit's own macros, and none of it a route you wrote.

One region carries the whole screen. #admin-main is the target of every swap: a sort, a page, a search, a saved form and a bulk action all replace it. The list partial and the form partial render into that same id, which is why a saved form comes back as the list with no second target declared anywhere — and why the admin ships no JavaScript of its own.

{% endcall %} {{ defs("What each argument decides", [ ("session_factory", "a sessionmaker, or any callable returning a Session usable as a context manager. One session per request. An async_sessionmaker is refused at construction, with a message, rather than half-working per request"), ("views=", "the ModelAdmin subclasses to register, in sidebar order"), ("base_template=", "what the page extends. The kit's shell by default, or your own base.html so the admin sits inside the app's chrome. The admin fills the shell's sidebar block with its model list, so a base that fills it too loses its own navigation here — home_url is the way back"), ("dependencies=", "put on every route the plugin adds, typically (Depends(auth.required),). That is the whole of the gating; empty means the admin is public"), ("url=\"/admin\"", "where it lives. Absolute, and not /"), ]) }} {{ defs("The options keep Django's names", [ ("the list", "list_display, list_display_links, search_fields, list_filter, ordering, sortable_by, list_per_page, page_sizes, empty_value_display"), ("the form", "fields, exclude, readonly_fields, labels, help_texts, widgets"), ("the behaviour", "@action for a bulk action and @display to name a method column; get_queryset, has_view/add/change/delete_permission, save_model, delete_model, label_for"), ]) }} {{ defs("Worth knowing before you reach for it", [ ("it composes ui/* and writes no class", "the stylesheet's @source paths reach only inside this repository, so a plugin distributed separately can add no utility to fjkit's CSS. The admin is the proof that the closed vocabulary is enough to build a whole application on"), ("SQLModel registers like anything else", "a table=True class is a mapped class, and its own validators run on submit through model_validate"), ("two dependencies", "fjkit and sqlalchemy>=2.0"), ("one async handler, on purpose", "it reads the body htmx posts from the checkbox column, with parse_qs, which keeps python-multipart off the dependency list. Every handler that renders is def, so Starlette runs it in the threadpool"), ]) }} {% endcall %} {# ============================================================== 06 #} {% set t06 %}Two contribution points, because the kit has two moments.{% endset %} {% call lesson("why", "06", "What a plugin is", t06) %} {% 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 %} {# ============================================================== 07 #} {% set t07 %}Middleware, handlers, routes — attributed to the plugin that added them.{% endset %} {% call lesson("mount", "07", "mount(): the app side", t07) %} {% 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 %} {# ============================================================== 08 #} {% set t08 %}The per-request context is the part that was missing.{% endset %} {% call lesson("extend", "08", "extend(): Jinja, and every render", t08) %} {% 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\": self.session(request)}, 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 session() reads for the line above.

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 04 is the worked example. A plugin that lives inside the fjkit repository has one more step: the Tailwind build scans an explicit list of directories in fjkit.css, so the plugin 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 %} {# ============================================================== 09 #} {% set t09 %}Everything a plugin can collide over fails at startup, not in production.{% endset %} {% call lesson("rules", "09", "Order, collisions, and limits", t09) %} {% 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), ("reach a sibling it did not declare", "a plugin that reads another says so — uses = (\"flash\",) — and gets it from setup.plugin(\"flash\"), or None when the app registered none. An undeclared lookup is a startup error, and so is a sibling listed after the plugin that uses it. Order is still the order you wrote: the host checks it and never reorders"), ("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 %} {% endcall %} {% endblock %}