{% 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 %} {# base.html 的側欄由這份清單建出來,所以在這裡改名就等於改了導覽。 id 要跟 `lesson()` 產生的錨點一致。app 會註冊的外掛排在前面; 自己寫外掛的介面排在後面,因為需要它的讀者較少。 #} {% set sections = [ {"id": "auth", "num": "01", "title": "auth plugin", "group": "現成的外掛"}, {"id": "write", "num": "02", "title": "flash plugin", "group": "現成的外掛"}, {"id": "charts", "num": "03", "title": "圖表", "group": "現成的外掛"}, {"id": "apidocs", "num": "04", "title": "API 主控台", "group": "現成的外掛"}, {"id": "admin", "num": "05", "title": "admin", "group": "現成的外掛"}, {"id": "why", "num": "06", "title": "plugin 是什麼", "group": "自己寫外掛"}, {"id": "mount", "num": "07", "title": "mount():app 那一側", "group": "自己寫外掛"}, {"id": "extend", "num": "08", "title": "extend():Jinja 與每一次渲染", "group": "自己寫外掛"}, {"id": "rules", "num": "09", "title": "順序、碰撞與界線", "group": "自己寫外掛"}, ] %} {% block content %} {% call stack(8) %} {% set intro %} plugin 把多數 app 都需要的常見功能整合進 fjkit app:登入、flash 訊息、圖表、API console、後台。 01–05 是 fjkit 內建或另行發佈的 plugin,以及各自的註冊方式。 06–09 說明如何自己寫 plugin。 {% endset %} {{ page_header("Plugins", intro) }} {# ============================================================== 01 #} {% set t01 %}瀏覽器拿 cookie,token 留在伺服器。{% endset %} {% call lesson("auth", "01", "auth plugin", t01, tag="fjkit.auth") %} {% call prose() %}

htmx app 的登入機制,內建於 fjkit,模組為 fjkit.auth。瀏覽器只拿到一個不透明、簽名過的 HttpOnly cookie。access token、refresh token 與 claims 全部留在伺服器端的 session store。

這正是 htmx app 需要的形狀:不必用 htmx:configRequest 掛 header, 不必寫 refresh 重試的接合程式碼,DOM 裡也沒有 token。cookie 由瀏覽器自動帶上。

{% 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("三個可替換的接縫", [ ("SessionStore", "開發用 MemoryStore、正式用 RedisStore,或你自己的實作,共五個 async 方法"), ("TokenSource", "憑證怎麼變成 session:比對密碼、OIDC code 交換、跟上游換 token。預設的 LocalSource 沒有上游"), ("Csrf", "cookie 是 ambient credential,所以 CSRF 回來了。SameSite=Lax 加上精確的 Origin 比對,兩者漏掉的東西不同"), ]) }} {% call prose() %}

路由看不到這些。在 router 上掛 Depends(auth.required),底下的 handler 簽名不用改。需要 session 的 handler 把它當參數拿:session: Session = Depends(auth.required), 公開頁面則用 Depends(auth.optional);手上有 plugin 和 request 的程式碼呼叫 auth.session(request);每個模板裡叫 session。

只從這幾個地方讀,原因是斷線。身分提供者連不上時,token 已經過期的請求會由任何索取 session 的地方回 503,什麼都不索取的 handler,例如登出,照常執行。middleware 自己的 request.state.auth 在那次請求裡是 None,直接讀它的 handler 會把已登入的使用者當成匿名。

{% endcall %} {% call stage("未登入的請求怎麼被拒絕", "@render 決定線上格式的那個問題,換成:該怎麼拒絕。") %} {% call table([{"label": "誰在問"}, {"label": "回什麼"}, {"label": "為什麼不是 401"}]) %} {{ cell("htmx", tone="strong") }} {{ cell(badge("401 + HX-Redirect", variant="info")) }} {{ cell("htmx 在讀狀態碼之前就處理 HX-Redirect,所以兩者可以同時成立") }} {{ cell("瀏覽器導覽", tone="strong") }} {{ cell(badge("303", variant="info")) }} {{ cell("沒有 body 的 401 就是一片空白;人要的是登入表單") }} {{ cell("腳本、curl、另一個服務", tone="strong") }} {{ cell(badge("401", variant="outline")) }} {{ cell("401 就是為這種呼叫端而存在") }} {% endcall %} {% endcall %} {% call prose() %}

