Repository: ROpdebee/mb-userscripts
Pull request #326 (2021-12-21 19:22:11)

build: Typescript overhaul
Something I complained of some time ago were the implicitly global declarations of libraries like jest, jQuery, etc. This leads to a couple of issues, like jest's `describe`, `expect`, and `it` being "available" in script source code. More recently, this is also causing issues in the E2E tests, where jest's declarations for `describe` etc conflict with WebdriverIO's.

As a solution, I split up the TS project into multiple, smaller subprojects. We now have 5 projects: `build`, `src/lib`, `src/mb_enhanced_cover_art_uploads` (+ one for each other script), `tests/unit`, and a global one that "imports" all of those. There are also some smaller reusable base projects which get extended by the concrete projects, these exist to share configurations. Finally, there's the `./tsconfig.non-composite.json` project which exists for tools which don't support project references (`tsconfig-paths` being one of them, which we use to map e.g. `@lib` to `src/lib` in `ts-node`).

I've also moved some `.d.ts` files closer to where they're relevant, as they didn't need to be `.d.ts` files at all.

"It works on my machine", but @kellnerd, because I changed stuff like `typeRoots` again, can you check that it still behaves as expected in your VSCode? Also, I'm not entirely convinced this is the best possible project structure, so any feedback would be much appreciated. For example, I'm not entirely convinced of having one project for the unit tests while the tested code is split into 3 separate projects, and including `test-utils` in that project as well.

