Quinn’s Mind Palace

Quick start: build a Wikipedia user script with TypeScript + NPM

Many Wikipedia user script developers write their code directly in JavaScript. That is totally fine, and for small scripts it is often the fastest way to ship something useful.

However, as your script grows, you may start to want a little more structure. TypeScript helps by catching common mistakes early, making refactors less scary, and giving you better editor help when you work with MediaWiki types.

This post is a quick start for building a Wikipedia user script with TypeScript and an NPM toolchain. You will set up a minimal project, bundle the output into a single JavaScript file, and end up with something that is easy to copy and paste into Wikipedia.

The entry point, src/main.ts

Let’s start with a src/main.ts file. Putting all your TypeScript files in one folder, such as src, is ideal. It saves trouble when you need to compile your code to JavaScript, since the end result is still a JavaScript file that you paste into Wikipedia.

src/main.ts
async function init(): Promise<void> {
    // Here, you should check if the page is a target page,
    // to prevent running the user script everywhere.
    // For example:
    //     const pageName = mw.config.get('wgPageName');
    //     return pageName === 'Project:Sandbox';
    // 
    // Useful variables you could reference:
    //     mw.config.get('wgPageName'),
    //     mw.config.get("wgNamespaceNumber"), and
    //     mw.config.get("wgPageContentModel").
    if (!isTargetPage()) {
        return;
    }
    
    // Here, load any MediaWiki modules or remote resources you rely on.
    // This is separate from NPM packages,
    // which you would usually import at the top of the file.
    // For example:
    //     await mw.loader.using(['mediawiki.util']);
    await loadDependencies();
    // Don't forget to load your CSS file in `loadDependencies()`!
    // E.g., mw.loader.load("//en.wikipedia.org/w/index.php?title=YourPathToCssFile.css&action=raw&ctype=text/css", "text/css");
    
    // Many user scripts modify parser output.
    // If you are developing such scripts,
    // you can bind your functions to the content hook.
    mw.hook("wikipage.content").add(() => {
        // Simple delay to reduce race conditions with other scripts.
        // If you do not need it, you can call processDom() directly.
        // If you need something more robust,
        // consider debouncing or observing DOM changes instead.
        setTimeout(() => processDom(), 200);
    });
}
void init();

If you envision your script becoming very popular, you might eventually run into a situation where it is loaded twice on the same page (for example, from both common.js and a gadget, or from multiple user script entry points). To avoid double initialization (a disaster!), you can implement what is called a singleton pattern. Replace the void init(); with the following:

src/main.ts (modification)
// Name your init guard key here; just pick whatever you like.
const INIT_GUARD_KEY = "__YOUR_USER_SCRIPT_NAME_INIT_GUARD_KEY__";

// Fetch the global object
const globalState = globalThis as typeof globalThis & {
    [INIT_GUARD_KEY]?: boolean; // Type definitions.
};
if (globalState[INIT_GUARD_KEY]) {
    // Initialization skipped because it already ran.
    // You could add a `console.log()` here if you like.
} else {
    globalState[INIT_GUARD_KEY] = true;
    void init();
}

This guard ensures init() only runs once per page load, even if the script is included twice.


Now you have a solid starting point to write your code more freely.

But wait, we haven’t talked about how to convert it back to JavaScript yet. Well, the easiest way is to use esbuild. To do that, we are going to need an NPM environment.

Configuring the NPM environment

The first file we need is a package.json:

package.json
{
    "name": "your_user_script_name",
    "version": "1.0.0",
    "private": true,
    "description": "Describe your user script here in one sentence.",
    "type": "module",
    "scripts": {
        "build": "node build.mjs",
        "watch": "node build.mjs --watch",
        "lint": "eslint \"src/**/*.{ts,tsx}\" --no-inline-config"
    },
    "devDependencies": {
        "@types/jquery": "^3.5.33",
        "@types/node": "^24.10.1",
        "@typescript-eslint/eslint-plugin": "^8.13.0",
        "@typescript-eslint/parser": "^8.13.0",
        "esbuild": "^0.27.2",
        "eslint": "^9.14.0",
        "types-mediawiki": "^2.0.0"
    }
}

In this package.json example, I already added a few dependencies. Normally, that’s also what you need in a Wikipedia user script. If you need more, just:

npm install package-you-want-to-install

# If you don't need the package bundled to the final JavaScript:
npm install -D package-you-want-to-install

You may also notice that we have three scripts listed. The first two are referencing build.mjs, and the last one is an eslint command.

Let’s create a build.mjs:

build.mjs
import esbuild from 'esbuild';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const watch = process.argv.includes('--watch');
const pkgJson = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8'));

const createBuildOptions = () => {
    const timestamp = new Date().toISOString();
    return {
        entryPoints: [path.join(__dirname, 'src', 'main.ts')],
        outfile: path.join(__dirname, 'dist', 'bundled.js'),
        bundle: true,
        format: 'iife',
        charset: 'utf8',
        target: ['es2017'],
        minify: false,
        sourcemap: false,
        banner: {
            js: `// Your User Script Name or [[Document Link]]
// Release: ${pkgJson.version}
// Timestamp: ${timestamp}
// <nowiki>`
        },
        footer: { js: '// </nowiki>' },
        logLevel: 'info',
    };
};

(async () => {
    try {
        const buildOptions = createBuildOptions();
        if (watch) {
            const ctx = await esbuild.context(buildOptions);
            await ctx.watch();
            console.log('[Build] Watching for changes...');
        } else {
            await esbuild.build(buildOptions);
            console.log('[Build] Build complete');
        }
    } catch (e) {
        console.error('[Build] Build failed:', e);
        process.exit(1);
    }
})();

From this file, you might already notice the createBuildOptions() which is the main hub to the build options we want to configure. For example:

