file

最近因為要架設新的部落格「牧村摺紙」的關係,又玩了不少新的 WordPress 外掛,尤其受到朋友敲碗的影響,原本這個部落格是只想製作英文版來跟世界的摺紙同好交流用的,但終究還是忍不住把它做成了雙語網站。我大致上把幾款主流的多國語系外掛都試過了,而最後覺得 TranslatePress 這一款是最符合我的需求的。它有幾個特點:

  1. 它雖然也有自動翻譯功能,但主要是手動翻譯為主打項目,這對像我這種有著太多專業用語、自動翻譯不可能理想的網站來說比較適合。
  2. 它是直接另外開資料表來儲存所有的翻譯對應,所以各頁面和文章的本質都不變,不會像其它外掛那樣需要分別對不同的語言建立不同版本的文章。1
  3. 它可以完全翻譯網頁上的一切介面文字和內容,包括稍後在網頁上動態寫入的新內容都能翻。2

不過,很快地朋友又向我敲碗了另外一個願望:因為這個部落格牽涉到很多一般人不熟悉的摺紙專有名詞,所以他希望我可以在網站上加入一個詞彙表(glossary),並且自動在文章中標出那些專有名詞、使得滑鼠移到上面的時候會自動出現氣球來簡短解釋這些詞彙的意思。做這件事情的 WordPress 外掛也是很多,本來這沒什麼稀奇的,但是偏偏不巧地我發現它們無一例外地全部都會跟我已經決定使用了的 TranslatePress 相衝突 3,所以我最後就決定乾脆我自己來寫一個我專屬的外掛、順便來滿足一些我特定的詞彙表需求好了。這是我第一次做 WordPress 的外掛,而在本篇中,就來跟各位分享我的開發心得。

完整的專案我有放在 GitHub 上面,各位有興趣的話可以在這邊參考(此連結指向與本文程式碼相符之原始版本;後續又有一些改良)。

開發環境設置

WordPress 外掛當然是用 PHP 寫的,而要在 VS Code 裡面進行 PHP 的開發,最好用的延伸模組就是 PHP Intelephense,因為只有它是不需要透過呼叫外部安裝的 PHP 來執行語言相關功能的,效能比別的類似模組好很多。而要讓它認得 WordPress 內建的函數,辦法就是在工作區設定中加入

"intelephense.stubs": [
    ...,
    "wordpress"
],

這一項,然後它就會完全提供所有 WordPress 內建函數的自動完成功能了。

在我的專案當中,所有外掛的檔案都是在 src 資料夾底下,而最後的建置其實也就只是把裡面的內容做成一個 zip 壓縮檔即可。所以我的 tasks.json 內容如下:

{
    "version": "2.0.0",
    "tasks": [
        {
            "label": "build",
            "type": "shell",
            "command": "zip",
            "args": [
                "-r",
                "../dist/${workspaceFolderBasename}.zip",
                "*"
            ],
            "options": {
                "cwd": "${workspaceFolder}/src"
            },
            "presentation": {
                "reveal": "never"
            },
            "problemMatcher": [],
            "group": {
                "kind": "build",
                "isDefault": true
            }
        }
    ]
}

這一串咒語呼叫了 Windows 系統內建的 zip 功能去製作壓縮檔。

架構策略

我想要做的這樣的一則外掛至少包含了三個核心元素:

  1. 一個把詞彙以及其解釋儲存在 WordPress 資料庫中的機制。
  2. 後台要有設定畫面可以去編輯詞彙表。
  3. 有一個程式會去爬遍頁面的內文,並且把找到的關鍵字加上氣球功能。

關於氣球的部份,就直接使用 BootStrap Tooltips 就好了,反正我採用的 WordPress 佈景主題本來也就是採用 BootStrap 4 的,不需要自己重刻輪子。

關於 3. 的部份,有後端跟前端兩種可能的作法,而我選擇採用前端的方式,一方面我比較熟悉前端的寫法,另一方面這樣也可以減輕後端的處理負擔。但是當然我並不希望每次輸出的頁面都包含了一個完整的詞彙表的 JSON 在裡頭,這樣是無謂地浪費傳輸量;我希望可以做成前端程式會去打一個 API 抓這個 JSON 資料過來,然後只要資料版本沒變、前端就一直快取這個結果就好了。因此我又需要再多一個元素:

  1. 提供一個 API 把 JSON 格式的詞彙表資料傳回給前端。