codecov[bot]: # [Codecov](https://codecov.io/gh/ROpdebee/mb-userscripts/pull/326?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Ruben+Opdebeeck) Report
> Merging [#326](https://codecov.io/gh/ROpdebee/mb-userscripts/pull/326?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Ruben+Opdebeeck) (6385d57) into [main](https://codecov.io/gh/ROpdebee/mb-userscripts/commit/ae5ef9bf7f0811b2a57db3c7442c1e3dea641a8e?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Ruben+Opdebeeck) (ae5ef9b) will **not change** coverage.
> The diff coverage is `n/a`.

[![Impacted file tree graph](https://codecov.io/gh/ROpdebee/mb-userscripts/pull/326/graphs/tree.svg?width=650&height=150&src=pr&token=CjulzlICM8&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Ruben+Opdebeeck)](https://codecov.io/gh/ROpdebee/mb-userscripts/pull/326?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Ruben+Opdebeeck)

```diff
@@            Coverage Diff            @@
##              main      #326   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           41        41           
  Lines          894       894           
  Branches       155       155           
=========================================
  Hits           894       894           
```


| [Impacted Files](https://codecov.io/gh/ROpdebee/mb-userscripts/pull/326?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Ruben+Opdebeeck) | Coverage Δ | |
|---|---|---|
| [src/lib/MB/EditNote.ts](https://codecov.io/gh/ROpdebee/mb-userscripts/pull/326/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Ruben+Opdebeeck#diff-c3JjL2xpYi9NQi9FZGl0Tm90ZS50cw==) | `100.00% <ø> (ø)` | |
| [src/lib/util/blob.ts](https://codecov.io/gh/ROpdebee/mb-userscripts/pull/326/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Ruben+Opdebeeck#diff-c3JjL2xpYi91dGlsL2Jsb2IudHM=) | `100.00% <ø> (ø)` | |
| [src/lib/util/metadata.ts](https://codecov.io/gh/ROpdebee/mb-userscripts/pull/326/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Ruben+Opdebeeck#diff-c3JjL2xpYi91dGlsL21ldGFkYXRhLnRz) | `100.00% <ø> (ø)` | |
| [src/lib/util/xhr.ts](https://codecov.io/gh/ROpdebee/mb-userscripts/pull/326/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Ruben+Opdebeeck#diff-c3JjL2xpYi91dGlsL3hoci50cw==) | `100.00% <ø> (ø)` | |
| [src/mb\_enhanced\_cover\_art\_uploads/fetch.ts](https://codecov.io/gh/ROpdebee/mb-userscripts/pull/326/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Ruben+Opdebeeck#diff-c3JjL21iX2VuaGFuY2VkX2NvdmVyX2FydF91cGxvYWRzL2ZldGNoLnRz) | `100.00% <ø> (ø)` | |
| [src/mb\_enhanced\_cover\_art\_uploads/form.ts](https://codecov.io/gh/ROpdebee/mb-userscripts/pull/326/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Ruben+Opdebeeck#diff-c3JjL21iX2VuaGFuY2VkX2NvdmVyX2FydF91cGxvYWRzL2Zvcm0udHM=) | `100.00% <ø> (ø)` | |
| [src/mb\_enhanced\_cover\_art\_uploads/maximise.ts](https://codecov.io/gh/ROpdebee/mb-userscripts/pull/326/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Ruben+Opdebeeck#diff-c3JjL21iX2VuaGFuY2VkX2NvdmVyX2FydF91cGxvYWRzL21heGltaXNlLnRz) | `100.00% <ø> (ø)` | |
| [.../mb\_enhanced\_cover\_art\_uploads/providers/amazon.ts](https://codecov.io/gh/ROpdebee/mb-userscripts/pull/326/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Ruben+Opdebeeck#diff-c3JjL21iX2VuaGFuY2VkX2NvdmVyX2FydF91cGxvYWRzL3Byb3ZpZGVycy9hbWF6b24udHM=) | `100.00% <ø> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/ROpdebee/mb-userscripts/pull/326?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Ruben+Opdebeeck).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Ruben+Opdebeeck)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/ROpdebee/mb-userscripts/pull/326?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Ruben+Opdebeeck). Last update [ae5ef9b...6385d57](https://codecov.io/gh/ROpdebee/mb-userscripts/pull/326?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Ruben+Opdebeeck). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Ruben+Opdebeeck).

ROpdebee: I've consolidated the non-leaf projects into `configs`, containing both the base configurations and the two glue projects (with a couple of comments and a README because I'll definitely forget 90% of the intricacies of a multi-project TS setup). Also simplified a bunch of the leaf projects by moving more options to the base configs. "Fixed" the `eslintrc.cjs` issue by ignoring it (see comment above), but we're now typechecking the tool config files too, and also linting the other configs.

The composite project (now the only `tsconfig.json` in the root) is used for everything except ESLint. ESLint uses the non-composite one (similar to the original project) because otherwise `@typescript-eslint` needs to run TypeScript for each referenced project multiple times, which is slow and can consume too much memory at times.

I'd say it's better than what it was before, but it still isn't great. At least it hides most of the ugly parts in a separate directory 😅

I should perhaps squash some of the commits in this PR prior to merging to prevent the git history having `tsconfig.json` files that only existed for a day.

Patch:
diff --git a/.eslintignore b/.eslintignore
index 6ff3fa09..47b6e7bf 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -1,5 +1,9 @@
-dist/**
-coverage/*
+/dist/**
+/coverage/*
 # Ignore top-level scripts for now
-mb_*.js
-lib/*
+/mb_*.js
+/lib/*
+/node_modules/**
+# ESLint gets really slow when it needs to lint its own configuration. Not sure
+# whether it ever even halts. This covers both .eslintrc.cjs files in the repo.
+.eslintrc.cjs
diff --git a/.eslintrc.cjs b/.eslintrc.cjs
index 59d799d9..8da4ddd8 100644
--- a/.eslintrc.cjs
+++ b/.eslintrc.cjs
@@ -18,7 +18,8 @@ module.exports = {
     parserOptions: {
         ecmaVersion: 12,
         sourceType: 'module',
-        project: './tsconfig.json',
+        project: 'configs/tsconfig.glue-eslint.json',
+        extraFileExtensions: ['.cjs'],
     },
     rules: {
         'indent': [
@@ -55,7 +56,7 @@ module.exports = {
             files: ['*.ts', '*.tsx'],
             parserOptions: {
                 parser: '@typescript-eslint/parser',
-                project: './tsconfig.json',
+                project: 'configs/tsconfig.glue-eslint.json',
             },
             rules: {
                 '@typescript-eslint/array-type': ['error', {
diff --git a/.gitignore b/.gitignore
index 45e1d134..7d0687b9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@ dist/
 .DS_Store
 coverage/
 jest_html_reporters.html
+.tsc-build
diff --git a/build/plugin-userscript.ts b/build/plugin-userscript.ts
index 7dda7441..14b4d517 100644
--- a/build/plugin-userscript.ts
+++ b/build/plugin-userscript.ts
@@ -1,11 +1,13 @@
-import { filterNonNull } from '../src/lib/util/array';
+import { filterNonNull } from '@lib/util/array';
 import fs from 'fs';
 import path from 'path';
+// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/34960
+import { URL } from 'url';
 
 import type { Plugin } from 'rollup';
 import type { PackageJson } from 'type-fest';
 
-import type { UserscriptMetadata, AllUserscriptMetadata } from 'userscriptMetadata';
+import type { UserscriptMetadata, AllUserscriptMetadata } from '@lib/util/metadata';
 
 interface UserscriptOptions {
     userscriptName: string;
diff --git a/build/rollup.ts b/build/rollup.ts
index 1d797ba9..939839c6 100644
--- a/build/rollup.ts
+++ b/build/rollup.ts
@@ -43,11 +43,13 @@ const TERSER_OPTIONS: MinifyOptions = {
     module: false,
 };
 
+const SKIP_PACKAGES = new Set(['lib', 'types']);
+
 export async function buildUserscripts(version: string, outputDir: string = OUTPUT_DIR): Promise<void> {
     const userscriptDirs = await fs.promises.readdir('./src');
 
     await Promise.all(userscriptDirs
-        .filter((name) => name !== 'lib' && !name.startsWith('.') && fs.statSync(path.resolve('./src', name)).isDirectory())
+        .filter((name) => !SKIP_PACKAGES.has(name) && !name.startsWith('.') && fs.statSync(path.resolve('./src', name)).isDirectory())
         .map((userscriptName) => buildUserscript(userscriptName, version, outputDir)));
 }
 
diff --git a/build/tsconfig.json b/build/tsconfig.json
new file mode 100644
index 00000000..65cce669
--- /dev/null
+++ b/build/tsconfig.json
@@ -0,0 +1,11 @@
+{
+    "extends": "../configs/tsconfig.base-node",
+    "include": ["**/*", "../src/*/meta.ts"],
+    "compilerOptions": {
+        // For rollup-plugin-consts
+        "skipLibCheck": true,
+    },
+    "references": [
+        { "path": "../src/lib" },
+    ]
+}
diff --git a/configs/README.md b/configs/README.md
new file mode 100644
index 00000000..22d357b4
--- /dev/null
+++ b/configs/README.md
@@ -0,0 +1,68 @@
+# Configurations
+
+## Typescript
+
+This project uses multiple TypeScript subprojects to enforce separation of
+source code, utility code, and tests. For example, we use a separate TS project
+for `src/lib` to enforce that `lib` code cannot depend on script code.
+Similarly, we separate unit test code from source code to ensure we cannot
+accidentally import test code into script code.
+
+The main benefit of this separation is that we can isolate global ambient type
+declarations for each subproject. For example, we can globally include the
+type declarations for `jest` (e.g. `describe`, `it`, and `expect`) in unit
+test code, without making it available to script source code, where `jest` will
+not be available. This also prevents code completion suggestions where they are
+not applicable. This can also help to prevent conflicting type declarations,
+e.g. two separate testing frameworks that both provide a global `describe`
+function with conflicting signatures.
+
+The main drawback of this approach is the large number of `tsconfig.json` files
+it creates. Each separate subproject contains its own `tsconfig.json` with its
+specific settings, such as globally available type declarations, path mappings
+(e.g. from `@lib` to `src/lib` for scripts), etc. This directory aggregates a
+number of auxiliary `tsconfig.json` files, falling into one of two categories.
+
+### 1. Extensible base configurations
+
+These define default configuration values for certain types of projects and can
+be extended via `tsconfig`'s `extends` value. We define a number of these base
+configurations:
+
+* [`tsconfig.base`](./tsconfig.base.json) is the core configuration defining
+  all default options for all subprojects. It is further split into various
+  extended reusable configurations for certain types of subprojects. _Note: The
+  default configuration assumes that the extending projects will be composite
+  projects. Certain options may need to be overridden for non-composite,
+  non-emitted projects._
+* [`tsconfig.base-composite`](./tsconfig.base-composite.json) extends the above
+  purely to disable loading ambient declarations by default, thereby forcing
+  each subproject to define its default ambient declarations explicitly. This
+  is not done in `tsconfig.base` since it is also extended by non-composite
+  projects which may need to load all ambient declarations (e.g.
+  `tsconfig.glue-eslint`, described below).
+* [`tsconfig.base-web`](./tsconfig.base-web.json) defines the default
+  configuration for source code targetting browsers, such as `src/lib` and all
+  userscript sources. It sets some default ES library options and makes sure
+  necessary ambient declarations are available.
+* [`tsconfig.base-node`](./tsconfig.base-node.json) defines the default
+  configuration for source code to be run on NodeJS, like the build scripts and
+  unit tests.
+* [`tsconfig.base-unit`](./tsconfig.base-unit.json) defines the default
+  configuration for unit test code, e.g. including `jest`'s global declarations
+  by default.
+
+### 2. Aggregate/glue projects
+
+These serve to aggregate the different subprojects so we can compile/lint all
+sources with one tool invocation.
+
+* [`tsconfig.glue-composite`](./tsconfig.glue-composite.json) is the main
+  aggregate project for use by TypeScript itself, i.e. the `tsc` compiler, for
+  type checking. This is linked to `tsconfig.json` in the project root.
+* [`tsconfig.glue-eslint`](./tsconfig.glue-eslint.json) is a configuration file
+  provided for tools which do not properly support project references, like
+  `@typescript-eslint`. It defines a standard, non-composite project which
+  simply includes all files in the compilation, without isolating the
+  subprojects.
+
diff --git a/configs/tsconfig.base-composite.json b/configs/tsconfig.base-composite.json
new file mode 100644
index 00000000..350cdace
--- /dev/null
+++ b/configs/tsconfig.base-composite.json
@@ -0,0 +1,28 @@
+// Base compiler options for use in composite subproject.
+// Subprojects will likely need to define the following properties:
+// * "types" in case ambient declarations of certain libraries need to be
+//   loaded by default for the subproject. The base configuration defines this
+//   as `[]`, i.e. no default ambient declarations. Note: Values defined in
+//   extension projects will NOT merge with those of the base configuration!
+{
+    "extends": "./tsconfig.base",
+    "compilerOptions": {
+        // By default, include no ambient declarations. Each subproject needs
+        // to explicitly define them, but we're providing an empty default to
+        // ensure there are no implicitly available declarations.
+        "types": [],
+
+        "composite": true,
+        // We're not compiling with `tsc`, but since we use multiple projects
+        // with project references, `tsc` needs declarations of referenced
+        // projects, which it needs to emit somewhere. Consolidate all of these
+        // in a separate directory which we'll add to `.gitignore`.
+        "emitDeclarationOnly": true,
+        "noEmit": false,
+        "outDir": "../.tsc-build",
+        // Set the root dir here so the compiled output keeps its original
+        // directory structure. This prevents `tsc` complaining that it will
+        // overwrite files.
+        "rootDir": "../",
+    }
+}
diff --git a/configs/tsconfig.base-node.json b/configs/tsconfig.base-node.json
new file mode 100644
index 00000000..13e64395
--- /dev/null
+++ b/configs/tsconfig.base-node.json
@@ -0,0 +1,10 @@
+// Basic configuration of code running in a NodeJS environment.
+{
+    "extends": "./tsconfig.base-composite",
+    "compilerOptions": {
+        "types": ["node"],
+        // Override "lib" to ensure "DOM" isn't included, as DOM isn't
+        // available in a standard node runtime.
+        "lib": ["ESNext"],
+    }
+}
diff --git a/configs/tsconfig.base-unit.json b/configs/tsconfig.base-unit.json
new file mode 100644
index 00000000..b6e59f6b
--- /dev/null
+++ b/configs/tsconfig.base-unit.json
@@ -0,0 +1,24 @@
+// Basic configuration for unit tests
+{
+    "extends": "./tsconfig.base-node",
+    "compilerOptions": {
+        // Include `jest`, `jest-extended` (for more `expect` methods), and
+        // `node` ambient declarations by default for all unit test projects.
+        // We're also mocking the GM functions automatically, so include the
+        // Greasemonkey ambient declarations automatically so they can be used.
+        // We need to explicitly set `node` even though it's specified in the
+        // `tsconfig-node-base` configuration, as this definition will fully
+        // overwrite it.
+        "types": ["jest", "jest-extended", "greasemonkey", "node"],
+        // Also include the DOM lib again, as we use `jest-environment-jsdom`,
+        // so browser APIs will be available in unit tests. Although we could
+        // use `@types/jsdom`, it leads to conflicts with `node-fetch`'s
+        // `Headers` type.
+        "lib": ["ESNext", "DOM", "DOM.Iterable"],
+        "paths": {
+            "@lib/*": ["src/lib/*"],
+            "@src/*": ["src/*"],
+            "@test-utils/*": ["tests/unit/utils/*"]
+        }
+    }
+}
diff --git a/configs/tsconfig.base-web.json b/configs/tsconfig.base-web.json
new file mode 100644
index 00000000..140d695f
--- /dev/null
+++ b/configs/tsconfig.base-web.json
@@ -0,0 +1,17 @@
+// Basic configuration of code for the web, i.e. lib and scripts.
+{
+    "extends": "./tsconfig.base-composite",
+    // Explicitly include the declarations for injected constants and CSS
+    // imports so they are available in all scripts. We do this via "files" as
+    // "types" is only meant for declarations in a strict directory structure,
+    // which these do not adhere to. Note that if an extending project supplies
+    // its own "files" key and needs these declarations, it should specify them
+    // explicitly, as its configuration will overwrite these values.
+    "files": ["../src/types/rollup-consts.d.ts", "../src/types/css.d.ts"],
+    "compilerOptions": {
+        // Include ambient declarations by default for stuff that is available
+        // by default in userscripts, like the GM API and JSX types provided by
+        // nativejsx, which we use during transpilation.
+        "types": ["greasemonkey", "nativejsx/types/jsx"],
+    }
+}
diff --git a/configs/tsconfig.base.json b/configs/tsconfig.base.json
new file mode 100644
index 00000000..f628183d
--- /dev/null
+++ b/configs/tsconfig.base.json
@@ -0,0 +1,60 @@
+// Base compiler options for use in all projects.
+// Subprojects will likely need to define the following properties:
+// * "extends": "./tsconfig-base.json" to extend this configuration
+// * "files", "include", and "exclude" to include/exclude files from the
+//   configuration
+// * For subprojects depending on other projects: "references" (this cannot be
+//   inherited through "extends"!)
+// * For extensions which are not meant to be composite projects: "composite"
+//   should be overridden to `false` and `outDir`, `emitDeclarationsOnly`, etc.
+//   may need to be overridden too, depending on the purpose of the extension.
+{
+    "compilerOptions": {
+        // Compilation target
+        "target": "ESNext",
+        "module": "ESNext",
+
+        // Enable strict type checks
+        "strict": true,
+        "noImplicitReturns": true,
+        "noImplicitOverride": true,
+
+        // Also check JS code, although there's very little of it actually
+        // included.
+        "checkJs": true,
+
+        // JSX will be transpiled with nativejsx.
+        "jsx": "preserve",
+
+        // We use ES modules, but run certain scripts in node.
+        "moduleResolution": "node",
+        "esModuleInterop": true,
+
+        // Make errors and warnings prettier
+        "pretty": true,
+
+        // Base URL is used to resolve non-absolute module names if they don't
+        // start with ./ or ../
+        "baseUrl": "../",
+
+        // Path mapping as shorthands to access certain imports. Not all of
+        // these will make sense for each subproject, but we set most of them
+        // here to centralise more configurations. If a mapping doesn't make
+        // sense for a subproject, TS will complain anyway.
+        "paths": {
+            "@lib/*": ["src/lib/*"],
+            "@src/*": ["src/*"],
+        }
+    },
+    "ts-node": {
+        // For ts-node, we need to set the compiled output to CommonJS because
+        // we're running the code in node. Although node has ESM support, it's
+        // unstable and prone to internal changes.
+        "compilerOptions": {
+            "module": "CommonJS",
+        },
+    },
+
+    // We don't set any "include", "files", or "exclude" options here, as each
+    // subproject will define these separately.
+}
diff --git a/configs/tsconfig.glue-composite.json b/configs/tsconfig.glue-composite.json
new file mode 120000
index 00000000..4ec6ff6a
--- /dev/null
+++ b/configs/tsconfig.glue-composite.json
@@ -0,0 +1,1 @@
+../tsconfig.json
\ No newline at end of file
diff --git a/configs/tsconfig.glue-eslint.json b/configs/tsconfig.glue-eslint.json
new file mode 100644
index 00000000..ac29a5b3
--- /dev/null
+++ b/configs/tsconfig.glue-eslint.json
@@ -0,0 +1,35 @@
+// Aggregate project for tools which don't properly support project references,
+// like `@typescript-eslint`. Although `@typescript-eslint` does sort of
+// support project references if we manually list all `tsconfig.json` files to
+// be included in the linting output, it's very slow and has numerous other
+// issues.
+{
+    "extends": "./tsconfig.base",
+    // Explicitly include all files in the whole project so the tool using this
+    // configuration can use all of them.
+    "include": ["../**/*"],
+    // Exclude files we don't want to consider.
+    "exclude": [
+        "../node_modules/**",
+        "../mb_*.user.js",
+        "../dist/**",
+        "../lib/**",
+        "../coverage/**",
+    ],
+    "compilerOptions": {
+        // All path aliases set up in the composite projects need to be merged
+        // here so they can be used by the tools.
+        "paths": {
+            "@lib/*": ["src/lib/*"],
+            "@src/*": ["src/*"],
+            "@test-utils/*": ["tests/unit/utils/*"]
+        },
+        // Same for type roots to ensure all of our custom ambient declarations
+        // are loaded.
+        "typeRoots": [
+            "../node_modules/@types",
+            "../src/types",
+            "../tests/types",
+        ]
+    }
+}
diff --git a/jest.config.cjs b/jest.config.cjs
index 356c9ea2..e1cb835d 100644
--- a/jest.config.cjs
+++ b/jest.config.cjs
@@ -6,13 +6,13 @@ module.exports = {
     testEnvironment: 'setup-polly-jest/jest-environment-jsdom',
     moduleNameMapper: {
         '^@lib/(.*)$': '<rootDir>/src/lib/$1',
-        '^@test-utils/(.*)$': '<rootDir>/tests/utils/$1',
+        '^@test-utils/(.*)$': '<rootDir>/tests/unit/utils/$1',
         '^@src/(.*)$': '<rootDir>/src/$1',
     },
     setupFilesAfterEnv: [
         'jest-extended/all',
-        './tests/utils/setup-gm-mocks.ts',
-        './tests/utils/fix-jsdom-env.ts',
+        './tests/unit/utils/setup-gm-mocks.ts',
+        './tests/unit/utils/fix-jsdom-env.ts',
     ],
     collectCoverageFrom: [
         'src/**/*.{js,ts}',
diff --git a/mb_qol_seed_recording_disambiguation.user.js b/mb_qol_seed_recording_disambiguation.user.js
index c0056b30..5c013144 100644
--- a/mb_qol_seed_recording_disambiguation.user.js
+++ b/mb_qol_seed_recording_disambiguation.user.js
@@ -1,6 +1,6 @@
 // ==UserScript==
 // @name         MB: QoL: Seed the batch recording comments script
-// @version      2021.12.18
+// @version      2021.12.23
 // @description  Seed the recording comments for the batch recording comments userscripts with live and DJ-mix data.
 // @author       ROpdebee
 // @license      MIT; https://opensource.org/licenses/MIT
@@ -129,7 +129,10 @@ function formatRecordingBareArea(area) {
     else country = country.name;
 
     state = state && state.primary_code.split('-')[1];
-    city = city && city.name;
+    // Exception for Washington D.C., it's set as a subdivision in MB, leading
+    // to comments for venues in DC to be "live, ...: <venue>, DC, USA" without
+    // the city name.
+    city = (city && city.name) || (state === 'DC' && 'Washington');
     let parts = [city, state, country].filter((part) => part);
     return parts.join(', ');
 }
diff --git a/package-lock.json b/package-lock.json
index 085c20ca..6ecccb45 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,7 @@
       "version": "1.0.0",
       "license": "MIT; https://opensource.org/licenses/MIT",
       "dependencies": {
-        "core-js": "3.20.0",
+        "core-js": "3.20.1",
         "jquery": "3.6.0",
         "p-throttle": "5.0.0",
         "ts-custom-error": "3.2.0"
@@ -33,17 +33,18 @@
         "@rollup/plugin-virtual": "2.0.3",
         "@rollup/pluginutils": "4.1.2",
         "@types/babel__core": "7.1.17",
+        "@types/confusing-browser-globals": "^1.0.0",
         "@types/greasemonkey": "4.0.2",
         "@types/jest": "27.0.3",
-        "@types/jest-when": "2.7.3",
-        "@types/jquery": "3.5.10",
+        "@types/jest-when": "2.7.4",
+        "@types/jquery": "3.5.11",
         "@types/postcss-preset-env": "6.7.3",
         "@types/rollup__plugin-virtual": "2.0.1",
         "@types/rollup-plugin-progress": "1.1.1",
         "@types/setup-polly-jest": "0.5.1",
-        "@typescript-eslint/eslint-plugin": "5.7.0",
-        "@typescript-eslint/parser": "5.7.0",
-        "babel-plugin-transform-async-to-promises": "0.8.16",
+        "@typescript-eslint/eslint-plugin": "5.8.0",
+        "@typescript-eslint/parser": "5.8.0",
+        "babel-plugin-transform-async-to-promises": "0.8.17",
         "confusing-browser-globals": "1.0.11",
         "eslint": "8.5.0",
         "eslint-plugin-eslint-comments": "3.2.0",
@@ -57,17 +58,18 @@
         "nativejsx": "github:ROpdebee/nativejsx",
         "node-fetch": "3.0.0",
         "postcss": "8.4.5",
-        "postcss-preset-env": "7.0.2",
-        "rollup": "2.61.1",
+        "postcss-preset-env": "7.1.0",
+        "rollup": "2.62.0",
         "rollup-plugin-consts": "1.0.2",
         "rollup-plugin-postcss": "4.0.2",
         "rollup-plugin-progress": "1.1.2",
-        "sass": "1.45.0",
+        "sass": "1.45.1",
         "setup-polly-jest": "0.10.0",
         "simple-git": "2.48.0",
         "terser": "5.10.0",
         "ts-jest": "27.1.2",
         "ts-node": "10.4.0",
+        "tsconfig-paths": "3.12.0",
         "type-fest": "2.8.0",
         "typescript": "4.5.4",
         "warcio": "1.5.0"
@@ -3185,6 +3187,12 @@
         "browserslist": "*"
       }
     },
+    "node_modules/@types/confusing-browser-globals": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@types/confusing-browser-globals/-/confusing-browser-globals-1.0.0.tgz",
+      "integrity": "sha512-2RQF2SJW3pAQgkHtlIefyxiRkdlBwXxqNiiDDoPyaR9R0SqIujHoP6ceLSAE95EKd7CCLV3F/n4ohOAMi5edpQ==",
+      "dev": true
+    },
     "node_modules/@types/estree": {
       "version": "0.0.50",
       "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.50.tgz",
@@ -3241,18 +3249,18 @@
       }
     },
     "node_modules/@types/jest-when": {
-      "version": "2.7.3",
-      "resolved": "https://registry.npmjs.org/@types/jest-when/-/jest-when-2.7.3.tgz",
-      "integrity": "sha512-BdDZnKj3ZO1VsRlJFyRx6yLa0hG9++qetnBKhESjCGRVAm6S4aaKXXLm9xGFmtAQpzuMC44wxhvkG2cl6axvyQ==",
+      "version": "2.7.4",
+      "resolved": "https://registry.npmjs.org/@types/jest-when/-/jest-when-2.7.4.tgz",
+      "integrity": "sha512-2OC69oyaD33tmSaOjtxvy7ZpBO85OWIw1AbpWVziL4bek5mr795H59qK5EKDpp4dLhtH1QIs54tXpoHEb2mE/A==",
       "dev": true,
       "dependencies": {
         "@types/jest": "*"
       }
     },
     "node_modules/@types/jquery": {
-      "version": "3.5.10",
-      "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.10.tgz",
-      "integrity": "sha512-w2qT5DFikh5TXrW/aOaCvCP8g2MMAfPXo3oeHR9v7dRuAZhu38PUWEkYrL4e9VRTcgZE4yER21AHndgpq2QPTQ==",
+      "version": "3.5.11",
+      "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.11.tgz",
+      "integrity": "sha512-lYZGdfOtUa0XFjIATQgiogqeTY5PNNMOmp3Jq48ghmJALL8t/IqABRqlEwdHfuUdA8iIE1uGD1HoI4a7Tiy6OA==",
       "dev": true,
       "dependencies": {
         "@types/sizzle": "*"
@@ -3264,6 +3272,12 @@
       "integrity": "sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==",
       "dev": true
     },
+    "node_modules/@types/json5": {
+      "version": "0.0.29",
+      "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
+      "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=",
+      "dev": true
+    },
     "node_modules/@types/node": {
       "version": "16.11.12",
       "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.12.tgz",
@@ -3463,13 +3477,13 @@
       "dev": true
     },
     "node_modules/@typescript-eslint/eslint-plugin": {
-      "version": "5.7.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.7.0.tgz",
-      "integrity": "sha512-8RTGBpNn5a9M628wBPrCbJ+v3YTEOE2qeZb7TDkGKTDXSj36KGRg92SpFFaR/0S3rSXQxM0Og/kV9EyadsYSBg==",
+      "version": "5.8.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.8.0.tgz",
+      "integrity": "sha512-spu1UW7QuBn0nJ6+psnfCc3iVoQAifjKORgBngKOmC8U/1tbe2YJMzYQqDGYB4JCss7L8+RM2kKLb1B1Aw9BNA==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/experimental-utils": "5.7.0",
-        "@typescript-eslint/scope-manager": "5.7.0",
+        "@typescript-eslint/experimental-utils": "5.8.0",
+        "@typescript-eslint/scope-manager": "5.8.0",
         "debug": "^4.3.2",
         "functional-red-black-tree": "^1.0.1",
         "ignore": "^5.1.8",
@@ -3510,15 +3524,15 @@
       }
     },
     "node_modules/@typescript-eslint/experimental-utils": {
-      "version": "5.7.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.7.0.tgz",
-      "integrity": "sha512-u57eZ5FbEpzN5kSjmVrSesovWslH2ZyNPnaXQMXWgH57d5+EVHEt76W75vVuI9qKZ5BMDKNfRN+pxcPEjQjb2A==",
+      "version": "5.8.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.8.0.tgz",
+      "integrity": "sha512-KN5FvNH71bhZ8fKtL+lhW7bjm7cxs1nt+hrDZWIqb6ViCffQcWyLunGrgvISgkRojIDcXIsH+xlFfI4RCDA0xA==",
       "dev": true,
       "dependencies": {
         "@types/json-schema": "^7.0.9",
-        "@typescript-eslint/scope-manager": "5.7.0",
-        "@typescript-eslint/types": "5.7.0",
-        "@typescript-eslint/typescript-estree": "5.7.0",
+        "@typescript-eslint/scope-manager": "5.8.0",
+        "@typescript-eslint/types": "5.8.0",
+        "@typescript-eslint/typescript-estree": "5.8.0",
         "eslint-scope": "^5.1.1",
         "eslint-utils": "^3.0.0"
       },
@@ -3530,18 +3544,18 @@
         "url": "https://opencollective.com/typescript-eslint"
       },
       "peerDependencies": {
-        "eslint": "*"
+        "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0"
       }
     },
     "node_modules/@typescript-eslint/parser": {
-      "version": "5.7.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.7.0.tgz",
-      "integrity": "sha512-m/gWCCcS4jXw6vkrPQ1BjZ1vomP01PArgzvauBqzsoZ3urLbsRChexB8/YV8z9HwE3qlJM35FxfKZ1nfP/4x8g==",
+      "version": "5.8.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.8.0.tgz",
+      "integrity": "sha512-Gleacp/ZhRtJRYs5/T8KQR3pAQjQI89Dn/k+OzyCKOsLiZH2/Vh60cFBTnFsHNI6WAD+lNUo/xGZ4NeA5u0Ipw==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/scope-manager": "5.7.0",
-        "@typescript-eslint/types": "5.7.0",
-        "@typescript-eslint/typescript-estree": "5.7.0",
+        "@typescript-eslint/scope-manager": "5.8.0",
+        "@typescript-eslint/types": "5.8.0",
+        "@typescript-eslint/typescript-estree": "5.8.0",
         "debug": "^4.3.2"
       },
       "engines": {
@@ -3561,13 +3575,13 @@
       }
     },
     "node_modules/@typescript-eslint/scope-manager": {
-      "version": "5.7.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.7.0.tgz",
-      "integrity": "sha512-7mxR520DGq5F7sSSgM0HSSMJ+TFUymOeFRMfUfGFAVBv8BR+Jv1vHgAouYUvWRZeszVBJlLcc9fDdktxb5kmxA==",
+      "version": "5.8.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.8.0.tgz",
+      "integrity": "sha512-x82CYJsLOjPCDuFFEbS6e7K1QEWj7u5Wk1alw8A+gnJiYwNnDJk0ib6PCegbaPMjrfBvFKa7SxE3EOnnIQz2Gg==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "5.7.0",
-        "@typescript-eslint/visitor-keys": "5.7.0"
+        "@typescript-eslint/types": "5.8.0",
+        "@typescript-eslint/visitor-keys": "5.8.0"
       },
       "engines": {
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@@ -3578,9 +3592,9 @@
       }
     },
     "node_modules/@typescript-eslint/types": {
-      "version": "5.7.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.7.0.tgz",
-      "integrity": "sha512-5AeYIF5p2kAneIpnLFve8g50VyAjq7udM7ApZZ9JYjdPjkz0LvODfuSHIDUVnIuUoxafoWzpFyU7Sqbxgi79mA==",
+      "version": "5.8.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.8.0.tgz",
+      "integrity": "sha512-LdCYOqeqZWqCMOmwFnum6YfW9F3nKuxJiR84CdIRN5nfHJ7gyvGpXWqL/AaW0k3Po0+wm93ARAsOdzlZDPCcXg==",
       "dev": true,
       "engines": {
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@@ -3591,13 +3605,13 @@
       }
     },
     "node_modules/@typescript-eslint/typescript-estree": {
-      "version": "5.7.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.7.0.tgz",
-      "integrity": "sha512-aO1Ql+izMrTnPj5aFFlEJkpD4jRqC4Gwhygu2oHK2wfVQpmOPbyDSveJ+r/NQo+PWV43M6uEAeLVbTi09dFLhg==",
+      "version": "5.8.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.8.0.tgz",
+      "integrity": "sha512-srfeZ3URdEcUsSLbkOFqS7WoxOqn8JNil2NSLO9O+I2/Uyc85+UlfpEvQHIpj5dVts7KKOZnftoJD/Fdv0L7nQ==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "5.7.0",
-        "@typescript-eslint/visitor-keys": "5.7.0",
+        "@typescript-eslint/types": "5.8.0",
+        "@typescript-eslint/visitor-keys": "5.8.0",
         "debug": "^4.3.2",
         "globby": "^11.0.4",
         "is-glob": "^4.0.3",
@@ -3633,12 +3647,12 @@
       }
     },
     "node_modules/@typescript-eslint/visitor-keys": {
-      "version": "5.7.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.7.0.tgz",
-      "integrity": "sha512-hdohahZ4lTFcglZSJ3DGdzxQHBSxsLVqHzkiOmKi7xVAWC4y2c1bIMKmPJSrA4aOEoRUPOKQ87Y/taC7yVHpFg==",
+      "version": "5.8.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.8.0.tgz",
+      "integrity": "sha512-+HDIGOEMnqbxdAHegxvnOqESUH6RWFRR2b8qxP1W9CZnnYh4Usz6MBL+2KMAgPk/P0o9c1HqnYtwzVH6GTIqug==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "5.7.0",
+        "@typescript-eslint/types": "5.8.0",
         "eslint-visitor-keys": "^3.0.0"
       },
       "engines": {
@@ -4095,9 +4109,9 @@
       }
     },
     "node_modules/babel-plugin-transform-async-to-promises": {
-      "version": "0.8.16",
-      "resolved": "https://registry.npmjs.org/babel-plugin-transform-async-to-promises/-/babel-plugin-transform-async-to-promises-0.8.16.tgz",
-      "integrity": "sha512-mm0UMekJVOTuIlkGIEH1fpnExyTIIJ2/L5ixTTI1zAVdMpbG7Em3LiS92p0TCWKiMkHhYYinz/zn2D/BPPJaxA==",
+      "version": "0.8.17",
+      "resolved": "https://registry.npmjs.org/babel-plugin-transform-async-to-promises/-/babel-plugin-transform-async-to-promises-0.8.17.tgz",
+      "integrity": "sha512-MAijrdLcXjZBkET+n4GA9g96Wt5AibTHRKHWU3Zuuenvkge3QgrYiYfXsNcHZbQzJ6XO73l4MRMDNP+ZE77u5A==",
       "dev": true
     },
     "node_modules/babel-preset-current-node-syntax": {
@@ -4315,9 +4329,9 @@
       }
     },
     "node_modules/caniuse-lite": {
-      "version": "1.0.30001289",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001289.tgz",
-      "integrity": "sha512-hV6x4IfrYViN8cJbGFVbjD7KCrhS/O7wfDgvevYRanJ/IN+hhxpTcXXqaxy3CzPNFe5rlqdimdEB/k7H0YzxHg==",
+      "version": "1.0.30001292",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001292.tgz",
+      "integrity": "sha512-jnT4Tq0Q4ma+6nncYQVe7d73kmDmE9C3OGTx3MvW7lBM/eY1S1DZTMBON7dqV481RhNiS5OxD7k9JQvmDOTirw==",
       "dev": true,
       "funding": {
         "type": "opencollective",
@@ -4507,9 +4521,9 @@
       }
     },
     "node_modules/core-js": {
-      "version": "3.20.0",
-      "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.20.0.tgz",
-      "integrity": "sha512-KjbKU7UEfg4YPpskMtMXPhUKn7m/1OdTHTVjy09ScR2LVaoUXe8Jh0UdvN2EKUR6iKTJph52SJP95mAB0MnVLQ==",
+      "version": "3.20.1",
+      "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.20.1.tgz",
+      "integrity": "sha512-btdpStYFQScnNVQ5slVcr858KP0YWYjV16eGJQw8Gg7CWtu/2qNvIM3qVRIR3n1pK2R9NNOrTevbvAYxajwEjg==",
       "hasInstallScript": true,
       "funding": {
         "type": "opencollective",
@@ -10573,14 +10587,14 @@
       }
     },
     "node_modules/postcss-preset-env": {
-      "version": "7.0.2",
-      "resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-7.0.2.tgz",
-      "integrity": "sha512-xgmkS+H2xhjPQWewnH4ViKBCHIZ15FfpldGZuBFaBIi/iipxHTLnBHKtkCp1L531SdNEPwo9md2psjOU1G77lw==",
+      "version": "7.1.0",
+      "resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-7.1.0.tgz",
+      "integrity": "sha512-YZI44uxVJQQu18TeHEoDtdLsjKLQpCpzt/4FAzadIcnNYwvKSQqvxaHE6uWobEWQrcfU42zIddMPUKgYQxZs8g==",
       "dev": true,
       "dependencies": {
         "autoprefixer": "^10.4.0",
         "browserslist": "^4.19.1",
-        "caniuse-lite": "^1.0.30001287",
+        "caniuse-lite": "^1.0.30001291",
         "css-blank-pseudo": "^3.0.0",
         "css-has-pseudo": "^3.0.0",
         "css-prefers-color-scheme": "^6.0.0",
@@ -11156,9 +11170,9 @@
       }
     },
     "node_modules/rollup": {
-      "version": "2.61.1",
-      "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.61.1.tgz",
-      "integrity": "sha512-BbTXlEvB8d+XFbK/7E5doIcRtxWPRiqr0eb5vQ0+2paMM04Ye4PZY5nHOQef2ix24l/L0SpLd5hwcH15QHPdvA==",
+      "version": "2.62.0",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.62.0.tgz",
+      "integrity": "sha512-cJEQq2gwB0GWMD3rYImefQTSjrPYaC6s4J9pYqnstVLJ1CHa/aZNVkD4Epuvg4iLeMA4KRiq7UM7awKK6j7jcw==",
       "dev": true,
       "bin": {
         "rollup": "dist/bin/rollup"
@@ -11348,9 +11362,9 @@
       "dev": true
     },
     "node_modules/sass": {
-      "version": "1.45.0",
-      "resolved": "https://registry.npmjs.org/sass/-/sass-1.45.0.tgz",
-      "integrity": "sha512-ONy5bjppoohtNkFJRqdz1gscXamMzN3wQy1YH9qO2FiNpgjLhpz/IPRGg0PpCjyz/pWfCOaNEaiEGCcjOFAjqw==",
+      "version": "1.45.1",
+      "resolved": "https://registry.npmjs.org/sass/-/sass-1.45.1.tgz",
+      "integrity": "sha512-pwPRiq29UR0o4X3fiQyCtrESldXvUQAAE0QmcJTpsI4kuHHcLzZ54M1oNBVIXybQv8QF2zfkpFcTxp8ta97dUA==",
       "dev": true,
       "dependencies": {
         "chokidar": ">=3.0.0 <4.0.0",
@@ -12083,6 +12097,39 @@
         "node": ">=0.4.0"
       }
     },
+    "node_modules/tsconfig-paths": {
+      "version": "3.12.0",
+      "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.12.0.tgz",
+      "integrity": "sha512-e5adrnOYT6zqVnWqZu7i/BQ3BnhzvGbjEjejFXO20lKIKpwTaupkCPgEfv4GZK1IBciJUEhYs3J3p75FdaTFVg==",
+      "dev": true,
+      "dependencies": {
+        "@types/json5": "^0.0.29",
+        "json5": "^1.0.1",
+        "minimist": "^1.2.0",
+        "strip-bom": "^3.0.0"
+      }
+    },
+    "node_modules/tsconfig-paths/node_modules/json5": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
+      "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
+      "dev": true,
+      "dependencies": {
+        "minimist": "^1.2.0"
+      },
+      "bin": {
+        "json5": "lib/cli.js"
+      }
+    },
+    "node_modules/tsconfig-paths/node_modules/strip-bom": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
+      "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
     "node_modules/tslib": {
       "version": "1.14.1",
       "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
@@ -15024,6 +15071,12 @@
         "browserslist": "*"
       }
     },
+    "@types/confusing-browser-globals": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@types/confusing-browser-globals/-/confusing-browser-globals-1.0.0.tgz",
+      "integrity": "sha512-2RQF2SJW3pAQgkHtlIefyxiRkdlBwXxqNiiDDoPyaR9R0SqIujHoP6ceLSAE95EKd7CCLV3F/n4ohOAMi5edpQ==",
+      "dev": true
+    },
     "@types/estree": {
       "version": "0.0.50",
       "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.50.tgz",
@@ -15080,18 +15133,18 @@
       }
     },
     "@types/jest-when": {
-      "version": "2.7.3",
-      "resolved": "https://registry.npmjs.org/@types/jest-when/-/jest-when-2.7.3.tgz",
-      "integrity": "sha512-BdDZnKj3ZO1VsRlJFyRx6yLa0hG9++qetnBKhESjCGRVAm6S4aaKXXLm9xGFmtAQpzuMC44wxhvkG2cl6axvyQ==",
+      "version": "2.7.4",
+      "resolved": "https://registry.npmjs.org/@types/jest-when/-/jest-when-2.7.4.tgz",
+      "integrity": "sha512-2OC69oyaD33tmSaOjtxvy7ZpBO85OWIw1AbpWVziL4bek5mr795H59qK5EKDpp4dLhtH1QIs54tXpoHEb2mE/A==",
       "dev": true,
       "requires": {
         "@types/jest": "*"
       }
     },
     "@types/jquery": {
-      "version": "3.5.10",
-      "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.10.tgz",
-      "integrity": "sha512-w2qT5DFikh5TXrW/aOaCvCP8g2MMAfPXo3oeHR9v7dRuAZhu38PUWEkYrL4e9VRTcgZE4yER21AHndgpq2QPTQ==",
+      "version": "3.5.11",
+      "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.11.tgz",
+      "integrity": "sha512-lYZGdfOtUa0XFjIATQgiogqeTY5PNNMOmp3Jq48ghmJALL8t/IqABRqlEwdHfuUdA8iIE1uGD1HoI4a7Tiy6OA==",
       "dev": true,
       "requires": {
         "@types/sizzle": "*"
@@ -15103,6 +15156,12 @@
       "integrity": "sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==",
       "dev": true
     },
+    "@types/json5": {
+      "version": "0.0.29",
+      "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
+      "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=",
+      "dev": true
+    },
     "@types/node": {
       "version": "16.11.12",
       "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.12.tgz",
@@ -15286,13 +15345,13 @@
       "dev": true
     },
     "@typescript-eslint/eslint-plugin": {
-      "version": "5.7.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.7.0.tgz",
-      "integrity": "sha512-8RTGBpNn5a9M628wBPrCbJ+v3YTEOE2qeZb7TDkGKTDXSj36KGRg92SpFFaR/0S3rSXQxM0Og/kV9EyadsYSBg==",
+      "version": "5.8.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.8.0.tgz",
+      "integrity": "sha512-spu1UW7QuBn0nJ6+psnfCc3iVoQAifjKORgBngKOmC8U/1tbe2YJMzYQqDGYB4JCss7L8+RM2kKLb1B1Aw9BNA==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/experimental-utils": "5.7.0",
-        "@typescript-eslint/scope-manager": "5.7.0",
+        "@typescript-eslint/experimental-utils": "5.8.0",
+        "@typescript-eslint/scope-manager": "5.8.0",
         "debug": "^4.3.2",
         "functional-red-black-tree": "^1.0.1",
         "ignore": "^5.1.8",
@@ -15313,55 +15372,55 @@
       }
     },
     "@typescript-eslint/experimental-utils": {
-      "version": "5.7.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.7.0.tgz",
-      "integrity": "sha512-u57eZ5FbEpzN5kSjmVrSesovWslH2ZyNPnaXQMXWgH57d5+EVHEt76W75vVuI9qKZ5BMDKNfRN+pxcPEjQjb2A==",
+      "version": "5.8.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.8.0.tgz",
+      "integrity": "sha512-KN5FvNH71bhZ8fKtL+lhW7bjm7cxs1nt+hrDZWIqb6ViCffQcWyLunGrgvISgkRojIDcXIsH+xlFfI4RCDA0xA==",
       "dev": true,
       "requires": {
         "@types/json-schema": "^7.0.9",
-        "@typescript-eslint/scope-manager": "5.7.0",
-        "@typescript-eslint/types": "5.7.0",
-        "@typescript-eslint/typescript-estree": "5.7.0",
+        "@typescript-eslint/scope-manager": "5.8.0",
+        "@typescript-eslint/types": "5.8.0",
+        "@typescript-eslint/typescript-estree": "5.8.0",
         "eslint-scope": "^5.1.1",
         "eslint-utils": "^3.0.0"
       }
     },
     "@typescript-eslint/parser": {
-      "version": "5.7.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.7.0.tgz",
-      "integrity": "sha512-m/gWCCcS4jXw6vkrPQ1BjZ1vomP01PArgzvauBqzsoZ3urLbsRChexB8/YV8z9HwE3qlJM35FxfKZ1nfP/4x8g==",
+      "version": "5.8.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.8.0.tgz",
+      "integrity": "sha512-Gleacp/ZhRtJRYs5/T8KQR3pAQjQI89Dn/k+OzyCKOsLiZH2/Vh60cFBTnFsHNI6WAD+lNUo/xGZ4NeA5u0Ipw==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/scope-manager": "5.7.0",
-        "@typescript-eslint/types": "5.7.0",
-        "@typescript-eslint/typescript-estree": "5.7.0",
+        "@typescript-eslint/scope-manager": "5.8.0",
+        "@typescript-eslint/types": "5.8.0",
+        "@typescript-eslint/typescript-estree": "5.8.0",
         "debug": "^4.3.2"
       }
     },
     "@typescript-eslint/scope-manager": {
-      "version": "5.7.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.7.0.tgz",
-      "integrity": "sha512-7mxR520DGq5F7sSSgM0HSSMJ+TFUymOeFRMfUfGFAVBv8BR+Jv1vHgAouYUvWRZeszVBJlLcc9fDdktxb5kmxA==",
+      "version": "5.8.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.8.0.tgz",
+      "integrity": "sha512-x82CYJsLOjPCDuFFEbS6e7K1QEWj7u5Wk1alw8A+gnJiYwNnDJk0ib6PCegbaPMjrfBvFKa7SxE3EOnnIQz2Gg==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/types": "5.7.0",
-        "@typescript-eslint/visitor-keys": "5.7.0"
+        "@typescript-eslint/types": "5.8.0",
+        "@typescript-eslint/visitor-keys": "5.8.0"
       }
     },
     "@typescript-eslint/types": {
-      "version": "5.7.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.7.0.tgz",
-      "integrity": "sha512-5AeYIF5p2kAneIpnLFve8g50VyAjq7udM7ApZZ9JYjdPjkz0LvODfuSHIDUVnIuUoxafoWzpFyU7Sqbxgi79mA==",
+      "version": "5.8.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.8.0.tgz",
+      "integrity": "sha512-LdCYOqeqZWqCMOmwFnum6YfW9F3nKuxJiR84CdIRN5nfHJ7gyvGpXWqL/AaW0k3Po0+wm93ARAsOdzlZDPCcXg==",
       "dev": true
     },
     "@typescript-eslint/typescript-estree": {
-      "version": "5.7.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.7.0.tgz",
-      "integrity": "sha512-aO1Ql+izMrTnPj5aFFlEJkpD4jRqC4Gwhygu2oHK2wfVQpmOPbyDSveJ+r/NQo+PWV43M6uEAeLVbTi09dFLhg==",
+      "version": "5.8.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.8.0.tgz",
+      "integrity": "sha512-srfeZ3URdEcUsSLbkOFqS7WoxOqn8JNil2NSLO9O+I2/Uyc85+UlfpEvQHIpj5dVts7KKOZnftoJD/Fdv0L7nQ==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/types": "5.7.0",
-        "@typescript-eslint/visitor-keys": "5.7.0",
+        "@typescript-eslint/types": "5.8.0",
+        "@typescript-eslint/visitor-keys": "5.8.0",
         "debug": "^4.3.2",
         "globby": "^11.0.4",
         "is-glob": "^4.0.3",
@@ -15381,12 +15440,12 @@
       }
     },
     "@typescript-eslint/visitor-keys": {
-      "version": "5.7.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.7.0.tgz",
-      "integrity": "sha512-hdohahZ4lTFcglZSJ3DGdzxQHBSxsLVqHzkiOmKi7xVAWC4y2c1bIMKmPJSrA4aOEoRUPOKQ87Y/taC7yVHpFg==",
+      "version": "5.8.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.8.0.tgz",
+      "integrity": "sha512-+HDIGOEMnqbxdAHegxvnOqESUH6RWFRR2b8qxP1W9CZnnYh4Usz6MBL+2KMAgPk/P0o9c1HqnYtwzVH6GTIqug==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/types": "5.7.0",
+        "@typescript-eslint/types": "5.8.0",
         "eslint-visitor-keys": "^3.0.0"
       }
     },
