{% extends "base.html" %} {% from "_parts.html" import code, defs, files, lesson, prose, stage, stage_with %} {% from "ui/button.html" import button, button_group %} {% from "ui/data.html" import badge, card, code_block, link %} {% from "ui/form.html" import field_row %} {% from "ui/layout.html" import grid, page_header, row, stack %} {% from "ui/table.html" import cell, table %} {# 「上手」的中文版。id、控制項與腳本掛點跟英文版逐字相同:learn.js 只有一支, 兩個語言共用,所以這裡只有文字不同。 #} {% set sections = [ {"id": "wiring", "num": "01", "title": "接線:config、mount、templates"}, {"id": "exchange", "num": "02", "title": "htmx 的請求與回應"}, {"id": "targets", "num": "03", "title": "hx-target 與 hx-swap"}, {"id": "triggers", "num": "04", "title": "hx-trigger:打字、捲動、輪詢"}, {"id": "partials", "num": "05", "title": "partial:一份檔案,整頁與局部共用"}, {"id": "oob", "num": "06", "title": "一次回應,換掉好幾塊:hx-swap-oob"}, {"id": "broadcast","num": "07", "title": "一個 id,好幾份 fragment:HX-Trigger"}, {"id": "pending", "num": "08", "title": "請求進行中的那段時間"}, {"id": "patterns", "num": "09", "title": "九種 htmx 模式,以及對應的 handler"}, {"id": "messages", "num": "10", "title": "訊息:三層"}, {"id": "errors", "num": "11", "title": "表單被退回的時候"}, {"id": "knob", "num": "12", "title": "換品牌:品牌 token 與狀態 token"}, {"id": "gate", "num": "13", "title": "fjkit check:封閉詞彙表"}, ] %} {% block content %} {% call stack(8) %} {% set intro %} 十三課:一支 fjkit app 怎麼接、htmx 實際送出什麼、表單被退回時會怎樣、換品牌怎麼換, 以及那個會讓 build 失敗的指令。 底下每個示範都會實際執行。 {% endset %} {{ page_header("在寫路由的地方,把介面一起寫完", intro) }} {# ============================================================== 01 #} {% set t01 %}接觸點只有三個;其餘寫的都是你的 app,不是前端。{% endset %} {% call lesson("wiring", "01", "接線:config、mount、templates", t01) %} {% call prose() %}

一支 fjkit app 只在三個地方接觸 kit。FjkitConfig 收下所有設定。 mount_fjkit() 從安裝好的套件供應 stylesheet 與 vendored JS, 並在每個 process 建立一次 Jinja environment。@render 指名路由要用的模板。

底下四個檔案可以逐一點開。沒有 package.json,也不需要開著 watcher。 repo 裡的 examples/fjkit-demo 就是同樣這四個檔案,放在一支能跑的 app 裡。

{% endcall %} {% call card(padded=false) %}
{{ code_block("", id="wiring-code") }}
{% endcall %} {% call prose() %}

第二個分頁值得配一張圖。base.html 只有四個 block,是因為它 extends 的 ui/shell.html 已經拿走其餘部分:<head>、 第一次繪製前就套用深色模式的那支腳本、指向 stylesheet 與三支 vendored 腳本的連結, 以及 header/main/footer 這副骨架。底下就是那個檔案畫出來的樣子。 用品牌色標起來的區塊是你要填的 block,其餘由外殼負責,你不去翻它就不會看到。

{% endcall %} {% call stage("ui/shell.html 畫出來的樣子", "要覆寫哪個 block,就在自己的 base.html 裡寫同名的 block。完整清單在速查表。") %} {% include "_shell_diagram.html" %} {% endcall %} {% call prose() %}

底下四個詞在這一頁有固定的意思,後面每一課都靠它們。

{% endcall %} {% set w_markup %}HTML 文字——模板產生、瀏覽器還沒解析的那串東西,例如 <div id="board">…</div>。後面說某條路由「有 markup 在等著」,意思是有一份模板準備把 handler 的回傳值變成 HTML,而不是變成 JSON{% endset %} {% set w_shell %}指 ui/shell.html,也就是上面那張圖:包在你的頁面外面的那份文件——<head>、header、<main>、footer 與 toaster{% endset %} {% set w_fragment %}一段不成文件的 HTML:沒有 <!doctype>、沒有 <head>,只有那幾個元素。htmx 的回應裝的就是它{% endset %} {% set w_partial %}渲染出 fragment 的那份模板。檔名以底線開頭,不 extends 外殼,整頁與 htmx 端點共用同一份——第 05 課{% endset %} {{ defs("這一頁固定用法的四個詞", [ ("markup(標記)", w_markup), ("外殼 shell", w_shell), ("fragment(片段)", w_fragment), ("partial", w_partial), ]) }} {% endcall %} {# ============================================================== 02 #} {% set t02 %}伺服器用 HTML 回應一次 swap。這中間沒有序列化,也沒有要同步的前端狀態。{% endset %} {% call lesson("exchange", "02", "htmx 的請求與回應", t02, tag="htmx") %} {% call prose() %}

一個元素說明要發哪個請求,以及答案要放在哪裡。伺服器回一段 HTML, 內容就是它本來會放進整頁的那段標記。htmx 把那段放進指名的元素裡。

沒有東西被序列化,前端不重新渲染,狀態也不會同時存在兩個地方。 按下底下的按鈕會發出真正的請求: 這一頁載了 htmx,並由瀏覽器裡的一支 mock server 扮演 FastAPI 那一側。

{% endcall %} {% call stage("一次來回", "前端唯一會變的,只有 hx-target 指名的那棵子樹。") %} {% include "_diagram.html" %} {% endcall %} {% set fire %} {% call row(gap=2) %} {{ badge("idle", variant="outline", id="dg-stage-label") }} {{ button("送出請求", variant="primary", size="sm", icon_name="arrow-right", icon_end=true, id="dg-run") }} {% endcall %} {% endset %} {% call stage_with("試一次", fire, "底下這段來回是真正的 htmx 請求。") %} {% call stack(4) %}
{% endcall %} {% endcall %} {% call prose() %}

路由靠 HX-* 標頭分辨這是局部更新還是整頁載入。 其餘部分是一個普通的 POST,兩端都沒有前端框架參與。

{% endcall %} {% endcall %} {# ============================================================== 03 #} {% set t03 %}兩個屬性決定一切:換哪個元素,以及怎麼換。{% endset %} {% call lesson("targets", "03", "hx-target 與 hx-swap", t03, tag="htmx") %} {% call prose() %}

{{ link("hx-target", "https://htmx.org/attributes/hx-target/") }} 挑元素, {{ link("hx-swap", "https://htmx.org/attributes/hx-swap/") }} 決定怎麼處理它。 htmx 最常見的誤解出在這一對上,所以底下用一塊真的板子試。 每一種組合都會發出真的請求,換進去的也是真的回應。

outerHTML 換掉目標元素本身,所以回應必須包含那層外框。 innerHTML(預設)換掉它裡面的內容,所以回應不能包含外框。 搞混這兩個會得到一塊板子裡面又一塊板子。

{% endcall %} {% call card(padded=false) %}
{% endcall %} {% call stage("那塊板子") %}
{% endcall %} {% call stage("這組合怎麼寫,以及回來了什麼", padded=false) %} {{ code_block("", id="swap-code") }}
{% endcall %} {% endcall %} {# ============================================================== 04 #} {% set t04 %}預設值夠用,其餘情況都靠一個屬性。{% endset %} {% call lesson("triggers", "04", "hx-trigger:打字、捲動、輪詢", t04, tag="htmx") %} {% call prose() %}

多數時候不必寫 trigger。button 在 click 時發,form 在 submit 時發, input 與 select 在 change 時發。 {{ link("hx-trigger", "https://htmx.org/attributes/hx-trigger/") }} 用於其餘情況: 邊打邊搜、延後載入與輪詢都靠它,不需要寫 JavaScript。 底下三個示範都會實際執行。

{% endcall %} {% set searching %}{{ badge("搜尋中…", variant="info", id="demo-searching") }}{% endset %} {% call stage_with("keyup changed delay:400ms", searching, "changed 會在值沒變時略過請求;delay:400ms 讓每一次按鍵都把倒數重新開始。") %} {% call stack(4) %}
{% endcall %} {% endcall %} {% set lazy_reset %}{{ button("重來", variant="ghost", size="xs", id="demo-lazy-reset") }}{% endset %} {% call stage_with("revealed —— 捲到看得見時才載入", lazy_reset, "延後的對象是一段渲染好的片段,作用與圖片的 loading=\"lazy\" 相同。") %}
{% endcall %} {% set poll_toggle %}{{ button("開始輪詢", variant="outline", size="xs", id="demo-poll-toggle") }}{% endset %} {% call stage_with("every 2s —— 輪詢", poll_toggle, "元素被移除時輪詢自動停止。需要更高的頻率時改用 SSE。") %}

建置狀態,由伺服器重新整理。

{% endcall %} {% endcall %} {# ============================================================== 05 #} {% set t05 %}整頁 include 的,就是 htmx 端點回傳的那份 fragment,兩者不會不一致。{% endset %} {% call lesson("partials", "05", "partial:一份檔案,整頁與局部共用", t05, tag="htmx") %} {% call prose() %}

partial 是名字以底線開頭的模板。它不 extends 外殼,自己宣告 import, 並把自己包在會被換掉的那個元素裡。整頁用 {% raw %}{% include %}{% endraw %} 放進它,htmx 端點直接回它。

所以 fjkit app 裡沒有「更新那一列」這條程式路徑: 每一次異動都重新渲染整塊板子的 partial 再換進去。 板子的樣子只有一份定義,第一次繪製與之後每個請求都用它。 檔案在 repo 的 examples/fjkit-demo/app/templates/tasks/。

{% endcall %} {% set flow_picker %} {% call button_group(id="flow-picker", role="group", aria_label="Request type") %} {{ button("GET /tasks", variant="secondary", size="sm", data_flow="page", aria_pressed="true") }} {{ button("POST /tasks (hx-post)", variant="ghost", size="sm", data_flow="swap", aria_pressed="false") }} {% endcall %} {% endset %} {% call stage_with("請求", flow_picker) %} {% call stack(4) %}
{{ code_block("", id="flow-code") }} {% endcall %} {% endcall %} {% call prose() %}

上面兩個 handler 指的是同一份檔案,而容易誤會的正是這個檔名。 _board.html 不是一筆任務。它是整塊板子——新增表單、篩選列、 統計卡,以及表格裡的每一列,全部包在那個所有控制項都指著的 <div id="board"> 裡。開頭的底線意思是「這不是一頁」,不是「這很小」。

所以在第三列按下 Advance,送出的是一個 POST,換回來的是整塊板子:八列全部重新渲染, 其中一列現在寫著 Doing。這就是 fjkit 的取捨——多渲染一些其實沒變的 HTML, 換到的是「板子長什麼樣」只有一份定義。底下是那三個檔案,以及裡面每一個 htmx 屬性。

{% endcall %} {{ files([ {"id": "part-page", "label": "tasks/page.html", "lang": "jinja", "source": "{# tasks/page.html —— 回應整頁的 GET #} {% extends \"base.html\" %} {% from \"ui/layout.html\" import page_header %} {% block content %} {{ page_header(\"Tasks\", \"這頁上每個控制項都在換 #board。\") }} {# 第一次繪製。POST /tasks 回的也是這一份檔案。 #} {% include \"tasks/_board.html\" %} {% endblock %}", "note": "extends 外殼、寫一個標題,然後 include partial。關於板子的事,沒有第二份描述。"}, {"id": "part-board", "label": "tasks/_board.html", "lang": "jinja", "source": "{# tasks/_board.html —— 整塊板子:新增表單、篩選列、統計卡與每一列 #} {% from \"ui/button.html\" import button, button_group %} {% from \"ui/table.html\" import table %} {% from \"tasks/macros.html\" import task_row, TASK_COLUMNS %} {# 外框寫在檔案裡,因為 hx-swap=\"outerHTML\" 換掉的就是它。 #}
{% include \"tasks/_new_task.html\" %} {# 篩選列。每顆按鈕都帶著 query string 重新抓一次整塊板子。 #} {% call button_group() %} {% for value, label in status_filters %} {{ button(label, size=\"xs\", hx_get=url_for(request, \"tasks_board\") ~ (\"?status=\" ~ value if value else \"\"), hx_target=\"#board\", hx_swap=\"outerHTML\") }} {% endfor %} {% endcall %} {% call table(TASK_COLUMNS, rows=tasks) %} {# 重複出現的東西寫成 macro,不是在迴圈裡 include。 #} {% for task in tasks %}{{ task_row(request, task, filter_query) }}{% endfor %} {% endcall %}
", "note": "沒有 extends、沒有 doctype,所以它單獨渲染得起來——htmx 端點才能直接回它。"}, {"id": "part-row", "label": "tasks/macros.html", "lang": "jinja", "source": "{# tasks/macros.html —— 一列,以及從它送出的 htmx #} {% macro task_row(request, task, filter_query=\"\") %} {{ cell(task.title, tone=\"strong\") }} {{ cell(badge(task.status.value | capitalize, variant=task.status_variant)) }} {% call row_actions() %} {# 推進一筆任務——換回來的是整塊板子。 #} {{ button(\"Advance\", variant=\"ghost\", size=\"xs\", hx_post=url_for(request, \"tasks_advance\", task_id=task.id) ~ filter_query, hx_target=\"#board\", hx_swap=\"outerHTML\", hx_disabled_elt=\"this\") }} {{ button(\"\", variant=\"ghost\", size=\"icon-xs\", icon_name=\"trash\", aria_label=\"Delete \" ~ task.title, hx_delete=url_for(request, \"tasks_delete\", task_id=task.id) ~ filter_query, hx_target=\"#board\", hx_swap=\"outerHTML\", hx_confirm=\"要刪掉這筆任務嗎?\") }} {% endcall %} {% endmacro %}", "note": "列是一個 macro,htmx 也寫在這裡:每個動作都指著 #board、都用 outerHTML,所以回應永遠是整份 partial。"}, ]) }} {% call grid(cols=2, gap=4) %} {% set wh_notrow %}_board.html 渲染的是表單、篩選、統計與全部的列。單獨一筆任務是 macros.html 裡的 task_row()——寫成 macro,因為重複出現的東西是 macro,而 partial 是拿來回應請求的{% endset %} {% set wh_wrapper %}partial 的第一行就是 <div id="board">。必須如此,因為每個控制項都寫 hx-swap="outerHTML":被換掉的就是那層外框,回應得把它帶回來{% endset %} {% set wh_context %}兩邊都需要 context 裡一模一樣的名字——tasks、stats、status_filters。router 在 board() 裡建一次,兩個 handler 都回傳它{% endset %} {% set wh_filter %}每個列上的動作都帶著 filter_query,所以在篩到 Doing 的板子上推進一筆任務,換回來的板子還是篩在 Doing{% endset %} {{ defs("一份檔案為什麼夠用", [ ("它是整塊板子,不是一列", wh_notrow), ("外框寫在檔案裡", wh_wrapper), ("同一份 context,只建一次", wh_context), ("篩選條件會跟著走", wh_filter), ]) }} {% set wh_cost %}每次都是整塊板子。那是幾 KB 的 HTML 交給瀏覽器解析,換掉的是它本來要算的差異——以及一份要跟著同步的前端模型{% endset %} {% set wh_when %}當板子大到你不想整塊重畫時,答案是切出一份有自己 id 的較小 partial,不是換一個更聰明的前端。第 06 課與第 07 課講的都是這個答案{% endset %} {% set wh_focus %}outerHTML 會連焦點所在的輸入框外層一起換掉。htmx 會依 id 還原焦點,所以要撐過 swap 的輸入框要有穩定的 id{% endset %} {% set wh_stream %}這裡用的是 templates.page()。只有在列數由使用者決定時,才改用 templates.stream(){% endset %} {{ defs("代價是什麼", [ ("線上多送的位元組", wh_cost), ("什麼時候不划算", wh_when), ("焦點", wh_focus), ("很大的板子", wh_stream), ]) }} {% endcall %} {% set board_busy %}{{ badge("處理中…", variant="info", id="demo-board-busy") }}{% endset %} {% call stage_with("板子示範——新增、推進、刪除", board_busy, "每個控制項都 target #demo-board,而每個回應都是整塊板子的 partial。") %} {% call stack(4) %}
{% endcall %} {% endcall %} {% call grid(cols=2, gap=4) %} {% set trap_page %}從 htmx 路由回傳 page.html,會把一整份文件塞進 div 裡。回 partial{% endset %} {% set trap_wrapper %}outerHTML 需要回應帶著那層外框,否則目標直接消失{% endset %} {% set trap_inherit %}hx-target 與 hx-swap 會從祖先繼承。卡片用 outerHTML 輪詢自己時,卡片裡的按鈕會繼承那個 swap,把本來只要填內容的元素整個換掉。巢在另一個 htmx 元素裡的觸發器,這兩個屬性都要明寫{% endset %} {{ defs("值得先知道的陷阱", [ ("整頁跑回來", trap_page), ("4xx 不會換", "htmx 預設忽略錯誤回應。shell 把 422(而且只有 422)加了回去,被退回的表單因此能以表單本身回來"), ("外框弄丟", trap_wrapper), ("篩選條件掉了", "列上的動作必須帶著目前的篩選,否則換進去的內容會默默把畫面重設"), ("swap 被繼承", trap_inherit), ]) }} {{ defs("伺服器端的幫手", [ ("HX-Request", "路由靠這個標頭知道自己正在回應一次局部更新"), ("HX-Trigger", "回應標頭,在前端觸發一個事件。fragment 裡沒有 toaster,fjkit 就是這樣從一次 swap 裡叫出 toast"), ("HX-Redirect", "從局部更新的回應裡做整頁轉址"), ("hx-swap-oob", "一次回應換掉好幾塊,下一課"), ]) }} {% endcall %} {% endcall %} {# ============================================================== 06 #} {% set t06 %}hx-target 只指得到一個元素。當一個答案要動六塊時,另外五塊自己帶著地址。{% endset %} {% call lesson("oob", "06", "一次回應,換掉好幾塊:hx-swap-oob", t06, tag="htmx") %} {% call prose() %}

到這裡為止每一次 swap 都只有一個 target。會變的東西都包在同一個元素底下時,一個 target 就夠: 板子是一個 <div id="board">,上面每一顆按鈕都指著它,每一次回應都是整塊板子。

搜尋結果不是單一區塊。一次查詢要動六塊:表格上方的計數、表格本身、側欄的進度卡、 facet 清單、同負責人面板,以及表格下方的明細面板。它們分散在三個地方, 唯一同時包住它們的元素是 <body>,而換掉 body 等於重新整理整頁。

所以回應把另外五塊走頻外(out of band)送回去,不走 hx-target 描述的那條唯一通道。每一塊靠自己身上的 id 指出去處。

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

看回應之前,先看觸發的那一端:它一點都不特別,而這正是重點。一個輸入框、一個 hx-get、一個 hx-target,形狀跟第 03 課一樣。 它指名 #search-matches,頁面上其他區塊它一個都不知道。

{% endcall %} {{ files([ {"id": "oob-call", "label": "macro 呼叫", "lang": "jinja", "source": "{# search/page.html —— 讓這一切開始的那一個控制項 #} {% call form(action=url_for(request, \"search_page\"), method=\"get\") %} {% call field_row(\"field-and-button\") %} {{ text_field(\"q\", label=\"Search tasks\", value=query, type=\"search\", hx_get=url_for(request, \"search_page\"), hx_trigger=\"input changed delay:250ms, search\", hx_target=\"#search-matches\", hx_swap=\"outerHTML\", hx_push_url=\"true\", hx_indicator=\"#search-busy\") }} {{ button(\"Search\", variant=\"primary\", type=\"submit\", icon_name=\"search\") }} {% endcall %} {% endcall %}", "note": "任何 fjkit macro 上的 hx_* 參數,都會變成它吐出的元素上的 hx-* 屬性。外面那層 form 帶著 action 與 method,所以關掉 JavaScript 這個查詢照樣送得出去。"}, {"id": "oob-html", "label": "實際吐出的 HTML", "lang": "html", "source": "", "note": "只有一個 target。htmx 會把回來的東西換進 #search-matches——而它並不知道這次回應等一下要動另外五塊。"}, ]) }} {% set oob_reply = "
…
← 沒有標記:這塊進 hx-target
…
← 自己飛去頂端的計數列
…
← 自己飛去側欄
…
← 自己飛去側欄
…
← 自己飛去側欄
…
← 自己飛去表格下方" %} {% call stage("一個按鍵拿回來的東西", "GET /search?q=cache,由一個 handler 與一個 response model 回答。", padded=false) %} {{ code_block(oob_reply, label="回應主體") }} {% endcall %} {% call grid(cols=2, gap=4) %} {% set step_lift %}把回應裡每一個帶 hx-swap-oob 的頂層元素抽出來{% endset %} {% set step_place %}依 id 找到頁面上同名的元素,各自換掉{% endset %} {% set step_rest %}剩下的才照常進 hx-target{% endset %} {{ defs("htmx 的處理順序", [ ("1 — 抽出", step_lift), ("2 — 就位", step_place), ("3 — 最後才是 target", step_rest), ]) }} {% set oob_values %}true 是換掉同 id 的元素;innerHTML、beforeend 這些 swap 詞彙在這裡一樣能用{% endset %} {% set oob_silent %}id 對不上時整塊被丟掉:不報錯,console 也沒有訊息,那一區停在上一次查詢的數字上{% endset %} {% set oob_toplevel %}包在 wrapper 裡的 oob 元素不算 oob,所以回應那支模板是一排平行 include,沒有外框{% endset %} {% set oob_target %}絕對不要標記 hx-target 指的那一塊:它會一起被抽走,target 換到的是空字串{% endset %} {{ defs("四件要知道的事", [ ("值可以怎麼寫", oob_values), ("它是靜靜失敗的", oob_silent), ("只認頂層", oob_toplevel), ("不要標記 target", oob_target), ]) }} {% endcall %} {% call prose() %}

fjkit 這一側沒有新機制。頁面與回應 {% raw %}{% include %}{% endraw %} 同樣六支 partial,差別只有一個變數:頁面直接渲染它們,回應讓它們帶上 hx-swap-oob。 每支 partial 自己給那個旗標一個預設值,所以單獨渲染也成立。

{% endcall %} {% set oob_tmpl = "{# _results.html —— 一次 htmx 請求拿回去的東西 #} {% include \"search/_matches.html\" %} {% with oob = true %} {% include \"search/_stats.html\" %} {% include \"search/_progress.html\" %} {% include \"search/_facets.html\" %} {% include \"search/_detail.html\" %} {% include \"search/_related.html\" %} {% endwith %} {# _stats.html —— 其餘每一塊的開頭都一樣 #} {% set oob = oob | default(false) %}
…
" %} {% call stage("同一批 partial,兩種渲染方式", none, padded=false) %} {{ code_block(oob_tmpl, label="Jinja") }} {% endcall %} {% call prose() %}

好處是版面位置由頁面決定,而且只決定一次。把 facet 卡從側欄搬到主欄, router、response model 與 swap 都不用改。檔案在 repo 的 examples/fjkit-demo/app/templates/search/。下一課講同一頁的另一半, 因為實際畫面兩種都需要。

{% endcall %} {% endcall %} {# ============================================================== 07 #} {% set t07 %}第 06 課的反面:觸發者產生一個 id,每一塊區域拿它問的問題都不一樣。{% endset %} {% call lesson("broadcast", "07", "一個 id,好幾份 fragment:HX-Trigger", t07, tag="htmx") %} {% call prose() %}

上一課的前提是一支 handler 能一次回答所有區域:一次查詢、六份 fragment、 一個裝得下全部的 response model。在同一頁上點開一列不是這個形狀。 點擊產生的是一個 id,在意它的每一塊面板各自需要不同的查詢: 那個任務的細節、那個負責人手上的其他任務。要一支 handler 回答全部, 它必須 import 這一頁上的每一個 service,而且多加第三塊面板就得回頭改它。

所以這次的選取只回應它自己那一塊:結果表,其中那一列被標記起來, 並把 id 放進 HX-Trigger 送出。htmx 把這個標頭轉成一個 DOM 事件, 在意它的 fragment 各自聽到之後,自己去要自己的資料。這條路由不知道訂閱者的數量, 也不知道它們存在。多一塊面板就是多一個訂閱者,沒有路由要改。

{% endcall %} {% set bcast_route = "@router.get(\"/search/select/{task_id}\", name=\"search_select\") @render(\"search/_matches.html\", hx_trigger=lambda task_id: {\"task-selected\": {\"task_id\": task_id}}) def select_task(service: ServiceDep, task_id: int, q: str = \"\") -> MatchesResponse: \"\"\"Mark the row, answer with the table, and tell the panels which id.\"\"\" return _matches(service, q, _selected(service, task_id).id)" %} {% call stage("負責「選取」的那條路由", "用 GET,因為選取沒有改動伺服器上的任何東西。結果表就是這顆按鈕的 hx-target,所以回應就地把它換掉。", padded=false) %} {{ code(bcast_route, label="router.py") }} {% endcall %} {% call prose() %}

事件宣告在 hx_trigger,它是 @render 的參數而不是 handler 自己寫的標頭, 這樣會渲染的路由用的是同一套寫法。callable 每次請求解析一次, 依名字從 handler 自己的參數取值——這裡是 task_id——外加 result, 也就是 handler 的回傳值。callable 回傳 None 就不發事件,所以一條路由可以只在某些時候廣播。

其餘部分是一條普通的 partial 路由:一份樣板、一個 response model,外加一個事件。 這個模式便宜的原因就在這裡——swap 與廣播是同一趟來回,寫在同一個地方。

同一頁的第二個動作是另一半。推進會改變狀態,所以它發出不同的事件, 在意的是另一組 fragment。

{% endcall %} {% set bcast_shared = "@router.post(\"/search/advance/{task_id}\", name=\"search_advance\") @render(\"search/_detail.html\", hx_trigger=lambda task_id: {\"task-changed\": {\"task_id\": task_id}}) def advance_task(service: ServiceDep, task_id: int) -> DetailResponse: \"\"\"Advance the task, answer with the detail panel, and say what changed.\"\"\" if service.advance(task_id) is None: raise HTTPException(status_code=404, detail=\"task not found\") return DetailResponse(selected=service.get(task_id))" %} {% call stage("第二個事件,來自一條會改變東西的路由", "回應本身就是那次 swap——detail 面板正是這顆按鈕的 hx-target——而標頭是另外四塊聽到的東西。", padded=false) %} {{ code(bcast_shared, label="router.py") }} {% endcall %} {% call prose() %}

兩個事件,每一份 fragment 各自挑它有理由聽的那個。計數面板聽 task-changed、 不聽 task-selected,因為選取不會改變計數。facet 面板兩個都不聽, 因為推進一個任務不會換負責人,也不會換優先級。聽哪些廣播由 fragment 自己決定, 沒有路由知道它存在。

{% endcall %} {% call stage("誰聽什麼", none, padded=false) %} {% call table([{"label": "fragment"}, {"label": "task-selected"}, {"label": "task-changed"}, {"label": "為什麼"}]) %} {{ cell("結果表", tone="strong") }} {{ cell(badge("不聽", variant="outline")) }} {{ cell(badge("聽", variant="success")) }} {{ cell("它就是選取回應的 target;推進會改變一列上寫的東西") }} {{ cell("detail 面板", tone="strong") }} {{ cell(badge("聽", variant="success")) }} {{ cell(badge("不聽", variant="outline")) }} {{ cell("它就是 advance 回應的 target——聽那個事件就是迴圈") }} {{ cell("同負責人面板", tone="strong") }} {{ cell(badge("聽", variant="success")) }} {{ cell(badge("聽", variant="success")) }} {{ cell("這些是誰的任務,以及它們的狀態") }} {{ cell("計數面板、進度卡", tone="strong") }} {{ cell(badge("不聽", variant="outline")) }} {{ cell(badge("聽", variant="success")) }} {{ cell("選取不會改變任何計數,推進會") }} {{ cell("facet 面板", tone="strong") }} {{ cell(badge("不聽", variant="outline")) }} {{ cell(badge("不聽", variant="outline")) }} {{ cell("那張卡上沒有任何一個 facet 跟狀態有關") }} {% endcall %} {% endcall %} {% call prose() %}

只有一條規則撐住這整套:一條路由回應的那份 fragment,不可以訂閱這條路由發出的事件。 它已經被就地換掉,再聽到那個事件就是同一次點擊把它抓第二次;如果那份 fragment 就是自己的 target,它不會停下來。htmx 不會擋,因為「訂閱者去要自己的 URL」正是那些屬性的意思。 所以結果表只聽 task-changed,detail 面板只聽 task-selected: 每一份對其中一個事件是 target、對另一個是訂閱者,不對同一個事件兩者兼具。

{% endcall %} {% set bcast_sub = "{# 只需要 id 的面板 #}
…
{# 還要看搜尋框的區域 #}
…
" %} {% call stage("訂閱端", "在意的區域都寫這些屬性;不在意的一個都不寫。", padded=false) %} {{ code_block(bcast_sub, label="Jinja") }} {% endcall %} {% call grid(cols=2, gap=4) %} {% set sub_trigger %}from:body——htmx 在發出請求的那個元素上觸發事件,事件再往上冒泡。面板離那個元素很遠,所以在事件必定抵達的地方聽{% endset %} {% set sub_vals %}hx-vals="js:…" 是 id 變回查詢參數的唯一地方。從事件讀取它、而不是把選取狀態記在伺服器上,端點才能維持無狀態,兩個分頁才互不干擾{% endset %} {% set sub_target %}hx-target="this"——每個訂閱者替換自己,所以一次回應只有一塊區域,發送端不需要知道這一頁有幾塊區域{% endset %} {% set sub_include %}hx-include——補上事件沒帶的值。計數算的是符合的結果,所以除了 id 還需要搜尋框的值。用一個 selector 從頁面上取,不必讓每個事件都帶著它{% endset %} {{ defs("要緊的幾個屬性", [ ("誰在聽", sub_trigger), ("id 怎麼進來", sub_vals), ("還缺什麼", sub_include), ("換掉的是誰", sub_target), ]) }} {% set bc_object %}trigger 的值必須是一個 JSON 物件。htmx 把物件原樣交給 event.detail;其他型別(含陣列)會被包成 {value: …},讀 event.detail.task_id 的監聽者拿不到值{% endset %} {% set bc_order %}HX-Trigger 在 swap 之前觸發。id 走事件這條路的原因就在這裡:存放選取狀態的 hidden input,在訂閱者讀它的當下還是舊值。需要相反的順序時,標頭是 HX-Trigger-After-Swap{% endset %} {% set bc_inherit %}hx-vals 會被繼承,htmx 沿著 DOM 往上合併它,而且這條路徑不看 hx-disinherit。訂閱者內部的 htmx 元素——那一列的標題,它本身就是開啟這一列的按鈕——必須寫 hx-vals="unset",那是唯一能中斷這趟走訪的值{% endset %} {% set bc_cost %}N 個訂閱者就是 N 次請求。扇出發生在頁面上而不是收斂在 handler 裡,這是這個做法的代價。查詢成本高時,用 hx-sync 或一層快取換回來{% endset %} {{ defs("四件要知道的事", [ ("送物件", bc_object), ("它在 swap 之前觸發", bc_order), ("繼承會滲進來", bc_inherit), ("N 塊面板,N 次請求", bc_cost), ]) }} {% endcall %} {% call prose() %}

這兩課回答不同的問題,demo 也沒有把它們拆成兩頁。/search 兩個都問: 一次查詢是伺服器可以完整回答的問題;一次選取是一個 id,三塊區域各自拿它問不同的事。

{% endcall %} {% call stage("一頁該用哪一種", none, padded=false) %} {% call table([{"label": "問題"}, {"label": "hx-swap-oob"}, {"label": "HX-Trigger"}]) %} {{ cell("是什麼造成了改變", tone="strong") }} {{ cell("一次查詢,伺服器有辦法一口氣回答所有區域") }} {{ cell("一個 id,每塊區域拿它問的問題都不同") }} {{ cell("誰知道有哪些區域", tone="strong") }} {{ cell("handler——所有區域都由它渲染") }} {{ cell("沒有人;每塊區域自己表態") }} {{ cell("每次互動的請求數", tone="strong") }} {{ cell(badge("1", variant="success")) }} {{ cell(badge("1 + 每個訂閱者一次", variant="outline")) }} {{ cell("要多加一塊區域", tone="strong") }} {{ cell("改 handler、改 model、改回應樣板") }} {{ cell("多一個訂閱者;其他什麼都不用動") }} {% endcall %} {% endcall %} {% call prose() %}

可以動手改的檔案在 repo 的 examples/fjkit-demo/app/features/search/ 與 app/templates/search/,跟上一課同一頁。那裡每一塊區域都帶著渲染時間戳, 所以哪一次動作動到哪幾塊在頁面上就讀得出來,不必打開 network 分頁。 一次查詢是一個請求、六個新的時間戳。一次選取是三個請求、三個時間戳——結果表、detail 面板、 同負責人面板——計數、進度卡與 facet 卡不動。一次推進是五個,只有 facet 卡不動。

{% endcall %} {% endcall %} {# ============================================================== 08 #} {% set t08 %}一次 swap 是一趟來回,預設沒有東西擋得住第二次點擊。兩個屬性涵蓋整段等待。{% endset %} {% call lesson("pending", "08", "請求進行中的那段時間", t08, tag="htmx") %} {% call prose() %}

htmx 在請求期間把 htmx-request 這個 class 掛到某個元素上,結束後移除。 沒有寫 hx-indicator 時,那個元素是觸發請求的元素本身;寫了就是選擇器指到的元素。 其餘交給 htmx 自己在啟動時注入的這段樣式:

{% endcall %} {% set ind_css = ".htmx-indicator { opacity: 0; visibility: hidden } .htmx-request .htmx-indicator, .htmx-request.htmx-indicator { opacity: 1; visibility: visible; transition: opacity 200ms ease-in }" %} {% call stage("htmx 注入的規則", "兩個選擇器,逗號分開。", padded=false) %} {{ code_block(ind_css, label="CSS") }} {% endcall %} {% call prose() %}

第一個選擇器對應 indicator 在被標記元素內部的情況,例如被點的那顆按鈕裡的 spinner。 第二個對應 indicator 就是被標記元素本身,也就是 fjkit 的做法: spinner(indicator=true) 只加上 htmx-indicator。 所以一個 loading 狀態不需要 CSS、不需要 JavaScript,也不需要額外一趟來回去問伺服器狀態。

{% endcall %} {% set ind_src = "{% call form(action=url_for(request, \"jobs_start\"), target=\"#job-list\", hx_indicator=\"#job-busy\", hx_disabled_elt=\"find button[type=submit]\") %} {% call field_row(\"field-and-button\") %} {{ select_field(\"kind\", label=\"Background job\", options=kind_options) }} {% call row(gap=2) %} {{ button(\"Start\", variant=\"primary\", type=\"submit\", icon_name=\"play\") }} {{ spinner(indicator=true, id=\"job-busy\", label=\"Starting the job\") }} {% endcall %} {% endcall %} {% endcall %}" %} {% call stage("兩個屬性,掛在同一張表單上", "examples/fjkit-demo/app/templates/jobs/page.html", padded=false) %} {{ code_block(ind_src, label="Jinja") }} {% endcall %} {% call prose() %}

hx-disabled-elt 用同一套機制處理另一個問題:請求期間把選擇器指到的元素設成 disabled,結束後清掉。擋住雙擊把一筆任務推進兩次、或送出兩份相同的表單, 只需要這一個屬性。選擇器接受 this、find …/closest … 這類相對寫法,或一般的 CSS 選擇器。

{% endcall %} {% call grid(cols=2, gap=4) %} {% set p_count %}兩者都是計數,不是開關:htmx 對每個元素記一個請求計數,所以先回來的那一個不會把另一個請求還在用的控制項提早解鎖{% endset %} {% set p_space %}indicator 用 visibility 與 opacity 隱藏,不用 display。它一直佔著位置,所以出現時畫面不會跳動{% endset %} {% set p_inherit %}兩者都會繼承:hx-indicator 寫在容器上,裡面所有觸發器共用同一個 spinner。this 與 inherit 都是合法的值{% endset %} {% set p_warn %}選擇器沒中時 htmx 會發出警告——The selector "…" returned no matches!——與對不上的 oob id 不同,後者是靜靜失敗{% endset %} {{ defs("它們的行為", [ ("計數的", p_count), ("不會讓畫面跳動", p_space), ("會繼承", p_inherit), ("它會出聲", p_warn), ]) }} {% set p_inside %}把 indicator 放在 target 外面:放在會被換掉的區塊裡,它會在請求進行中連同 id 一起被換走{% endset %} {% set p_default %}完全不寫 hx-indicator 時,class 直接掛在觸發元素上,所以對按鈕寫 .htmx-request 的樣式是最省事的 loading 狀態{% endset %} {% set p_marker %}htmx 用 data-disabled-by-htmx 標記自己 disable 掉的元素,所以本來就是 disabled 的控制項,結束後仍然是 disabled{% endset %} {% set p_flash %}有些 indicator 只閃一下,那是請求快,不是 indicator 壞掉。jobs 頁的工作在回應之後才跑,所以那張卡片需要輪詢{% endset %} {{ defs("值得先知道的陷阱", [ ("不要放在 target 裡面", p_inside), ("免費的那個版本", p_default), ("它記得", p_marker), ("請求太快", p_flash), ]) }} {% endcall %} {% endcall %} {# ============================================================== 09 #} {% set t09 %}畫面不同,形狀相同:每一種都是一個 trigger、一個 target 與一份 partial。{% endset %} {% call lesson("patterns", "09", "九種 htmx 模式,以及對應的 handler", t09, tag="htmx") %} {% call prose() %}

整套屬性詞彙一個螢幕放得下, 「速查表」那一頁印出了全部。 要花時間的是它反覆組成的那幾種排列。這裡把它們連同 fjkit 的寫法列出來: 一個分頁是 macro 呼叫,另一個是回應它的 handler。

底下每個 handler 都回一份 partial,而且都是 def, 所以 Starlette 會把它送進 threadpool。每種模式都連到對應的 htmx 頁面。上游的 {{ link("文件", "https://htmx.org/docs/") }} 與 {{ link("屬性參考", "https://htmx.org/reference/") }} 短到可以整份讀完。

{% endcall %} {% set pattern_doc %} {% call row(gap=2) %} {{ badge("", variant="outline", id="pattern-tag") }} {% endcall %} {% endset %} {% call stage_with("模式", pattern_doc) %} {% call grid(cols=2, gap=4) %}
{{ code_block("", id="pattern-code") }}
{% endcall %} {% endcall %} {% call grid(cols=2, gap=4) %} {{ defs("它們的共通點", [ ("一份 partial", "頁面 include 它,端點回傳它;同一段 fragment 不存在兩種渲染"), ("前端沒有狀態", "游標、篩選與編輯模式都存在 URL 裡,或存在「畫面上是哪一份 partial」裡"), ("狀態放在 URL", "任何必須撐過重新整理的東西,都是一個整頁 GET 答得出來的 query string"), ("退化得下去", "拿掉 hx-* 關鍵字,同一批 macro 吐出的表單與連結照樣能用"), ]) }} {{ defs("刻意還沒做的", [ ("對話框", "先用 hx-confirm:那是瀏覽器內建的,而且可用"), ("排序", "換一組 query 參數的篩選列;table macro 在 0.4 支援"), ]) }} {% endcall %} {% endcall %} {# ============================================================== 10 #} {% set t10 %}一則訊息,三層——而且只有最上面那層需要 secret。{% endset %} {% call lesson("messages", "10", "訊息:三層", t10, tag="fjkit.messages") %} {% call prose() %}

「給發出這次請求的人看一行字」由三層組成:畫出它的元件、讓它搭上這個回應的方法, 以及讓它活過一次轉址的方法。只有最後一層需要簽章用的 secret, 所以三層裡只有它是 plugin,另外兩層在核心。

{% endcall %} {% call stage("三層", "其中兩層在核心,因為它們不需要任何設定。") %} {% call table([{"label": "層"}, {"label": "住在哪裡"}, {"label": "核心還是 plugin"}]) %} {{ cell("元件", tone="strong") }} {{ cell("ui/feedback.html 的 toaster 區域,由 ui/shell.html 在每一頁畫出來——裡面有沒有東西都畫") }} {{ cell(badge("核心", variant="secondary")) }} {{ cell("搭上這個回應", tone="strong") }} {{ cell("fjkit.messages——messages.add(request, …),路由、service、exception handler 都可以叫") }} {{ cell(badge("核心", variant="secondary")) }} {{ cell("跨過一次轉址", tone="strong") }} {% set flash_cell %}fjkit.flash——一個短命的簽名 cookie, 在「外掛」那一頁{% endset %} {{ cell(flash_cell) }} {{ cell(badge("plugin", variant="info")) }} {% endcall %} {% endcall %} {{ code("from fjkit import messages messages.add(request, \"Saved\", category=\"success\") messages.add(request, \"Could not save\", \"The title is required.\", category=\"error\")", label="任何拿得到 request 的地方") }} {% call prose() %}

不需要 plugin、secret 或任何設定,這條線就是為此劃的。簽一顆 cookie 需要 secret; 要求每一支 app 都準備一個(包括沒有表單、也沒有訊息要顯示的 app),等於讓 mount_fjkit(app) 不再零設定可跑。不需要 secret 的都留在核心。

第三層也不是第二層的超集合。轉址會換掉整頁,本來要顯示訊息的那份文件在回應抵達前 就不存在了,唯一跨得過去的只有 cookie。但對不是轉址的回應來說,同一顆 cookie 是 錯的工具:一則馬上就要渲染出來的訊息不必多跑一趟。所以兩者是疊起來而不是合而為一: FlashPlugin 把 cookie 排進 fjkit.messages,shell 裡因此只有一個 迴圈,而不是每種送達方式各一個。

{% endcall %} {% call stage("它怎麼到瀏覽器", "通道由 @render 決定,呼叫端從頭到尾不需要知道是哪一條。") %} {% call table([{"label": "這個回應是什麼"}, {"label": "走哪條通道"}, {"label": "誰把它畫出來"}]) %} {{ cell("整頁渲染", tone="strong") }} {{ cell("context") }} {{ cell("shell 的 toaster 區域,直接寫進 markup") }} {{ cell("一段 fragment,一次 htmx swap", tone="strong") }} {{ cell(badge("HX-Trigger", variant="info")) }} {{ cell("shell 裡那支監聽器,把事件變回一個 toast") }} {% endcall %} {% endcall %} {% call prose() %}

走標頭而不是 markup,因為 fragment 裡沒有 toaster:一次 htmx swap 換掉的是一個區域, 而 toast 該待的區域在頁面上的別處。它也能用在 htmx 不會 swap 的回應上:htmx 在 handleAjaxResponse 裡讀 HX-Trigger 的時機,早於它查 responseHandling 決定這個狀態碼要不要換內容,所以一個 422 或 500 照樣能在 使用者正在看的那一頁上跳出 toast。第 11 節整套送達機制就是這個。

{% endcall %} {% set consumed %} 有模板迭代過那個佇列才算送達;只查詢「裡面有沒有東西」不算,因為那不是把它顯示出來。 flash plugin 整個依賴這個區別 {% endset %} {% set category_note %} info、success、warning、error 是意義, 不是顏色。toast() 把每一種對到一個圖示與一段停留時間,fjkit.css 把每一種對到一個角色 token,所以「錯誤是紅的」只決定一次,而且撐得過換品牌 {% endset %} {{ defs("值得知道的幾件事", [ ("fjkit_messages() 是 global,不是 context key", "路由不用傳任何東西,一份從沒聽過 messages 的模板照樣顯示得出來"), ("靠被迭代來消耗", consumed), ("HX-Trigger 是合併,不是覆蓋", "handler 自己設的事件會留著。只因為排了一個 toast 就把它丟掉,是那種兩邊同時出現才會現形的 bug"), ("category", category_note), ]) }} {% endcall %} {# ============================================================== 11 #} {% set t11 %}表單被退回時,錯誤標在欄位下面,不再是一整頁 JSON,而且不需要任何一條路由要求。{% endset %} {% call lesson("errors", "11", "表單被退回的時候", t11, tag="fjkit.errors") %} {% call prose() %}

FastAPI 對一份被退回的表單回的是 {"detail":[{"type":"missing",…}]} 加一個 422。 對 API 來說這正確,對一頁畫面來說等於什麼都沒有:htmx 預設不會 swap 4xx, 所以正在填表單的人看到的是按了沒反應,而一般的 POST 會把他丟到一整頁 JSON 上。

解法是利用那個回應原本就有的內容。它點名每個失敗的欄位,用的正是表單送出時的名字, 訊息也是給人讀的;而表單還在頁面上,使用者打過的每一個字都還在控制項裡。 沒有東西需要重新渲染、記住或重建。

所以 mount_fjkit() 把 422 原封不動送回去,由 shell 載入的一支 script js/errors.js 把每則訊息寫到它的欄位下面。每個 handler 的簽名還是 FastAPI 寫的那個, 也沒有任何一條路由要替「被退回」這個狀態指名模板,因為沒有東西要渲染。

{% endcall %} {% call stage("每種呼叫端拿到什麼", "@render 決定線上格式的那個問題,換到一次被拒絕的送出上再問一次。") %} {% call table([{"label": "誰在問"}, {"label": "回什麼"}, {"label": "為什麼"}]) %} {{ cell("htmx", tone="strong") }} {{ cell(badge("FastAPI 的 422,由頁面畫出來", variant="info")) }} {{ cell("表單還在原地;script 把每則訊息寫到 loc 指名的控制項下面,打過的字不動") }} {{ cell("瀏覽器導覽", tone="strong") }} {{ cell(badge("422 加一頁畫面", variant="info")) }} {{ cell("一般的
已經離開那一頁了,所以用你自己的 shell 渲染 errors/page.html") }} {{ cell("腳本、curl、另一個服務", tone="strong") }} {{ cell(badge("FastAPI 原本的回應", variant="outline")) }} {{ cell("一個位元組都不動,狀態碼也是原本那個。與第一列是同樣的位元組,差別只在誰在讀") }} {% endcall %} {% endcall %} {% call prose() %}

最後那一列值得講明白。沒有要 markup 的呼叫端拿到的還是 {"detail": …}, 因為把一個 API 的錯誤變成一頁 HTML,會是比這裡要修的問題更糟的 bug。 第一列是同樣的位元組,這正是整個設計:一個同時也是別人 API 的端點只有一份錯誤契約, 頁面讀它的方式跟其他客戶端一樣。

這對一條路由只有一項要求,而且 FastAPI 本來就在要求:

{% endcall %} {{ files([ {"id": "invalid-route", "label": "router.py", "lang": "python", "source": "@router.post(\"/tasks\", name=\"tasks_create\") @render(\"tasks/_board.html\") def create_task(service: ServiceDep, payload: TaskCreate) -> BoardResponse: service.create(payload) return _board(service)", "note": "模型就是簽名。它的欄位、限制與 OpenAPI schema 是同一份宣告,被退回時由 FastAPI 回報,放在 body 底下,帶著欄位的名字。"}, {"id": "invalid-form", "label": "_new_task.html", "lang": "jinja", "source": "{% call form(action=url_for(request, \"tasks_create\"), target=\"#board\", reset_on_success=true, encoding=\"json\", id=\"new-task\") %} {{ text_field(\"title\", label=\"New task\", required=true) }} {{ select_field(\"priority\", label=\"Priority\", options=priority_options, selected=\"normal\") }} {% endcall %}", "note": "一份表單,寫一次,裡面沒有任何關於錯誤的東西。script 用 name 找到控制項,用 aria-describedby 找到它下面那個段落,兩者都是每個欄位 macro 本來就接好的線。"}, ]) }} {% call prose() %}

保留使用者填過的內容是零成本的,因為沒有東西碰到它。以前對 422 的做法是重畫表單、 把值回填進去,這行得通,直到某個欄位有多個值,或表單需要某樣只有那個沒跑過的 handler 才建得出來的東西。表單不再重畫,這兩個問題一起消失。

沒有欄位可以落腳的訊息——模型層級的規則、解析不了的 body、壞掉的路徑片段——會透過 fjkit.messages 用的同一個事件升成一個 toast。同一份表單的下一次送出會清掉上一次的訊息, 所以一次被接受的重送不會把過期的退回留在畫面上。

{% endcall %} {% set model_error %} 刻意不接住它。它跟一份被退回的表單長得一樣,但它不是:它同樣可能是 service 裡三層深的 Task(**row) 失敗,而把一個 bug 畫成一句親切的訊息,等於用「某個沒人填過的欄位」 的道歉把它藏起來。把模型宣告在簽名上,FastAPI 就會丟出會被接住的那種 request 層級錯誤 {% endset %} {% set unexpected %} 預設是關的,而這個預設有用:開發時終端機裡的一份 traceback,勝過瀏覽器裡一句得體的 道歉。FjkitConfig.for_production() 會把它打開,連同另外兩個只在開發時有意義的 開關。422 那個 handler 一律安裝,因為被退回的表單不是 bug {% endset %} {{ defs("其餘的介面", [ ("你自己的程式碼丟出的 ValidationError", model_error), ("catch_unexpected_errors", unexpected), ("unexpected_error", "那則 toast 的內容。不設就是 kit 自己的一句 Something went wrong;給一個 Message 就整批換掉,給一個 (request, exc) -> Message 的函式就能依例外種類挑句子。用例外挑句子,不要把 str(exc) 放進去,那是你 app 的內部"), ("errors/page.html", "導覽失敗時落到的那一頁。在自己的模板目錄放一個同路徑的檔案就能換掉,用的是與 fjkit eject 相同的遮蔽機制,所以沒有另一套「自訂錯誤頁」要學"), ("js/errors.js", "每一頁載入的第三個、也是最後一個檔案,排在 htmx 與 Basecoat 之後。它聽 htmx:beforeSwap,所以不是 FastAPI 清單的 422——某條路由自己回了 markup——會被放過"), ]) }} {% endcall %} {# ============================================================== 12 #} {% set t12 %}換品牌是一個 CSS 變數。狀態顏色不跟著變。{% endset %} {% call lesson("knob", "12", "換品牌:品牌 token 與狀態 token", t12, tag="theming") %} {% call prose() %}

拖動色相滑桿,編輯的是 :root 上的 --primary, 也就是一支 app 寫在自己 stylesheet 裡的那三行,中間沒有建置步驟。 整頁都會跟著變,包括側欄與這些 card,因為這一頁蓋在同一組 token 上。

狀態標籤不動。狀態顏色刻意不接到品牌 token 上, 因為「綠色代表完成」必須撐過一次改版。

{% endcall %} {# `.field > input[type="range"]` is Basecoat's — the slider needs no class of its own, and its JS binds the fill track. The same is true of the checkbox in the playground on the Components page. #} {% call card("你會寫的那三行") %} {# field_row has no "four" template — two columns, twice. #} {% call field_row("two") %}
預設組
配色 {% call button_group(id="theme-picker") %} {{ button("淺色", variant="ghost", size="sm", data_theme_choice="light") }} {{ button("深色", variant="ghost", size="sm", data_theme_choice="dark") }} {{ button("自動", variant="secondary", size="sm", data_theme_choice="auto", aria_pressed="true") }} {% endcall %}
{% endcall %} {% endcall %} {% call stage("重新上色後的頁面") %}
{% endcall %} {% call stage("換品牌的完整流程", padded=false) %} {{ code_block("", id="knob-code") }} {% endcall %} {% call grid(cols=2, gap=4) %} {% set d_primary %}品牌填色,外加 focus 用的 ring{% endset %} {# `fjkit check` scans every line for colour literals, not just class attributes, so prose cannot name the utility it is warning you off. Recorded in docs/BACKLOG.md: the colour rules should skip comments and text nodes. #} {% set d_pfg %}品牌填色上的文字,不是固定不變的色值{% endset %} {{ defs("會跟著品牌變的", [ ("primary", d_primary), ("primary-foreground", d_pfg), ("background · card", "頁底與各種表面,往品牌色帶一點"), ("muted · border", "低調的表面、細線、次要文字"), ]) }} {{ defs("刻意不跟著品牌變的", [ ("success", "完成、通過、健康"), ("warning", "需要注意,但還沒壞"), ("info", "進行中、中性的提示"), ("destructive", "刪除、失敗、無法回復"), ]) }} {% endcall %} {% endcall %} {# ============================================================== 13 #} {% set t13 %}被強制執行的慣例,勝過只寫在文件裡的慣例。{% endset %} {% call lesson("gate", "13", "fjkit check:封閉詞彙表", t13) %} {% call prose() %}

fjkit check 讀模板,拒絕兩種東西:顏色字面值,以及元件詞彙表以外的 class。 utility class 即使目前能用也一樣被拒。app 若依賴某個只是碰巧被 fjkit 外殼輸出的間距 utility,外殼哪天不再輸出它,app 就會靜靜地壞掉。

編輯底下的標記。這支檢查器跑 fjkit 自己的規則,用的是實際的 class 清單 ——…——與 CI 裡跑的、掃這個站模板的是同一份程式碼。

{% endcall %} {% set samples %} {% call button_group() %} {{ button("utility class 版本", variant="ghost", size="xs", data_sample="bad") }} {{ button("fjkit 的版本", variant="ghost", size="xs", data_sample="good") }} {% endcall %} {% endset %} {% call stage_with("app/templates/tasks/_board.html", samples) %} {% call grid(cols=2, gap=4) %} {# The checker appends one `alert` per violation. Without a gap their borders butt together and every boundary renders as a doubled line with the two rounded corners colliding, so the container is a `stack` rather than a bare div. #} {% call stack(gap=2, id="checker-output", aria_live="polite") %}{% endcall %} {% endcall %} {% endcall %} {% call prose() %}

被擋下來的 class 指名了還不存在的元件或參數。 0.1 落地時,改寫前的模板有 258 條違規,改寫後是 0。

{% endcall %} {% call grid(cols=2, gap=4) %} {{ defs("指令", [ ("fastapi dev", "啟動 app;沒有其他東西需要一起重建"), ("fjkit check", "檢查每個 class 都在詞彙表裡"), ("fjkit eject", "逃生口:把一支 macro 拿進來改;同檔其餘的仍是 kit 的"), ("fjkit build-css", "fjkit 自己的發佈步驟,不是你的"), ]) }} {% set d_components %}每個簽名與列舉的實際渲染{% endset %} {{ defs("接著讀", [ ("元件", d_components), ("examples/fjkit-demo/", "同樣這些模式放在一支能跑的 app 裡,在 repo 中"), ("src/fjkit/static/src/fjkit.css", "每個 token 只定義一次;換品牌覆蓋的就是這支檔案"), ("docs/jinja-performance.md", "page() 對 stream(),以及那些建議背後的數字"), ]) }} {% endcall %} {% endcall %} {% endcall %} {% endblock %} {% block page_scripts %} {% endblock %}