不管是從零做的新主題,還是上一篇剛從傳統佈景主題搬過來的,要交出去之前都有同一關:程式碼本身的品質,這一關過去多半靠自律,但 AI 讓產出速度翻了好幾倍之後,靠自律守不住了。
一個小時 AI 就能產出十幾個區塊,沒有人能用肉眼一個個確認 escaping 有沒有做、字串有沒有包翻譯函式。品質關卡必須變成一句指令跑完的東西。
| 關卡 | 管什麼 | 抓得到的問題 |
|---|---|---|
| PHPCS | 排版與慣例、escaping、命名前綴 | echo $var 沒跳脫、函式沒加主題前綴 |
| PHPStan | 型別與邏輯 | 「這個函式可能回傳 false」沒處理 |
| PHPUnit | 實際行為 | render.php 的輸出跟你以為的不一樣 |
| i18n | 多語系 | 字串沒包 __()、text domain 打錯、POT 過期 |
我們這個主題有 17 支 PHP 檔,其中 15 支是 render.php,每一支都在處理 attributes、組 markup、輸出到前台,而且每一支都是使用者輸入直接進到 HTML 的地方,這時候就要設計一套工作流程來檢查每個輸入與輸出的過濾。
大家都知道跑 phpcs,但真正決定它有沒有用的是設定檔,我們主題的 .phpcs.xml.dist 有三段:
<!-- 編譯產物不是主題原始碼 -->
<exclude-pattern>/build/*</exclude-pattern>
<exclude-pattern>/node_modules/*</exclude-pattern>
<!-- render.php 在 WP_Block::render() 裡執行,區域變數不是真正的全域變數 -->
<rule ref="WordPress.NamingConventions.PrefixAllGlobals">
<exclude-pattern>/blocks/*/render.php</exclude-pattern>
</rule>
第一段是必要的,build/ 是 wp-scripts 產生的複製品,不排除的話所有問題都會報兩次。
第二段是重點:render.php 執行在 WP_Block::render() 的函式範圍裡,$title、$tag 這些變數是區域變數,不會覆蓋全域,所以 PrefixAllGlobals 那條規則對它沒有意義,因此這邊把它排除規則,避免出現不必要的警示。
在做設定檔的時候記得要請 AI 在寫排除規則要附理由,而且理由要是「這條規則在這個情境不適用」,不能是「這樣才不會報錯」。 AI 產出的設定檔最常見的毛病,就是把後者包裝成前者。
跑起來的樣子:
$ composer phpcs
............ 17 個檔案 ............
Time: 355ms; Memory: 14MB
另外還有一個狀況是在用 phpcbf 自動修正時,會把單行的陣列拆成多行,結果原本寫在同一行的 // phpcs:ignore 註解跟 echo 分家,ignore 失效,反而多出一個錯誤。自動修完一定要再跑一次 phpcs 確認,不要修完就當作過關。
這一關最容易騙過自己。跑起來是這樣:
$ composer phpstan
[OK] No errors
看起來很棒。但先看設定檔:
parameters:
level: 5
paths:
- functions.php
它只掃了 functions.php 一支檔案。 那 15 支 render.php、真正處理使用者資料的地方一支都沒掃到。這種「設定看起來完整、範圍其實縮到剩一支檔案」的情況,是 AI 生設定檔時最常見的疏失,而且因為結果是漂亮的綠色 OK,這沒有實際下去看設定檔很難察覺。
我把範圍擴大重跑:
$ ./vendor/bin/phpstan analyse functions.php blocks/ --level=5
[OK] No errors
結果一樣是綠的,但這次才是真的綠,差別在於前者是「沒掃到問題」,後者是「掃過了沒問題」。看到 PHPStan 通過的第一件事永遠是確認 paths,第二件事是確認 level(5 只是中間,長期目標是 8 或 9)。
主題骨架產出來時通常會附幾個範例測試,我們這邊是三個:確認主題有啟用、確認 editor style 有註冊、確認 templates/index.html 存在。它們會過,但它們什麼都沒保護,因為沒有一個測試碰到 render.php。
真正該測的是區塊的伺服器端渲染。以之前做過的那個輪轉區塊為例,我補了這幾個測試:
class Testimonial_Carousel_Test extends WP_UnitTestCase {
public function test_renders_nothing_without_items() {
$html = do_blocks( '<!-- wp:block-theme/testimonial-carousel {"items":[]} /-->' );
$this->assertSame( '', trim( $html ) );
}
public function test_first_slide_is_visible_before_hydration() {
$html = do_blocks( $this->block_markup() ); // 兩則評價.
$this->assertSame( 1, substr_count( $html, '<figure hidden' ) );
$this->assertStringContainsString( 'dot is-current', $html );
}
public function test_escapes_item_values() {
$html = do_blocks(
'<!-- wp:block-theme/testimonial-carousel {"items":[{"quote":"<b>bold</b>","author":"A"}]} /-->'
);
$this->assertStringNotContainsString( '<b>bold</b>', $html );
$this->assertStringContainsString( '<b>', $html );
}
}
這三個測試分別守住三件不同的事:空資料時不要吐出半個空殼、伺服器端渲染的狀態正確、以及輸出有跳脫。第三個測試用 <b> 當範例,是因為 esc_html() 對所有標籤一視同仁,換成任何一種標籤結果都相同。
執行結果:
$ composer test
........ 8 / 8 (100%)
OK (8 tests, 11 assertions)
寫這幾個測試時我還踩到一個很有代表性的失敗。原本想測下方圓點導覽的數量,寫了 substr_count( $html, '__dot"' ),斷言兩個結果只拿到一個,原因是第一顆圓點會被加上 is-current,class 變成 __dot is-current,尾巴那個引號不見了所以搜不到。
這個失敗示範了測試的價值:**它逼你面對 markup 真正長什麼樣,而不是你以為的樣子,**AI 寫測試時特別容易犯這種錯,因為它是照著「應該長這樣」的印象寫斷言,沒有真的去看輸出。所以 AI 產的測試第一次跑失敗,先別急著改程式碼,多半是斷言寫錯了。
先跑一次看看:
$ wp i18n make-pot . languages/block-theme.pot --domain=block-theme
Success: POT file successfully generated.
然後把新產生的 POT 跟專案裡現有的那份比對:
現有 languages/block-theme.pot:14 個字串(POT-Creation-Date: 2026-07-08)
重新產生的:109 個字串
差了 95 個字串。 這就是 i18n 這關的典型死法 — 它不會報錯、不會讓網站壞掉,只會安靜地過期。每次 AI 幫你多做一個區塊、多加幾個 __(),POT 檔就更舊一點,直到某天使用者問你「為什麼後台有一半沒翻譯?」
這裡有個容易混淆的地方。wp i18n 只有五個子指令,沒有任何一個會告訴你「哪些字串還沒翻譯」:
make-json 從 PO 抽 JS 字串產生 JSON
make-mo PO → MO
make-php PO → PHP
make-pot 從原始碼產生 POT
update-po 用 POT 更新既有 PO
原因是**「未翻譯」這個概念不屬於 POT**。POT 是模板,它的 msgstr 本來就全是空的 — 抽出 109 個字串就是 109 個空翻譯,這是正常狀態。要看翻譯進度,對象是特定語言的 PO 檔(zh_TW.po),工具是 msgfmt --statistics,或跑完 update-po 之後看剩幾個空 msgstr。
所以這一關其實有兩個獨立的問題,要分開檢查:
| 問題 | 怎麼檢查 | 症狀 |
|---|---|---|
| 字串沒被抽出來(POT 過期) | 重跑 make-pot 比對字串數 |
翻譯的人根本看不到那些字串 |
| 字串抽出來但沒翻 | msgfmt --statistics zh_TW.po |
後台顯示原文 |
上面那個 95 的差距屬於第一種。而我們這個主題的 languages/ 底下只有一個 POT、一個 PO 都沒有,代表它連第二個問題都還沒開始 — 這在只做繁體中文站的專案很常見,但如果哪天要交付給需要多語系的客戶,這一步就跑不掉。
另外兩個實測發現的細節:
一、記得排除 build/。 不排除的話,每個字串的來源註解會出現兩次:
#: blocks/testimonial-carousel/render.php:69
#: build/blocks/testimonial-carousel/render.php:69
msgid "Previous testimonial"
字串數不會變(msgid 相同會合併),但 POT 檔會多出一倍的雜訊。加上 --exclude=build,node_modules,vendor 就乾淨了。
二、block.json 裡的字串也會被掃進去。 區塊的 title 和 description 只要有宣告 textdomain,make-pot 就會收進 POT,這是 WordPress 內建的機制,不用自己另外處理。
PHPCS 不會抱怨你在區塊 CSS 裡寫死一個 #f57c00,PHPStan 也不會。但那個寫死的色碼會慢慢侵蝕整套設計系統。程式碼品質與設計品質是兩套關卡,都要跑。
四個工具分開跑遲早有一關會被跳過,實際做法是把它們綁成一個指令:
"scripts": {
"phpcs": "phpcs",
"phpstan": "phpstan analyse",
"test": "phpunit",
"make-pot": "wp i18n make-pot . languages/block-theme.pot --domain=block-theme"
}
然後在 Everything-WP 裡用 /verify 一次跑完並產生報告,交付前跑一次、每次讓 AI 大改之後也跑一次,更嚴謹一點就接到 CI,推上去自動跑,過不了就不能合併。
AI 在這一關能做的事很多:修 PHPCS 的錯、補測試、掃 i18n 漏網的字串。但有三件事你要自己盯:
phpcs:ignore 都要有理由,理由要說得出「這條規則為什麼在這裡不適用」functions.php 的 PHPStan文章目錄:https://oberonlai.blog/category/2026-ithome/