@@ -15726,9 +15785,9 @@
       }
     },
     "babel-plugin-transform-async-to-promises": {
-      "version": "0.8.16",
-      "resolved": "https://registry.npmjs.org/babel-plugin-transform-async-to-promises/-/babel-plugin-transform-async-to-promises-0.8.16.tgz",
-      "integrity": "sha512-mm0UMekJVOTuIlkGIEH1fpnExyTIIJ2/L5ixTTI1zAVdMpbG7Em3LiS92p0TCWKiMkHhYYinz/zn2D/BPPJaxA==",
+      "version": "0.8.17",
+      "resolved": "https://registry.npmjs.org/babel-plugin-transform-async-to-promises/-/babel-plugin-transform-async-to-promises-0.8.17.tgz",
+      "integrity": "sha512-MAijrdLcXjZBkET+n4GA9g96Wt5AibTHRKHWU3Zuuenvkge3QgrYiYfXsNcHZbQzJ6XO73l4MRMDNP+ZE77u5A==",
       "dev": true
     },
     "babel-preset-current-node-syntax": {
@@ -15900,9 +15959,9 @@
       }
     },
     "caniuse-lite": {
-      "version": "1.0.30001289",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001289.tgz",
-      "integrity": "sha512-hV6x4IfrYViN8cJbGFVbjD7KCrhS/O7wfDgvevYRanJ/IN+hhxpTcXXqaxy3CzPNFe5rlqdimdEB/k7H0YzxHg==",
+      "version": "1.0.30001292",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001292.tgz",
+      "integrity": "sha512-jnT4Tq0Q4ma+6nncYQVe7d73kmDmE9C3OGTx3MvW7lBM/eY1S1DZTMBON7dqV481RhNiS5OxD7k9JQvmDOTirw==",
       "dev": true
     },
     "chalk": {
@@ -16065,9 +16124,9 @@
       }
     },
     "core-js": {
-      "version": "3.20.0",
-      "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.20.0.tgz",
-      "integrity": "sha512-KjbKU7UEfg4YPpskMtMXPhUKn7m/1OdTHTVjy09ScR2LVaoUXe8Jh0UdvN2EKUR6iKTJph52SJP95mAB0MnVLQ=="
+      "version": "3.20.1",
+      "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.20.1.tgz",
+      "integrity": "sha512-btdpStYFQScnNVQ5slVcr858KP0YWYjV16eGJQw8Gg7CWtu/2qNvIM3qVRIR3n1pK2R9NNOrTevbvAYxajwEjg=="
     },
     "core-js-compat": {
       "version": "3.19.3",
@@ -20414,14 +20473,14 @@
       }
     },
     "postcss-preset-env": {
-      "version": "7.0.2",
-      "resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-7.0.2.tgz",
-      "integrity": "sha512-xgmkS+H2xhjPQWewnH4ViKBCHIZ15FfpldGZuBFaBIi/iipxHTLnBHKtkCp1L531SdNEPwo9md2psjOU1G77lw==",
+      "version": "7.1.0",
+      "resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-7.1.0.tgz",
+      "integrity": "sha512-YZI44uxVJQQu18TeHEoDtdLsjKLQpCpzt/4FAzadIcnNYwvKSQqvxaHE6uWobEWQrcfU42zIddMPUKgYQxZs8g==",
       "dev": true,
       "requires": {
         "autoprefixer": "^10.4.0",
         "browserslist": "^4.19.1",
-        "caniuse-lite": "^1.0.30001287",
+        "caniuse-lite": "^1.0.30001291",
         "css-blank-pseudo": "^3.0.0",
         "css-has-pseudo": "^3.0.0",
         "css-prefers-color-scheme": "^6.0.0",
@@ -20853,9 +20912,9 @@
       }
     },
     "rollup": {
-      "version": "2.61.1",
-      "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.61.1.tgz",
-      "integrity": "sha512-BbTXlEvB8d+XFbK/7E5doIcRtxWPRiqr0eb5vQ0+2paMM04Ye4PZY5nHOQef2ix24l/L0SpLd5hwcH15QHPdvA==",
+      "version": "2.62.0",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.62.0.tgz",
+      "integrity": "sha512-cJEQq2gwB0GWMD3rYImefQTSjrPYaC6s4J9pYqnstVLJ1CHa/aZNVkD4Epuvg4iLeMA4KRiq7UM7awKK6j7jcw==",
       "dev": true,
       "requires": {
         "fsevents": "~2.3.2"
@@ -21000,9 +21059,9 @@
       "dev": true
     },
     "sass": {
-      "version": "1.45.0",
-      "resolved": "https://registry.npmjs.org/sass/-/sass-1.45.0.tgz",
-      "integrity": "sha512-ONy5bjppoohtNkFJRqdz1gscXamMzN3wQy1YH9qO2FiNpgjLhpz/IPRGg0PpCjyz/pWfCOaNEaiEGCcjOFAjqw==",
+      "version": "1.45.1",
+      "resolved": "https://registry.npmjs.org/sass/-/sass-1.45.1.tgz",
+      "integrity": "sha512-pwPRiq29UR0o4X3fiQyCtrESldXvUQAAE0QmcJTpsI4kuHHcLzZ54M1oNBVIXybQv8QF2zfkpFcTxp8ta97dUA==",
       "dev": true,
       "requires": {
         "chokidar": ">=3.0.0 <4.0.0",
@@ -21548,6 +21607,35 @@
         }
       }
     },