除此之外,在部落格上面我還有一個專屬的詞彙表頁面,是會全部列出所有詞彙、解釋、以及我自己的額外翻譯註解的。所以我還需要:

  1. 註冊一個 WordPress 短代碼,以便在頁面上輸出完整的詞彙表。

那麼底下我們就來一一把這些部份做出來吧。

資料儲存

WordPress 外掛有很多可以儲存資料的方法,例如註冊一種新的貼文類別、或是自己開一個外掛專屬的資料表等等。我不覺得我需要把事情搞得那麼大,因為到頭來在目標 4. 當中我反正要輸出 JSON 字串,我乾脆一開始就全部把資料存成一個 JSON 字串就好,如此一來我只要借用一下 WordPress 的選項資料表中的一筆紀錄就好了(該表允許無限長度的字串內容,所以不用怕會爆),根本不用大費周章。老練的 WordPress 開發工程師大概會覺得我這種作法是邪魔歪道吧!管它的,以我熟悉的方式快速開發出來最重要,我也沒打算花一大堆時間讀文件去學正宗的作法。

我定義我的詞彙表中每一筆資料的格式如下:

{
    "id": 0, // 序號,識別用
    "s": "英文單數形",
    "p": "英文複數形", // 兩者都要,才能正確地抓出頁面第一次出現該詞彙的地方
    "j": "日文", // 氣球功能用不到,但詞彙表頁面會顯示
    "c": "中文",
    "d": "英文解釋",
    "t": "中文解釋",
    "e": "翻譯註解"
}

這邊的屬性名稱命名得很隨性,這無所謂,自己知道就好。然後我的整體 JSON 資料就是由這樣的物件所組成的陣列。我要把這整個字串存成一個外掛選項,名為 feg_datafeg 代表 front-end-glossary,我這個外掛的名字)。除此之外,我還需要再儲存一個版本號(以便讓前端分辨它是否需要更新詞彙表版本),我選擇直接用時間戳記來當作版本號,儲存在 feg_data_version 當中。因此,我的程式碼第一段如下:

define('DATA', 'feg_data');
define('VER', 'feg_data_version');

function feg_activate() {
    add_option(DATA, '[]');
    add_option(VER, time());
    register_uninstall_hook(__FILE__, 'feg_uninstall');
}

function feg_uninstall() {
    delete_option(DATA);
    delete_option(VER);
}

register_activation_hook(__FILE__, 'feg_activate');

這邊利用 WordPress 提供的 hook 來註冊當外掛啟用時要加上初始化的選項、而在刪除外掛的時候要把選項刪掉以免留下垃圾資料。

後台畫面

由於我的後台頁面會一次撈出全部的資料並且動態地編輯它們,我的後台頁面很自然地會用我最熟悉的 Vue.js 來寫。這部份我就不多加解釋,因為它的設計本身跟這個外掛不是關係很大。先來看到向 WordPress 註冊後台頁面的主體程式碼:

// 負責輸出後台頁面的函數
function feg_options_html() {
    if (!current_user_can('manage_options')) return;
    $data = get_option(DATA);
?>
    <!-- 這邊的細節等一下再給出 -->
<?php
}

// 註冊兩個會被設定的選項成為一個群組 feg
add_action('admin_init', function () {
    register_setting('feg', DATA);
    register_setting('feg', VER, array(
        'type' => 'number'
    ));
});

// 註冊一個即將管理剛才建立的群組的頁面
add_action('admin_menu', function () {
    add_menu_page(
        'Front-end Glossary', // 頁面名稱
        'Glossary', // WordPress 選單上的項目名稱
        'manage_options', // 權限設定
        'feg', // 群組名稱
        'feg_options_html', // 剛才的函數
        'dashicons-book-alt' // WordPress 選單上要用的圖示
    );
});

然後是上面的頁面輸出細節:

<!-- 我習慣用 BootStrap 刻畫面,所以掛一個進來 -->
<link rel="stylesheet" integrity="undefined" crossorigin="anonymous"
        href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css">

