Type to search by title, content, or category

Esc to close, Enter for all results

/ Coder

Zero-DB Traditional Chinese Conversion for Moodle with OpenCC (and How to Bypass RequireJS Pitfalls)

Built a Simplified Chinese AI course on Moodle and want to serve Traditional Chinese learners without maintaining a duplicate course? Combine Moodle language packs with a RequireJS-safe OpenCC frontend injection.

1. Motivation and BLUF (Bottom Line Up Front)

If you have built a Simplified Chinese AI course on Moodle, don't want to leave out Traditional Chinese learners, and don't want the burden of duplicating and maintaining a separate Traditional Chinese course, give this approach a try.

The constraints are clear: do not alter the existing Simplified Chinese database, do not duplicate courses and question banks into two separate branches to maintain, and avoid rebuilding Docker containers or maintaining custom PHP backend filter plugins—while still allowing learners who switch to Traditional Chinese to see a fully localized interface, course text, and quiz questions.

Today I verified the entire workflow in a live Moodle environment. The working solution consists of two layers:

  1. System UI Layer: Install Moodle's official zh_tw (Traditional Chinese) language pack in Site Administration to handle navigation bars, buttons, and system prompts.
  2. Course & Question Bank Content Layer: Use Moodle's built-in Additional HTML setting to inject a frontend script powered by opencc-js (cn -> twp) right before the closing </body> tag. This converts Simplified Chinese DOM text and regional vocabulary (e.g., 文件 → 檔案, 视频 → 影片) into Taiwan Traditional Chinese on the fly.

What looked like a one-line <script> tag injection turned into two unexpected debugging hurdles on real Moodle pages: Moodle's built-in RequireJS hijacking UMD modules and misleading <html lang> attributes. Below is the underlying mechanism, the debugging journey, and the copy-pasteable production snippet.


2. Core Mechanics: Why Naive Approaches Fail

Before writing the snippet, three architectural boundaries in Moodle need to be clarified:

2.1 Official Language Packs Do Not Touch Course Content in the Database

Moodle's official zh_tw language pack is comprehensive, but it only translates static UI strings defined in Moodle's source code (such as turning "我的主页" into "我的主頁" in the top bar). Course titles, section introductions, page resources, and quiz stems stored in the database are completely untouched by language packs.

Writing a backend PHP Filter plugin would require compiling C++ dependencies and PHP extensions inside the server container (since there is no official OpenCC plugin in the Moodle plugins directory) and spending server CPU cycles on dictionary matching for every request. Offloading conversion to the browser incurs zero server load and can be rolled back anytime by deleting the snippet.

2.2 Detecting Simplified vs. Traditional Chinese: Ignore <html lang>, Check body.classList

Intuitively, a frontend script checking whether the current page is in Traditional Chinese would inspect document.documentElement.lang (the <html lang="..."> attribute).

However, testing on a live Moodle instance revealed that whether you visit ?lang=zh_cn or ?lang=zh_tw, document.documentElement.lang always returns "zh", making it impossible to distinguish Simplified from Traditional Chinese. The place where Moodle reliably exposes the active language pack is the <body> element's class list:

  • Simplified Chinese (zh_cn): document.body.className contains lang-zh_cn
  • Taiwan Traditional Chinese (zh_tw): document.body.className contains lang-zh_tw
  • Hong Kong Traditional Chinese (zh_hk): document.body.className contains lang-zh_hk

Checking document.body.classList.contains('lang-zh_tw') or lang-zh_hk right at the start of the script ensures that Simplified Chinese learners exit immediately without downloading any conversion dictionaries.

2.3 Does Frontend Text Conversion Break Quiz Auto-Grading?

It depends on how each Moodle question type submits answers:

  • Multiple choice (multichoice), True/False (truefalse), and Matching (matching): Completely unaffected. The script only alters the displayed text nodes; when a learner submits the quiz, the browser sends option IDs (such as 1, 2, 3) to the backend.
  • Select missing words (gapselect): Despite sounding like a fill-in-the-blank question, inspecting Moodle's question bank XML shows that it renders as <select> dropdown menus on the page. Submission sends the selected option index, so grading is 100% unaffected.
  • Short answer (shortanswer) and embedded SHORTANSWER Cloze (cloze): These require learners to type text into an <input> box. If the backend answer key only stores Simplified Chinese, submitting Traditional Chinese characters directly would be marked wrong. To handle this, we attach a capture-phase submit listener that converts text input values back to Simplified Chinese before the form is sent to the server.

3. Debugging Log: opencc-js Hijacked by RequireJS

With the design settled, my first iteration dynamically appended a <script> tag loading [email protected]/dist/umd/full.js from jsDelivr CDN. The browser console immediately threw:

Uncaught ReferenceError: OpenCC is not defined

Round 1: Why Wasn't window.OpenCC Defined?

Inspecting the header of opencc-js/dist/umd/full.js shows a standard UMD wrapper:

(function (global, factory) {
  typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
  typeof define === 'function' && define.amd ? define(['exports'], factory) :
  (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.OpenCC = {}));
})(this, (function (exports) { 'use strict'; ... }));

Checking typeof window.define in the Moodle console returned "function" with window.define.amd truthy—Moodle globally mounts RequireJS. When the UMD wrapper sees define.amd, it takes the second branch (define(['exports'], factory)) and registers itself as an anonymous AMD module instead of falling through to the third branch that assigns window.OpenCC.

Round 2: Why Temporarily Nulling Out window.define Crashed on Real Pages

A common workaround for RequireJS hijacking is setting window.define = undefined before inserting the <script> tag and restoring it inside script.onload.

While this worked in isolated console tests, saving it into Moodle's Additional HTML and refreshing an actual course page left the course titles in Simplified Chinese and triggered an even worse error in the console:

Uncaught TypeError: Cannot read properties of undefined (reading 'Converter')
Uncaught Error: Mismatched anonymous define() module: function (exports) { 'use strict'; ...

The reason is that during Moodle page initialization, RequireJS is actively loading dozens of core Moodle AMD modules asynchronously. Nulling out window.define during the several hundred milliseconds while <script> downloads over the network—or restoring it while scripts interleave—either breaks Moodle's own module loader or causes RequireJS to catch an unmatched anonymous define() call.

Final Solution: fetch + new Function to Force the CommonJS Branch

To export opencc-js cleanly without touching window.define at all, we can bypass the <script> tag entirely: fetch the UMD source code as plain text via fetch(), and execute it inside an isolated local scope using new Function.

Look closely at the first branch of the UMD header:

typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : ...

It checks both exports and module (passing only exports without module still falls through and causes exports.Converter is not a function). By declaring 'module', 'exports', 'define' as parameters to new Function and passing undefined for define, we deterministically force the UMD bundle into the first (CommonJS) branch:

function loadOpenCC() {
    if (window._OpenCC) return Promise.resolve(window._OpenCC);
    return fetch('https://cdn.jsdelivr.net/npm/[email protected]/dist/umd/full.js')
        .then(function(res) { return res.text(); })
        .then(function(code) {
            var module = { exports: {} };
            var fn = new Function('module', 'exports', 'define', code);
            fn(module, module.exports, undefined);
            window._OpenCC = module.exports;
            return window._OpenCC;
        });
}

With this loader in place, refreshing Moodle produced zero RequireJS console errors and initialized the converter reliably every time.


4. Step-by-Step Implementation

Step 1: Install the Traditional Chinese Language Pack in Moodle

  1. Log in to Moodle as an administrator and navigate to Site administration → Language → Language packs.
  2. Find 繁體中文 (zh_tw) in the list of available language packs on the right (install zh_hk as well if you also serve learners in Hong Kong) and click Install.
  3. Once installed, the language switcher menu will appear in the top navigation bar.

Step 2: Inject the Conversion Script via "Additional HTML"

  1. Go to Site administration → Appearance → Additional HTML, or open the path directly: /admin/settings.php?section=additionalhtml
  2. Scroll down to the third textarea: Before BODY is closed (#id_s__additionalhtmlfooter).
  3. Keep any existing snippets in that field (many sites place Google Analytics or Tag Manager scripts here—do not overwrite them), and append the following script at the bottom:
<!-- OpenCC Simplified-to-Traditional Conversion (Moodle RequireJS Isolated Edition) -->
<script>
(function() {
    // 1. Run only when the user is in a Traditional Chinese locale (zh_tw / zh_hk)
    var isTraditional = document.body.classList.contains('lang-zh_tw') || 
                        document.body.classList.contains('lang-zh_hk');
    if (!isTraditional) return;

    // 2. Isolate loading via fetch + new Function to avoid Moodle RequireJS define conflicts
    function loadOpenCC() {
        if (window._OpenCC) return Promise.resolve(window._OpenCC);
        return fetch('https://cdn.jsdelivr.net/npm/[email protected]/dist/umd/full.js')
            .then(function(res) { return res.text(); })
            .then(function(code) {
                var module = { exports: {} };
                var fn = new Function('module', 'exports', 'define', code);
                fn(module, module.exports, undefined);
                window._OpenCC = module.exports;
                return window._OpenCC;
            });
    }

    loadOpenCC().then(function(OpenCC) {
        // cn -> twp: Mainland Simplified to Taiwan Traditional with regional phrases
        var converter = OpenCC.Converter({ from: 'cn', to: 'twp' });
        var reverseConverter = OpenCC.Converter({ from: 'tw', to: 'cn' });

        // 3. Walk and convert text nodes, skipping code blocks and input controls
        function convertElement(node) {
            if (!node) return;
            var walker = document.createTreeWalker(
                node,
                NodeFilter.SHOW_TEXT,
                {
                    acceptNode: function(textNode) {
                        var p = textNode.parentNode;
                        if (!p) return NodeFilter.FILTER_REJECT;
                        var tag = p.tagName;
                        if (['SCRIPT', 'STYLE', 'CODE', 'PRE', 'TEXTAREA'].indexOf(tag) !== -1) {
                            return NodeFilter.FILTER_REJECT;
                        }
                        if (tag === 'INPUT' || p.isContentEditable) {
                            return NodeFilter.FILTER_REJECT;
                        }
                        return NodeFilter.FILTER_ACCEPT;
                    }
                }
            );
            var curr;
            while ((curr = walker.nextNode())) {
                curr.nodeValue = converter(curr.nodeValue);
            }
        }

        convertElement(document.body);
        document.title = converter(document.title);
        document.querySelectorAll('input[placeholder]').forEach(function(el) {
            el.placeholder = converter(el.placeholder);
        });

        // 4. Observe dynamically loaded Moodle DOM nodes (collapsed sections, drawers, AJAX modals)
        var debounceTimer = null;
        var pendingNodes = [];
        var observer = new MutationObserver(function(mutations) {
            mutations.forEach(function(m) {
                m.addedNodes.forEach(function(n) {
                    if (n.nodeType === Node.ELEMENT_NODE) {
                        pendingNodes.push(n);
                    }
                });
            });
            clearTimeout(debounceTimer);
            debounceTimer = setTimeout(function() {
                while (pendingNodes.length > 0) {
                    convertElement(pendingNodes.shift());
                }
            }, 60);
        });
        observer.observe(document.body, { childList: true, subtree: true });

        // 5. Convert text inputs back to Simplified Chinese before form submission
        document.addEventListener('submit', function(e) {
            e.target.querySelectorAll('input[type="text"], textarea').forEach(function(input) {
                if (input.value) {
                    input.value = reverseConverter(input.value);
                }
            });
        }, true);
    }).catch(function(err) {
        console.error('[OpenCC] Load error:', err);
    });
})();
</script>
  1. Click Save changes at the bottom of the page.
    • A quick browser-automation tip: If you use a browser automation tool to click Save in Moodle admin, never run a bare document.querySelector('button[type="submit"]').click()—the global search button in Moodle's top navbar is also button[type="submit"] and appears first in DOM order. Always scope the selector to form#adminsettings button[type="submit"].

Step 3: Verify Bilingual Switching

Open any course page and switch between Simplified and Traditional Chinese in the top-right language menu:

Check ItemSimplified Locale (?lang=zh_cn)Traditional Locale (?lang=zh_tw)
Course & Section Titles第一单元:为什么要学习……第一單元:為什麼要學習……
Resource Type Badges课程指南(文件)課程指南(檔案)
Media Activity Labels视频 / 音频影片 / 音訊
Common Terminology伙伴项目 / 量身定制夥伴專案 / 量身定製
Console Errors0 (script exits early, zero network requests)0 (no RequireJS conflict errors)

Thanks to OpenCC's from: 'cn', to: 'twp' regional phrase mode, not only are character glyphs converted, but cross-strait vocabulary differences such as 文件/檔案, 视频/影片, and 音频/音訊 are translated naturally as well.


5. Summary and Applicability Boundaries

Combining Moodle's official language pack with an Additional HTML injection of opencc-js works well for Moodle sites authored in Simplified Chinese that want to serve Traditional Chinese learners with minimal maintenance overhead:

  • Best Fit: Courses consisting primarily of web pages, external video links, and objective quiz questions (multiple choice, true/false, matching, and dropdown missing-word selection), where you want zero database changes and a 5-minute rollout.
  • When Not to Rely on It Alone:
    1. Text baked into raster images or scanned PDFs cannot be converted by DOM text-node replacement.
    2. If your quizzes rely heavily on free-text shortanswer or embedded SHORTANSWER Cloze inputs: while Step 5 in the script attaches a capture-phase submit listener to convert Traditional Chinese input back to Simplified Chinese, I have only verified page rendering so far and have not yet run an end-to-end grading test across Moodle's quiz submission and AJAX auto-save flows. Before running high-stakes exams with free-text inputs, test a full quiz submission with a student account or add Traditional Chinese synonyms directly to the question bank's accepted answers.