+    "tsconfig-paths": {
+      "version": "3.12.0",
+      "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.12.0.tgz",
+      "integrity": "sha512-e5adrnOYT6zqVnWqZu7i/BQ3BnhzvGbjEjejFXO20lKIKpwTaupkCPgEfv4GZK1IBciJUEhYs3J3p75FdaTFVg==",
+      "dev": true,
+      "requires": {
+        "@types/json5": "^0.0.29",
+        "json5": "^1.0.1",
+        "minimist": "^1.2.0",
+        "strip-bom": "^3.0.0"
+      },
+      "dependencies": {
+        "json5": {
+          "version": "1.0.1",
+          "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
+          "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
+          "dev": true,
+          "requires": {
+            "minimist": "^1.2.0"
+          }
+        },
+        "strip-bom": {
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
+          "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=",
+          "dev": true
+        }
+      }
+    },
     "tslib": {
       "version": "1.14.1",
       "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
diff --git a/package.json b/package.json
index 2330cda1..cbd51dba 100644
--- a/package.json
+++ b/package.json
@@ -3,11 +3,11 @@
   "version": "1.0.0",
   "description": "Build package for MB userscripts",
   "scripts": {
-    "build": "NODE_ENV=production ts-node build/build.ts",
-    "build-dev": "ts-node build/build.ts",
-    "lint": "eslint . --ext .js,.jsx,.ts,.tsx --max-warnings=0",
-    "typecheck": "tsc",
-    "deploy": "NODE_ENV=production ts-node build/deploy.ts",
+    "build": "NODE_ENV=production ts-node -r tsconfig-paths/register build/build.ts",
+    "build-dev": "ts-node -r tsconfig-paths/register build/build.ts",
+    "lint": "eslint . --ext .js,.jsx,.ts,.tsx,.cjs --max-warnings=0",
+    "typecheck": "tsc -b",
+    "deploy": "NODE_ENV=production ts-node -r tsconfig-paths/register build/deploy.ts",
     "test": "NODE_NO_WARNINGS=1 jest --coverage"
   },
   "repository": {
@@ -40,17 +40,18 @@
     "@rollup/plugin-virtual": "2.0.3",
     "@rollup/pluginutils": "4.1.2",
     "@types/babel__core": "7.1.17",
+    "@types/confusing-browser-globals": "^1.0.0",
     "@types/greasemonkey": "4.0.2",
     "@types/jest": "27.0.3",
-    "@types/jest-when": "2.7.3",
-    "@types/jquery": "3.5.10",
+    "@types/jest-when": "2.7.4",
+    "@types/jquery": "3.5.11",
     "@types/postcss-preset-env": "6.7.3",
     "@types/rollup__plugin-virtual": "2.0.1",
     "@types/rollup-plugin-progress": "1.1.1",
     "@types/setup-polly-jest": "0.5.1",
-    "@typescript-eslint/eslint-plugin": "5.7.0",
-    "@typescript-eslint/parser": "5.7.0",
-    "babel-plugin-transform-async-to-promises": "0.8.16",
+    "@typescript-eslint/eslint-plugin": "5.8.0",
+    "@typescript-eslint/parser": "5.8.0",
+    "babel-plugin-transform-async-to-promises": "0.8.17",
     "confusing-browser-globals": "1.0.11",
     "eslint": "8.5.0",
     "eslint-plugin-eslint-comments": "3.2.0",
@@ -64,23 +65,24 @@
     "nativejsx": "github:ROpdebee/nativejsx",
     "node-fetch": "3.0.0",
     "postcss": "8.4.5",
-    "postcss-preset-env": "7.0.2",
-    "rollup": "2.61.1",
+    "postcss-preset-env": "7.1.0",
+    "rollup": "2.62.0",
     "rollup-plugin-consts": "1.0.2",
     "rollup-plugin-postcss": "4.0.2",
     "rollup-plugin-progress": "1.1.2",
-    "sass": "1.45.0",
+    "sass": "1.45.1",
     "setup-polly-jest": "0.10.0",
     "simple-git": "2.48.0",
     "terser": "5.10.0",
     "ts-jest": "27.1.2",
     "ts-node": "10.4.0",
+    "tsconfig-paths": "3.12.0",
     "type-fest": "2.8.0",
     "typescript": "4.5.4",
     "warcio": "1.5.0"
   },
   "dependencies": {
-    "core-js": "3.20.0",
+    "core-js": "3.20.1",
     "jquery": "3.6.0",
     "p-throttle": "5.0.0",
     "ts-custom-error": "3.2.0"
diff --git a/src/.eslintrc.cjs b/src/.eslintrc.cjs
index d5cb3936..c03c186b 100644
--- a/src/.eslintrc.cjs
+++ b/src/.eslintrc.cjs
@@ -13,4 +13,10 @@ module.exports = {
         // compat layer.
         'no-restricted-globals': ['error', 'origin', 'GM'].concat(restrictedGlobals),
     },
+    overrides: [{
+        files: ['*.d.ts'],
+        rules: {
+            'init-declarations': 'off',
+        },
+    }],
 };
diff --git a/src/lib/MB/EditNote.ts b/src/lib/MB/EditNote.ts
index 5e914210..fe65c570 100644
--- a/src/lib/MB/EditNote.ts
+++ b/src/lib/MB/EditNote.ts
@@ -1,7 +1,7 @@
 // Edit note helpers
 
 import { qs } from '@lib/util/dom';
-import { GMinfo } from '@src/compat';
+import { GMinfo } from '@lib/compat';
 
 const separator = '\n–\n';
 
diff --git a/src/compat.ts b/src/lib/compat.ts
similarity index 54%
rename from src/compat.ts
rename to src/lib/compat.ts
index d20da0a0..af243fba 100644
--- a/src/compat.ts
+++ b/src/lib/compat.ts
@@ -1,7 +1,18 @@
 // Compatibility wrappers
 // istanbul ignore file: Covered by E2E
 
+
+// Fix GM.Request interface through declaration merging.
+declare global {
+    namespace GM {
+        interface Request {
+            responseType?: string;
+        }
+    }
+}
+
 /* eslint-disable no-restricted-globals */
+
 function existsInGM(name: string): boolean {
     return typeof GM !== 'undefined' && typeof (GM as Record<string, unknown>)[name] !== 'undefined';
 }
@@ -35,9 +46,46 @@ export const GMinfo: typeof GM.info = existsInGM('info')
 
 /* eslint-enable no-restricted-globals */
 
+interface CloneIntoOptions {
+    /**
+     * A Boolean value that determines if functions should be cloned. If
+     * omitted the default value is false. Cloned functions have the same
+     * semantics as functions exported using `Components.utils.exportFunction`.
+     */
+    cloneFunctions: boolean;
+    /**
+     * A Boolean value that determines if objects reflected from C++, such as
+     * DOM objects, should be cloned. If omitted the default value is false.
+     */
+    wrapReflectors: boolean;
+}
+
+// Declare the missing `cloneInto` function.
+/**
+ * This function provides a safe way to take an object defined in a privileged
+ * scope and create a structured clone of it in a less-privileged scope. It
+ * returns a reference to the clone:
+ * ```javascript
+ * var clonedObject = cloneInto(myObject, targetWindow);
+ * ```
+ *
+ * You can then assign the clone to an object in the target scope as an expando
+ * property, and scripts running in that scope can access it:
+ * ```javascript
+ * targetWindow.foo = clonedObject;
+ * ```
+ * In this way privileged code, such as an add-on, can share an object with
+ * less-privileged code like a normal web page script.
+ * @param {T} obj   The object to clone.
+ * @param {object} targetScope   The object to attach the object to.
+ * @param {CloneIntoOptions | undefined } options   Options
+ * @returns {T} A reference to the cloned object.
+ */
+declare function cloneInto<T>(obj: T, targetScope: Window, options?: CloneIntoOptions): T;
+
 /**
- * Clone an object to be usable in the page context (unsafeWindow).
- * If we cannot clone the object, or unsafeWindow doesn't exist, returns the
+ * Clone an object to be usable in the page context (`unsafeWindow`).
+ * If we cannot clone the object, or `unsafeWindow` doesn't exist, returns the
  * object itself.
  */
 export function cloneIntoPageContext<T>(object: T): T {
diff --git a/src/lib/tsconfig.json b/src/lib/tsconfig.json
new file mode 100644
index 00000000..630e934d
--- /dev/null
+++ b/src/lib/tsconfig.json
@@ -0,0 +1,4 @@
+{
+    "extends": "../../configs/tsconfig.base-web.json",
+    "include": ["**/*"]
+}
diff --git a/src/lib/util/blob.ts b/src/lib/util/blob.ts
index b1c57c87..db9419c6 100644
--- a/src/lib/util/blob.ts
+++ b/src/lib/util/blob.ts
@@ -1,4 +1,4 @@
-import { getFromPageContext } from '@src/compat';
+import { getFromPageContext } from '@lib/compat';
 
 function hexEncode(buffer: ArrayBuffer): string {
     // https://stackoverflow.com/a/40031979
diff --git a/src/lib/util/metadata.ts b/src/lib/util/metadata.ts
index 5d2a0434..e39fae00 100644
--- a/src/lib/util/metadata.ts
+++ b/src/lib/util/metadata.ts
@@ -2,6 +2,33 @@
  * Userscript metadata helpers.
  */
 
+export interface UserscriptCustomMetadata {
+    name: string;
+    description: string;
+    match: readonly string[] | string;
+    exclude?: readonly string[] | string;
+    require?: readonly string[] | string;
+    // https://wiki.greasespot.net/Metadata_Block#.40run-at
+    'run-at'?: 'document-start' | 'document-end' | 'document-idle';
+    grant?: readonly string[] | string;
+    connect?: readonly string[] | string;
+    resource?: readonly string[] | string;
+}
+
+export interface UserscriptDefaultMetadata {
+    author: string;
+    license?: string;
+    namespace: string;
+    homepageURL: string;
+    supportURL: string;
+    downloadURL: string;
+    updateURL: string;
+}
+
+export type UserscriptMetadata = UserscriptCustomMetadata & Partial<UserscriptDefaultMetadata>;
+export type AllUserscriptMetadata = UserscriptCustomMetadata & UserscriptDefaultMetadata;
+
+
 export function transformMBMatchURL(requireString: string): string {
     return `*://*.musicbrainz.org/${requireString}`;
 }
diff --git a/src/lib/util/xhr.ts b/src/lib/util/xhr.ts
index e09b9dee..356f9b59 100644
--- a/src/lib/util/xhr.ts
+++ b/src/lib/util/xhr.ts
@@ -1,6 +1,6 @@
 // Async XHR interfaces
 
-import { GMxmlHttpRequest } from '@src/compat';
+import { GMxmlHttpRequest } from '@lib/compat';
 import { CustomError } from 'ts-custom-error';
 
 // eslint-disable-next-line no-restricted-globals
diff --git a/src/mb_enhanced_cover_art_uploads/fetch.ts b/src/mb_enhanced_cover_art_uploads/fetch.ts
index a346273b..f0045e56 100644
--- a/src/mb_enhanced_cover_art_uploads/fetch.ts
+++ b/src/mb_enhanced_cover_art_uploads/fetch.ts
@@ -2,7 +2,7 @@ import { LOGGER } from '@lib/logging/logger';
 import { ArtworkTypeIDs } from '@lib/MB/CoverArt';
 import { gmxhr } from '@lib/util/xhr';
 import { urlBasename } from '@lib/util/urls';
-import { getFromPageContext } from '@src/compat';
+import { getFromPageContext } from '@lib/compat';
 import { getMaximisedCandidates } from './maximise';
 import { getProvider } from './providers';
 import type { CoverArt, CoverArtProvider } from './providers/base';
diff --git a/src/mb_enhanced_cover_art_uploads/form.ts b/src/mb_enhanced_cover_art_uploads/form.ts
index 800d52cc..38de2e4f 100644
--- a/src/mb_enhanced_cover_art_uploads/form.ts
+++ b/src/mb_enhanced_cover_art_uploads/form.ts
@@ -3,8 +3,7 @@ import type { EditNote } from '@lib/MB/EditNote';
 import { assertDefined } from '@lib/util/assert';
 import { retryTimes } from '@lib/util/async';
 import { qs, qsa } from '@lib/util/dom';
-
-import { cloneIntoPageContext, getFromPageContext } from '@src/compat';
+import { cloneIntoPageContext, getFromPageContext } from '@lib/compat';
 
 import type { FetchedImage, FetchedImages } from './fetch';
 
@@ -14,6 +13,12 @@ export async function enqueueImages({ images }: FetchedImages, defaultTypes: Art
     }));
 }
 