And create a tsconfig.json to configure TypeScript:

tsconfig.json
{
    "compilerOptions": {
        "target": "ES2017",
        "module": "ESNext",
        "moduleResolution": "Node",
        "lib": [
            "ES2017",
            "DOM"
        ],
        "esModuleInterop": true,
        "skipLibCheck": true,
        "strict": true,
        "forceConsistentCasingInFileNames": true,
        "types": [
            "@types/node",
            "types-mediawiki"
        ],
    },
    "include": [
        "src/**/*.ts",
        "src/**/*.tsx"
    ]
}

Linting via eslint

Next, the beautiful part of the TypeScript. You would (normally) want to configure linting. Here is an example using eslint to do linting. Create an eslint.config.cjs:

eslint.config.cjs
const tseslint = require('@typescript-eslint/eslint-plugin');
const tsParser = require('@typescript-eslint/parser');

const typeChecked = tseslint.configs['recommended-type-checked'] || {};
const typeCheckedRules = typeChecked.rules || {};

module.exports = [
    {
        ignores: ['dist/**', 'node_modules/**'],
    },

    // TypeScript
    {
        files: ['src/**/*.{ts,tsx}'],
        languageOptions: {
            parser: tsParser,
            parserOptions: {
                projectService: true,
                tsconfigRootDir: __dirname,
                ecmaVersion: 2021,
                sourceType: 'module',
            },
        },
        plugins: {
            '@typescript-eslint': tseslint,
        },
        rules: {
            ...typeCheckedRules,
            '@typescript-eslint/no-explicit-any': 'warn',

            // If you have your own tab preferences,
            // turn off these two rules:
            indent: 'off',
            'no-tabs': 'off',
        },
    },
];

Now, you can open your terminal and run linting.

npm run lint

I want to bundle the stylesheets as well

Oh, and the stylesheets. If you have one or more stylesheets, and you want them to be bundled into the same dist/bundled.js for the ease of copy-pasting, here is how. If you’d rather have it separately, skip this part.


First, you need to tell esbuild how to handle CSS imports (so it can bundle the file content into JavaScript). Add this parameter to the build options in build.mjs:

build.mjs (modification)
const createBuildOptions = () => {
    // ...
    return {
        // ...
        
        loader: {
            // Tell esbuild to load CSS files as text so they're bundled into the JS.
            '.css': 'text'
        },
    };
};

Then, define TypeScript types for CSS imports. Create a global.d.ts:

global.d.ts
// Allow importing CSS files as strings.
declare module '*.css' {
    const content: string;
    export default content;
}

And of course, add this global.d.ts to tsconfig.json:

tsconfig.json (modification)
{
    // ...
    "include": [
        // ...
        "global.d.ts"
    ]
}

Finally, the most important step, inject the CSS files to the script! For simplicity, let’s just put it in src/main.ts (you can choose another file should you prefer):

src/main.ts (modification)
// For example, I have one single CSS file `src/styles.css`.
import styles from './styles.css';
// You can add more should you need.

function injectStyles(css: string): void {
    if (!css) return;
    try {
        const styleEl = document.createElement('style');
        styleEl.appendChild(document.createTextNode(css));
        document.head.appendChild(styleEl);
    } catch {
        // Fallback for older environments.
        const div = document.createElement('div');
        div.innerHTML = `<style>${css}</style>`;
        const styleEl = div.firstElementChild as HTMLElement | null;
        if (styleEl) {
            document.head.appendChild(styleEl);
        }
    }
}

async function init(): Promise<void> {
    // ...
    
    // Put this next to `importDependencies()` or maybe inside it.
    
    // If you want to run automated tests on `src/main.ts`,
    // you might want this conditional to skip style injection.
    if (typeof document !== 'undefined') {
        // Inject `src/styles.css`.
        injectStyles(styles);
        // You can add more should you need.
    }
    
    // ...
}

Build the bundle!

Now you have it. By running the following command in the terminal, you would get a generated dist/bundled.js:

npm run build

If you want a quick smoke test, you can paste the dist/bundled.js code into your browser console on a wiki page and see whether it runs. For the real deployment, create your user script page with the bundled code, then load it from common.js or global.js if you like.

If you are rapidly developing and you want your dist/bundled.js to stay fresh whenever you modify the source code, use the watch command:

npm run watch

Afterword

At this point, your project should look like this:

your-user-script/
    src/
        main.ts
    dist/
        bundled.js
    package.json
    build.mjs
    tsconfig.json

That’s it. This is the setup I usually reach for when I want a Wikipedia user script that stays maintainable as it grows.

You can start small with just src/main.ts and a basic build, then iterate from there. Over time, you can add more entry points, split code into modules, and tighten lint rules without changing how you deploy the final script.


If you are migrating an existing JavaScript user script, a practical approach is to port it file by file while keeping behavior stable.

One way to do this (if your script is already split into modules) is to start with a “wrapper” TypeScript module that simply re-exports the existing JavaScript implementation. For example, keep foo.js as-is, create foo.ts, and put export * from './foo.js'; in it. Then update the rest of your code to import from foo.ts. At this point you have effectively “ported” one file to TypeScript without changing runtime behavior.

After that, you can gradually move the actual implementation from foo.js into foo.ts and add types as you go.

Depending on your module style, you may also need to keep import paths consistent (for example, always importing with an explicit extension like ./foo.js or ./foo.ts, or configuring your build to resolve both). The core idea is the same: add a TypeScript “facade” first, then migrate the internals in small, reviewable steps. Doing it in small steps makes it much easier to review changes, and it reduces the chance of breaking a working script.


Hope this guide helps you move from raw JavaScript to TypeScript smoothly, and enjoy the benefits of type checking along the way.

#Front-end