只有中間那一列不能是 401:一般導覽沒有機制可以回應它,只會停在空白頁上。 htmx 那一列處理完 HX-Redirect 就 return,還沒讀到狀態碼, 所以狀態碼可以照實回,而瀏覽器照樣整頁移動。導覽的判定是 Sec-Fetch-Mode: navigate, 沒有這個標頭時改看 Accept 有沒有提到 text/html;其餘都是 render_mode="auto" 下會拿到 JSON 的呼叫端。那個 401 不帶 WWW-Authenticate:RFC 9110 要求這個欄位,但所有已註冊的機制在這裡都不適用。 Basic 會讓瀏覽器跳出自己的帳密對話框,而那正是這個 plugin 要取代的登入表單。

{% endcall %} {{ defs("動手之前值得知道的", [ ("每個請求一次 store 查詢", "這是「刪一筆就能撤銷 session」與「refresh token 不出伺服器」的代價"), ("refresh 有鎖", "否則同時發出的四個 swap 會送出四次 refresh,而有輪替的上游在第二次到達時撤銷整個 family"), ("refresh 期間不換 session id", "換了的話並行的 swap 會各帶回一張不同的 Set-Cookie,最後一張生效,其餘變孤兒"), ("登入路由是 async def", "它們要 await store,真正的 TokenSource 還要 await 身分提供者。只負責渲染的路由維持 def"), ]) }} {% endcall %} {# ============================================================== 02 #} {% set t02 %}一則活得比產生它的那個回應更久的訊息。{% endset %} {% call lesson("write", "02", "flash plugin", t02, tag="fjkit.flash") %} {% call prose() %}

轉址會讓訊息消失。HX-Trigger 是叫頁面觸發一個事件,但頁面在那個標頭抵達之前 已經被換掉;一般導覽更是全程沒有 JavaScript 參與。唯一跨得過去的只有 cookie。

所以 flash 是一個短命的簽名 cookie:在轉址的那個回應上種下去,在落地的那個請求上讀回來, 跟其他訊息一樣排進 fjkit.messages,並在真的顯示過之後才清掉。 它因此只出現一次,重新整理不會再跳。ui/shell.html 裡的 toaster 區域本來就在, 也本來就在畫那個佇列,所以註冊 plugin 就是全部的設定,模板不用改。

{% 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 宣告 uses = ("flash",),拿的是註冊在這個名字下的那個 plugin, 所以 flash 要排在前面。要給它一個跟路由用的不同設定的 flash,就傳 flash=;明確傳入的參數優先於查詢。這條依賴是單向且選用的:auth 沒有 flash 也能跑,而一條剛存好資料的路由要的是 flash.add(response, "Saved"),跟 session 無關。

它在 Jinja 那一側沒有任何東西:沒有 extend(),也沒有自己的 context key。 middleware 把 cookie 排進 fjkit.messages,所以模板不會知道這則訊息 是搭 cookie 來的,還是它正在渲染的那個 handler 給的。這就是「上手」那一頁講的分層換來的結果: shell 裡一個迴圈,而不是每種送達方式各一個。

那顆 cookie 在 messages.shown(request) 為真時才被清掉,也就是有模板迭代過 那個佇列,或佇列被序列化進 HX-Trigger。一次頁面載入發出的請求不只頁面本身, htmx 的輪詢與 prefetch 都算。若把「顯示過」定義成「這個請求帶了那顆 cookie」, 其中任何一個都會吃掉訊息,讓它該出現的那一頁空著。

{% endcall %} {{ code("def mount(self, setup: AppSetup) -> None: setup.add_middleware(_FlashMiddleware, plugin=self) # 那支 middleware 進來時與出去時做的事: carried = self.plugin.load(request) if carried: messages.extend(request, carried) # 跟其他訊息一樣排進去 response = await call_next(request) if carried and messages.shown(request): self.plugin.clear(response) # 真的到過某一頁之後才清", label="fjkit/flash.py") }} {% call prose() %}

在任何地方呼叫 flash.add(response, "Saved", category="success"), 訊息就會以 toast 出現在瀏覽器接下來落地的那一頁上,而那一頁的路由對它一無所知。 這也是判斷一樣東西該不該做成 plugin 的標準:這個值,路由自己傳得過去嗎? 這裡傳不過去,因為發出訊息的路由不是渲染它的路由。

{% endcall %} {{ defs("鴨子型別,不是繼承", [ ("name", "一個 str 屬性,必要,而且只有這一個"), ("mount / extend", "兩個都可選;沒有定義的那個不會被呼叫"), ("沒有基底類別", "Plugin 是 Protocol,所以一個 plugin 不需要 import fjkit 才能成為 plugin"), ]) }} {% endcall %} {# ============================================================== 03 #} {% set t03 %}圖形的形狀由 Python 決定,圖形的外裝由瀏覽器決定。{% endset %} {% call lesson("charts", "03", "圖表", t03, tag="fjkit_charts") %} {% call prose() %}

圖表使用 {{ 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() %}

路由回傳一組 Chart 物件。頁面用 chart() macro 逐一畫出, 並以 chart_scripts() 載入 Plotly。瀏覽器不另外抓取圖表資料。

{% 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=\"各負責人的未結案任務\", summary=\"12 件未結案任務中,Ana 佔 5 件。\", figure=go.Figure(go.Bar(x=owners, y=counts)), ), ]}", "note": "Chart 需要設定四個欄位:id、title、summary 與 figure。summary 為必填:它是螢幕閱讀器讀出的文字,也是 JavaScript 關閉時顯示的內容。figure 直接接收 go.Figure;Chart 會驗證它,無效的 trace 會在這裡失敗,而不是渲染出一個空框。"}, {"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) 把一張圖畫在一張 card 裡。chart_scripts() 只在這一頁載入 Plotly,沒有圖表的頁面不會下載它。"}, ]) }} {% set f_fields %}Plotly 為這三種類型記載的任何屬性:{{ link("bar", "https://plotly.com/python/reference/bar/") }}、{{ link("scatter", "https://plotly.com/python/reference/scatter/") }}、{{ link("pie", "https://plotly.com/python/reference/pie/") }} 與 {{ link("layout", "https://plotly.com/python/reference/layout/") }}。Chart 只檢查 trace 的 type;其餘欄位不檢查,原樣傳遞,拼錯的欄位會被 Plotly 默默忽略{% endset %} {{ defs("Chart.figure", [ ("接受的型別", "go.Figure。一般 dict 會被拒絕"), ("移除 layout.template", "plotly.py 一定會寫入一份預設 template,其中有 111 個顏色值。移除之後,charts.js 從頁面的主題 token 設定軸文字、格線與背景,圖表因此跟著淺色與深色模式切換"), ("驗證 figure", "不合法的 figure 在路由裡就拋出驗證錯誤,而不是在瀏覽器畫出一個空框"), ("trace 類型", "bar、scatter 與 pie,也就是內附 Plotly build 支援的類型。其他類型無法通過驗證"), ("可以設定的欄位", f_fields), ("不要設定的欄位", "顏色:marker_color、line_color、字型顏色、背景。資料序列使用 Plotly 的色盤,其餘由主題決定。Chart 不會擋下這些欄位;請在測試中呼叫 assert_no_colour_in(charts) 檢查"), ]) }} {{ defs("誰負責什麼", [ ("trace,以及它們代表什麼", "Python,寫在 Chart.figure。只有算出這些數字的 service 知道其中哪一個該是長條"), ("軸標文字、格線、刻度顏色、圓餅切片之間的間隙", "瀏覽器。charts.js 每次繪製時從當下的 token 解析出來,因為 Plotly 的預設是淺灰底上的深灰字——一張為白底畫的圖,卻可能落在深色頁面上。切換主題就是重畫一次"), ("series 的顏色", "Plotly 自己的色盤,主題不動它。figure 本身完全不帶顏色:寫死色碼會違反 fjkit check 對模板的同一條規則,而且在兩種配色其中之一必定是錯的"), ("高度", "Chart.height,由負責繪製的同一支腳本套用。在 markup 裡寫 style=\"\" 是 app 開始自己寫 CSS 的另一條路"), ("替代文字", "Chart.summary,用建 trace 的同一批數字寫出來。Plotly 的 SVG 是上千個沒有標籤的 ,所以螢幕閱讀器讀到的是這句 caption;沒有 JavaScript 時,它就是全部的內容"), ]) }} {{ defs("每個參數決定什麼", [ ("url=\"/_fjkit-charts\"", "這個 plugin 自己的資產放在哪。要放在 config.static_url 之外:Starlette 比對的是第一個前綴吻合的 mount,所以巢狀在 kit static mount 底下的路徑會被它吃掉,然後對一個沒有這些檔案的目錄回 404"), ("plotly_url=", "改從別處提供 Plotly,而不是用 wheel 裡那一份——你的 app 已經在服務的某個路徑,或一個 URL。預設是 plugin 自己 url 底下的 vendored bundle,跟其他 kit 資產一樣帶快取戳記"), ]) }} {{ defs("動手之前值得知道", [ ("summary 是必填欄位", "不是一個會被忘掉的關鍵字。沒有替代文字的圖表對螢幕閱讀器等於不存在,而 macro 沒辦法替你把那句話寫出來"), ("用的是 basic bundle,不是完整版", "只有 scatter、bar 與 pie。plotly.js-dist-min 是 4.85 MB 的 3D、地圖、金融與統計 trace,多數儀表板一個都不畫;而在沒有 build step 的前提下,選 bundle 是 Plotly 給的唯一一個體積旋鈕"), ("在 wheel 裡不等於在頁面上", "bundle 放在哪個 distribution,決定一次安裝帶進什麼;§4.2 管的是一個頁面下載什麼。從不畫圖的 app 兩者都不付"), ("版本 pin 跟著 bytes 走", "PLOTLY_VERSION 在 fjkit_charts 裡,由 scripts/vendor_plotly.py 抓取。kit 已經不知道 Plotly 的存在"), ]) }} {% endcall %} {# ============================================================== 04 #} {% set t04 %}在這一頁登入,底下每一次呼叫都以你的身分送出。{% endset %} {% call lesson("apidocs", "04", "API 主控台", t04, tag="fjkit_apidocs") %} {% call prose() %}

Swagger UI 的 Authorize 只能表達 OpenAPI 文件描述得出來的東西:API key、HTTP scheme、 OAuth2 flow。第 01 節那種 session——簽章過的 HttpOnly cookie,token 留在伺服器 ——四種都不是。它的 Try it out 也送不出去:那是在瀏覽器裡跑 fetch(), 而那顆 cookie 正好不給 JavaScript 讀。

所以兩件事都搬到伺服器。登入是一個由你的 app 提供的 Python 物件,對 fjkit.auth 的 app 來說就是登入頁本來就在呼叫的那個 TokenSource。送出請求則在你自己的 app 裡行程內重放,帶著呼叫者自己的 cookie 走完整條 middleware。 token 換發、撤銷與 Origin 檢查因此用的是你已經寫好的那一份,而不是文件頁裡的第二實作。

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

主控台是獨立的 distribution——只渲染頁面的 app 沒有理由背一套 Swagger UI 的替代品—— 所以要多裝一個套件:

{% 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() %}

接線到此結束。不用寫路由、不用渲染模板、不用服務靜態檔:plugin 自己帶路由與模板, 並宣告 uses = ("auth",),把登入面板接到 app 註冊在這個名字下的那個 plugin。 app 用別的方式認證時傳 flow=;只想留這一頁時,設 FastAPI(docs_url=None, redoc_url=None)。

{% endcall %} {% call stage("Swagger 做不到的那一半", "兩邊讀的是同一份 /openapi.json,差別從那之後才開始。") %} {% call table([{"label": "問題"}, {"label": "Swagger UI"}, {"label": "fjkit_apidocs"}]) %} {{ cell("怎麼登入", tone="strong") }} {{ cell("文件描述得出來的那四種") }} {{ cell("你的 app 自己的程式——一個 Python 物件") }} {{ cell("請求在哪裡跑", tone="strong") }} {{ cell("在瀏覽器,用 fetch()") }} {{ cell("在 app 裡,用 ASGI 重放") }} {{ cell("帶了什麼出去", tone="strong") }} {{ cell("JavaScript 摸得到的東西") }} {{ cell("你的 session cookie,HttpOnly 也照帶") }} {% endcall %} {% endcall %} {{ defs("每個參數決定什麼", [ ("url=\"/api-docs\"", "這一頁的位置。不是 /docs:plugin 掛載時 FastAPI 早就把它拿走了,先註冊的路由勝出,而一頁永遠不會渲染的文件比換個網址更糟,所以路徑被佔用時 plugin 會在啟動時警告"), ("flow=", "怎麼從這一頁登入。預設是同一份 config 裡的 AuthPlugin;沒有的話,面板只會顯示 anonymous"), ("dependencies=", "加在 plugin 新增的每一條路由上,也就是擋住這一頁的方式。公開 API 可以留空,內部服務不行"), ("try_it=False", "只渲染參考文件、拿掉主控台,用於不接受在伺服器端重放請求的環境"), ("home_url=", "側欄頁尾的返回連結。操作清單已經佔滿側欄,沒有它就沒有離開這一頁的入口"), ]) }} {% call prose() %}

它同時是第 08 節 add_template_dir() 在 kit 裡的實例。主控台的模板與它的程式碼 放在一起,在 fjkit_apidocs/templates/apidocs/,由 extend() 把那個 目錄交給 loader。裡面那層 apidocs/ 讓模板名維持 apidocs/page.html, 所以你的 app 只要寫一份自己的 templates/apidocs/<名稱>.html 就能換掉其中任何一支,因為 app 的目錄排在最前面被搜。

{% endcall %} {{ defs("動手之前值得知道", [ ("它的路由不在你的文件裡", "include_in_schema=False,跟 FastAPI 把 /docs 排除在外是同一個理由:這些是讀 API 的方式,不是 API 的一部分"), ("一次呼叫有上限", "body 64 KB、逾時 30 秒,兩個都是參數。主控台是讀 API 的工具,不是壓測工具"), ("form body 會拆成欄位", "一條 Annotated[str, Form()] 的路由拿到的是一欄一個輸入框,而不是一個文字框,不必自己手寫 urlencoding。JSON body 則是一份可編輯的範例,內容由 schema 預先填好"), ("這一頁上沒有任何東西讀得到憑證", "登入是你的 app 定義的表單,不是一個貼 token 的框,性質與那顆 cookie 相同"), ]) }} {% endcall %} {# ============================================================== 05 #} {% set t05 %}註冊一個 model,就得到列表、表單,以及它們之間的路由。{% endset %} {% call lesson("admin", "05", "admin", t05, tag="fjkit_admin") %} {% call prose() %}

charter 第 02 節把 admin 產生器放在 kit 之外,理由是它少不了的那件事:讀你的 schema。 fjkit 不 import 任何 ORM,將來也不會。那排除的是「這個 wheel 做這件事」,不是「這件事不該有人做」。

所以 fjkit-admin 的形狀跟上一節的 API 主控台一樣:它擁有一段 URL 前綴、 把模板放在自己的 admin/ 命名空間下讓 app 可以逐一覆蓋、在 base_template 指定的模板裡渲染,並用 dependencies 擋住每一條路由。 它多出來的是 kit 不能帶的資料層:一個 sessionmaker,以及它的 view 註冊的 SQLAlchemy model。

{% 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(\"標記完成\", confirm=\"要把選取的任務標記為完成嗎?\") def mark_done(self, request, session, tasks): for task in tasks: task.done = True return f\"已標記 {len(tasks)} 件\" admin = AdminPlugin(SessionLocal, views=(TaskAdmin,), base_template=\"base.html\") mount_fjkit(app, FjkitConfig(template_dir=…, plugins=(auth, admin)))", label="app/main.py") }} {% call prose() %}

這樣就有一個帶搜尋、篩選、可排序表頭、分頁與批次動作的列表,以及新增、修改、刪除三種表單。 全部由 kit 自己的 macro 渲染,沒有一條路由是你寫的。

整個畫面由一個區塊承載。#admin-main 是每一次 swap 的 target:排序、換頁、搜尋、 存檔後的表單與批次動作,全都替換它。列表 partial 與表單 partial 渲染進同一個 id, 所以表單存檔後會直接以列表的形式回來,任何地方都不需要宣告第二個 target—— 也因此 admin 自己不出貨任何 JavaScript。

{% endcall %} {{ defs("每個參數決定什麼", [ ("session_factory", "一個 sessionmaker,或任何回傳可當 context manager 使用的 Session 的 callable。一個請求一個 session。傳 async_sessionmaker 會在建構時被拒絕並附上訊息,而不是每個請求半殘"), ("views=", "要註冊的 ModelAdmin 子類別,順序即側欄順序"), ("base_template=", "這一頁 extend 什麼。預設是 kit 的 shell,也可以給你自己的 base.html,讓 admin 坐在 app 的框架裡。admin 會用它的 model 清單填掉 shell 的 sidebar block,所以一個同樣要填 sidebar 的 base 會在這裡失去自己的導覽——home_url 是回去的路"), ("dependencies=", "加在 plugin 新增的每一條路由上,通常是 (Depends(auth.required),)。擋住 admin 的手段只有這一個;留空就是公開的"), ("url=\"/admin\"", "admin 住在哪。要絕對路徑,且不能是 /"), ]) }} {{ defs("選項沿用 Django 的名字", [ ("列表", "list_display、list_display_links、search_fields、list_filter、ordering、sortable_by、list_per_page、page_sizes、empty_value_display"), ("表單", "fields、exclude、readonly_fields、labels、help_texts、widgets"), ("行為", "@action 定義批次動作、@display 為方法欄位命名;get_queryset、has_view/add/change/delete_permission、save_model、delete_model、label_for"), ]) }} {{ defs("動手之前值得知道", [ ("它只組合 ui/*,不寫任何 class", "stylesheet 的 @source 路徑只到得了這個 repository 裡面,所以獨立發佈的 plugin 沒辦法為 fjkit 的 CSS 增加任何 utility。admin 因此也是一個證明:封閉詞彙表足以撐起一整個應用程式"), ("SQLModel 跟其他 model 一樣註冊", "table=True 的類別就是一個 mapped class,送出時它自己的 validator 會透過 model_validate 執行"), ("兩個依賴", "fjkit 與 sqlalchemy>=2.0"), ("刻意留下一個 async handler", "它要讀 htmx 從勾選欄位送出的 body,用 parse_qs 讀,因此 python-multipart 不必進依賴清單。其餘負責渲染的 handler 都是 def,交給 Starlette 丟到 threadpool"), ]) }} {% endcall %} {# ============================================================== 06 #} {% set t06 %}兩個貢獻時機,對應 kit 的兩個時機。{% endset %} {% call lesson("why", "06", "plugin 是什麼", t06) %} {% call prose() %}

macro 只管一頁畫出什麼。handler 執行之前發生的事,以及每個模板都該知道、 但不該由每條路由傳過去的值,都不歸它管。session 兩者都要, 語系與 feature flag 只要後者,flash 訊息只要前者。

plugin 是一個物件,兩個方法都可選。mount() 在 app 建構時執行, extend() 在 Jinja environment 建立時執行。只給每個模板一個值的 plugin 只寫 extend();只在請求經過時處理的 plugin 只寫 mount()。

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

註冊到此結束。mount_fjkit() 本來就要讀這份 config 來掛靜態檔與建立 environment,所以 plugin 不需要第二個呼叫,也沒有 import 順序要記。

兩個時機刻意保持可見。把它藏起來的代價,是某個 plugin 的 mount() 沒有執行而沒有人發現。

{% endcall %} {% endcall %} {# ============================================================== 07 #} {% set t07 %}middleware、handler、路由——而且都記在加它的那個 plugin 頭上。{% endset %} {% call lesson("mount", "07", "mount():app 那一側", t07) %} {% call prose() %}

AppSetup 把呼叫轉給 FastAPI,並多做一件事:署名。 出問題時錯誤訊息會指名那個 plugin,不必逐一拔掉來找。

{% endcall %} {{ defs("AppSetup", [ ("add_middleware(cls, **options)", "Starlette 依註冊的反序執行 middleware,所以 plugins= 裡排在後面的 plugin 包在前面那些的外層"), ("add_exception_handler(exc, handler)", "middleware 裡拋出的例外到不了 handler:Starlette 的 handler 那一層在使用者 middleware 的內側"), ("include_router(router)", "給自帶端點的 plugin"), ("mount_static(url, directory)", "給自帶資產的 plugin,掛載名稱由 plugin 名字衍生"), ("warn(message)", "啟動期的設定檢查;訊息會冠上 plugin 名稱,以 PluginWarning 發出"), ("app, config", "活的 FastAPI app 與 FjkitConfig,給上面兩個表達不了的檢查用"), ]) }} {% call prose() %}

warn() 用於只有這個 plugin 認得出來的組合。例如正式設定配上記憶體版 session store:在開發機上正常,換到四個 worker 後面會隨機把人登出, 而且 traceback 裡沒有一行指向原因。

{% endcall %} {% endcall %} {# ============================================================== 08 #} {% set t08 %}per-request 的 context,就是 globals 缺的那一塊。{% endset %} {% call lesson("extend", "08", "extend():Jinja 與每一次渲染", t08) %} {% call prose() %}

FjkitConfig.globals 每個 process 只算一次,適合版本字串, 不適合「現在登入的是誰」。add_context_processor() 補上另一半: 每次渲染執行一次,拿得到當下的 request。

{% endcall %} {{ defs("EnvSetup", [ ("add_template_dir(path)", "搜尋順序排在 app 的模板之後、kit 的之前——plugin 可以蓋掉 kit 的 macro,永遠蓋不掉 app 自己寫的檔案"), ("add_global(name, value)", "整個 process 一個值"), ("add_filter(name, fn)", "一個 Jinja filter"), ("add_context_processor(fn, provides=[…])", "併進每個模板的 context,每次渲染一次"), ]) }} {{ code("def extend(self, setup: EnvSetup) -> None: setup.add_context_processor( lambda request: {\"session\": self.session(request)}, provides=[\"session\"], )", label="auth plugin 的 extend(),全部") }} {% call prose() %}

兩個限制都是刻意的。processor 必須同步:它在 Templates.page() 裡執行,而那是 def handler 從 threadpool 進來的。 需要 I/O 的 plugin 在 middleware 裡做完,把結果留在 request.state 上, 上面那一行的 session() 讀的正是這個。

provides= 是強制宣告,不是自動推得。沒有它,兩個 plugin 會默默搶同一個 user,只能靠渲染頁面時碰巧注意到。有了它,衝突變成一個點名雙方的啟動期錯誤。

add_template_dir() 是帶 UI 的 plugin 把 UI 帶進來的方式,第 04 節是範例。 位於 fjkit repository 內的 plugin 多一個步驟:Tailwind 的掃描範圍是 fjkit.css 裡一份明列的目錄清單,所以該 plugin 要把自己的模板目錄加進那份清單。 漏掉不會產生任何錯誤:模板照樣渲染,但裡面的 utility class 都不在樣式表裡,頁面看起來是壞的。

{% endcall %} {% endcall %} {# ============================================================== 09 #} {% set t09 %}所有可能相撞的東西都在啟動時報錯,不會留到正式環境。{% endset %} {% call lesson("rules", "09", "順序、碰撞與界線", t09) %} {% call stage("模板搜尋順序", "先找到的贏。") %} {% call table([{"label": "順序"}, {"label": "屬於"}, {"label": "可以蓋掉"}]) %} {{ cell("1", tone="muted", numeric=true) }} {{ cell("app 的 template_dir", tone="strong") }} {{ cell("全部——這就是 fjkit eject") }} {{ cell("2", tone="muted", numeric=true) }} {{ cell("每個 plugin,依 plugins= 的順序", tone="strong") }} {{ cell("kit 的 macro,永遠不含 app 的檔案") }} {{ cell("3", tone="muted", numeric=true) }} {{ cell("kit", tone="strong") }} {{ cell("沒有") }} {% endcall %} {% endcall %} {{ defs("啟動時就拒絕", [ ("兩個 plugin 同名", "它們會在 request.state. 上相撞,之後的錯誤訊息無法分辨是哪一個"), ("兩個 plugin 搶同一個 global、filter 或 context key", "錯誤訊息會點名雙方,並要求改名或拿掉一個"), ("processor 回傳沒宣告過的 key", "strict_undefined 會抓到,所以在開發階段就浮現"), ]) }} {% set no_markup %} 沒有任何 hook 能把 markup 塞進 shell。有的話,任何 plugin 都能在每一頁放一段 <script>,零建置與封閉詞彙表會同時失守。要出 UI 的 plugin 出的是模板,而且跟其他東西一樣要過 fjkit check。 {% endset %} {{ defs("plugin 做不到的事", [ ("注入 markup", no_markup), ("讀取沒宣告的同伴", "要讀別的 plugin 就先宣告——uses = (\"flash\",)——再用 setup.plugin(\"flash\") 拿;app 沒註冊時拿到 None。沒宣告就查是啟動錯誤,被用的 plugin 排在使用者後面也是。順序仍然是你寫的順序:host 只檢查,不重排"), ("在啟動時開連線", "沒有 lifespan hook。plugin 收的是 app 已經建好的 client,RedisStore 有一個 client 參數就是這個原因"), ]) }} {% endcall %} {% endcall %} {% endblock %}