+declare global {
+    interface Window {
+        $: typeof jQuery;
+    }
+}
+
 async function enqueueImage(image: FetchedImage, defaultTypes: ArtworkTypeIDs[], defaultComment: string): Promise<void> {
     dropImage(image.content);
     await retryTimes(setImageParameters.bind(
diff --git a/src/mb_enhanced_cover_art_uploads/maximise.ts b/src/mb_enhanced_cover_art_uploads/maximise.ts
index 5660e7e3..7eb5ac15 100644
--- a/src/mb_enhanced_cover_art_uploads/maximise.ts
+++ b/src/mb_enhanced_cover_art_uploads/maximise.ts
@@ -4,13 +4,209 @@ import { LOGGER } from '@lib/logging/logger';
 import { retryTimes } from '@lib/util/async';
 import { DispatchMap } from '@lib/util/domain_dispatch';
 import { urlBasename } from '@lib/util/urls';
+import type { GMxmlHttpRequest } from '@lib/compat';
 import { DiscogsProvider } from './providers/discogs';
 
+interface maxurlOptions {
+    /**
+     * If set to false, it will return only the URL if there aren't any special properties.
+     * Recommended to keep true.
+     *
+     * The only reason this option exists is as a small hack for a helper userscript used to find new rules,
+     * to check if IMU already supports a rule.
+     */
+    fill_object?: boolean;
+
+    /**
+     * Maximum amount of times it should be run.
+     * Recommended to be at least 5.
+     */
+    iterations?: number;
+
+    /**
+     * Whether or not to store to, and use an internal cache for URLs.
+     * Set this to `"read"`` if you want to use the cache without storing results to it.
+     */
+    use_cache?: boolean | 'read';
+
+    /**
+     * Timeout (in seconds) for cache entries in the URL cache.
+     */
+    urlcache_time?: number;
+
+    /**
+     * List of "problems" (such as watermarks or possibly broken image) to exclude.
+     *
+     * By default, all problems are excluded.
+     * You can access the excluded problems through `maximage.default_options.exclude_problems`
+     * By setting it to `[]`, no problems will be excluded.
+     */
+    exclude_problems?: string[];
+
+    /** Whether or not to exclude videos. */
+    exclude_videos?: boolean;
+
+    /**
+     * This will include a "history" of objects found through iterations.
+     * Disabling this will only keep the objects found through the last successful iteration.
+     */
+    include_pastobjs?: boolean;
+
+    /**
+     * This will try to find the original page for an image, even if it requires extra requests.
+     */
+    force_page?: boolean;
+
+    /**
+     * This allows rules that use 3rd-party websites to find larger images.
+     */
+    allow_thirdparty?: boolean;
+
+    /**
+     * This is useful for implementing a blacklist or whitelist.
+     * If unspecified, it accepts all URLs.
+     */
+    filter?: (url: string) => boolean;
+
+    /**
+     * Helper function to perform HTTP requests, used for sites like Flickr.
+     *
+     * The API is expected to be like `GM_xmlHTTPRequest`'s API.
+     * An implementation using node's request module can be found in `reddit-bot/dourl.js`.
+     */
+    do_request?: typeof GMxmlHttpRequest;
+    /** Callback. */
+    cb?: (result: maxurlResult[]) => void;
+}
+
+export interface maxurlResult {
+  /** The URL of the image. */
+  url: string;
+
+  /** Whether or not this URL is a video. */
+  video: boolean;
+
+  /**
+   * Whether it's expected that it will always work or not.
+   *
+   * Don't rely on this value if you don't have to.
+   */
+  always_ok: boolean;
+
+  /** Whether or not the URL is likely to work. */
+  likely_broken: boolean;
+
+  /** Whether or not the server supports a HEAD request. */
+  can_head: boolean;
+
+  /** HEAD errors that can be ignored. */
+  head_ok_errors: number[];
+
+  /** Whether or not the server might return the wrong Content-Type header in the HEAD request. */
+  head_wrong_contenttype: boolean;
+
+  /** Whether or not the server might return the wrong Content-Length header in the HEAD request. */
+  head_wrong_contentlength: boolean;
+
+  /**
+   * This is used in the return value of the exported function.
+   * If you're using a callback (as shown in the code example above),
+   * this value will always be false.
+   */
+  waiting: boolean;
+
+  /** Whether or not the returned URL is expected to redirect to another URL. */
+  redirects: boolean;
+
+  /** Whether or not the URL is temporary/only works on the current IP (such as a generated download link). */
+  is_private: boolean;
+
+  /** Whether or not the URL is expected to be the original image stored on the website's servers. */
+  is_original: boolean;
+
+  /** If this is true, you shouldn't input this URL again into IMU. */
+  norecurse: boolean;
+
+  /**
+   * Whether or not this URL should be used.
+   * If `true`, treat this like a 404.
+   * If `"mask"`, this image is an overlayed mask.
+   */
+  bad: boolean | 'mask';
+
+  /**
+   * Same as above, but contains a list of objects, e.g.:
+   * ```javascript
+   * [{
+   *    headers: {"Content-Length": "1000"},
+   *    status: 301
+   * }]
+   * ```
+   *
+   * If one of the objects matches the response, it's a bad image.
+   * You can use `maximage.check_bad_if(bad_if, resp)` to check.
+   * (`resp` is expected to be an XHR-like object).
+   */
+  bad_if: Array<Record<string, unknown>>;
+
+  /**
+   * Whether or not this URL is a "fake" URL that was used internally (i.e. if true, don't use this).
+   */
+  fake: boolean;
+
+  /**
+   * Headers required to view the returned URL.
+   * If a header is `null`, don't include that header.
+   */
+  headers: Record<string, string>;
+
+  /** Additional properties that could be useful. */
+  extra: {
+    /** The original page where this image was hosted. */
+    page?: string;
+
+    /** The title/caption attached to the image. */
+    caption?: string;
+  };
+
+  /** If set, this is a more descriptive filename for the image. */
+  filename: string;
+
+  /** A list of problems with this image. Use `exclude_problems` to exclude images with specific problems. */
+  problems: {
+    /** If true, the image is likely larger than the one inputted, but it also has a watermark (when the inputted one doesn't). */
+    watermark: boolean;
+
+    /** If true, the image is likely smaller than the one inputted, but it has no watermark. */
+    smaller: boolean;
+
+    /** If true, the image might be entirely different from the one inputted. */
+    possibly_different: boolean;
+
+    /** If true, the image might be broken (such as GIFs on Tumblr). */
+    possibly_broken: boolean;
+  };
+}
+
+export interface maxurlInterface {
+    (url: string, options: maxurlOptions): void;
+
+    check_bad_if(badif: Array<Record<string, unknown>>, resp: XMLHttpRequestResponseType): boolean;
+    default_options: maxurlOptions;
+    is_internet_url(url: string): boolean;
+    clear_caches(): void;
+    // loop: any
+}
+
+// eslint-disable-next-line init-declarations
+declare const $$IMU_EXPORT$$: maxurlInterface;
+
+
 // IMU does its initialisation synchronously, and it's loaded before the
 // userscript is executed, so $$IMU_EXPORT$$ should already exist now. However,
 // it does not exist in tests, and we can't straightforwardly inject this variable
 // without importing the module, thereby dereferencing it.
-export /* for tests */ function maxurl(url: string, options: maxurlOptions): Promise<void> {
+function maxurl(url: string, options: maxurlOptions): Promise<void> {
     // In environments with GM.* APIs, the GM.getValue and GM.setValue functions
     // are asynchronous, leading to IMU defining its exports asynchronously too.
     // We can't await that, unfortunately. This is only really an issue when
diff --git a/src/mb_enhanced_cover_art_uploads/meta.ts b/src/mb_enhanced_cover_art_uploads/meta.ts
index 2b9dcd67..b3ef4809 100644
--- a/src/mb_enhanced_cover_art_uploads/meta.ts
+++ b/src/mb_enhanced_cover_art_uploads/meta.ts
@@ -1,6 +1,6 @@
-import type { UserscriptMetadata } from 'userscriptMetadata';
+import type { UserscriptMetadata } from '@lib/util/metadata';
 
-import { transformMBMatchURL } from '../lib/util/metadata';
+import { transformMBMatchURL } from '@lib/util/metadata';
 
 const metadata: UserscriptMetadata = {
     name: 'MB: Enhanced Cover Art Uploads',
diff --git a/src/mb_enhanced_cover_art_uploads/providers/amazon.ts b/src/mb_enhanced_cover_art_uploads/providers/amazon.ts
index f926ef01..72a7d689 100644
--- a/src/mb_enhanced_cover_art_uploads/providers/amazon.ts
+++ b/src/mb_enhanced_cover_art_uploads/providers/amazon.ts
@@ -3,7 +3,7 @@ import { ArtworkTypeIDs } from '@lib/MB/CoverArt';
 import { assertNonNull } from '@lib/util/assert';
 import { parseDOM, qsMaybe } from '@lib/util/dom';
 import { safeParseJSON } from '@lib/util/json';
-import { GMgetResourceUrl } from '@src/compat';
+import { GMgetResourceUrl } from '@lib/compat';
 
 import type { CoverArt } from './base';
 import { CoverArtProvider } from './base';
diff --git a/src/mb_enhanced_cover_art_uploads/tsconfig.json b/src/mb_enhanced_cover_art_uploads/tsconfig.json
new file mode 100644
index 00000000..e97c66e0
--- /dev/null
+++ b/src/mb_enhanced_cover_art_uploads/tsconfig.json
@@ -0,0 +1,10 @@
+{
+    "extends": "../../configs/tsconfig.base-web.json",
+    "include": ["**/*"],
+    "references": [
+        { "path": "../lib/" }
+    ],
+    "compilerOptions": {
+        "types": ["jquery", "nativejsx/types/jsx", "greasemonkey"]
+    }
+}
diff --git a/types/css.d.ts b/src/types/css.d.ts
similarity index 100%
rename from types/css.d.ts
rename to src/types/css.d.ts
diff --git a/types/rollup-consts.d.ts b/src/types/rollup-consts.d.ts
similarity index 100%
rename from types/rollup-consts.d.ts
rename to src/types/rollup-consts.d.ts
diff --git a/types/warcio.d.ts b/tests/types/warcio.d.ts
similarity index 76%
rename from types/warcio.d.ts
rename to tests/types/warcio.d.ts
index 7de027d1..0af270dd 100644
--- a/types/warcio.d.ts
+++ b/tests/types/warcio.d.ts
@@ -36,12 +36,12 @@ interface StatusAndHeaders {
 
 declare module 'warcio/src/warcrecord' {
     class WARCRecord {
-        static create(options: WARCRecordCreateOptions = {}, reader?: AsyncIterable<Uint8Array>): WARCRecord;
+        static create(options: WARCRecordCreateOptions, reader?: AsyncIterable<Uint8Array>): WARCRecord;
         static createWARCInfo(opts: { filename: string; warcVersion: string }, info: Record<string, string>): WARCRecord;
         warcHeader(name: string): string | null;
 
-        async readFully(): Promise<Uint8Array>;
-        async contentText(): Promise<string>;
+        readFully(): Promise<Uint8Array>;
+        contentText(): Promise<string>;
         get warcType(): WARCType;
         get warcTargetURI(): string;
         get warcContentType(): string;
@@ -50,15 +50,16 @@ declare module 'warcio/src/warcrecord' {
 }
 
 declare module 'warcio/src/warcserializer' {
+    import type { WARCRecord } from 'warcio/src/warcrecord';
     class WARCSerializer {
-        static async serialize(record: WARCRecord, opts?: WARCSerializerOptions): Promise<Uint8Array>;
+        static serialize(record: WARCRecord, opts?: WARCSerializerOptions): Promise<Uint8Array>;
     }
 }
 
 declare module 'warcio/src/warcparser' {
     import type { WARCRecord } from 'warcio/src/warcrecord';
     class WARCParser {
-        static async iterRecords(source: Iterable<Uint8Array>, options?: WARCParserOptions): AsyncIterable<WARCRecord>;
+        static iterRecords(source: Iterable<Uint8Array>, options?: WARCParserOptions): AsyncIterable<WARCRecord>;
     }
 }
 
diff --git a/tsconfig.json b/tsconfig.json
index 59f7580c..7c0b7093 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,48 +1,38 @@
+// Aggregate project referencing all composite sub-projects, for use with `tsc`
+// type checker and `ts-node`.
+// NOTE: Only refer to this file via `tsconfig.json` in the root, otherwise it will
+// use the wrong root directory.
 {
+    "extends": "./configs/tsconfig.base",
+    // We'll also typecheck the config files in the root
+    "files": [
+        "./babel.config.cjs",
+        "./jest.config.cjs",
+        "./.eslintrc.cjs",
+        "./src/.eslintrc.cjs",
+    ],
+    "include": [],
+    // Specific compiler options necessary for `tsconfig-paths`. It expects the
+    // path mapping in the root `tsconfig.json`, it cannot extract it from
+    // referenced projects. These mappings do not have an impact on the
+    // referenced projects though.
     "compilerOptions": {
-        "target": "ESNext",
-        "module": "ESNext",
-        "strict": true,
-        "checkJs": true,
         "noEmit": true,
-        "jsx": "preserve",
-        "moduleResolution": "node",
-        "esModuleInterop": true,
-        "noImplicitReturns": true,
-        "noImplicitOverride": true,
-        "pretty": true,
         "baseUrl": "./",
-        "typeRoots": [
-            "node_modules/@types",
-            "types",
-        ],
         "paths": {
-            "*": [
-                "types/*",
-                "node_modules/@types/*",
-            ],
-            "@lib/*": ["./src/lib/*"],
-            "@test-utils/*": ["./tests/utils/*"],
-            "@src/*": ["./src/*"],
-        },
-        "skipLibCheck": true,
+            "@lib/*": ["src/lib/*"],
+            "@src/*": ["src/*"],
+            "@test-utils/*": ["tests/unit/utils/*"],
+        }
     },
-    // Override compiler options for execution in ts-node. We need to convert
-    // to CommonJS modules for compatibility with node. Although node has ESM
-    // support, it is experimental and not always stable.
-    "ts-node": {
-        "compilerOptions": {
-            "module": "CommonJS",
-        },
-    },
-    "include": ["**/*"],
-    "exclude": [
-        "node_modules/**",
-        "mb_*.user.js",
-        "dist/**",
-        "lib/**",
-        "coverage/**",
-        "node_modules/rollup-plugin-consts/consts.d.ts",
-        "vendor/**",
+    // Reference all sub-projects
+    "references": [
+        { "path": "./build" },
+        { "path": "./src/lib" },
+        { "path": "./src/mb_enhanced_cover_art_uploads" },
+        { "path": "./tests/unit/build" },
+        { "path": "./tests/unit/lib" },
+        { "path": "./tests/unit/mb_enhanced_cover_art_uploads" },
+        { "path": "./tests/unit/utils" },
     ]
 }
diff --git a/types/__MB__.d.ts b/types/__MB__.d.ts
deleted file mode 100644
index 7fb0863d..00000000
--- a/types/__MB__.d.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-/**
- * Type declarations for the __MB__ global.
- */
-
-interface __MB__ {
-    $c: {
-        user: {
-            name: string;
-            entityType: 'editor';
-            gravatar: string;
-            id: number;
-            has_confirmed_email_address: boolean;
-            privileges: number;
-            preferences: {
-                datetime_format: string;
-                timezone: string;
-            };
-        };
-    };
-}
-
-declare const __MB__: __MB__;
-
-interface __CoverArt {
-    validate_file(file: File): JQuery.Promise<string, string>;
-}
-
-// Incomplete
-interface MB {
-    CoverArt: __CoverArt;
-}
-
-declare const MB: MB;
diff --git a/types/global.d.ts b/types/global.d.ts
deleted file mode 100644
index f22a2d86..00000000
--- a/types/global.d.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import 'jest-extended';
-
-interface CloneIntoOptions {
-    cloneFunctions: boolean;
-    wrapReflectors: boolean;
-}
-
-declare global {
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    const cloneInto: <T>(obj: T, targetScope: any, options?: CloneIntoOptions) => T;
-    interface Window {
-        $: typeof jQuery;
-    }
-}
-
-export {};
diff --git a/types/gmfix.d.ts b/types/gmfix.d.ts
deleted file mode 100644
index 1ef76e35..00000000
--- a/types/gmfix.d.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-declare namespace GM {
-    interface Request {
-        responseType?: string;
-    }
-}
diff --git a/types/maxurl.d.ts b/types/maxurl.d.ts
deleted file mode 100644
index 2035ca91..00000000
--- a/types/maxurl.d.ts
+++ /dev/null
@@ -1,155 +0,0 @@
-interface maxurlOptions {
-    // If set to false, it will return only the URL if there aren't any special properties
-    // Recommended to keep true.
-    //
-    // The only reason this option exists is as a small hack for a helper userscript used to find new rules,
-    //  to check if IMU already supports a rule.
-    fill_object?: boolean;
-
-    // Maximum amount of times it should be run.
-    // Recommended to be at least 5.
-    iterations?: number;
-
-    // Whether or not to store to, and use an internal cache for URLs.
-    // Set this to "read" if you want to use the cache without storing results to it.
-    use_cache?: boolean;
-
-    // Timeout (in seconds) for cache entries in the URL cache
-    urlcache_time?: number;
-
-    // List of "problems" (such as watermarks or possibly broken image) to exclude.
-    //
-    // By default, all problems are excluded.
-    // You can access the excluded problems through maximage.default_options.exclude_problems
-    // By setting it to [], no problems will be excluded.
-    exclude_problems?: string[];
-
-    // Whether or not to exclude videos
-    exclude_videos?: boolean;
-
-    // This will include a "history" of objects found through iterations.
-    // Disabling this will only keep the objects found through the last successful iteration.
-    include_pastobjs?: boolean;
-
-    // This will try to find the original page for an image, even if it requires extra requests.
-    force_page?: boolean;
-
-    // This allows rules that use 3rd-party websites to find larger images
-    allow_thirdparty?: boolean;
-
-    // This is useful for implementing a blacklist or whitelist.
-    //  If unspecified, it accepts all URLs.
-    filter?: (url: string) => boolean;
-
-    // Helper function to perform HTTP requests, used for sites like Flickr
-    //  The API is expected to be like GM_xmlHTTPRequest's API.
-    // An implementation using node's request module can be found in reddit-bot/dourl.js
-    do_request?: (options: GM.Request) => void;
-    // Callback
-    cb?: (result: maxurlResult[]) => void;
-}
-
-interface maxurlResult {
-  // The URL of the image
-  url: string;
-
-  // Whether or not this URL is a video
-  video: boolean;
-
-  // Whether it's expected that it will always work or not.
-  //  Don't rely on this value if you don't have to
-  always_ok: boolean;
-
-  // Whether or not the URL is likely to work.
-  likely_broken: boolean;
-
-  // Whether or not the server supports a HEAD request.
-  can_head: boolean;
-
-  // HEAD errors that can be ignored
-  head_ok_errors: number[];
-
-  // Whether or not the server might return the wrong Content-Type header in the HEAD request
-  head_wrong_contenttype: boolean;
-
-  // Whether or not the server might return the wrong Content-Length header in the HEAD request
-  head_wrong_contentlength: boolean;
-
-  // This is used in the return value of the exported function.
-  //  If you're using a callback (as shown in the code example above),
-  //  this value will always be false
-  waiting: boolean;
-
-  // Whether or not the returned URL is expected to redirect to another URL
-  redirects: boolean;
-
-  // Whether or not the URL is temporary/only works on the current IP (such as a generated download link)
-  is_private: boolean;
-
-  // Whether or not the URL is expected to be the original image stored on the website's servers.
-  is_original: boolean;
-
-  // If this is true, you shouldn't input this URL again into IMU.
-  norecurse: boolean;
-
-  // Whether or not this URL should be used.
-  // If true, treat this like a 404
-  // If "mask", this image is an overlayed mask
-  bad: boolean | 'mask';
-
-  // Same as above, but contains a list of objects, e.g.:
-  // [{
-  //    headers: {"Content-Length": "1000"},
-  //    status: 301
-  // }]
-  // If one of the objects matches the response, it's a bad image.
-  // You can use maximage.check_bad_if(bad_if, resp) to check.
-  //  (resp is expected to be an XHR-like object)
-  bad_if: Array<Record<string, unknown>>;
-
-  // Whether or not this URL is a "fake" URL that was used internally (i.e. if true, don't use this)
-  fake: boolean;
-
-  // Headers required to view the returned URL
-  //  If a header is null, don't include that header.
-  headers: Record<string, string>;
-
-  // Additional properties that could be useful
-  extra: {
-    // The original page where this image was hosted
-    page?: string;
-
-    // The title/caption attached to the image
-    caption?: string;
-  };
-
-  // If set, this is a more descriptive filename for the image
-  filename: string;
-
-  // A list of problems with this image. Use exclude_problems to exclude images with specific problems
-  problems: {
-    // If true, the image is likely larger than the one inputted, but it also has a watermark (when the inputted one doesn't)
-    watermark: boolean;
-
-    // If true, the image is likely smaller than the one inputted, but it has no watermark
-    smaller: boolean;
-
-    // If true, the image might be entirely different from the one inputted
-    possibly_different: boolean;
-
-    // If true, the image might be broken (such as GIFs on Tumblr)
-    possibly_broken: boolean;
-  };
-}
-
-interface maxurl {
-    (url: string, options: maxurlOptions): void;
-
-    check_bad_if(badif: Array<Record<string, unknown>>, resp: XMLHttpRequestResponseType): boolean;
-    default_options: maxurlOptions;
-    is_internet_url(url: string): boolean;
-    clear_caches(): void;
-    // loop: any
-}
-
-declare const $$IMU_EXPORT$$: maxurl;
diff --git a/types/userscriptMetadata.d.ts b/types/userscriptMetadata.d.ts
deleted file mode 100644
index a55080eb..00000000
--- a/types/userscriptMetadata.d.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-interface UserscriptCustomMetadata {
-    name: string;
-    description: string;
-    match: readonly string[] | string;
-    exclude?: readonly string[] | string;
-    require?: readonly string[] | string;
-    // https://wiki.greasespot.net/Metadata_Block#.40run-at
-    'run-at'?: 'document-start' | 'document-end' | 'document-idle';
-    grant?: readonly string[] | string;
-    connect?: readonly string[] | string;
-    resource?: readonly string[] | string;
-}
-
-export interface UserscriptDefaultMetadata {
-    author: string;
-    license?: string;
-    namespace: string;
-    homepageURL: string;
-    supportURL: string;
-    downloadURL: string;
-    updateURL: string;
-}
-
-export type UserscriptMetadata = UserscriptCustomMetadata & Partial<UserscriptDefaultMetadata>;
-export type AllUserscriptMetadata = UserscriptCustomMetadata & UserscriptDefaultMetadata;


Test patch:
diff --git a/tests/unit/build/tsconfig.json b/tests/unit/build/tsconfig.json
new file mode 100644
index 00000000..17e298a8
--- /dev/null
+++ b/tests/unit/build/tsconfig.json
@@ -0,0 +1,7 @@
+{
+    "extends": "../../../configs/tsconfig.base-unit",
+    "include": ["**/*"],
+    "references": [
+        { "path": "../../../build" }
+    ]
+}
diff --git a/tests/unit/lib/tsconfig.json b/tests/unit/lib/tsconfig.json
new file mode 100644
index 00000000..816bf5d9
--- /dev/null
+++ b/tests/unit/lib/tsconfig.json
@@ -0,0 +1,8 @@
+{
+    "extends": "../../../configs/tsconfig.base-unit",
+    "include": ["**/*"],
+    "references": [
+        { "path": "../../../src/lib" },
+        { "path": "../utils" }
+    ]
+}
diff --git a/tests/unit/lib/util/metadata.test.ts b/tests/unit/lib/util/metadata.test.ts
index 69d80885..a2f5f72d 100644
--- a/tests/unit/lib/util/metadata.test.ts
+++ b/tests/unit/lib/util/metadata.test.ts
@@ -1,6 +1,5 @@
 import { transformMBMatchURL } from '@lib/util/metadata';
 
-
 describe('transforming match URLs', () => {
     it('should prefix MB domain', () => {
         expect(transformMBMatchURL('release/add'))
diff --git a/tests/unit/mb_enhanced_cover_art_uploads/maximise.test.ts b/tests/unit/mb_enhanced_cover_art_uploads/maximise.test.ts
index 7ac5ecac..2a5b9fa0 100644
--- a/tests/unit/mb_enhanced_cover_art_uploads/maximise.test.ts
+++ b/tests/unit/mb_enhanced_cover_art_uploads/maximise.test.ts
@@ -1,8 +1,9 @@
 import * as libAsync from '@lib/util/async';
+import type { maxurlResult, maxurlInterface } from '@src/mb_enhanced_cover_art_uploads/maximise';
 import { getMaximisedCandidates } from '@src/mb_enhanced_cover_art_uploads/maximise';
 import { DiscogsProvider } from '@src/mb_enhanced_cover_art_uploads/providers/discogs';
 
-const fakeImu = jest.fn() as unknown as jest.MockedFunction<typeof $$IMU_EXPORT$$>;
+const fakeImu = jest.fn<ReturnType<maxurlInterface>, Parameters<maxurlInterface>>();
 
 function setIMUResult(results: maxurlResult[]): void {
     fakeImu.mockImplementation(async (_url, options) => {
diff --git a/tests/unit/mb_enhanced_cover_art_uploads/tsconfig.json b/tests/unit/mb_enhanced_cover_art_uploads/tsconfig.json
new file mode 100644
index 00000000..064fdd62
--- /dev/null
+++ b/tests/unit/mb_enhanced_cover_art_uploads/tsconfig.json
@@ -0,0 +1,8 @@
+{
+    "extends": "../../../configs/tsconfig.base-unit",
+    "include": ["**/*"],
+    "references": [
+        { "path": "../../../src/mb_enhanced_cover_art_uploads" },
+        { "path": "../utils" }
+    ]
+}
diff --git a/tests/utils/fix-jsdom-env.ts b/tests/unit/utils/fix-jsdom-env.ts
similarity index 100%
rename from tests/utils/fix-jsdom-env.ts
rename to tests/unit/utils/fix-jsdom-env.ts
diff --git a/tests/utils/gm_mocks.ts b/tests/unit/utils/gm_mocks.ts
similarity index 87%
rename from tests/utils/gm_mocks.ts
rename to tests/unit/utils/gm_mocks.ts
index 2b0a1e59..06211d90 100644
--- a/tests/utils/gm_mocks.ts
+++ b/tests/unit/utils/gm_mocks.ts
@@ -1,5 +1,5 @@
 // Utilities to mock GM functions
-import * as compat from '@src/compat';
+import * as compat from '@lib/compat';
 
 export const mockGMgetResourceURL = compat.GMgetResourceUrl as jest.MockedFunction<typeof compat.GMgetResourceUrl>;
 export const mockGMxmlHttpRequest = compat.GMxmlHttpRequest as jest.MockedFunction<typeof compat.GMxmlHttpRequest>;
diff --git a/tests/utils/pollyjs/gmxhr-adapter/index.ts b/tests/unit/utils/pollyjs/gmxhr-adapter/index.ts
similarity index 100%
rename from tests/utils/pollyjs/gmxhr-adapter/index.ts
rename to tests/unit/utils/pollyjs/gmxhr-adapter/index.ts
diff --git a/tests/utils/pollyjs/headers.ts b/tests/unit/utils/pollyjs/headers.ts
similarity index 100%
rename from tests/utils/pollyjs/headers.ts
rename to tests/unit/utils/pollyjs/headers.ts
diff --git a/tests/utils/pollyjs/index.ts b/tests/unit/utils/pollyjs/index.ts
similarity index 100%
rename from tests/utils/pollyjs/index.ts
rename to tests/unit/utils/pollyjs/index.ts
diff --git a/tests/utils/pollyjs/tests/gmxhr-adapter.test.ts b/tests/unit/utils/pollyjs/tests/gmxhr-adapter.test.ts
similarity index 100%
rename from tests/utils/pollyjs/tests/gmxhr-adapter.test.ts
rename to tests/unit/utils/pollyjs/tests/gmxhr-adapter.test.ts
diff --git a/tests/utils/pollyjs/warc-persister/har2warc.ts b/tests/unit/utils/pollyjs/warc-persister/har2warc.ts
similarity index 100%
rename from tests/utils/pollyjs/warc-persister/har2warc.ts
rename to tests/unit/utils/pollyjs/warc-persister/har2warc.ts
diff --git a/tests/utils/pollyjs/warc-persister/index.ts b/tests/unit/utils/pollyjs/warc-persister/index.ts
similarity index 100%
rename from tests/utils/pollyjs/warc-persister/index.ts
rename to tests/unit/utils/pollyjs/warc-persister/index.ts
diff --git a/tests/utils/pollyjs/warc-persister/types.ts b/tests/unit/utils/pollyjs/warc-persister/types.ts
similarity index 100%
rename from tests/utils/pollyjs/warc-persister/types.ts
rename to tests/unit/utils/pollyjs/warc-persister/types.ts
diff --git a/tests/utils/pollyjs/warc-persister/warc2har.ts b/tests/unit/utils/pollyjs/warc-persister/warc2har.ts
similarity index 100%
rename from tests/utils/pollyjs/warc-persister/warc2har.ts
rename to tests/unit/utils/pollyjs/warc-persister/warc2har.ts
diff --git a/tests/utils/setup-gm-mocks.ts b/tests/unit/utils/setup-gm-mocks.ts
similarity index 100%
rename from tests/utils/setup-gm-mocks.ts
rename to tests/unit/utils/setup-gm-mocks.ts
diff --git a/tests/utils/shared_behaviour.ts b/tests/unit/utils/shared_behaviour.ts
similarity index 100%
rename from tests/utils/shared_behaviour.ts
rename to tests/unit/utils/shared_behaviour.ts
diff --git a/tests/unit/utils/tsconfig.json b/tests/unit/utils/tsconfig.json
new file mode 100644
index 00000000..dec3f210
--- /dev/null
+++ b/tests/unit/utils/tsconfig.json
@@ -0,0 +1,8 @@
+{
+    "extends": "../../../configs/tsconfig.base-unit",
+    "include": ["**/*"],
+    "files": ["../../types/warcio.d.ts"],
+    "references": [
+        { "path": "../../../src/lib" },
+    ]
+}