<!-- 額外 CSS 設定 -->
<style>
    html, body { scroll-padding: 50px; }
    body { background-color: #f1f1f1 !important; }
</style>

<!-- 掛 Vue.js 進來 -->
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.12/dist/vue.js"></script>

<!-- 頁面的主體程式碼 -->
<div class="wrap">
    <h1><?= esc_html(get_admin_page_title()) ?></h1>

    <!-- options.php 是 WordPress 固定用來接收設定值的端點 -->
    <form action="options.php" method="post" id="feg_form">

        <!--
            只要表單欄位跟我們註冊的群組一致,
            剩下的事情 WordPress 會自己幫我們搞定。
            這兩個欄位都需要填入原本的選項設定值,
            免得使用者萬一誤觸儲存按鈕的時候裡面沒有值。
        -->
        <input type="hidden" name="<?= DATA ?>"
            value="<?= esc_attr($data) ?>" id="feg_data">
        <input type="hidden" name="<?= VER ?>"
            value="<?= get_option(VER) ?>" id="feg_data_version">

        <!-- 這邊是我用來 mount Vue.js 的地方 -->
        <div id="feg_app"></div>

        <?php
        // 自動產生 WordPress 內部機制所需要的額外欄位
        settings_fields('feg');

        // 產生和 WordPress 風格一致的 submit 按鈕
        submit_button('儲存');
        ?>
    </form>

    <!-- 這裡面有我啟動 Vue.js 的程式碼 -->
    <script src="<?= plugin_dir_url(__FILE__) ?>js/admin.js"></script>
</div>

最後是 admin.js 的內容,裡面跟啟動 Vue 有關的我就不贅述了,跟 WordPress 比較有關的是底下這些程式碼:

// 從輸出的畫面上把資料讀取進來
const field = document.getElementById('feg_data');
const org_data = field.value;
let feg_data = JSON.parse(org_data);

// 啟動 Vue
let app = new Vue({
    data: {
        entries: feg_data
    },
    ...
});

// 設置表單送出的事件處理
document.getElementById('feg_form').addEventListener('submit', function() {
    let json = JSON.stringify(app.entries);
    if(json != org_data) {
        field.value = json;
        // 只有當資料有發生變化的時候才去更新版本號碼
        document.getElementById('feg_data_version').value = new Date().getTime();
    }
});

前端程式

首先我們要利用外掛來注入一小段內容到每一個頁面之上:

function feg_head() {
    $version = get_option(VER);
    // 這是我們的 API 之網址
    $url = esc_attr(get_rest_url(null, "front-end-glossary/$version"))
?>
    <script src="<?= plugin_dir_url(__FILE__) ?>js/main.js" async defer
        data-url="<?= $url ?>"></script>
    <style>
        .feg {
            border-bottom: 1px dashed gray;
            white-space: nowrap;
        }
    </style>
<?php
}

// 註冊 action,要求插入內容到 <head> 之中;
// 順位 1 表示一段注入放在越前面越好
add_action('wp_head', 'feg_head', 1);

而 main.js 的完整內容如下:

(async function() {
    // 不要在 TranslatePress 的翻譯頁面中使用此功能
    if(location.href.includes("trp-edit-translation")) return;

    // 抓出版本號,並且去 fetch API
    let script = document.currentScript || Array.prototype.slice.call(document.getElementsByTagName('script')).pop();
    let response = await fetch(script.dataset.url, {
        cache: 'force-cache' // 若有快取就強迫使用
    });
    let json = JSON.parse(await response.json()), key, find, desc;

    // 依照當前網頁的語系做不同的處理
    if(document.documentElement.lang == "en-US") {
        key = e => e.s;
        find = e => {
            if(!e.s) return null;
            // 英文要同時考慮單數複數形,而且要比對全字
            if(e.p) return new RegExp(`\\b(${e.s}|${e.p})\\b`, "i")
            return new RegExp(`\\b${e.s}\\b`, "i");
        };
        desc = e => e.d;
    } else {
        key = e => e.c;
        find = e => e.c && new RegExp(e.c);
        desc = e => e.t;
    }
    for(let e of json) e.reg = find(e);
    json = json.filter(e => e.reg);
    // 排序,較長的關鍵字優先,以考慮到巢狀關鍵字的狀況
    json.sort((e1, e2) => key(e2).length - key(e1).length);

    // 經驗顯示這邊最好稍微等待一下,免得和 TranslatePress 的前端程式打架
    await new Promise(resolve => {
        setTimeout(() => resolve(), 500);
    });

    // 爬文的遞迴函數
    function visit(reg, title, node) {
        if(node instanceof Text) {
            const text = node.wholeText;
            let result = reg.exec(text);
            if(result) {
                let found = result[0];
                let i = result.index;
                let before = text.substring(0, i);
                let after = text.substr(i + found.length);
                let html = `<span>${before}</span><span class="feg" data-toggle="tooltip" title="${title}">${found}</span><span>${after}</span>`;
                jQuery(node).replaceWith(html);
                return true;
            }
            return false;
        } else {
            let tag = node.tagName?.toLowerCase();
            if(tag == "header" || tag == "h1" || tag == "a") return false;
            if(node.classList?.contains("feg")) return false;
            for(let c of node.childNodes) if(visit(reg, title, c)) return true;
            return false;
        }
    }

    // 開始進行爬文
    let article = document.querySelectorAll("article.post");
    for(let e of json) {
        let title = desc(e).replace(/"/g, "&‎quot;");
        for(let a of article) if(visit(e.reg, title, a)) break;
    }

    // 啟動 BootStrap Tooltips
    jQuery('[data-toggle="tooltip"]').tooltip();
})();

提供 API

WordPress 提供了簡便的語法可以註冊 API:

function feg_rest_api(WP_REST_Request $request) {
    $data = get_option(DATA);
    $response = new WP_REST_Response($data);
    // 加上一個 header,表明只要版本號不變,內容就永遠不會更動
    $response->header('Cache-Control', 'immutable');
    return $response;
}

add_action('rest_api_init', function () {
    // 這邊的 API 網址格式雖然有一個版本參數,但實際上我們不會去理會它;
    // 版本參數的唯一作用是讓前端去判斷是否可以繼續使用快取
    register_rest_route('front-end-glossary', '/(?P<version>\d+)', array(
        'methods' => 'GET',
        'callback' => 'feg_rest_api',
        'permission_callback' => '__return_true',
    ));
});

註冊短代碼

寫短代碼的時候,唯一要注意的事情是短代碼的 callback 函數裡面不能直接 echo 或者用 template 來輸出內容,應該要把要輸出的東西整理成字串再一起傳回給 WordPress 去處理。

function feg_shortcode() {
    $json = json_decode(get_option(DATA), true);
    $result = "<ul>\n";
    foreach ($json as $entry) {
        $result .= "<li><p>{$entry['s']} / {$entry['j']} / {$entry['c']} : ";
        $result .= "<span class='en-us-only'>{$entry['d']}</span>";
        $result .= "<span class='zh-tw-only'>{$entry['t']}</span>";
        if ($entry['e'] != '') {
            $result .= "<br><span class='zh-tw'>{$entry['e']}</span>";
        }
        $result .= "</p></li>\n";
    }
    $result .= "</ul>";
    return $result;
}

add_shortcode("feglossary", 'feg_shortcode');

全部就是這樣,很容易就完成了一個外掛!


  1. 這樣的好處在於如果懶得 100% 翻譯,某些內容不翻也行,會直接原文呈現,維護上比較沒有壓力;但從另一個角度來說,它的缺點就是翻譯版與原版一定要是字句忠實對應,不能靈活地在不同語系版本中呈現出截然不同的內容(但這點我無妨)。 

  2. 不過免費版的有一個限制,就是沒有提供 SEO 相關資訊的翻譯功能,這部份要靠它的一個付費附加元件才能做到。而 SEO 資訊甚至包括網頁的 <title> 都算在內,這部份沒有翻出來對我來說實在有點難以忍受;幸好我後來自己設法把這個功能加了上去,這是題外話,在此不多說。 

  3. 原因大概是因為 TranslatePress 把自身的執行順序設定得超級後面(具體來說它設為 99999),以便能夠翻譯到一切的東西,而且沒有地方可以更改這一項設定,這使得那些 glossary 外掛都會比它更早執行,而一旦 glossary 去修改內文、把關鍵字加上標籤之後,TP 就認不得完整的字句、從而沒能正確翻譯了。我當然也是可以改成讓 TP 去認得修改過後的字句去進行翻譯,可是這樣一來只要我一增加新的詞彙我就得跟著去修改 TP 的翻譯對應,這樣維護起來太累了。 


分享此頁至:
最後修改日期: 2021/07/02

留言

撰寫回覆或留言

發佈留言必須填寫的電子郵件地址不會公開。您的留言可能會在審核之後才出現在頁面上。