前言

上一篇中,我把 Kanboard 的任務內容拆成結構化 Task Document,並在每次儲存時產生 Markdown 相容內容。段落、標題、圖片、附件和 Markdown 都已經可以新增、排序、儲存與顯示。

第一版完成後,外掛看起來已經具備擴充能力,但真正加入更多區塊時,我才發現「支援一種區塊」的知識被分散在太多地方。編輯器能新增,不代表摘要能呈現;PHP 驗證通過,也不代表前端知道如何編輯;Markdown projection 更新了,公開頁的 renderer 仍可能缺少對應行為。

這篇記錄 KnockersTaskBlocks 如何從多組型別判斷,重構成一個具有完整區塊契約的執行環境。

新增一個區塊,原本要修改哪些地方?

當時同一種區塊至少散落在以下位置:

  • blocks.json:名稱、分類、關鍵字與預設內容。
  • PHP Block Registry:Schema validation 與版本 migration。
  • Content Projection:轉成 Markdown 相容內容。
  • PHP 摘要模板:登入任務頁的 HTML。
  • React editor:選擇應該載入哪一個編輯器。
  • 前端文件工具:草稿預覽使用的 Markdown projection。

一開始使用 switchif/elseif 很合理,因為區塊數量少,行為也不複雜。但每加入一種新區塊,就要記得同步修改所有位置。

漏掉其中一個步驟時,問題通常不會在編譯階段出現,而是在使用者操作後才發現:

  • Picker 看得到元件,儲存時卻被 server 拒絕。
  • 編輯器可以儲存,任務摘要只顯示空白。
  • 登入頁正常,公開頁卻沒有 renderer。
  • 新版本可以開啟,降回舊版後未知內容被重寫或丟失。

這表示真正缺少的不是另一個條件判斷,而是一份可以驗證「區塊是否完整」的契約。

第一個調整:外部程式只認得 TaskContentRuntime

重構後,外掛其他部分不再直接判斷每一種區塊,而是透過一個較深的 TaskContentRuntime 介面:

prepareForSave($document, $taskContext);
render($document, $renderContext);
editorManifest();

這三個入口分別處理:

  1. 儲存前的 migration、驗證與相容內容投影。
  2. 根據登入、摘要、唯讀或公開情境產生安全輸出。
  3. 提供前端目前可用的區塊與限制。

Controller 不需要知道 headingimage 有什麼差別;Model 不需要自行呼叫 projection;Template 也不再決定某個 type 應使用哪一份 renderer。

這個改動的目的不是減少檔案數量,而是讓區塊知識留在同一個模組邊界裡。外部呼叫者只要知道「準備儲存」和「呈現文件」,不必理解內部每一種型別。

Block Catalog 必須是 Allow-list

區塊文件是使用者可提交的 JSON,不能相信裡面的 type、renderer 名稱或 PHP class。

因此 Runtime 內部使用固定的 Block Catalog:

paragraph       → Text adapter
heading         → Heading adapter
image / file    → Asset adapter
markdown        → Markdown adapter
interactive_html → Interactive HTML adapter
swimlane_diagram → Swimlane Diagram adapter

JSON 只保存穩定的 typeversion,不能自行指定模板路徑或 class name。Runtime 會從 allow-list 取得對應 adapter,再執行 validation、migration、projection 與 rendering。

基礎區塊也不需要為了形式一致而每一種都建立大型 class。段落與提示可以共用文字行為,三種清單可以共用 list adapter,圖片和附件則共用 asset 規則。重點是行為集中,而不是把每個小判斷拆成更多淺層檔案。

一種區塊的完成條件

重構後,任何可由編輯器新增的區塊都必須同時提供:

  • Manifest 定義與目前版本。
  • 預設內容。
  • Server-side validation。
  • 舊版本 migration。
  • React editor registration。
  • 任務摘要 renderer。
  • Markdown 或純文字備援。
  • 公開頁呈現策略。
  • 自動測試案例。

只完成其中一部分,不能算是支援該區塊。契約測試會比對 Catalog、Editor Registry 和 Renderer Registry,確保可插入的 type 在每一層都有對應實作。

這比依靠開發者記得更新六個檔案可靠得多,因為遺漏會在測試與打包時發生,而不是等使用者開啟任務後才看到空白。

Server-first,再由前端增強

任務摘要不應該因為 React bundle 載入失敗就完全看不到內容。

因此一般段落、標題、清單與附件仍可以先由 PHP 輸出有語意、可列印的 HTML。需要互動的重型區塊才由 JavaScript hydration 增強。

PHP 安全輸出
   ├─ 一般區塊:直接成為完整內容
   └─ 重型區塊:先輸出相容提示與 mount config
                         │
                         └─ JavaScript 按需載入互動 renderer

每個區塊也有自己的錯誤邊界。一個流程圖載入失敗時,其他段落和附件仍然存在;整個 client runtime 無法啟動時,Server 產生的 Compatibility Content 仍可閱讀。

這讓「前端增強」真的只是增強,而不是另一份必須成功才能看見資料的單頁應用程式。

未知區塊不能直接刪除

版本化文件還會遇到升級與降版問題。

假設新版外掛加入 timeline 區塊,之後因事故暫時回復到不認得它的舊版。如果舊版 validator 直接移除未知內容,重新儲存一次就會造成永久資料遺失。

最後採用 bounded opaque round-trip:

  • 認得的舊版本先 migration,再驗證與儲存。
  • 認得 type、但版本比程式更新時,保留原始 content。
  • 完全未知的 type 在大小限制內原樣保留。
  • 舊版編輯器可以排序或刪除,但不能改寫未知 content。
  • 摘要顯示不支援提示,並保留 Compatibility Content。

「保留」不是無限制接受任意 JSON。未知內容仍受單一區塊與整份文件的 byte limit 約束,避免 forward compatibility 變成繞過 guardrail 的入口。

重構如何驗證?

這次不能只確認重構後畫面看起來一樣。測試分成三層:

PHP

  • 每種區塊的有效與無效資料。
  • Migration 前後結果。
  • Markdown projection。
  • 摘要 HTML。
  • 未知 type 與較新 version 的保留。
  • 附件 ownership、公開頁與 XSS 邊界。

React

  • Manifest 與 Editor Registry 完整性。
  • 修改、排序、刪除與唯讀 fallback。
  • 每種區塊的 round-trip。
  • 單一區塊錯誤不影響整份文件。

瀏覽器

每種區塊都至少經過新增、儲存、摘要顯示、重新編輯和再次儲存,並檢查順序與內容沒有改變。公開頁、手機寬度、無 JavaScript 與列印情境則另外驗證。

這次重構真正解決了什麼?

Block Runtime 並沒有讓每種區塊的複雜度消失。互動式 HTML 仍需要 sandbox,流程圖仍需要自己的資料模型,附件仍要驗證權限。

它解決的是「複雜度應該放在哪裡」:

  • Controller 不再維護 type switch。
  • Model 不再自行拼接相容內容。
  • Template 不再決定所有區塊行為。
  • 前後端 Registry 可以透過契約測試互相核對。
  • 新區塊必須一次交付編輯、驗證、呈現、降級與測試。

當外掛只有兩三種區塊時,這種重構可能顯得太早;但當下一個需求是執行任意 HTML 與繪製跨職能流程圖時,統一 Runtime 就成為必要的安全邊界。

下一篇將進入第一個重型區塊:Kanboard 互動式 HTML 外掛開發實錄:從 srcdoc 失敗到獨立 Runner